├── .gitignore
├── .prettierrc
├── .vscode
├── settings.json
└── tailwind.json
├── LICENSE
├── README.md
├── _locales
├── en
│ └── messages.json
├── es
│ └── messages.json
├── fr
│ └── messages.json
├── hi
│ └── messages.json
├── ru
│ └── messages.json
└── zh_CN
│ └── messages.json
├── assets
├── dev
│ ├── 16.png
│ ├── 24.png
│ ├── 32.png
│ ├── 48.png
│ └── 64.png
├── local
│ ├── gobbler-screenshot.png
│ └── read-me.png
└── prod
│ ├── 128.png
│ ├── 16.png
│ ├── 24.png
│ ├── 256.png
│ ├── 32.png
│ ├── 48.png
│ └── 64.png
├── background.ts
├── eslint.config.ts
├── index.html
├── manifest.config.ts
├── onboarding.html
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
└── favicon.ico
├── server
├── index.ts
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json
├── src
├── App.tsx
├── app.tsx
├── components
│ ├── badge.tsx
│ ├── button.tsx
│ ├── collection-panel-button.tsx
│ ├── empty-state.tsx
│ ├── error-state.tsx
│ ├── full-page-loader.tsx
│ ├── help-icon.tsx
│ ├── icon-button.tsx
│ ├── index.ts
│ ├── logo.tsx
│ ├── modal.tsx
│ ├── no-results.tsx
│ ├── prompt-feedback-modal.tsx
│ ├── review-prompt.tsx
│ ├── spinner.tsx
│ ├── tabs.tsx
│ ├── toast.tsx
│ └── tooltip.tsx
├── constants
│ ├── collection.ts
│ ├── edit-fields.ts
│ ├── file-type-labels.ts
│ ├── links.ts
│ ├── page-data.ts
│ ├── server-config.ts
│ ├── svgo-plugins.ts
│ └── transitions.ts
├── css
│ ├── alias.css
│ ├── base.css
│ ├── components.css
│ └── index.css
├── hooks
│ ├── index.ts
│ ├── use-clipboard.ts
│ ├── use-color-mode.ts
│ ├── use-database.ts
│ ├── use-edit-data.ts
│ ├── use-export-actions.ts
│ ├── use-export-data.ts
│ ├── use-intersection-observer.ts
│ ├── use-mount-effect.ts
│ ├── use-navigation.ts
│ ├── use-pasted-svg.ts
│ ├── use-remove-collection.ts
│ ├── use-reset-environment.ts
│ └── use-upload.ts
├── index.tsx
├── layout
│ ├── collection
│ │ ├── card
│ │ │ ├── card-actions
│ │ │ │ ├── action-menu-item.tsx
│ │ │ │ ├── action-menu.tsx
│ │ │ │ ├── card-copy.tsx
│ │ │ │ ├── card-select.tsx
│ │ │ │ └── use-card-actions.ts
│ │ │ ├── card-content.tsx
│ │ │ ├── cors-restricted-actions.tsx
│ │ │ ├── default-actions.tsx
│ │ │ ├── index.tsx
│ │ │ ├── svg-name.tsx
│ │ │ └── svg-size.tsx
│ │ ├── collection-drop-zone.tsx
│ │ ├── index.tsx
│ │ ├── infinite-scroll.tsx
│ │ ├── main-bar
│ │ │ ├── index.tsx
│ │ │ ├── main-actions
│ │ │ │ ├── copy-action.tsx
│ │ │ │ ├── delete-action.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── move-action.tsx
│ │ │ │ └── use-main-actions.ts
│ │ │ ├── pagination.tsx
│ │ │ ├── search.tsx
│ │ │ ├── selected-quantity.tsx
│ │ │ └── selection-control.tsx
│ │ ├── main-panel
│ │ │ ├── edit-panel.tsx
│ │ │ ├── export-footer.tsx
│ │ │ ├── export-panel.tsx
│ │ │ ├── file-name.tsx
│ │ │ ├── index.tsx
│ │ │ ├── jpeg-settings
│ │ │ │ └── index.tsx
│ │ │ ├── png-settings
│ │ │ │ └── index.tsx
│ │ │ ├── sprite-settings
│ │ │ │ └── index.tsx
│ │ │ ├── svg-settings
│ │ │ │ ├── index.tsx
│ │ │ │ ├── reset-button.tsx
│ │ │ │ └── svgo-option.tsx
│ │ │ └── webp-settings
│ │ │ │ └── index.tsx
│ │ ├── show-paste-cue.tsx
│ │ ├── skeleton-collection.tsx
│ │ └── upload-modal.tsx
│ ├── details
│ │ ├── editor
│ │ │ ├── action-bar.tsx
│ │ │ ├── editor-onboarding.tsx
│ │ │ └── index.tsx
│ │ ├── export-sidebar
│ │ │ ├── apply-button.tsx
│ │ │ ├── footer.tsx
│ │ │ ├── index.tsx
│ │ │ ├── main.tsx
│ │ │ ├── svgo-option.tsx
│ │ │ └── use-export-resize.ts
│ │ ├── header.tsx
│ │ ├── index.tsx
│ │ ├── preview-sidebar
│ │ │ ├── index.tsx
│ │ │ ├── preview-data-uri.tsx
│ │ │ ├── preview-react.tsx
│ │ │ ├── preview-svg
│ │ │ │ ├── index.tsx
│ │ │ │ ├── preview-background-button.tsx
│ │ │ │ └── preview-svg-footer.tsx
│ │ │ ├── use-preview-resize.ts
│ │ │ └── use-svgr.tsx
│ │ └── use-optimize.ts
│ ├── onboarding
│ │ ├── index.tsx
│ │ └── onboarding-graphic.png
│ ├── settings
│ │ ├── about-settings.tsx
│ │ ├── category.tsx
│ │ ├── export-settings.tsx
│ │ ├── general-settings.tsx
│ │ ├── index.tsx
│ │ ├── item.tsx
│ │ └── keyboard-shortcut.tsx
│ ├── sidebar
│ │ ├── index.tsx
│ │ ├── sidebar-content.tsx
│ │ ├── sidebar-footer
│ │ │ ├── debug-data-item.tsx
│ │ │ ├── feedback-item.tsx
│ │ │ ├── index.tsx
│ │ │ ├── reset-environment.tsx
│ │ │ ├── review-item.tsx
│ │ │ └── settings-item.tsx
│ │ ├── sidebar-header
│ │ │ ├── index.tsx
│ │ │ ├── new-collection-item.tsx
│ │ │ └── use-create-collection.ts
│ │ └── sidebar-main
│ │ │ ├── collection-item-icon.tsx
│ │ │ ├── collection-item.tsx
│ │ │ ├── collection.tsx
│ │ │ └── index.tsx
│ └── top-bar
│ │ ├── card-color-button.tsx
│ │ ├── card-color-onboarding.tsx
│ │ ├── collection-title.tsx
│ │ ├── index.tsx
│ │ ├── size-select.tsx
│ │ ├── sort-menu.tsx
│ │ ├── theme-button.tsx
│ │ ├── view-name-feature-notice.tsx
│ │ └── view-popover.tsx
├── onboarding.tsx
├── providers
│ ├── collection
│ │ ├── index.tsx
│ │ └── reducer.ts
│ ├── dashboard
│ │ ├── index.tsx
│ │ └── reducer.ts
│ ├── details
│ │ ├── index.tsx
│ │ └── reducer.ts
│ ├── edit
│ │ ├── index.tsx
│ │ └── reducer.ts
│ ├── export
│ │ ├── index.tsx
│ │ └── reducer.ts
│ ├── index.ts
│ └── user
│ │ ├── index.tsx
│ │ └── reducer.ts
├── routes
│ ├── collection
│ │ ├── index.tsx
│ │ └── loader.ts
│ ├── dashboard
│ │ ├── index.tsx
│ │ └── loader.ts
│ ├── details
│ │ ├── index.tsx
│ │ └── loader.ts
│ ├── index.ts
│ ├── root
│ │ ├── index.tsx
│ │ └── loader.ts
│ └── settings
│ │ ├── index.tsx
│ │ └── loader.ts
├── scripts
│ ├── classes
│ │ ├── g-element.ts
│ │ ├── image.ts
│ │ ├── inline.ts
│ │ ├── svg.ts
│ │ └── symbol.ts
│ ├── find-svg.ts
│ ├── index.ts
│ ├── svg-factory.ts
│ └── types.ts
├── types.ts
├── utilities
│ ├── extension-utilities.ts
│ ├── form-utilities.ts
│ ├── i18n.ts
│ ├── logger.ts
│ ├── root-utilities.ts
│ ├── sprite-builder.ts
│ ├── storage-utilities.ts
│ ├── svg-utilities.ts
│ └── zip-release.ts
└── vite-environment.d.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | releases
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "tabWidth": 2,
4 | "trailingComma": "all",
5 | "singleQuote": true,
6 | "arrowParens": "always",
7 | "proseWrap": "always",
8 | "printWidth": 100,
9 | "plugins": ["prettier-plugin-tailwindcss"],
10 | "tailwindFunctions": ["clsx"]
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "json.schemas": [
3 | {
4 | "fileMatch": ["manifest.json"],
5 | "url": "https://json.schemastore.org/chrome-manifest.json"
6 | }
7 | ],
8 | "css.customData": [".vscode/tailwind.json"]
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/tailwind.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1.1,
3 | "atDirectives": [
4 | {
5 | "name": "@tailwind",
6 | "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
7 | "references": [
8 | {
9 | "name": "Tailwind Documentation",
10 | "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
11 | }
12 | ]
13 | },
14 | {
15 | "name": "@apply",
16 | "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
17 | "references": [
18 | {
19 | "name": "Tailwind Documentation",
20 | "url": "https://tailwindcss.com/docs/functions-and-directives#apply"
21 | }
22 | ]
23 | },
24 | {
25 | "name": "@responsive",
26 | "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
27 | "references": [
28 | {
29 | "name": "Tailwind Documentation",
30 | "url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
31 | }
32 | ]
33 | },
34 | {
35 | "name": "@screen",
36 | "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
37 | "references": [
38 | {
39 | "name": "Tailwind Documentation",
40 | "url": "https://tailwindcss.com/docs/functions-and-directives#screen"
41 | }
42 | ]
43 | },
44 | {
45 | "name": "@variants",
46 | "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
47 | "references": [
48 | {
49 | "name": "Tailwind Documentation",
50 | "url": "https://tailwindcss.com/docs/functions-and-directives#variants"
51 | }
52 | ]
53 | }
54 | ]
55 | }
56 |
--------------------------------------------------------------------------------
/assets/dev/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/dev/16.png
--------------------------------------------------------------------------------
/assets/dev/24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/dev/24.png
--------------------------------------------------------------------------------
/assets/dev/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/dev/32.png
--------------------------------------------------------------------------------
/assets/dev/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/dev/48.png
--------------------------------------------------------------------------------
/assets/dev/64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/dev/64.png
--------------------------------------------------------------------------------
/assets/local/gobbler-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/local/gobbler-screenshot.png
--------------------------------------------------------------------------------
/assets/local/read-me.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/local/read-me.png
--------------------------------------------------------------------------------
/assets/prod/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/prod/128.png
--------------------------------------------------------------------------------
/assets/prod/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/prod/16.png
--------------------------------------------------------------------------------
/assets/prod/24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/prod/24.png
--------------------------------------------------------------------------------
/assets/prod/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/prod/256.png
--------------------------------------------------------------------------------
/assets/prod/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/prod/32.png
--------------------------------------------------------------------------------
/assets/prod/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/prod/48.png
--------------------------------------------------------------------------------
/assets/prod/64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/assets/prod/64.png
--------------------------------------------------------------------------------
/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import eslint from '@eslint/js'
2 | import tsparser from '@typescript-eslint/parser'
3 | import perfectionist from 'eslint-plugin-perfectionist'
4 | import reactHooks from 'eslint-plugin-react-hooks'
5 | import unicornPlugin from 'eslint-plugin-unicorn'
6 | import globals from 'globals'
7 | import tseslint from 'typescript-eslint'
8 |
9 | export default tseslint.config([
10 | eslint.configs.recommended,
11 | tseslint.configs.recommended,
12 | unicornPlugin.configs.recommended,
13 | reactHooks.configs['recommended-latest'],
14 | perfectionist.configs['recommended-natural'],
15 | {
16 | files: ['**/*.ts', '**/*.tsx'],
17 | languageOptions: {
18 | globals: {
19 | ...globals.browser,
20 | ...globals.es2020,
21 | },
22 | parser: tsparser,
23 | parserOptions: {
24 | ecmaVersion: 'latest',
25 | sourceType: 'module',
26 | },
27 | },
28 | rules: {
29 | '@typescript-eslint/ban-ts-comment': 'off',
30 | 'unicorn/consistent-function-scoping': 'off',
31 | 'unicorn/no-array-callback-reference': 'off',
32 | 'unicorn/prefer-module': 'off',
33 | },
34 | },
35 | {
36 | ignores: ['dist', 'build', 'out', 'coverage', 'node_modules'],
37 | },
38 | ])
39 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | SVG Gobbler
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/manifest.config.ts:
--------------------------------------------------------------------------------
1 | import type { ManifestV3Export } from '@crxjs/vite-plugin'
2 |
3 | import packageJson from './package.json'
4 | // @ts-ignore
5 | import { serverEndpoint } from './src/constants/server-config'
6 |
7 | export default {
8 | action: { default_title: 'SVG Gobbler' },
9 | background: { service_worker: 'background.ts' },
10 | commands: {
11 | _execute_action: {
12 | suggested_key: {
13 | default: 'Ctrl+Shift+G',
14 | },
15 | },
16 | },
17 | default_locale: 'en',
18 | description: packageJson.description,
19 | homepage_url: 'https://svggobbler.com',
20 | host_permissions: [serverEndpoint.svgr],
21 | icons: {
22 | 16: 'assets/prod/16.png',
23 | 32: 'assets/prod/32.png',
24 | 48: 'assets/prod/48.png',
25 | 128: 'assets/prod/128.png',
26 | 256: 'assets/prod/256.png',
27 | },
28 | manifest_version: 3,
29 | name: 'SVG Gobbler',
30 | permissions: ['activeTab', 'scripting', 'storage', 'contextMenus'],
31 | version: packageJson.version,
32 | web_accessible_resources: [
33 | {
34 | matches: [''],
35 | resources: ['assets/dev/**/*.png', 'assets/prod/**/*.png'],
36 | },
37 | ],
38 | }
39 |
--------------------------------------------------------------------------------
/onboarding.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Welcome to SVG Gobbler
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | autoprefixer: {},
4 | tailwindcss: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/public/favicon.ico
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import * as ff from '@google-cloud/functions-framework'
2 | import { Storage } from '@google-cloud/storage'
3 | import { Config, State, transform } from '@svgr/core'
4 | import { format } from 'prettier'
5 |
6 | const storage = new Storage()
7 |
8 | export type ServerMessage =
9 | | { payload: StringMessage; type: 'error' }
10 | | { payload: StringMessage; type: 'feedback' }
11 | | { payload: SVGRMessage; type: 'svgr' }
12 |
13 | export type StringMessage = {
14 | message: string
15 | }
16 |
17 | export type SVGRMessage = {
18 | config: Config
19 | state: State
20 | svg: string
21 | }
22 |
23 | ff.http('svgr', async (request: ff.Request, response: ff.Response) => {
24 | switch (request.body.type) {
25 | case 'error': {
26 | try {
27 | const { message } = request.body.payload as StringMessage
28 | const bucketName = 'svg-gobbler'
29 | const destinationFileName = `error/error-${Date.now()}.txt`
30 | await storage.bucket(bucketName).file(destinationFileName).save(message)
31 | } catch (error) {
32 | console.error(error)
33 | response.send('Unable to upload message to database 😥')
34 | }
35 | break
36 | }
37 |
38 | case 'feedback': {
39 | try {
40 | const { message } = request.body.payload as StringMessage
41 | const bucketName = 'svg-gobbler'
42 | const destinationFileName = `feedback/feedback-${Date.now()}.txt`
43 | await storage.bucket(bucketName).file(destinationFileName).save(message)
44 | } catch (error) {
45 | console.error(error)
46 | response.send('Unable to upload message to database 😥')
47 | }
48 | break
49 | }
50 |
51 | case 'svgr': {
52 | try {
53 | const { config, state, svg } = request.body.payload as SVGRMessage
54 | const result = await transform(svg, config, state)
55 | const formatted = await format(result, {
56 | parser: 'babel-ts',
57 | // eslint-disable-next-line @typescript-eslint/no-require-imports, unicorn/prefer-module
58 | plugins: [require('prettier/plugins/babel')],
59 | })
60 | response.send(formatted)
61 | } catch (error) {
62 | console.error(error)
63 | response.send('Unable to transform SVG 😥')
64 | }
65 | break
66 | }
67 |
68 | default: {
69 | response.send('Unable to process request 😥')
70 | }
71 | }
72 | })
73 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "scripts": {
8 | "build": "tsc -w",
9 | "serve": "functions-framework --target=svgr"
10 | },
11 | "author": "",
12 | "license": "ISC",
13 | "devDependencies": {
14 | "@types/babel__core": "^7.20.5",
15 | "typescript": "^5.3.2"
16 | },
17 | "dependencies": {
18 | "@google-cloud/functions-framework": "^3.3.0",
19 | "@google-cloud/storage": "^7.11.0",
20 | "@svgr/core": "^8.1.0",
21 | "@svgr/plugin-jsx": "^8.1.0",
22 | "prettier": "^3.1.0"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "declaration": true,
5 | "declarationMap": true,
6 | "target": "es2016",
7 | "module": "commonjs",
8 | "esModuleInterop": true,
9 | "strict": true,
10 | "jsx": "react"
11 | },
12 | "include": ["index.ts"],
13 | "exclude": ["node_modules"]
14 | }
15 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { createMemoryRouter, RouterProvider } from 'react-router-dom'
2 | import { ErrorState } from 'src/components'
3 | import {
4 | collectionLoader,
5 | CollectionRoute,
6 | dashboardLoader,
7 | DashboardRoute,
8 | detailLoader,
9 | DetailsRoute,
10 | rootLoader,
11 | RootRoute,
12 | settingsLoader,
13 | SettingsRoute,
14 | } from 'src/routes'
15 |
16 | export default function App() {
17 | const router = createMemoryRouter([
18 | {
19 | element: ,
20 | errorElement: ,
21 | loader: rootLoader,
22 | path: '/',
23 | },
24 | {
25 | children: [
26 | {
27 | element: ,
28 | errorElement: ,
29 | loader: collectionLoader,
30 | path: 'collection/:id',
31 | },
32 | {
33 | element: ,
34 | errorElement: ,
35 | loader: settingsLoader,
36 | path: 'settings',
37 | },
38 | ],
39 | element: ,
40 | errorElement: ,
41 | loader: dashboardLoader,
42 | path: '/dashboard',
43 | },
44 | {
45 | element: ,
46 | errorElement: ,
47 | loader: detailLoader,
48 | path: '/details/:collectionId/:id',
49 | },
50 | ])
51 |
52 | return
53 | }
54 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import { createMemoryRouter, RouterProvider } from 'react-router-dom'
2 | import { ErrorState } from 'src/components'
3 | import {
4 | collectionLoader,
5 | CollectionRoute,
6 | dashboardLoader,
7 | DashboardRoute,
8 | detailLoader,
9 | DetailsRoute,
10 | rootLoader,
11 | RootRoute,
12 | settingsLoader,
13 | SettingsRoute,
14 | } from 'src/routes'
15 |
16 | export default function App() {
17 | const router = createMemoryRouter([
18 | {
19 | element: ,
20 | errorElement: ,
21 | loader: rootLoader,
22 | path: '/',
23 | },
24 | {
25 | children: [
26 | {
27 | element: ,
28 | errorElement: ,
29 | loader: collectionLoader,
30 | path: 'collection/:id',
31 | },
32 | {
33 | element: ,
34 | errorElement: ,
35 | loader: settingsLoader,
36 | path: 'settings',
37 | },
38 | ],
39 | element: ,
40 | errorElement: ,
41 | loader: dashboardLoader,
42 | path: '/dashboard',
43 | },
44 | {
45 | element: ,
46 | errorElement: ,
47 | loader: detailLoader,
48 | path: '/details/:collectionId/:id',
49 | },
50 | ])
51 |
52 | return
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/badge.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { forwardRef, HTMLAttributes } from 'react'
3 |
4 | type BadgeProperties = HTMLAttributes & {
5 | text: string
6 | variant?: BadgeVariant
7 | }
8 |
9 | type BadgeVariant = 'blue' | 'gray' | 'green' | 'indigo' | 'pink' | 'purple' | 'red' | 'yellow'
10 |
11 | const variantStyles: Record = {
12 | blue: 'bg-blue-50 text-blue-700 ring-blue-700/10',
13 | gray: 'bg-gray-50 text-gray-600 ring-gray-500/10',
14 | green: 'bg-green-50 text-green-700 ring-green-600/20',
15 | indigo: 'bg-indigo-50 text-indigo-700 ring-indigo-700/10',
16 | pink: 'bg-pink-50 text-pink-700 ring-pink-700/10',
17 | purple: 'bg-purple-50 text-purple-700 ring-purple-700/10',
18 | red: 'bg-red-50 text-red-700 ring-red-600/10',
19 | yellow: 'bg-yellow-50 text-yellow-800 ring-yellow-600/20',
20 | }
21 |
22 | export const Badge = forwardRef((properties, reference) => {
23 | const { className, text, variant = 'gray', ...rest } = properties
24 | const classes = clsx(
25 | 'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
26 | variantStyles[variant],
27 | className,
28 | )
29 |
30 | return (
31 |
32 | {text}
33 |
34 | )
35 | })
36 |
--------------------------------------------------------------------------------
/src/components/button.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonHTMLAttributes, forwardRef } from 'react'
2 |
3 | import { Spinner } from './spinner'
4 |
5 | export const buttonBaseStyles =
6 | 'rounded-lg flex items-center gap-1 font-semibold transition-all duration-200 ease-in-out focus justify-center'
7 |
8 | export const buttonVariantStyles = {
9 | destructive:
10 | 'ring-1 ring-inset ring-red-300 dark:ring-red-700 hover:bg-red-100 dark:hover:bg-red-800 shadow-sm text-red-600',
11 | ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800',
12 | primary: 'bg-red-600 hover:bg-red-500 text-white shadow-sm',
13 | secondary:
14 | 'ring-1 ring-inset ring-gray-300 dark:ring-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 shadow-sm',
15 | }
16 |
17 | export const buttonSizeStyles = {
18 | lg: 'px-3 py-2 text-base',
19 | md: 'px-2.5 py-1.5 text-sm',
20 | sm: 'px-2 py-1 text-sm',
21 | xl: 'px-3.5 py-2.5 text-base',
22 | xs: 'px-2 py-1 text-xs',
23 | }
24 |
25 | export type ButtonProperties = ButtonHTMLAttributes & {
26 | loading?: boolean
27 | size?: keyof typeof buttonSizeStyles
28 | variant?: keyof typeof buttonVariantStyles
29 | }
30 |
31 | /**
32 | * General Button component.
33 | * Uses 20px icons for all sizes except xs, which uses 16px icons.
34 | */
35 | export const Button = forwardRef(
36 | (
37 | {
38 | children,
39 | className = '',
40 | disabled,
41 | loading,
42 | size = 'md',
43 | type = 'button',
44 | variant = 'primary',
45 | ...rest
46 | },
47 | reference,
48 | ) => {
49 | const combinedClassName = [
50 | buttonBaseStyles,
51 | buttonVariantStyles[variant],
52 | buttonSizeStyles[size],
53 | className,
54 | ]
55 | .join(' ')
56 | .trim()
57 |
58 | return (
59 |
69 | )
70 | },
71 | )
72 |
--------------------------------------------------------------------------------
/src/components/collection-panel-button.tsx:
--------------------------------------------------------------------------------
1 | import { Bars3Icon } from '@heroicons/react/24/outline'
2 | import { useDashboard } from 'src/providers'
3 |
4 | import { IconButton, Tooltip } from '.'
5 |
6 | /**
7 | * The modular button that opens the collection panel in mobile viewports.
8 | */
9 | export const CollectionPanelButton = () => {
10 | const { dispatch: sidebarDispatch } = useDashboard()
11 |
12 | function openSidebar() {
13 | sidebarDispatch({ payload: true, type: 'set-open' })
14 | }
15 |
16 | return (
17 |
18 |
19 |
20 | Open collection panel
21 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/empty-state.tsx:
--------------------------------------------------------------------------------
1 | import { MagnifyingGlassIcon, PlusIcon } from '@heroicons/react/24/outline'
2 | import clsx from 'clsx'
3 | import { useState } from 'react'
4 | import { useDropzone } from 'react-dropzone'
5 | import { useUpload } from 'src/hooks'
6 | import { UploadModal } from 'src/layout/collection/upload-modal'
7 | import { formUtilities } from 'src/utilities/form-utilities'
8 |
9 | import { Button } from '.'
10 | import { loc } from '../utilities/i18n'
11 |
12 | /**
13 | * This is displayed when there are no SVGs found sourcing the client page.
14 | * It is also rendered when the user has deleted all SVGs from the collection.
15 | */
16 | export const EmptyState = () => {
17 | const [open, setOpen] = useState(false)
18 | const upload = useUpload()
19 |
20 | const { getInputProps, getRootProps, isDragActive } = useDropzone({
21 | accept: { 'image/svg+xml': ['.svg'] },
22 | maxSize: 10 * 1024 * 1024,
23 | multiple: true,
24 | noClick: true,
25 | noKeyboard: true,
26 | onDropAccepted,
27 | })
28 |
29 | async function onDropAccepted(files: File[]) {
30 | const fileSvgs = await formUtilities.handleUpload(files)
31 | upload(fileSvgs)
32 | }
33 |
34 | return (
35 |
44 |
45 |
46 |
{loc('empty_title')}
47 |
{loc('empty_desc')}
48 |
49 |
53 |
54 |
55 |
56 |
57 |
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/error-state.tsx:
--------------------------------------------------------------------------------
1 | import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
2 | import { useRef } from 'react'
3 | import { useRouteError } from 'react-router-dom'
4 | import { useDatabase } from 'src/hooks'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | import { Button } from '.'
8 |
9 | export const ErrorState = () => {
10 | const error = useRouteError()
11 | const sendMessage = useDatabase('error')
12 | const textAreaReference = useRef(null)
13 |
14 | const refresh = () => {
15 | globalThis.location.reload()
16 | }
17 |
18 | const sendMessageHandler = () => {
19 | sendMessage(textAreaReference.current?.value ?? 'No error message')
20 | refresh()
21 | }
22 |
23 | return (
24 |
25 |
26 |
27 |
31 |
{loc('error_title')}
32 |
{loc('error_desc')}
33 |
36 |
43 |
44 |
47 |
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/full-page-loader.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 |
3 | import { Logo } from './logo'
4 |
5 | export const FullPageLoader = () => {
6 | return (
7 |
8 |
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/help-icon.tsx:
--------------------------------------------------------------------------------
1 | import { QuestionMarkCircleIcon } from '@heroicons/react/24/solid'
2 |
3 | import { Tooltip } from '.'
4 |
5 | type HelpIconProperties = {
6 | content: string
7 | }
8 |
9 | /**
10 | * Displays a help icon with a tooltip on the hover of a group element.
11 | * Must be placed inside a group className to render.
12 | */
13 | export const HelpIcon = ({ content }: HelpIconProperties) => {
14 | return (
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/icon-button.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react'
2 |
3 | import {
4 | buttonBaseStyles as buttonBaseStyles,
5 | ButtonProperties as ButtonProperties,
6 | buttonVariantStyles as buttonVariantStyles,
7 | } from '.'
8 |
9 | type IconButtonProperties = ButtonProperties
10 |
11 | /**
12 | * General Icon Button component.
13 | * 20px icons are used for all sizes except xs, which uses 16px icons.
14 | */
15 | export const IconButton = forwardRef(
16 | (properties, reference) => {
17 | const { className, size = 'md', type = 'button', variant = 'primary', ...rest } = properties
18 |
19 | const sizeStyles = {
20 | lg: 'px-2 py-2',
21 | md: 'px-1.5 py-1.5',
22 | sm: 'px-1 py-1',
23 | xl: 'px-2.5 py-2.5',
24 | xs: 'px-1 py-1',
25 | }
26 |
27 | const combinedClassName = `${buttonBaseStyles} ${buttonVariantStyles[variant]} ${sizeStyles[size]} ${className}`
28 |
29 | return
30 | },
31 | )
32 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './badge'
2 | export * from './button'
3 | export * from './collection-panel-button.tsx'
4 | export * from './empty-state'
5 | export * from './error-state'
6 | export * from './help-icon'
7 | export * from './icon-button'
8 | export * from './logo'
9 | export * from './modal'
10 | export * from './prompt-feedback-modal.tsx'
11 | export * from './review-prompt'
12 | export * from './spinner.tsx'
13 | export * from './tabs'
14 | export * from './toast'
15 | export * from './tooltip'
16 |
--------------------------------------------------------------------------------
/src/components/logo.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, HTMLAttributes } from 'react'
2 |
3 | export const Logo = forwardRef>((properties, reference) => (
4 |
18 | ))
19 |
--------------------------------------------------------------------------------
/src/components/no-results.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
3 | import { Fragment } from 'react'
4 | import { loc } from 'src/utilities/i18n'
5 |
6 | /**
7 | * The empty state used when there are no results to show based on the current
8 | * filter, search, and view settings.
9 | */
10 | export const NoResults = () => (
11 |
19 |
20 |
21 |
22 |
{loc('no_results_title')}
23 |
{loc('no_results_desc')}
24 |
25 |
26 |
27 | )
28 |
--------------------------------------------------------------------------------
/src/components/review-prompt.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { links } from 'src/constants/links'
3 | import { useDashboard, useUser } from 'src/providers'
4 | import { extension } from 'src/utilities/extension-utilities'
5 | import { loc } from 'src/utilities/i18n'
6 | import { StorageUtilities } from 'src/utilities/storage-utilities'
7 |
8 | import { Toast } from './toast'
9 |
10 | export const ReviewPrompt = () => {
11 | const [show, setShow] = useState(false)
12 | const { dispatch, state: userState } = useUser()
13 |
14 | const {
15 | state: { collections },
16 | } = useDashboard()
17 |
18 | useEffect(() => {
19 | if (
20 | collections.length >= 3 &&
21 | !userState.onboarding.viewedReview &&
22 | process.env.NODE_ENV === 'production'
23 | ) {
24 | setShow(true)
25 | }
26 | }, [collections.length, userState.onboarding.viewedReview])
27 |
28 | const setReviewPromptViewed = () => {
29 | const payload = { ...userState, onboarding: { ...userState.onboarding, viewedReview: true } }
30 | StorageUtilities.setStorageData('user', payload)
31 | dispatch({ payload, type: 'set-user' })
32 | setShow(false)
33 | }
34 |
35 | const handleReviewPrompt = () => {
36 | setReviewPromptViewed()
37 | const link = extension.isFirefox ? links.firefoxWebstore : links.chromeWebstore
38 | window.open(link, '_blank')
39 | }
40 |
41 | return (
42 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react'
2 |
3 | export const Spinner = forwardRef>(
4 | (properties, reference) => {
5 | return (
6 |
20 | )
21 | },
22 | )
23 |
--------------------------------------------------------------------------------
/src/components/tabs.tsx:
--------------------------------------------------------------------------------
1 | import { Tab as HeadlessTab } from '@headlessui/react'
2 | import clsx from 'clsx'
3 | import { HTMLAttributes, PropsWithChildren } from 'react'
4 |
5 | const Group = (properties: PropsWithChildren) =>
6 |
7 | const List = (properties: PropsWithChildren>) => {
8 | const { className, ...rest } = properties
9 |
10 | return (
11 |
15 | )
16 | }
17 |
18 | const Panels = (properties: PropsWithChildren>) => (
19 |
20 | )
21 |
22 | const Panel = (properties: PropsWithChildren>) => (
23 |
24 | )
25 |
26 | const Tab = (properties: PropsWithChildren) => (
27 |
35 | )
36 |
37 | export const Tabs = {
38 | Group,
39 | List,
40 | Panel,
41 | Panels,
42 | Tab,
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as RTooltip from '@radix-ui/react-tooltip'
2 | import clsx from 'clsx'
3 | import { PropsWithChildren } from 'react'
4 |
5 | type TooltipProperties = {
6 | /**
7 | * The content of the tooltip.
8 | */
9 | content: string
10 | /**
11 | * The side the tooltip will render in relation to the trigger element.
12 | * Defaults to 'bottom'
13 | */
14 | side?: 'bottom' | 'left' | 'right' | 'top'
15 | }
16 |
17 | export const Tooltip = ({
18 | children,
19 | content,
20 | side = 'bottom',
21 | }: PropsWithChildren) => (
22 |
23 | {children}
24 |
25 |
38 | {content}
39 |
40 |
41 |
42 |
43 | )
44 |
--------------------------------------------------------------------------------
/src/constants/collection.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import { loc } from 'src/utilities/i18n'
3 | import { Collection } from 'types'
4 |
5 | export const initCollection = (): Collection => ({
6 | href: '',
7 | id: nanoid(),
8 | name: loc('sidebar_new_collection'),
9 | origin: '',
10 | })
11 |
--------------------------------------------------------------------------------
/src/constants/edit-fields.ts:
--------------------------------------------------------------------------------
1 | import { EditField } from 'src/layout/collection/main-panel/edit-panel'
2 | import { loc } from 'src/utilities/i18n'
3 |
4 | export const editFields: EditField[] = [
5 | {
6 | label: loc('edit_id'),
7 | tooltip: loc('edit_id_tooltip'),
8 | value: 'id',
9 | },
10 | {
11 | label: loc('edit_height'),
12 | tooltip: loc('edit_height_tooltip'),
13 | value: 'height',
14 | },
15 | {
16 | label: loc('edit_width'),
17 | tooltip: loc('edit_width_tooltip'),
18 | value: 'width',
19 | },
20 | {
21 | label: loc('edit_class'),
22 | tooltip: loc('edit_class_tooltip'),
23 | value: 'class',
24 | },
25 | {
26 | label: loc('edit_viewbox'),
27 | tooltip: loc('edit_viewbox_tooltip'),
28 | value: 'viewBox',
29 | },
30 | {
31 | label: loc('edit_fill'),
32 | tooltip: loc('edit_fill_tooltip'),
33 | value: 'fill',
34 | },
35 | ]
36 |
--------------------------------------------------------------------------------
/src/constants/file-type-labels.ts:
--------------------------------------------------------------------------------
1 | import { type FileType } from 'src/providers'
2 |
3 | export const fileTypeLabels: Record = {
4 | jpeg: 'JPEG',
5 | png: 'PNG',
6 | sprite: 'Sprite',
7 | svg: 'SVG',
8 | webp: 'WebP',
9 | }
10 |
--------------------------------------------------------------------------------
/src/constants/links.ts:
--------------------------------------------------------------------------------
1 | export const links = {
2 | chromeWebstore:
3 | 'https://chromewebstore.google.com/detail/svg-gobbler/mpbmflcodadhgafbbakjeahpandgcbch?hl=en',
4 | firefoxWebstore: 'https://addons.mozilla.org/en-US/firefox/addon/svg-gobbler/',
5 | githubIssues: 'https://github.com/rossmoody/svg-gobbler/issues',
6 | githubIssuesNew: 'https://github.com/rossmoody/svg-gobbler/issues/new',
7 | githubReleases: 'https://github.com/rossmoody/svg-gobbler/releases',
8 | githubRepository: 'https://github.com/rossmoody/svg-gobbler',
9 | githubSvgoRepository: 'https://github.com/svg/svgo',
10 | miniDataUriRepository: 'https://github.com/tigt/mini-svg-data-uri',
11 | rossMoodyHomepage: 'https://rossmoody.com',
12 | svgGobblerHomepage: 'https://svggobbler.com',
13 | }
14 |
--------------------------------------------------------------------------------
/src/constants/page-data.ts:
--------------------------------------------------------------------------------
1 | import { PageData } from 'src/types'
2 |
3 | export const pageData: PageData = {
4 | data: [],
5 | host: '',
6 | href: '',
7 | origin: '',
8 | }
9 |
--------------------------------------------------------------------------------
/src/constants/server-config.ts:
--------------------------------------------------------------------------------
1 | export const isDevelopmentEnvironment = process.env.NODE_ENV === 'development'
2 |
3 | const server = {
4 | dev: { svgr: 'http://localhost:8080/*' },
5 | prod: { svgr: 'https://us-west2-svg-gobbler.cloudfunctions.net/svg-gobbler-svgr' },
6 | }
7 |
8 | export const serverEndpoint = isDevelopmentEnvironment ? server.dev : server.prod
9 |
--------------------------------------------------------------------------------
/src/constants/transitions.ts:
--------------------------------------------------------------------------------
1 | export const transitions = {
2 | menu: {
3 | enter: 'transition ease-out duration-100',
4 | enterFrom: 'transform opacity-0 scale-95',
5 | enterTo: 'transform opacity-100 scale-100',
6 | leave: 'transition ease-in duration-75',
7 | leaveFrom: 'transform opacity-100 scale-100',
8 | leaveTo: 'transform opacity-0 scale-95',
9 | },
10 | popover: {
11 | enter: 'transition ease-out duration-100',
12 | enterFrom: 'transform opacity-0 scale-95',
13 | enterTo: 'transform opacity-100 scale-100',
14 | leave: 'transition ease-in duration-75',
15 | leaveFrom: 'transform opacity-100 scale-100',
16 | leaveTo: 'transform opacity-0 scale-95',
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/src/css/alias.css:
--------------------------------------------------------------------------------
1 | /*
2 | Broad shared design token values
3 | */
4 |
5 | .text {
6 | @apply text-gray-900 dark:text-gray-200;
7 | }
8 |
9 | .text-muted {
10 | @apply text-gray-500 dark:text-gray-400;
11 | }
12 |
13 | .surface {
14 | @apply bg-white transition-colors duration-300 ease-in-out dark:bg-gray-900;
15 | }
16 |
17 | .focus {
18 | @apply focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
19 | focus-visible:outline-red-600;
20 | }
21 |
22 | .anchor {
23 | @apply cursor-pointer text-red-600 underline hover:text-red-500;
24 | }
25 |
--------------------------------------------------------------------------------
/src/css/base.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss/base';
2 | @import 'tailwindcss/components';
3 | @import 'tailwindcss/utilities';
4 |
5 | .dark {
6 | color-scheme: dark;
7 | }
8 |
9 | #root {
10 | min-height: 100dvh;
11 | @apply surface text;
12 | }
13 |
--------------------------------------------------------------------------------
/src/css/index.css:
--------------------------------------------------------------------------------
1 | @import './base.css';
2 | @import './alias.css';
3 | @import './components.css';
4 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './use-clipboard'
2 | export * from './use-color-mode'
3 | export * from './use-database'
4 | export * from './use-export-data'
5 | export * from './use-intersection-observer'
6 | export * from './use-mount-effect'
7 | export * from './use-pasted-svg'
8 | export * from './use-reset-environment'
9 | export * from './use-upload'
10 |
--------------------------------------------------------------------------------
/src/hooks/use-clipboard.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | /**
4 | * Copies a given string to the clipboard.
5 | * Changes the text to 'Copied' for 1 second after copying.
6 | */
7 | export const useClipboard = (label = 'Copy') => {
8 | const [text, setText] = useState(label)
9 |
10 | const copyToClipboard = (text: string) => {
11 | setText('Copied')
12 | navigator.clipboard.writeText(text)
13 | setTimeout(() => {
14 | setText(label)
15 | }, 1000)
16 | }
17 |
18 | return {
19 | copyToClipboard,
20 | text,
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/hooks/use-color-mode.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { CollectionState, useCollection } from 'src/providers'
3 | import { StorageUtilities } from 'src/utilities/storage-utilities'
4 |
5 | export function useColorMode() {
6 | const { dispatch, state } = useCollection()
7 |
8 | useEffect(() => {
9 | document.body.classList.toggle('dark', state.view.colorMode === 'dark')
10 | }, [state.view.colorMode])
11 |
12 | const toggleColorMode = () => {
13 | const colorMode = state.view.colorMode === 'light' ? 'dark' : 'light'
14 | const canvas = colorMode === 'light' ? '#fff' : '#1A2338'
15 |
16 | const view: CollectionState['view'] = {
17 | ...state.view,
18 | canvas,
19 | colorMode,
20 | }
21 |
22 | document.body.classList.toggle('dark', colorMode === 'dark')
23 | dispatch({ payload: view, type: 'set-view' })
24 | StorageUtilities.setStorageData('view', view)
25 | }
26 |
27 | return { colorMode: state.view.colorMode, toggleColorMode }
28 | }
29 |
30 | export default useColorMode
31 |
--------------------------------------------------------------------------------
/src/hooks/use-database.ts:
--------------------------------------------------------------------------------
1 | import type { ServerMessage } from 'server'
2 |
3 | import { serverEndpoint } from 'src/constants/server-config'
4 | import { logger } from 'src/utilities/logger'
5 |
6 | /**
7 | * Send a message to the database for logging user feedback or errors that are provided.
8 | */
9 | export const useDatabase = (type: 'error' | 'feedback') => {
10 | return async (message: string = '') => {
11 | const feedbackMessage: ServerMessage = {
12 | payload: { message },
13 | type,
14 | }
15 |
16 | try {
17 | await fetch(serverEndpoint.svgr, {
18 | body: JSON.stringify(feedbackMessage),
19 | headers: { 'Content-Type': 'application/json' },
20 | method: 'POST',
21 | })
22 | } catch (error) {
23 | logger.error('Could not send feature request.', error)
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/hooks/use-edit-data.ts:
--------------------------------------------------------------------------------
1 | import { useCollection, useEdit } from 'src/providers'
2 | import { StorageUtilities } from 'src/utilities/storage-utilities'
3 |
4 | export const useEditData = () => {
5 | const { state: editState } = useEdit()
6 | const { dispatch: collectionDispatch, state: collectionState } = useCollection()
7 |
8 | async function handleUpdateProperties() {
9 | const { collectionId, data, selected } = collectionState
10 | const { custom, standard } = editState
11 |
12 | const updatedSelectedSvgs = selected.map((svg) => {
13 | const svgClone = svg.createClone()
14 |
15 | for (const [key, value] of Object.entries(standard)) {
16 | if (value && svgClone.asElement) {
17 | svgClone.asElement.setAttribute(key, value)
18 | svgClone.svg = svgClone.asElement.outerHTML
19 | svgClone.stampLastEdited()
20 | }
21 | }
22 |
23 | if (custom.name && custom.value && svgClone.asElement) {
24 | svgClone.asElement.setAttribute(custom.name, custom.value)
25 | svgClone.svg = svgClone.asElement.outerHTML
26 | svgClone.stampLastEdited()
27 | }
28 |
29 | return svgClone
30 | })
31 |
32 | const updatedCollection = data.map((svg) => {
33 | const updatedSvg = updatedSelectedSvgs.find((updated) => updated.id === svg.id)
34 | return updatedSvg || svg
35 | })
36 |
37 | const pageData = await StorageUtilities.getPageData(collectionId)
38 | StorageUtilities.setPageData(collectionId, { ...pageData, data: updatedCollection })
39 | collectionDispatch({ payload: updatedCollection, type: 'set-data' })
40 | collectionDispatch({ payload: updatedSelectedSvgs, type: 'set-selected' })
41 | collectionDispatch({ type: 'process-data' })
42 | }
43 |
44 | return { handleUpdateProperties }
45 | }
46 |
--------------------------------------------------------------------------------
/src/hooks/use-intersection-observer.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react'
2 |
3 | /**
4 | * A simple hook that uses the Intersection Observer API to detect when an element
5 | * enters or exits the viewport.
6 | */
7 | export const useIntersectionObserver = (options: IntersectionObserverInit = {}) => {
8 | const [isIntersecting, setIsIntersecting] = useState(false)
9 | const elementReference = useRef(null)
10 |
11 | useEffect(() => {
12 | const element = elementReference.current
13 | if (!element) return
14 |
15 | const observer = new IntersectionObserver(([entry]) => {
16 | setIsIntersecting(entry.isIntersecting)
17 | }, options)
18 |
19 | observer.observe(element)
20 |
21 | return () => {
22 | observer.disconnect()
23 | }
24 | }, [options])
25 |
26 | return { elementRef: elementReference, isIntersecting }
27 | }
28 |
--------------------------------------------------------------------------------
/src/hooks/use-mount-effect.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | /**
4 | * A hook that runs a function only once when the component mounts.
5 | * @param fn The function to run only once when the component mounts
6 | */
7 | export const useMountEffect = (function_: () => void) => {
8 | // eslint-disable-next-line react-hooks/exhaustive-deps
9 | useEffect(function_, [])
10 | }
11 |
--------------------------------------------------------------------------------
/src/hooks/use-navigation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * SVG Gobbler runs in a webview, so we can't use the browser's history to navigate back.
3 | * Instead, we use React Router's `useNavigate` hook to navigate around the app.
4 | */
5 |
6 | import { useNavigate } from 'react-router-dom'
7 |
8 | export const useNavigation = () => {
9 | const navigate = useNavigate()
10 |
11 | const handleNavigation = (event: KeyboardEvent) => {
12 | if (event.metaKey && event.key === '[') {
13 | navigate(-1)
14 | }
15 |
16 | if (event.metaKey && event.key === ']') {
17 | navigate(1)
18 | }
19 | }
20 |
21 | globalThis.addEventListener('keydown', handleNavigation)
22 |
23 | return () => {
24 | globalThis.removeEventListener('keydown', handleNavigation)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/hooks/use-pasted-svg.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid'
2 | import { useEffect } from 'react'
3 | import { type UserState, useUser } from 'src/providers'
4 | import { StorageUtilities } from 'src/utilities/storage-utilities'
5 |
6 | import { SvgUtilities } from '../utilities/svg-utilities'
7 | import { useUpload } from './use-upload'
8 |
9 | /**
10 | * Hook to listen for a paste event and upload the pasted SVG if it is valid.
11 | * Also sets the onboarding flag to true if the user pastes an SVG.
12 | */
13 | export const usePastedSvg = () => {
14 | const { dispatch, state } = useUser()
15 | const upload = useUpload()
16 |
17 | useEffect(() => {
18 | const handlePaste = (event: ClipboardEvent) => {
19 | // Prevent pasting SVGs when the upload modal is visible
20 | const uploadModalVisible = document.querySelector('#upload-modal')
21 | if (uploadModalVisible) return
22 |
23 | const pastedText = event.clipboardData?.getData('text/plain')
24 | if (pastedText && SvgUtilities.isValidSvg(pastedText)) {
25 | event.preventDefault()
26 | upload([{ name: nanoid(), svg: pastedText }])
27 |
28 | const payload: UserState = {
29 | ...state,
30 | onboarding: { ...state.onboarding, hasPastedSvg: true, viewedSvgInClipboard: true },
31 | }
32 | StorageUtilities.setStorageData('user', payload)
33 | dispatch({ payload, type: 'set-user' })
34 | }
35 | }
36 |
37 | globalThis.addEventListener('paste', handlePaste)
38 | return () => {
39 | globalThis.removeEventListener('paste', handlePaste)
40 | }
41 | }, [upload, state, dispatch])
42 | }
43 |
--------------------------------------------------------------------------------
/src/hooks/use-remove-collection.ts:
--------------------------------------------------------------------------------
1 | import type { Collection } from 'src/types'
2 |
3 | import { useLocation, useNavigate } from 'react-router-dom'
4 | import { initCollection } from 'src/constants/collection'
5 | import { pageData } from 'src/constants/page-data'
6 | import { useDashboard } from 'src/providers'
7 | import { StorageUtilities } from 'src/utilities/storage-utilities'
8 |
9 | export function useRemoveCollection() {
10 | const navigate = useNavigate()
11 | const { pathname } = useLocation()
12 | const { dispatch, state } = useDashboard()
13 |
14 | return function (collection: Collection) {
15 | const isActiveCollection = pathname.includes(collection.id)
16 | const collectionsWithoutRemoved = state.collections.filter(({ id }) => id !== collection.id)
17 |
18 | // If there are no collections left, create an empty one
19 | if (collectionsWithoutRemoved.length === 0) {
20 | const newCollection = initCollection()
21 | collectionsWithoutRemoved.push(newCollection)
22 | StorageUtilities.setPageData(newCollection.id, pageData)
23 | }
24 |
25 | dispatch({ payload: collectionsWithoutRemoved, type: 'set-collections' })
26 | chrome.storage.local.remove(collection.id)
27 | StorageUtilities.setStorageData('collections', collectionsWithoutRemoved)
28 |
29 | if (isActiveCollection) {
30 | return navigate(`collection/${collectionsWithoutRemoved[0].id}`)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/hooks/use-reset-environment.ts:
--------------------------------------------------------------------------------
1 | import type { Collection } from 'src/types'
2 |
3 | import { nanoid } from 'nanoid'
4 | import { PageData } from 'src/types'
5 | import { StorageUtilities } from 'src/utilities/storage-utilities'
6 | import { SvgUtilities } from 'src/utilities/svg-utilities'
7 |
8 | export const useResetEnvironment = () => {
9 | const reset = async () => {
10 | await chrome.storage.local.clear()
11 |
12 | const collection: Collection = {
13 | href: 'svggobbler.com',
14 | id: nanoid(),
15 | name: 'Welcome to SVG Gobbler',
16 | origin: 'svggobbler.com',
17 | }
18 |
19 | const pageData: PageData = {
20 | data: [
21 | SvgUtilities.createStorageSvg({
22 | name: 'svggobbler.com',
23 | svg: '',
24 | }),
25 | ],
26 | host: 'svggobbler.com',
27 | href: 'https://svggobbler.com',
28 | origin: 'svggobbler.com',
29 | }
30 |
31 | await StorageUtilities.setPageData(collection.id, pageData)
32 | await StorageUtilities.setStorageData('collections', [collection])
33 | globalThis.location.reload()
34 | }
35 |
36 | return { reset }
37 | }
38 |
--------------------------------------------------------------------------------
/src/hooks/use-upload.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react'
2 | import { useRevalidator } from 'react-router-dom'
3 | import { useCollection } from 'src/providers'
4 | import { Inline, StorageSvg } from 'src/scripts'
5 | import { type FileSvg } from 'src/types'
6 | import { StorageUtilities } from 'src/utilities/storage-utilities'
7 | import { SvgUtilities } from 'src/utilities/svg-utilities'
8 |
9 | /**
10 | * Upload a given array of svg strings to chrome storage, update the collection
11 | * context and reset the route to reflect the new data for the current collection.
12 | */
13 | export const useUpload = () => {
14 | const { dispatch, state } = useCollection()
15 | const { revalidate } = useRevalidator()
16 |
17 | return useCallback(
18 | async function (fileSvgs: FileSvg[]) {
19 | const { collectionId } = state
20 |
21 | // Get current page data for storage
22 | let pageData = await StorageUtilities.getPageData(collectionId)
23 | const newData: StorageSvg[] = fileSvgs.map(SvgUtilities.createStorageSvg)
24 |
25 | // Append new strings to collection's page data
26 | pageData = {
27 | ...pageData,
28 | data: [...newData, ...pageData.data],
29 | }
30 |
31 | // Update the collection's page data
32 | await StorageUtilities.setPageData(collectionId, pageData)
33 |
34 | // Update the collection context state
35 | const newSvgClasses = newData.map((item) => new Inline(item))
36 | dispatch({ payload: [...state.data, ...newSvgClasses], type: 'set-data' })
37 | dispatch({ type: 'process-data' })
38 | revalidate()
39 | },
40 | [dispatch, state, revalidate],
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 |
3 | import App from './app'
4 | import './css/index.css'
5 |
6 | ReactDOM.createRoot(document.querySelector('#root')!).render()
7 |
--------------------------------------------------------------------------------
/src/layout/collection/card/card-actions/action-menu-item.tsx:
--------------------------------------------------------------------------------
1 | import { Menu } from '@headlessui/react'
2 | import clsx from 'clsx'
3 | import { PropsWithChildren } from 'react'
4 |
5 | type Properties = {
6 | onClick: () => void
7 | }
8 |
9 | export const ActionMenuItem = ({ children, onClick }: PropsWithChildren) => (
10 |
11 | {({ active }) => (
12 |
20 | {children}
21 |
22 | )}
23 |
24 | )
25 |
--------------------------------------------------------------------------------
/src/layout/collection/card/card-actions/action-menu.tsx:
--------------------------------------------------------------------------------
1 | import { Menu, Transition } from '@headlessui/react'
2 | import {
3 | ArrowDownTrayIcon,
4 | ClipboardDocumentIcon,
5 | DocumentDuplicateIcon,
6 | EllipsisHorizontalIcon,
7 | TrashIcon,
8 | } from '@heroicons/react/24/outline'
9 | import clsx from 'clsx'
10 | import { Fragment } from 'react/jsx-runtime'
11 | import { IconButton } from 'src/components'
12 | import { transitions } from 'src/constants/transitions'
13 | import { loc } from 'src/utilities/i18n'
14 |
15 | import { CardData } from '..'
16 | import { ActionMenuItem } from './action-menu-item'
17 | import { useCardActions } from './use-card-actions'
18 |
19 | export const CardActionMenu = ({ data }: CardData) => {
20 | const { copyOriginal, deleteItem, downloadOriginal, duplicateItem } = useCardActions(data)
21 |
22 | return (
23 |
29 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/src/layout/collection/card/card-actions/card-copy.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Button } from 'src/components'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | import { type CardData } from '..'
6 | import { useCardActions } from './use-card-actions'
7 |
8 | export const CardCopy = ({ data }: CardData) => {
9 | const [label, setLabel] = useState(loc('main_copy'))
10 | const { copyOptimized } = useCardActions(data)
11 |
12 | function handleCopy() {
13 | setLabel(loc('export_copied'))
14 | copyOptimized()
15 | setTimeout(() => {
16 | setLabel(loc('main_copy'))
17 | }, 1200)
18 | }
19 |
20 | return (
21 |
22 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/layout/collection/card/card-actions/card-select.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { useMemo } from 'react'
3 | import { useCollection } from 'src/providers'
4 |
5 | import { CardData } from '..'
6 |
7 | export const CardSelect = ({ data }: CardData) => {
8 | const { dispatch, state } = useCollection()
9 |
10 | const isSelected = useMemo(() => {
11 | return state.selected.some((svg) => svg.id === data.id)
12 | }, [state.selected, data])
13 |
14 | const handleSelect = (event: React.ChangeEvent) => {
15 | switch (event.target.checked) {
16 | case false: {
17 | return dispatch({ payload: data, type: 'remove-selected' })
18 | }
19 | case true: {
20 | return dispatch({ payload: data, type: 'add-selected' })
21 | }
22 | }
23 | }
24 |
25 | return (
26 |
33 |
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/src/layout/collection/card/card-actions/use-card-actions.ts:
--------------------------------------------------------------------------------
1 | import type { StorageSvg, Svg } from 'src/scripts'
2 |
3 | import { nanoid } from 'nanoid'
4 | import { useRevalidator } from 'react-router-dom'
5 | import { useCollection } from 'src/providers'
6 | import { Inline } from 'src/scripts'
7 | import { formUtilities } from 'src/utilities/form-utilities'
8 | import { StorageUtilities } from 'src/utilities/storage-utilities'
9 | import { SvgUtilities } from 'src/utilities/svg-utilities'
10 | import { optimize } from 'svgo'
11 |
12 | export const useCardActions = (data: Svg) => {
13 | const { state } = useCollection()
14 | const { revalidate } = useRevalidator()
15 |
16 | async function duplicateItem() {
17 | const storageSvgDuplicate: StorageSvg = {
18 | corsRestricted: data.corsRestricted,
19 | id: nanoid(),
20 | lastEdited: new Date().toISOString(),
21 | name: data.name,
22 | svg: data.svg,
23 | }
24 | const newData = [new Inline(storageSvgDuplicate), ...state.data]
25 | const pageData = await StorageUtilities.getPageData(state.collectionId)
26 | StorageUtilities.setPageData(state.collectionId, {
27 | ...pageData,
28 | data: SvgUtilities.createStorageSvgs(newData),
29 | })
30 | revalidate()
31 | }
32 |
33 | async function deleteItem() {
34 | const filteredData = state.data.filter((item) => item.id !== data.id)
35 | const pageData = await StorageUtilities.getPageData(state.collectionId)
36 | StorageUtilities.setPageData(state.collectionId, {
37 | ...pageData,
38 | data: SvgUtilities.createStorageSvgs(filteredData),
39 | })
40 | revalidate()
41 | }
42 |
43 | function copyOriginal() {
44 | formUtilities.copyStringToClipboard(data.svg)
45 | }
46 |
47 | async function copyOptimized() {
48 | const optimizedString = optimize(data.svg)
49 | formUtilities.copyStringToClipboard(optimizedString.data)
50 | }
51 |
52 | async function downloadOriginal() {
53 | const pageData = await StorageUtilities.getPageData(state.collectionId)
54 | formUtilities.downloadSvgString(data.svg, pageData.host)
55 | }
56 |
57 | return {
58 | copyOptimized,
59 | copyOriginal,
60 | deleteItem,
61 | downloadOriginal,
62 | duplicateItem,
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/layout/collection/card/card-content.tsx:
--------------------------------------------------------------------------------
1 | import type { Image } from 'src/scripts'
2 |
3 | import clsx from 'clsx'
4 |
5 | import { type CardData } from '.'
6 |
7 | export const CardContent = ({ data }: CardData) => {
8 | if (data.corsRestricted) {
9 | return (
10 |
14 | )
15 | }
16 |
17 | return (
18 | svg]:absolute [& > svg]:inset-0 [& > svg]:inline-block relative inline-block w-full overflow-hidden pb-[100%] align-top',
21 | )}
22 | dangerouslySetInnerHTML={{ __html: data.presentationSvg }}
23 | />
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/layout/collection/card/cors-restricted-actions.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowTopRightOnSquareIcon, InformationCircleIcon } from '@heroicons/react/24/outline'
2 | import { Fragment, PropsWithChildren } from 'react'
3 | import { Button, Tooltip } from 'src/components'
4 | import { type Image } from 'src/scripts'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | import { type CardData } from '.'
8 |
9 | /**
10 | * The functionality of a given card when it is cors restricted.
11 | */
12 | export const CorsRestrictedActions = ({ children, data }: PropsWithChildren) => {
13 | const handleOpenInNewTab = () => {
14 | window.open((data as Image).absoluteImageUrl, '_blank')
15 | }
16 |
17 | return (
18 |
19 | {/* Info icon in place of checkbox */}
20 |
21 |
22 |
23 |
24 |
25 |
26 | {/* Open in new tab button */}
27 |
28 |
37 |
38 | {children}
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/layout/collection/card/default-actions.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, PropsWithChildren } from 'react'
2 | import { Link } from 'react-router-dom'
3 | import { useCollection } from 'src/providers'
4 |
5 | import { type CardData } from '.'
6 | import { CardActionMenu } from './card-actions/action-menu'
7 | import { CardCopy } from './card-actions/card-copy'
8 | import { CardSelect } from './card-actions/card-select'
9 |
10 | /**
11 | * The functionality of a given card when it is not cors restricted.
12 | */
13 | export const DefaultActions = ({ children, data }: PropsWithChildren) => {
14 | const { state } = useCollection()
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
25 | {children}
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/layout/collection/card/index.tsx:
--------------------------------------------------------------------------------
1 | import type { SvgType } from 'src/scripts'
2 |
3 | import clsx from 'clsx'
4 | import { forwardRef, HTMLAttributes, useMemo } from 'react'
5 | import { useCollection } from 'src/providers'
6 |
7 | import { CardContent } from './card-content'
8 | import { CorsRestrictedActions } from './cors-restricted-actions'
9 | import { DefaultActions } from './default-actions'
10 | import { SvgSize } from './svg-size'
11 |
12 | export type CardData = {
13 | data: SvgType
14 | }
15 |
16 | export type CardProperties = CardData & HTMLAttributes
17 |
18 | export const Card = forwardRef((properties, reference) => {
19 | const { className, data, ...rest } = properties
20 | const { state } = useCollection()
21 |
22 | const Actions = useMemo(() => {
23 | return data.corsRestricted ? CorsRestrictedActions : DefaultActions
24 | }, [data.corsRestricted])
25 |
26 | return (
27 |
38 |
39 |
40 |
44 |
45 |
46 |
47 |
48 | )
49 | })
50 |
--------------------------------------------------------------------------------
/src/layout/collection/card/svg-name.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 | import { useCollection } from 'src/providers'
3 | import { StorageUtilities } from 'src/utilities/storage-utilities'
4 |
5 | import { CardData } from '.'
6 |
7 | export const SvgName = ({ data }: CardData) => {
8 | const { dispatch, state } = useCollection()
9 |
10 | const handleChange = async (event: React.FormEvent) => {
11 | const name = (event.target as HTMLSpanElement).textContent
12 | ?.replace(/[\r\n]+/g, ' ')
13 | .replaceAll(/\s+/g, ' ')
14 | .trim()
15 | if (!name || name === data.name) return
16 |
17 | data.updateName(name)
18 | const pageData = await StorageUtilities.getPageData(state.collectionId)
19 | pageData.data = pageData.data.map((svg) => (svg.id === data.id ? data : svg))
20 | StorageUtilities.setPageData(state.collectionId, pageData)
21 | dispatch({ type: 'process-data' })
22 | }
23 |
24 | return (
25 |
36 |
37 |
43 | {data.name}
44 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/src/layout/collection/card/svg-size.tsx:
--------------------------------------------------------------------------------
1 | import { useCollection } from 'src/providers'
2 |
3 | import { CardData } from '.'
4 |
5 | export const SvgSize = ({ data }: CardData) => {
6 | const {
7 | state: {
8 | view: { filters },
9 | },
10 | } = useCollection()
11 |
12 | if (data.corsRestricted) {
13 | return
14 | }
15 |
16 | if (filters['show-size']) {
17 | return (
18 |
19 | {data.fileSize}
20 |
21 | )
22 | }
23 |
24 | return (
25 |
26 | {data.fileSize}
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/layout/collection/collection-drop-zone.tsx:
--------------------------------------------------------------------------------
1 | import { DocumentPlusIcon } from '@heroicons/react/24/outline'
2 | import clsx from 'clsx'
3 | import { PropsWithChildren } from 'react'
4 | import { FileRejection, useDropzone } from 'react-dropzone'
5 | import { useUpload } from 'src/hooks'
6 | import { formUtilities } from 'src/utilities/form-utilities'
7 | import { loc } from 'src/utilities/i18n'
8 |
9 | export const CollectionDropZone = ({ children }: PropsWithChildren) => {
10 | const upload = useUpload()
11 |
12 | const { getInputProps, getRootProps, isDragActive } = useDropzone({
13 | accept: { 'image/svg+xml': ['.svg'] },
14 | maxSize: 10 * 1024 * 1024,
15 | multiple: true,
16 | noClick: true,
17 | noKeyboard: true,
18 | onDrop,
19 | })
20 |
21 | async function onDrop(acceptedFiles: File[], fileRejections: FileRejection[]) {
22 | if (acceptedFiles.length > 0) {
23 | const fileSvgs = await formUtilities.handleUpload(acceptedFiles)
24 | upload(fileSvgs)
25 | }
26 |
27 | if (fileRejections.length > 0) {
28 | // TODO: Handle file rejections
29 | }
30 | }
31 |
32 | return (
33 |
34 |
40 |
48 |
53 |
54 |
55 |
56 |
57 | {loc('drop_files')}
58 | {' '}
59 | {loc('to_upload')}
60 |
61 |
62 |
{loc('upload_file_limit')}
63 |
64 |
65 |
66 |
67 | {children}
68 |
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/src/layout/collection/index.tsx:
--------------------------------------------------------------------------------
1 | import type { CollectionData } from 'src/types'
2 |
3 | import { Transition } from '@headlessui/react'
4 | import { useEffect } from 'react'
5 | import { FeedbackModal, ReviewPrompt } from 'src/components'
6 | import { NoResults } from 'src/components/no-results'
7 | import { useCollection } from 'src/providers'
8 | import { SvgType } from 'src/scripts'
9 |
10 | import { Card } from './card'
11 | import { SvgName } from './card/svg-name'
12 | import { CollectionDropZone } from './collection-drop-zone'
13 | import { InfiniteScroll } from './infinite-scroll'
14 | import { ShowPasteCue } from './show-paste-cue'
15 |
16 | export const Collection = ({ data }: Pick) => {
17 | const { dispatch, state } = useCollection()
18 |
19 | /**
20 | * We do this here instead of routes because data is awaited in
21 | * Suspense within the route component.
22 | */
23 | useEffect(() => {
24 | dispatch({ payload: data, type: 'set-data' })
25 | dispatch({ type: 'process-data' })
26 | return () => dispatch({ type: 'reset' })
27 | }, [data, dispatch])
28 |
29 | function generateMinSize() {
30 | switch (state.view.size) {
31 | case 96: {
32 | return '10rem'
33 | }
34 | case 128: {
35 | return '12.5rem'
36 | }
37 | case 192: {
38 | return '15rem'
39 | }
40 | case 256: {
41 | return '17.5rem'
42 | }
43 | default: {
44 | return '8.75rem'
45 | }
46 | }
47 | }
48 |
49 | if (state.processedData.length === 0) {
50 | return
51 | }
52 |
53 | return (
54 |
55 |
56 |
60 | {state.processedData.map((svg, index) => (
61 |
72 |
73 |
74 |
75 | ))}
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/src/layout/collection/infinite-scroll.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useIntersectionObserver } from 'src/hooks'
3 | import { useCollection } from 'src/providers'
4 |
5 | export const InfiniteScroll = () => {
6 | const { dispatch } = useCollection()
7 | const { elementRef, isIntersecting } = useIntersectionObserver()
8 |
9 | useEffect(() => {
10 | if (isIntersecting) {
11 | dispatch({ type: 'load-more' })
12 | }
13 | }, [isIntersecting, dispatch])
14 |
15 | return } />
16 | }
17 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/index.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useCollection } from 'src/providers'
3 |
4 | import { MainActions } from './main-actions'
5 | import { Pagination } from './pagination'
6 | import { Search } from './search'
7 | import { SelectedQuantity } from './selected-quantity'
8 | import { SelectionControl } from './selection-control'
9 |
10 | export const Mainbar = () => {
11 | const { state } = useCollection()
12 |
13 | const MainbarContextInfo = useMemo(() => {
14 | return state.selected.length > 0 ? SelectedQuantity : Pagination
15 | }, [state.selected.length])
16 |
17 | return (
18 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/main-actions/copy-action.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, useRef, useState } from 'react'
2 | import { Button, Modal } from 'src/components'
3 | import { useDashboard } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 |
6 | import { useMainActions } from './use-main-actions'
7 |
8 | export const CopyItemModal = () => {
9 | const [isModalOpen, setModalOpen] = useState(false)
10 | const { state: dashboardState } = useDashboard()
11 | const { duplicateItems } = useMainActions()
12 | const collectionSelectReference = useRef(null)
13 |
14 | const openModal = () => {
15 | setModalOpen(true)
16 | }
17 |
18 | const duplicateItemsToCollection = () => {
19 | const selectedCollectionId = collectionSelectReference.current?.value
20 | if (!selectedCollectionId) return
21 | duplicateItems(selectedCollectionId)
22 | setModalOpen(false)
23 | }
24 |
25 | const isDisabled = dashboardState.collections.length === 1
26 |
27 | return (
28 |
29 |
32 |
33 | {loc('main_copy_collection')}
34 |
35 |
36 |
39 |
46 |
47 |
48 |
49 |
50 |
53 |
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/main-actions/delete-action.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from 'src/components'
2 | import { loc } from 'src/utilities/i18n'
3 |
4 | import { useMainActions } from './use-main-actions'
5 |
6 | export const DeleteAction = () => {
7 | const { deleteSelectedItems } = useMainActions()
8 |
9 | return (
10 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/main-actions/index.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 | import { useCollection } from 'src/providers'
3 |
4 | import { CopyItemModal } from './copy-action'
5 | import { DeleteAction } from './delete-action'
6 | import { MoveItemModal } from './move-action'
7 |
8 | export const MainActions = () => {
9 | const { state } = useCollection()
10 |
11 | return (
12 | 0}
20 | >
21 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/main-actions/move-action.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, useRef, useState } from 'react'
2 | import { Button, Modal } from 'src/components'
3 | import { useCollection, useDashboard } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 |
6 | import { useMainActions } from './use-main-actions'
7 |
8 | export const MoveItemModal = () => {
9 | const [isModalOpen, setModalOpen] = useState(false)
10 | const { state: dashboardState } = useDashboard()
11 | const { state: collectionState } = useCollection()
12 | const { moveSelectedItems } = useMainActions()
13 | const collectionSelectReference = useRef(null)
14 |
15 | const openModal = () => {
16 | setModalOpen(true)
17 | }
18 |
19 | const moveItemsToCollection = () => {
20 | const selectedCollectionId = collectionSelectReference.current?.value
21 | if (!selectedCollectionId) return
22 | moveSelectedItems(selectedCollectionId)
23 | setModalOpen(false)
24 | }
25 |
26 | const options = dashboardState.collections.filter(
27 | (collection) => collection.id !== collectionState.collectionId,
28 | )
29 |
30 | const isDisabled = dashboardState.collections.length === 1
31 |
32 | return (
33 |
34 |
37 |
38 |
39 | {loc('main_move_collection')}
40 |
41 |
44 |
51 |
52 |
53 |
54 |
57 |
58 |
59 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/main-actions/use-main-actions.ts:
--------------------------------------------------------------------------------
1 | import { useRevalidator } from 'react-router-dom'
2 | import { useCollection } from 'src/providers'
3 | import { StorageSvg } from 'src/scripts'
4 | import { StorageUtilities } from 'src/utilities/storage-utilities'
5 | import { SvgUtilities } from 'src/utilities/svg-utilities'
6 |
7 | export const useMainActions = () => {
8 | const { revalidate } = useRevalidator()
9 | const { dispatch, state: collectionState } = useCollection()
10 |
11 | const { collectionId } = collectionState
12 | const selectedItems = collectionState.selected
13 | const nonSelectedItems = collectionState.data.filter((item) => !selectedItems.includes(item))
14 | const nonSelectedItemStorageSvgs: StorageSvg[] = SvgUtilities.createStorageSvgs(nonSelectedItems)
15 | const selectedItemsStorageSvgs: StorageSvg[] = SvgUtilities.createStorageSvgs(selectedItems)
16 |
17 | function resetCollection() {
18 | dispatch({ type: 'unselect-all' })
19 | dispatch({ type: 'process-data' })
20 | revalidate()
21 | }
22 |
23 | const deleteSelectedItems = async () => {
24 | const currentPageData = await StorageUtilities.getPageData(collectionId)
25 |
26 | await StorageUtilities.setPageData(collectionId, {
27 | ...currentPageData,
28 | data: nonSelectedItemStorageSvgs,
29 | })
30 |
31 | resetCollection()
32 | }
33 |
34 | const moveSelectedItems = async (targetCollectionId: string) => {
35 | const targetPageData = await StorageUtilities.getPageData(targetCollectionId)
36 | const currentPageData = await StorageUtilities.getPageData(collectionId)
37 |
38 | await StorageUtilities.setPageData(targetCollectionId, {
39 | ...targetPageData,
40 | data: [...selectedItemsStorageSvgs, ...targetPageData.data],
41 | })
42 |
43 | await StorageUtilities.setPageData(collectionId, {
44 | ...currentPageData,
45 | data: nonSelectedItemStorageSvgs,
46 | })
47 |
48 | resetCollection()
49 | }
50 |
51 | const duplicateItems = async (targetCollectionId: string) => {
52 | const targetPageData = await StorageUtilities.getPageData(targetCollectionId)
53 |
54 | await StorageUtilities.setPageData(targetCollectionId, {
55 | ...targetPageData,
56 | data: [...selectedItemsStorageSvgs, ...targetPageData.data],
57 | })
58 |
59 | resetCollection()
60 | }
61 |
62 | return { deleteSelectedItems, duplicateItems, moveSelectedItems }
63 | }
64 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/pagination.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useCollection } from 'src/providers'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | export const Pagination = () => {
6 | const { state } = useCollection()
7 |
8 | const filteredResultLength = useMemo(() => {
9 | if (state.view.filters['hide-cors']) {
10 | return state.data.filter((svg) => !svg.corsRestricted).length
11 | }
12 |
13 | return state.data.length
14 | }, [state.data, state.view.filters])
15 |
16 | return (
17 |
18 | {loc('main_showing')} {state.processedData.length}{' '}
19 | {loc('main_of')} {filteredResultLength}{' '}
20 | {loc('main_results')}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/search.tsx:
--------------------------------------------------------------------------------
1 | import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'
2 | import clsx from 'clsx'
3 | import { useState } from 'react'
4 | import { IconButton } from 'src/components'
5 | import { useCollection } from 'src/providers'
6 |
7 | export const Search = () => {
8 | const { dispatch, state } = useCollection()
9 | const [active, setActive] = useState(false)
10 |
11 | function onInputFocus() {
12 | setActive(true)
13 | }
14 |
15 | function onSearchInputBlur() {
16 | if (state.search.length > 0) return
17 | setActive(false)
18 | }
19 |
20 | function onSearchInputChange(event: React.ChangeEvent) {
21 | dispatch({ payload: event.target.value, type: 'set-search' })
22 | dispatch({ type: 'process-data' })
23 | }
24 |
25 | function clearSearch() {
26 | dispatch({ payload: '', type: 'set-search' })
27 | dispatch({ type: 'process-data' })
28 | setActive(false)
29 | }
30 |
31 | return (
32 |
38 |
39 |
51 | {active && (
52 |
58 |
59 |
60 | )}
61 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/selected-quantity.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { useCollection } from 'src/providers'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | export const SelectedQuantity = () => {
6 | const { state } = useCollection()
7 |
8 | const filteredResultLength = useMemo(() => {
9 | if (state.view.filters['hide-cors']) {
10 | return state.data.filter((svg) => !svg.corsRestricted).length
11 | }
12 |
13 | return state.data.length
14 | }, [state.data, state.view.filters])
15 |
16 | return (
17 |
18 | {state.selected.length} {loc('main_of')}{' '}
19 | {filteredResultLength} {loc('selected')}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/src/layout/collection/main-bar/selection-control.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 | import { Fragment, useEffect, useRef } from 'react'
3 | import { useCollection } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 |
6 | export const SelectionControl = () => {
7 | const { dispatch: collectionDispatch, state: collectionState } = useCollection()
8 | const checkboxReference = useRef(null)
9 |
10 | const selectedItems = collectionState.selected.length
11 | const availableItems = collectionState.data.filter((item) => !item.corsRestricted).length
12 | const allItemsAreSelected = selectedItems === availableItems
13 |
14 | const handleCheckboxChange = () => {
15 | if (allItemsAreSelected) {
16 | collectionDispatch({ type: 'unselect-all' })
17 | } else {
18 | collectionDispatch({ type: 'select-all' })
19 | }
20 | }
21 |
22 | useEffect(() => {
23 | if (checkboxReference.current === null) return
24 | checkboxReference.current.indeterminate = selectedItems > 0 && !allItemsAreSelected
25 | }, [selectedItems, availableItems, allItemsAreSelected])
26 |
27 | return (
28 | 0}
37 | >
38 |
39 |
47 |
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/src/layout/collection/main-panel/export-panel.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 | import { forwardRef } from 'react'
3 | import { fileTypeLabels } from 'src/constants/file-type-labels'
4 | import { FileType, fileTypes, useExport } from 'src/providers'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | import { Filename } from './file-name'
8 | import { JpegSettings } from './jpeg-settings'
9 | import { PngSettings } from './png-settings'
10 | import { SpriteSettings } from './sprite-settings'
11 | import { SvgSettings } from './svg-settings'
12 | import { WebPSettings } from './webp-settings'
13 |
14 | export const transitionConfig = {
15 | enter: 'transition-all duration-500 ease-in',
16 | enterFrom: 'opacity-0 h-0 translate-y-2',
17 | enterTo: 'opacity-100 h-20 translate-y-0',
18 | leave: 'transition-all duration-500 ease-out',
19 | leaveFrom: 'opacity-100 h-20 translate-y-0',
20 | leaveTo: 'opacity-0 h-0 translate-y-2',
21 | }
22 |
23 | export const ExportPanel = forwardRef((properties, reference) => {
24 | const { dispatch: exportDispatch, state: exportState } = useExport()
25 |
26 | const handleTypeChange = (event: React.ChangeEvent) => {
27 | exportDispatch({ payload: event.target.value as FileType, type: 'set-file-type' })
28 | }
29 |
30 | return (
31 |
32 |
33 |
36 |
48 |
49 |
50 |
51 |
52 |
{loc('export_settings')}
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | )
72 | })
73 |
--------------------------------------------------------------------------------
/src/layout/collection/main-panel/index.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 | import clsx from 'clsx'
3 | import { Tabs } from 'src/components'
4 | import { useCollection } from 'src/providers'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | import { EditPanel } from './edit-panel'
8 | import { ExportFooter } from './export-footer'
9 | import { ExportPanel } from './export-panel'
10 |
11 | export const MainPanel = () => {
12 | const { state: collectionState } = useCollection()
13 |
14 | return (
15 | 0}
23 | >
24 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/src/layout/collection/main-panel/jpeg-settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { HelpIcon } from 'src/components'
2 | import { useExport } from 'src/providers'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | import { imageTooltip } from '../webp-settings'
6 |
7 | export const JpegSettings = () => {
8 | const { dispatch, state } = useExport()
9 |
10 | const handleSizeChange = (event: React.ChangeEvent) => {
11 | dispatch({ payload: Number(event.target.value), type: 'set-jpeg-size' })
12 | }
13 |
14 | const handleQualityChange = (event: React.ChangeEvent) => {
15 | dispatch({ payload: Number(event.target.value), type: 'set-jpeg-quality' })
16 | }
17 |
18 | return (
19 |
20 |
21 |
22 |
25 |
26 |
27 |
34 |
35 |
36 |
39 |
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/layout/collection/main-panel/png-settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { HelpIcon } from 'src/components'
2 | import { useExport } from 'src/providers'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | import { imageTooltip } from '../webp-settings'
6 |
7 | export const PngSettings = () => {
8 | const { dispatch, state } = useExport()
9 |
10 | const handleSizeChange = (event: React.ChangeEvent) => {
11 | dispatch({ payload: Number(event.target.value), type: 'set-png-size' })
12 | }
13 |
14 | return (
15 |
16 |
17 |
18 |
21 |
22 |
23 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/layout/collection/main-panel/sprite-settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { HelpIcon } from 'src/components'
2 | import { useExport } from 'src/providers'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | export const SpriteSettings = () => {
6 | const { dispatch, state } = useExport()
7 |
8 | function handlePrefixChange(event: React.ChangeEvent) {
9 | dispatch({ payload: event.target.value, type: 'set-sprite-prefix' })
10 | }
11 |
12 | function handleSuffixChange(event: React.ChangeEvent) {
13 | dispatch({ payload: event.target.value, type: 'set-sprite-suffix' })
14 | }
15 |
16 | const useExample = ``
17 |
18 | return (
19 |
20 |
21 |
22 |
25 |
26 |
27 |
34 | {state.settings.sprite.prefix && (
35 |
36 | {useExample}
37 |
38 | )}
39 |
40 |
41 |
42 |
45 |
46 |
47 |
54 | {state.settings.sprite.suffix && (
55 |
56 | {useExample}
57 |
58 | )}
59 |
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/src/layout/collection/main-panel/svg-settings/svgo-option.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { HelpIcon } from 'src/components'
3 | import { SvgoPlugin } from 'src/constants/svgo-plugins'
4 | import { useExport } from 'src/providers'
5 |
6 | type SvgoOptionProperties = {
7 | plugin: SvgoPlugin
8 | }
9 |
10 | export const SvgoOption = ({ plugin }: SvgoOptionProperties) => {
11 | const { dispatch, state } = useExport()
12 |
13 | const isChecked = useMemo(() => {
14 | return state.settings.svg.svgoPlugins.some((p) => p.name === plugin.name)
15 | }, [state.settings.svg.svgoPlugins, plugin])
16 |
17 | const handleOptionChange = (event: React.ChangeEvent) => {
18 | switch (event.target.checked) {
19 | case false: {
20 | dispatch({ payload: plugin, type: 'remove-svgo-plugin' })
21 | break
22 | }
23 |
24 | case true: {
25 | dispatch({ payload: plugin, type: 'add-svgo-plugin' })
26 | break
27 | }
28 | }
29 | }
30 |
31 | return (
32 |
33 |
40 |
41 |
44 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/src/layout/collection/main-panel/webp-settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { HelpIcon } from 'src/components'
2 | import { useExport } from 'src/providers'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | export const imageTooltip =
6 | 'Applied to the largest side (height or width) of the image while scaling proportionally.'
7 |
8 | export const WebPSettings = () => {
9 | const { dispatch, state } = useExport()
10 |
11 | const handleSizeChange = (event: React.ChangeEvent) => {
12 | dispatch({ payload: Number(event.target.value), type: 'set-webp-size' })
13 | }
14 |
15 | const handleQualityChange = (event: React.ChangeEvent) => {
16 | dispatch({ payload: Number(event.target.value), type: 'set-webp-quality' })
17 | }
18 |
19 | return (
20 |
21 |
22 |
23 |
26 |
27 |
28 |
35 |
36 |
37 |
40 |
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/src/layout/collection/show-paste-cue.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { Toast } from 'src/components'
3 | import { type UserState, useUser } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 | import { StorageUtilities } from 'src/utilities/storage-utilities'
6 |
7 | /**
8 | * A toast that educates the user about the ability to paste SVGs into the app.
9 | */
10 | export const ShowPasteCue = () => {
11 | const [show, setShow] = useState(false)
12 | const { dispatch: userDispatch, state: userState } = useUser()
13 |
14 | useEffect(() => {
15 | if (userState.onboarding.hasPastedSvg && !userState.onboarding.viewedSvgInClipboard) {
16 | setShow(true)
17 | } else {
18 | setShow(false)
19 | }
20 | }, [userState.onboarding.hasPastedSvg, userState.onboarding.viewedSvgInClipboard])
21 |
22 | const setReviewPromptViewed = () => {
23 | const payload: UserState = {
24 | ...userState,
25 | onboarding: { ...userState.onboarding, viewedSvgInClipboard: true },
26 | }
27 | StorageUtilities.setStorageData('user', payload)
28 | userDispatch({ payload, type: 'set-user' })
29 | setShow(false)
30 | }
31 |
32 | return (
33 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/layout/collection/skeleton-collection.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * The placeholder content for the colleciton page that shares styles and layout.
3 | */
4 | export const SkeletonCollection = () => (
5 |
6 |
7 | {Array.from({ length: 20 }).map((_, index) => (
8 |
12 | ))}
13 |
14 |
15 | )
16 |
--------------------------------------------------------------------------------
/src/layout/details/editor/action-bar.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowRightIcon, BoltIcon, SparklesIcon } from '@heroicons/react/24/outline'
2 | import { useMemo } from 'react'
3 | import { useDetails } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 | import { SvgUtilities } from 'src/utilities/svg-utilities'
6 |
7 | import { useOptimize } from '../use-optimize'
8 |
9 | export const ActionBar = () => {
10 | const { dispatch, state } = useDetails()
11 | const { format, optimize } = useOptimize()
12 |
13 | const onOptimize = () => {
14 | dispatch({ payload: optimize(state.currentString), type: 'update-current-string' })
15 | }
16 |
17 | const onFormat = () => {
18 | const formatted = format(state.currentString)
19 | dispatch({ payload: formatted, type: 'update-current-string' })
20 | }
21 |
22 | const bytes = useMemo(() => {
23 | return {
24 | after: SvgUtilities.getPrettyBytes(state.currentString),
25 | before: SvgUtilities.getPrettyBytes(state.originalString),
26 | }
27 | }, [state.currentString, state.originalString])
28 |
29 | return (
30 |
31 |
35 |
36 | {bytes.before} {bytes.after}
37 |
38 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/layout/details/editor/editor-onboarding.tsx:
--------------------------------------------------------------------------------
1 | import * as Tooltip from '@radix-ui/react-tooltip'
2 | import clsx from 'clsx'
3 | import { PropsWithChildren } from 'react'
4 | import { useUser } from 'src/providers'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | export const EditorOnboarding = ({ children }: PropsWithChildren) => {
8 | const { state } = useUser()
9 |
10 | if (state.onboarding.viewedEditSvg) {
11 | return children
12 | }
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
33 | {loc('details_live')}
34 |
35 |
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/src/layout/details/editor/index.tsx:
--------------------------------------------------------------------------------
1 | import { xml } from '@codemirror/lang-xml'
2 | import { tokyoNightStorm } from '@uiw/codemirror-theme-tokyo-night-storm'
3 | import CodeMirror, { EditorView } from '@uiw/react-codemirror'
4 | import { merge } from 'lodash'
5 | import { useCallback } from 'react'
6 | import { useDetails, useUser } from 'src/providers'
7 | import { StorageUtilities } from 'src/utilities/storage-utilities'
8 |
9 | import { ActionBar } from './action-bar'
10 | import { EditorOnboarding } from './editor-onboarding'
11 |
12 | export const DetailsEditor = () => {
13 | const { dispatch, state } = useDetails()
14 | const { dispatch: userDispatch, state: userState } = useUser()
15 |
16 | const onChange = useCallback(
17 | (value: string) => {
18 | dispatch({ payload: value, type: 'update-current-string' })
19 | },
20 | [dispatch],
21 | )
22 |
23 | const onFocus = useCallback(() => {
24 | if (!userState.onboarding.viewedEditSvg) {
25 | const newUser = merge(userState, { onboarding: { viewedEditSvg: true } })
26 | userDispatch({ payload: newUser, type: 'set-user' })
27 | StorageUtilities.setStorageData('user', newUser)
28 | }
29 | }, [userDispatch, userState])
30 |
31 | return (
32 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/src/layout/details/export-sidebar/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from 'src/components'
2 | import { useClipboard } from 'src/hooks'
3 | import { useDetails } from 'src/providers'
4 | import { formUtilities } from 'src/utilities/form-utilities'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | export const ExportDetailFooter = () => {
8 | const { state } = useDetails()
9 | const { copyToClipboard, text } = useClipboard(loc('card_action_copy'))
10 |
11 | const handleCopy = () => {
12 | copyToClipboard(state.currentString)
13 | }
14 |
15 | const handleDownload = async () => {
16 | formUtilities.downloadSvgString(state.currentString, state.name)
17 | }
18 |
19 | return (
20 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/layout/details/export-sidebar/index.tsx:
--------------------------------------------------------------------------------
1 | import { loc } from 'src/utilities/i18n'
2 |
3 | import { ExportDetailFooter } from './footer'
4 | import { ExportDetailMain } from './main'
5 | import { useExportResize } from './use-export-resize'
6 |
7 | export const ExportSidebar = () => {
8 | const { ref, width } = useExportResize()
9 |
10 | return (
11 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/layout/details/export-sidebar/svgo-option.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { HelpIcon } from 'src/components'
3 | import { SvgoPlugin } from 'src/constants/svgo-plugins'
4 | import { useDetails } from 'src/providers'
5 |
6 | type Properties = {
7 | plugin: SvgoPlugin
8 | }
9 |
10 | export const SvgoOption = ({ plugin }: Properties) => {
11 | const { dispatch, state } = useDetails()
12 |
13 | const isChecked = useMemo(() => {
14 | return state.export.svgoConfig.plugins.some((p: SvgoPlugin) => p.name === plugin.name)
15 | }, [state.export.svgoConfig.plugins, plugin.name])
16 |
17 | const handleOptionChange = () => {
18 | if (isChecked) {
19 | dispatch({ payload: plugin, type: 'remove-plugin' })
20 | } else {
21 | dispatch({ payload: plugin, type: 'add-plugin' })
22 | }
23 |
24 | dispatch({ type: 'process-current-string' })
25 | }
26 |
27 | return (
28 |
29 |
36 |
37 |
40 |
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/layout/details/export-sidebar/use-export-resize.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react'
2 |
3 | export const useExportResize = () => {
4 | const [width, setWidth] = useState(320)
5 | const reference = useRef(null)
6 | const resizeSide = useRef<'right' | null>(null)
7 |
8 | const onMouseMove = useCallback((event: MouseEvent) => {
9 | if (reference.current && resizeSide.current === 'right') {
10 | const newWidth = event.clientX - reference.current.getBoundingClientRect().left
11 | if (newWidth > 0) setWidth(newWidth)
12 | }
13 | }, [])
14 |
15 | const onMouseUp = useCallback(() => {
16 | document.removeEventListener('mousemove', onMouseMove)
17 | document.removeEventListener('mouseup', onMouseUp)
18 | // eslint-disable-next-line unicorn/no-null
19 | resizeSide.current = null
20 | }, [onMouseMove])
21 |
22 | const onMouseDown = useCallback(
23 | (event: MouseEvent) => {
24 | if (reference.current) {
25 | const boundingRect = reference.current.getBoundingClientRect()
26 | const threshold = 20 // pixels from the edge to detect resize
27 |
28 | if (
29 | event.clientX >= boundingRect.right - threshold &&
30 | event.clientX <= boundingRect.right + threshold
31 | ) {
32 | resizeSide.current = 'right'
33 | document.addEventListener('mousemove', onMouseMove)
34 | document.addEventListener('mouseup', onMouseUp)
35 | }
36 | }
37 | },
38 | [onMouseMove, onMouseUp],
39 | )
40 |
41 | useEffect(() => {
42 | const element = reference.current
43 |
44 | if (element) {
45 | element.addEventListener('mousedown', onMouseDown)
46 | }
47 |
48 | return () => {
49 | if (element) {
50 | element.removeEventListener('mousedown', onMouseDown)
51 | }
52 | }
53 | }, [onMouseDown])
54 |
55 | return { ref: reference, width }
56 | }
57 |
--------------------------------------------------------------------------------
/src/layout/details/index.tsx:
--------------------------------------------------------------------------------
1 | import type { DetailsParameters } from 'src/types'
2 |
3 | import { forwardRef, useEffect } from 'react'
4 | import { useLoaderData } from 'react-router-dom'
5 | import { useUser } from 'src/providers'
6 | import { useDetails } from 'src/providers/details'
7 |
8 | import { DetailsEditor } from './editor'
9 | import { ExportSidebar } from './export-sidebar'
10 | import { Header } from './header'
11 | import { PreviewSidebar } from './preview-sidebar'
12 |
13 | export const DetailsLayout = forwardRef((_, reference) => {
14 | const data = useLoaderData() as DetailsParameters
15 | const { dispatch: detailsDispatch } = useDetails()
16 | const { dispatch: userDispatch } = useUser()
17 |
18 | useEffect(() => {
19 | detailsDispatch({ payload: data, type: 'init' })
20 | userDispatch({ payload: data.user, type: 'set-user' })
21 | }, [data, detailsDispatch, userDispatch])
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | )
33 | })
34 |
--------------------------------------------------------------------------------
/src/layout/details/preview-sidebar/index.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs } from 'src/components'
2 | import { loc } from 'src/utilities/i18n'
3 |
4 | import { DataURI } from './preview-data-uri'
5 | import { PreviewReact } from './preview-react'
6 | import { PreviewSvg } from './preview-svg'
7 | import { usePreviewResize } from './use-preview-resize'
8 |
9 | export const PreviewSidebar = () => {
10 | const { ref, width } = usePreviewResize()
11 |
12 | return (
13 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/layout/details/preview-sidebar/preview-data-uri.tsx:
--------------------------------------------------------------------------------
1 | import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'
2 | import miniUri from 'mini-svg-data-uri'
3 | import { useMemo } from 'react'
4 | import { Button } from 'src/components'
5 | import { useClipboard } from 'src/hooks'
6 | import { useDetails } from 'src/providers'
7 | import { SvgUtilities } from 'src/utilities/svg-utilities'
8 |
9 | export const DataURI = () => {
10 | const { copyToClipboard, text } = useClipboard()
11 | const { state } = useDetails()
12 |
13 | const uriData = useMemo(() => {
14 | return [
15 | {
16 | name: 'Minifed Data URI',
17 | value: miniUri(state.currentString),
18 | },
19 | {
20 | name: 'base64',
21 | value: 'data:image/svg+xml;base64,' + btoa(state.currentString),
22 | },
23 | {
24 | name: 'encodeURIComponent',
25 | value: 'data:image/svg+xml,' + encodeURIComponent(state.currentString),
26 | },
27 | ]
28 | }, [state.currentString])
29 |
30 | return (
31 |
32 | {uriData.map((item) => (
33 |
34 |
35 | {item.name} -{' '}
36 | {SvgUtilities.getPrettyBytes(item.value)}
37 |
38 |
39 |
47 |
50 |
51 |
52 | ))}
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/layout/details/preview-sidebar/preview-svg/index.tsx:
--------------------------------------------------------------------------------
1 | import Draggable from 'react-draggable'
2 | import { useDetails } from 'src/providers'
3 |
4 | import { PreviewSvgFooter } from './preview-svg-footer'
5 |
6 | export const PreviewSvg = () => {
7 | const { state } = useDetails()
8 |
9 | const { background, scale } = state.preview.svg
10 |
11 | return (
12 | <>
13 |
14 |
25 |
26 | >
27 | )
28 | }
29 |
30 | export default PreviewSvg
31 |
--------------------------------------------------------------------------------
/src/layout/details/preview-sidebar/preview-svg/preview-background-button.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { buttonBaseStyles, Tooltip } from 'src/components'
3 | import { PreviewBackgroundClass, useDetails } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 |
6 | type PreviewBackgroundButtonProperties = {
7 | type: PreviewBackgroundClass
8 | }
9 |
10 | export const PreviewBackgroundButton = ({ type }: PreviewBackgroundButtonProperties) => {
11 | const { dispatch, state } = useDetails()
12 |
13 | const handleBackgroundChange = () => {
14 | dispatch({ payload: type, type: 'set-preview-background' })
15 | }
16 |
17 | const ringStyle =
18 | state.preview.svg.background === type ? 'ring-red-500' : 'ring-gray-300 dark:ring-gray-700'
19 |
20 | return (
21 |
22 |
26 |
27 | )
28 | }
29 |
30 | export default PreviewBackgroundButton
31 |
--------------------------------------------------------------------------------
/src/layout/details/preview-sidebar/preview-svg/preview-svg-footer.tsx:
--------------------------------------------------------------------------------
1 | import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline'
2 | import { IconButton, Tooltip } from 'src/components'
3 | import { useDetails } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 |
6 | import { PreviewBackgroundButton } from './preview-background-button'
7 |
8 | export const PreviewSvgFooter = () => {
9 | const { dispatch, state } = useDetails()
10 |
11 | function handleZoomIn() {
12 | dispatch({ payload: String(state.preview.svg.scale + 0.25), type: 'set-preview-scale' })
13 | }
14 |
15 | function handleZoomOut() {
16 | if (state.preview.svg.scale <= 0.1) return
17 | dispatch({ payload: String(state.preview.svg.scale - 0.25), type: 'set-preview-scale' })
18 | }
19 |
20 | const scalePercentage = Math.round(state.preview.svg.scale * 100)
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {scalePercentage}%
32 |
33 |
34 |
35 |
36 |
37 |
38 |
44 |
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/layout/details/preview-sidebar/use-preview-resize.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react'
2 |
3 | export const usePreviewResize = () => {
4 | const [width, setWidth] = useState(400)
5 | const reference = useRef(null)
6 | const resizeSide = useRef(null)
7 |
8 | const onMouseMove = useCallback((event: MouseEvent) => {
9 | if (reference.current && resizeSide.current === 'left') {
10 | const newWidth = reference.current.getBoundingClientRect().right - event.clientX
11 | if (newWidth > 0) setWidth(newWidth)
12 | }
13 | }, [])
14 |
15 | const onMouseUp = useCallback(() => {
16 | document.removeEventListener('mousemove', onMouseMove)
17 | document.removeEventListener('mouseup', onMouseUp)
18 | // eslint-disable-next-line unicorn/no-null
19 | resizeSide.current = null
20 | }, [onMouseMove])
21 |
22 | const onMouseDown = useCallback(
23 | (event: MouseEvent) => {
24 | if (reference.current) {
25 | const boundingRect = reference.current.getBoundingClientRect()
26 | const threshold = 20 // pixels from the edge to detect resize
27 |
28 | if (
29 | event.clientX >= boundingRect.left - threshold &&
30 | event.clientX <= boundingRect.left + threshold
31 | ) {
32 | resizeSide.current = 'left'
33 | document.addEventListener('mousemove', onMouseMove)
34 | document.addEventListener('mouseup', onMouseUp)
35 | }
36 | }
37 | },
38 | [onMouseMove, onMouseUp],
39 | )
40 |
41 | useEffect(() => {
42 | const element = reference.current
43 | if (element) {
44 | element.addEventListener('mousedown', onMouseDown)
45 | }
46 | return () => {
47 | if (element) {
48 | element.removeEventListener('mousedown', onMouseDown)
49 | }
50 | }
51 | }, [onMouseDown])
52 |
53 | return { ref: reference, width }
54 | }
55 |
--------------------------------------------------------------------------------
/src/layout/details/preview-sidebar/use-svgr.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import { serverEndpoint } from 'src/constants/server-config'
3 | import { useDetails } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 | import { logger } from 'src/utilities/logger'
6 |
7 | import { ServerMessage } from '../../../../server/index'
8 |
9 | export const useSvgr = () => {
10 | const [loading, setLoading] = useState(false)
11 |
12 | const {
13 | dispatch,
14 | state: { currentString, preview },
15 | } = useDetails()
16 |
17 | useEffect(() => {
18 | setLoading(true)
19 | ;(async () => {
20 | const message: ServerMessage = {
21 | payload: {
22 | config: preview.svgr.config,
23 | state: preview.svgr.state,
24 | svg: currentString,
25 | },
26 | type: 'svgr',
27 | }
28 |
29 | try {
30 | const response = await fetch(serverEndpoint.svgr, {
31 | body: JSON.stringify(message),
32 | headers: { 'Content-Type': 'application/json' },
33 | method: 'POST',
34 | })
35 | const result = await response.text()
36 | dispatch({ payload: result, type: 'set-svgr-result' })
37 | } catch (error) {
38 | logger.error(error)
39 | dispatch({ payload: `😥 ${loc('details_svgr_error')}`, type: 'set-svgr-result' })
40 | }
41 |
42 | setLoading(false)
43 | })()
44 | }, [currentString, dispatch, preview.svgr.config, preview.svgr.state])
45 |
46 | return { loading }
47 | }
48 |
--------------------------------------------------------------------------------
/src/layout/details/use-optimize.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react'
2 | import { useDetails } from 'src/providers'
3 | import { optimize as svgoOptimize } from 'svgo'
4 |
5 | /**
6 | * Handle common optimization callbacks related to SVGO
7 | */
8 | export const useOptimize = () => {
9 | const { state } = useDetails()
10 |
11 | /**
12 | * Optimize a given svg string with the current svgo config
13 | */
14 | const optimize = useCallback(
15 | (svg: string) => {
16 | const { data } = svgoOptimize(svg, state.export.svgoConfig)
17 | return data
18 | },
19 | [state.export.svgoConfig],
20 | )
21 |
22 | /**
23 | * Format a given svg string with no plugins.
24 | */
25 | const format = useCallback((svg: string) => {
26 | const { data } = svgoOptimize(svg, {
27 | js2svg: {
28 | indent: 2,
29 | pretty: true,
30 | },
31 | plugins: [],
32 | })
33 | return data
34 | }, [])
35 |
36 | /**
37 | * Normalize and minify a given svg string with no plugins.
38 | * Often used to compare two strings or get the size of a string.
39 | */
40 | const minify = useCallback((svg: string) => {
41 | const { data } = svgoOptimize(svg, {
42 | js2svg: {
43 | indent: 0,
44 | pretty: false,
45 | },
46 | plugins: [],
47 | })
48 | return data
49 | }, [])
50 |
51 | return { format, minify, optimize }
52 | }
53 |
--------------------------------------------------------------------------------
/src/layout/onboarding/onboarding-graphic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rossmoody/svg-gobbler/0b6cb41d24847212b4da1b4c68bc1382d511f049/src/layout/onboarding/onboarding-graphic.png
--------------------------------------------------------------------------------
/src/layout/settings/about-settings.tsx:
--------------------------------------------------------------------------------
1 | import { links } from 'src/constants/links'
2 | import { loc } from 'src/utilities/i18n'
3 |
4 | import { Category } from './category'
5 | import { Item } from './item'
6 |
7 | export const AboutSettings = () => (
8 |
9 | -
10 |
11 | {loc('settings_contribute')}
12 |
13 | {loc('settings_contribute_desc')}{' '}
14 |
15 | {loc('settings_contribute_desc_2')}
16 |
17 | .
18 |
19 |
20 |
21 |
22 | {loc('settings_bug')}
23 |
24 | {loc('settings_bug_desc')}{' '}
25 |
26 | {loc('settings_open_issue')}
27 |
28 | .
29 |
30 |
31 |
32 |
33 | {loc('settings_disclaimer')}
34 | {loc('settings_disclaimer_desc')}
35 |
36 |
37 |
38 | )
39 |
--------------------------------------------------------------------------------
/src/layout/settings/category.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from 'react'
2 |
3 | type CategoryProperties = {
4 | description: string
5 | title: string
6 | }
7 |
8 | export const Category = ({ children, description, title }: PropsWithChildren) => (
9 |
10 |
11 |
{title}
12 |
{description}
13 |
14 | {children}
15 |
16 | )
17 |
--------------------------------------------------------------------------------
/src/layout/settings/export-settings.tsx:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 | import { useEffect, useState } from 'react'
3 | import { SvgoPlugin, svgoPlugins } from 'src/constants/svgo-plugins'
4 | import { loc } from 'src/utilities/i18n'
5 | import { StorageUtilities } from 'src/utilities/storage-utilities'
6 |
7 | import { Category } from './category'
8 | import { Item } from './item'
9 |
10 | export const ExportSettings = () => {
11 | const [storagePlugins, setStoragePlugins] = useState([])
12 |
13 | useEffect(() => {
14 | const fetchSvgoPlugins = async () => {
15 | const plugins = await StorageUtilities.getStorageData('plugins')
16 | setStoragePlugins(plugins ?? [])
17 | }
18 |
19 | fetchSvgoPlugins()
20 | }, [])
21 |
22 | const isChecked = (plugin: SvgoPlugin) => {
23 | return storagePlugins.some((p) => p.name === plugin.name)
24 | }
25 |
26 | const handleCheckboxChange =
27 | (plugin: SvgoPlugin) => (event: React.ChangeEvent) => {
28 | const { checked } = event.target
29 |
30 | if (checked) {
31 | const plugins = [...storagePlugins, plugin]
32 | setStoragePlugins(plugins)
33 | StorageUtilities.setStorageData('plugins', plugins)
34 | } else {
35 | const filteredPlugins = storagePlugins.filter((p) => p.name !== plugin.name)
36 | setStoragePlugins(filteredPlugins)
37 | StorageUtilities.setStorageData('plugins', filteredPlugins)
38 | }
39 | }
40 |
41 | return (
42 |
43 | -
44 |
45 | {loc('settings_default_svgo')}
46 | {loc('settings_default_svgo_desc')}
47 |
48 | {_.sortBy(svgoPlugins, 'name').map((plugin) => (
49 |
50 |
57 |
58 |
61 | {plugin.description}
62 |
63 |
64 | ))}
65 |
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/src/layout/settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 | import { CollectionPanelButton } from 'src/components'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | import { AboutSettings } from './about-settings'
6 | import { ExportSettings } from './export-settings'
7 | import { GeneralSettings } from './general-settings'
8 |
9 | export const SettingsLayout = () => {
10 | return (
11 |
22 | {/* Header */}
23 |
24 |
25 |
26 | {loc('settings_settings')}
27 |
28 |
29 | {/* Settings forms */}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/src/layout/settings/item.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from 'react'
2 |
3 | const Heading = ({ children }: PropsWithChildren) => {
4 | return {children}
5 | }
6 |
7 | const Description = ({ children }: PropsWithChildren) => {
8 | return {children}
9 | }
10 |
11 | const Setting = ({ children }: PropsWithChildren) => {
12 | return {children}
13 | }
14 |
15 | export const Section = ({ children }: PropsWithChildren) => {
16 | return {children}
17 | }
18 |
19 | export const Item = ({ children }: PropsWithChildren) => {
20 | return {children}
21 | }
22 |
23 | Item.Heading = Heading
24 | Item.Description = Description
25 | Item.Section = Section
26 | Item.Setting = Setting
27 |
--------------------------------------------------------------------------------
/src/layout/settings/keyboard-shortcut.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export const KeyboardShortcut = () => {
4 | const [shortcut, setShortcut] = useState('')
5 |
6 | useEffect(() => {
7 | const getKeyboardShortcut = async () => {
8 | const commands = await chrome.commands.getAll()
9 | const executeCommand = commands.find((command) => command.name === '_execute_action')
10 | setShortcut(executeCommand?.shortcut ?? '')
11 | }
12 |
13 | getKeyboardShortcut()
14 | }, [])
15 |
16 | if (!shortcut) {
17 | return not set
18 | }
19 |
20 | return (
21 |
22 | {shortcut}
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-content.tsx:
--------------------------------------------------------------------------------
1 | import { Logo } from 'src/components'
2 |
3 | import { SideFooter } from './sidebar-footer'
4 | import { SidebarHeader } from './sidebar-header'
5 | import { SidebarMain } from './sidebar-main'
6 |
7 | export const SidebarContent = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-footer/debug-data-item.tsx:
--------------------------------------------------------------------------------
1 | import { CodeBracketIcon } from '@heroicons/react/24/outline'
2 | import JsonView from '@uiw/react-json-view'
3 | import { nordTheme } from '@uiw/react-json-view/nord'
4 | import { useState } from 'react'
5 | import { Button, Modal } from 'src/components'
6 | import { logger } from 'src/utilities/logger'
7 | import { StorageUtilities } from 'src/utilities/storage-utilities'
8 |
9 | export const DebugData = () => {
10 | const [debugData, setDebugData] = useState({})
11 | const [open, setOpen] = useState(false)
12 |
13 | function onClose() {
14 | setOpen(false)
15 | }
16 |
17 | function onOpen() {
18 | StorageUtilities.getStorageData('debug-data')
19 | .then((data) => {
20 | setDebugData(data as object)
21 | setOpen(true)
22 | })
23 | .catch(logger.error)
24 | }
25 |
26 | return (
27 |
28 |
32 |
33 | Debug Data
34 |
35 |
42 | } />
43 |
44 |
45 |
46 |
49 |
52 |
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-footer/feedback-item.tsx:
--------------------------------------------------------------------------------
1 | import { MegaphoneIcon } from '@heroicons/react/24/outline'
2 | import { useState } from 'react'
3 | import { Button, Modal } from 'src/components'
4 | import { useDatabase } from 'src/hooks'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | export const FeedbackItem = () => {
8 | const [open, setOpen] = useState(false)
9 | const sendMessage = useDatabase('feedback')
10 |
11 | const handleRequestPrompt = (event: React.FormEvent) => {
12 | event.preventDefault()
13 | const formData = new FormData(event.target as HTMLFormElement)
14 | const email = formData.get('feedback-email')
15 | const feedback = formData.get('feedback-textarea')
16 | const message = `Email: ${email}\nFeedback: ${feedback}`
17 | sendMessage(message)
18 | onClose()
19 | }
20 |
21 | function onClose() {
22 | setOpen(false)
23 | }
24 |
25 | function onOpen() {
26 | setOpen(true)
27 | }
28 |
29 | return (
30 |
31 |
35 |
36 |
66 |
67 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-footer/index.tsx:
--------------------------------------------------------------------------------
1 | import { isDevelopmentEnvironment } from 'src/constants/server-config'
2 |
3 | import { DebugData } from './debug-data-item'
4 | import { FeedbackItem } from './feedback-item'
5 | import { ResetEnvironment } from './reset-environment'
6 | import { ReviewItem } from './review-item'
7 | import { SettingsItem } from './settings-item'
8 |
9 | type Item = {
10 | Component: React.ComponentType
11 | id: string
12 | shouldRender: boolean
13 | }
14 |
15 | const items: Item[] = [
16 | { Component: SettingsItem, id: 'settings', shouldRender: true },
17 | { Component: FeedbackItem, id: 'feedback', shouldRender: !isDevelopmentEnvironment },
18 | { Component: ReviewItem, id: 'review', shouldRender: !isDevelopmentEnvironment },
19 | { Component: ResetEnvironment, id: 'reset', shouldRender: isDevelopmentEnvironment },
20 | { Component: DebugData, id: 'json', shouldRender: isDevelopmentEnvironment },
21 | ]
22 |
23 | export const SideFooter = () => (
24 |
36 | )
37 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-footer/reset-environment.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUturnDownIcon } from '@heroicons/react/24/outline'
2 | import { useResetEnvironment } from 'src/hooks'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | export const ResetEnvironment = () => {
6 | const { reset } = useResetEnvironment()
7 |
8 | return (
9 |
10 |
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-footer/review-item.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowTopRightOnSquareIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
2 | import { links } from 'src/constants/links'
3 | import { extension } from 'src/utilities/extension-utilities'
4 | import { loc } from 'src/utilities/i18n'
5 |
6 | export const ReviewItem = () => {
7 | function navigateToChromeWebStore() {
8 | const link = extension.isFirefox ? links.firefoxWebstore : links.chromeWebstore
9 | window.open(link, '_blank')
10 | }
11 |
12 | return (
13 |
14 |
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-footer/settings-item.tsx:
--------------------------------------------------------------------------------
1 | import { Cog6ToothIcon } from '@heroicons/react/24/outline'
2 | import { NavLink } from 'react-router-dom'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | export const SettingsItem = () => {
6 | return (
7 |
8 |
9 |
10 | {loc('sidebar_settings')}
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-header/index.tsx:
--------------------------------------------------------------------------------
1 | import { PlusIcon } from '@heroicons/react/24/outline'
2 | import { useState } from 'react'
3 | import { loc } from 'src/utilities/i18n'
4 |
5 | import { NewCollectionModal } from './new-collection-item'
6 |
7 | export const SidebarHeader = () => {
8 | const [open, setOpen] = useState(false)
9 |
10 | return (
11 |
12 |
16 |
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-header/use-create-collection.ts:
--------------------------------------------------------------------------------
1 | import type { Collection, PageData } from 'src/types'
2 |
3 | import { nanoid } from 'nanoid'
4 | import { useNavigate } from 'react-router-dom'
5 | import { useDashboard } from 'src/providers'
6 | import { StorageSvg } from 'src/scripts'
7 | import { formUtilities } from 'src/utilities/form-utilities'
8 | import { StorageUtilities } from 'src/utilities/storage-utilities'
9 | import { SvgUtilities } from 'src/utilities/svg-utilities'
10 |
11 | export const useCreateCollection = (files: File[]) => {
12 | const navigate = useNavigate()
13 | const { dispatch, state } = useDashboard()
14 |
15 | return async function (event: React.FormEvent) {
16 | event.preventDefault()
17 | const formData = new FormData(event.currentTarget)
18 | const name = formData.get('name') as string
19 | const id = nanoid()
20 | const svgFileData = await formUtilities.handleUpload(files)
21 | const svgStorageData: StorageSvg[] = svgFileData.map(SvgUtilities.createStorageSvg)
22 |
23 | const pageData: PageData = {
24 | data: svgStorageData,
25 | host: '',
26 | href: '',
27 | origin: '',
28 | }
29 |
30 | const collection: Collection = {
31 | href: '',
32 | id,
33 | name,
34 | origin: '',
35 | }
36 |
37 | const collections = [collection, ...state.collections]
38 |
39 | await StorageUtilities.setPageData(id, pageData)
40 | await StorageUtilities.setStorageData('collections', collections)
41 | dispatch({ payload: collections, type: 'set-collections' })
42 | navigate(`/dashboard/collection/${id}`)
43 |
44 | // Close the modal, I'm being lazy here
45 | globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-main/collection-item-icon.tsx:
--------------------------------------------------------------------------------
1 | import data from '@emoji-mart/data'
2 | import Picker from '@emoji-mart/react'
3 | import { Popover } from '@headlessui/react'
4 | import { useCallback, useMemo } from 'react'
5 | import { useDashboard } from 'src/providers'
6 | import { StorageUtilities } from 'src/utilities/storage-utilities'
7 |
8 | import { type CollectionItemProperties } from './collection-item'
9 |
10 | type EmojiMartData = {
11 | id: string
12 | keywords: string[]
13 | name: string
14 | native: string
15 | shortcodes: string
16 | unified: string
17 | }
18 |
19 | export const CollectionItemIcon = ({ collection }: CollectionItemProperties) => {
20 | const { dispatch, state } = useDashboard()
21 | const { emoji, name, origin } = collection
22 |
23 | const setCollectionEmoji = useCallback(
24 | ({ native }: EmojiMartData) => {
25 | const updatedCollection = { ...collection, emoji: native }
26 | const updatedCollections = state.collections.map((c) =>
27 | c.id === collection.id ? updatedCollection : c,
28 | )
29 | dispatch({ payload: updatedCollection, type: 'set-collection-icon' })
30 | StorageUtilities.setStorageData('collections', updatedCollections)
31 | },
32 | [collection, state.collections, dispatch],
33 | )
34 |
35 | const collectionIcon = useMemo(() => {
36 | if (emoji) {
37 | return emoji
38 | }
39 |
40 | if (origin) {
41 | return (
42 |
47 | )
48 | }
49 |
50 | return '📁'
51 | }, [emoji, name, origin])
52 |
53 | return (
54 |
55 |
56 |
57 | {collectionIcon}
58 |
59 |
60 |
61 | {({ close }) => (
62 | {
65 | setCollectionEmoji(emojiMartData)
66 | close()
67 | }}
68 | perLine={7}
69 | />
70 | )}
71 |
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-main/collection-item.tsx:
--------------------------------------------------------------------------------
1 | import type { Collection as TCollection } from 'src/types'
2 |
3 | import { useSortable } from '@dnd-kit/sortable'
4 | import { CSS } from '@dnd-kit/utilities'
5 | import { ChevronUpDownIcon } from '@heroicons/react/24/outline'
6 | import { PropsWithChildren } from 'react'
7 | import { useUser } from 'src/providers'
8 |
9 | export type CollectionItemProperties = {
10 | /**
11 | * The collection from which to render the item
12 | */
13 | collection: TCollection
14 | }
15 |
16 | export const CollectionItem = ({
17 | children,
18 | collection,
19 | }: PropsWithChildren) => {
20 | const { state: userState } = useUser()
21 |
22 | const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition } =
23 | useSortable({
24 | id: collection.id,
25 | })
26 |
27 | const style = {
28 | transform: CSS.Transform.toString(transform),
29 | transition,
30 | }
31 |
32 | return (
33 |
34 |
35 | {!userState.settings.sortCollections && (
36 |
41 |
42 |
43 | )}
44 | {children}
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-main/collection.tsx:
--------------------------------------------------------------------------------
1 | import { XMarkIcon } from '@heroicons/react/24/outline'
2 | import clsx from 'clsx'
3 | import { NavLink } from 'react-router-dom'
4 | import { useDashboard } from 'src/providers'
5 | import { Collection as TCollection } from 'src/types'
6 |
7 | import { useRemoveCollection } from '../../../hooks/use-remove-collection'
8 | import { CollectionItemIcon } from './collection-item-icon'
9 |
10 | type CollectionProperties = {
11 | collection: TCollection
12 | }
13 |
14 | export const Collection = ({ collection }: CollectionProperties) => {
15 | const { dispatch: sidebarDispatch } = useDashboard()
16 | const handleRemoveCollection = useRemoveCollection()
17 |
18 | function onClose() {
19 | sidebarDispatch({ payload: false, type: 'set-open' })
20 | }
21 |
22 | return (
23 | {
25 | return clsx(isActive && 'bg-gray-100 dark:bg-gray-700', 'collection-item group')
26 | }}
27 | onClick={onClose}
28 | to={`collection/${collection.id}`}
29 | >
30 |
31 |
32 | {collection.name}
33 |
34 |
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/src/layout/sidebar/sidebar-main/index.tsx:
--------------------------------------------------------------------------------
1 | import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core'
2 | import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
3 | import { useCallback } from 'react'
4 | import { useDashboard } from 'src/providers'
5 | import { StorageUtilities } from 'src/utilities/storage-utilities'
6 |
7 | import { Collection } from './collection'
8 | import { CollectionItem } from './collection-item'
9 |
10 | export const SidebarMain = () => {
11 | const { dispatch, state } = useDashboard()
12 |
13 | const handleDragEnd = useCallback(
14 | ({ active, over }: DragEndEvent) => {
15 | if (!over || active.id === over.id) return
16 |
17 | const oldIndex = state.collections.findIndex((collection) => collection.id === active.id)
18 | const newIndex = state.collections.findIndex((collection) => collection.id === over.id)
19 |
20 | const newCollections = arrayMove(state.collections, oldIndex, newIndex)
21 | dispatch({ payload: newCollections, type: 'set-collections' })
22 | StorageUtilities.setStorageData('collections', newCollections)
23 | },
24 | [dispatch, state.collections],
25 | )
26 |
27 | return (
28 |
29 |
30 | id)}
32 | strategy={verticalListSortingStrategy}
33 | >
34 | {state.collections.map((collection) => (
35 |
36 |
37 |
38 | ))}
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/layout/top-bar/card-color-button.tsx:
--------------------------------------------------------------------------------
1 | import { PaintBrushIcon } from '@heroicons/react/24/outline'
2 | import { merge } from 'lodash'
3 | import { useCallback, useRef } from 'react'
4 | import { IconButton, Tooltip } from 'src/components'
5 | import { useCollection, useUser } from 'src/providers'
6 | import { loc } from 'src/utilities/i18n'
7 | import { StorageUtilities } from 'src/utilities/storage-utilities'
8 |
9 | import { CardColorOnboarding } from './card-color-onboarding'
10 |
11 | export const CardColorButton = () => {
12 | const { dispatch: userDispatch, state: userState } = useUser()
13 | const { dispatch, state } = useCollection()
14 | const colorInputReference = useRef(null)
15 |
16 | const handleClick = () => {
17 | colorInputReference.current?.click()
18 | }
19 |
20 | const handleChange = (event: React.ChangeEvent) => {
21 | dispatch({ payload: event.target.value, type: 'set-canvas-color' })
22 | StorageUtilities.setStorageData('view', { ...state.view, canvas: event.target.value })
23 | }
24 |
25 | const onFocus = useCallback(() => {
26 | if (!userState.onboarding.viewedCardColor) {
27 | const newUser = merge(userState, { onboarding: { viewedCardColor: true } })
28 | userDispatch({ payload: newUser, type: 'set-user' })
29 | StorageUtilities.setStorageData('user', newUser)
30 | }
31 | }, [userDispatch, userState])
32 |
33 | return (
34 |
35 |
42 |
43 |
50 |
51 | {loc('topbar_canvas')}
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/layout/top-bar/card-color-onboarding.tsx:
--------------------------------------------------------------------------------
1 | import * as Tooltip from '@radix-ui/react-tooltip'
2 | import clsx from 'clsx'
3 | import { useMemo } from 'react'
4 | import { useCollection, useUser } from 'src/providers'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | export const CardColorOnboarding = () => {
8 | const { state: userState } = useUser()
9 | const { state: collectionState } = useCollection()
10 |
11 | const collectionHasSvgWithWhite = useMemo(() => {
12 | const whiteValues = ['white', '#fff', '#ffffff']
13 |
14 | return collectionState.processedData.some(({ presentationSvg }) =>
15 | whiteValues.some((whiteValue) => presentationSvg.includes(whiteValue)),
16 | )
17 | }, [collectionState.processedData])
18 |
19 | const shouldShowCardColorOnboarding = useMemo(() => {
20 | return (
21 | collectionHasSvgWithWhite &&
22 | !userState.onboarding.viewedCardColor &&
23 | process.env.NODE_ENV === 'production'
24 | )
25 | }, [collectionHasSvgWithWhite, userState.onboarding.viewedCardColor])
26 |
27 | if (shouldShowCardColorOnboarding) {
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
47 | {loc('onboarding_card_color')}
48 |
49 |
50 |
51 |
52 | )
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/layout/top-bar/collection-title.tsx:
--------------------------------------------------------------------------------
1 | import { Transition } from '@headlessui/react'
2 | import { useMemo } from 'react'
3 | import { useCollection, useDashboard } from 'src/providers'
4 | import { loc } from 'src/utilities/i18n'
5 | import { StorageUtilities } from 'src/utilities/storage-utilities'
6 |
7 | export const CollectionTitle = () => {
8 | const { state: mainState } = useCollection()
9 | const { dispatch: sidebarDispatch, state: sidebarState } = useDashboard()
10 |
11 | const title = useMemo(() => {
12 | return sidebarState.collections.find((c) => c.id === mainState.collectionId)?.name
13 | }, [mainState.collectionId, sidebarState.collections])
14 |
15 | function handleBlur(event: React.FocusEvent) {
16 | const newTitle = event.target.textContent ?? loc('topbar_collection')
17 |
18 | if (newTitle !== title) {
19 | const newCollections = sidebarState.collections.map((c) => {
20 | if (c.id === mainState.collectionId) {
21 | return { ...c, name: newTitle }
22 | }
23 | return c
24 | })
25 | sidebarDispatch({ payload: newCollections, type: 'set-collections' })
26 | StorageUtilities.setStorageData('collections', newCollections)
27 | }
28 | // Reset scroll position for overflow-ellipsis
29 | event.currentTarget.scrollLeft = 0
30 | }
31 |
32 | return (
33 |
41 |
48 | {title}
49 |
50 |
51 | )
52 | }
53 |
54 | function handleKeyDown(event: React.KeyboardEvent) {
55 | if (event.key === 'Enter') {
56 | event.currentTarget.blur()
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/layout/top-bar/size-select.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { useCollection } from 'src/providers'
3 | import { loc } from 'src/utilities/i18n'
4 | import { StorageUtilities } from 'src/utilities/storage-utilities'
5 |
6 | export const sizes = [
7 | { label: '16px', value: 16 },
8 | { label: '20px', value: 20 },
9 | { label: '24px', value: 24 },
10 | { label: '40px', value: 40 },
11 | { label: '48px', value: 48 },
12 | { label: '64px', value: 64 },
13 | { label: '96px', value: 96 },
14 | { label: '128px', value: 128 },
15 | { label: '192px', value: 192 },
16 | { label: '256px', value: 256 },
17 | ] as const
18 |
19 | export const SizeSelect = () => {
20 | const { dispatch, state } = useCollection()
21 |
22 | function handleSizeChange(event: React.ChangeEvent) {
23 | const view = { ...state.view, size: Number(event.target.value) }
24 | dispatch({ payload: view, type: 'set-view' })
25 | StorageUtilities.setStorageData('view', view)
26 | }
27 |
28 | return (
29 | <>
30 |
33 |
49 | >
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/src/layout/top-bar/theme-button.tsx:
--------------------------------------------------------------------------------
1 | import { MoonIcon, SunIcon } from '@heroicons/react/24/outline'
2 | import { IconButton, Tooltip } from 'src/components'
3 | import { useColorMode } from 'src/hooks'
4 | import { loc } from 'src/utilities/i18n'
5 |
6 | export const ThemeButton = () => {
7 | const { colorMode, toggleColorMode } = useColorMode()
8 |
9 | return (
10 |
11 |
12 | {colorMode === 'dark' ? (
13 |
14 | ) : (
15 |
16 | )}
17 | {loc('topbar_color')}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/layout/top-bar/view-name-feature-notice.tsx:
--------------------------------------------------------------------------------
1 | import * as Tooltip from '@radix-ui/react-tooltip'
2 | import clsx from 'clsx'
3 | import { useMemo } from 'react'
4 | import { useUser } from 'src/providers'
5 | import { loc } from 'src/utilities/i18n'
6 |
7 | export const ViewNameFeatureNotice = () => {
8 | const { state: userState } = useUser()
9 |
10 | const shouldShowNewFeatureTooltip = useMemo(() => {
11 | return (
12 | !userState.features.viewedNameFeature &&
13 | userState.onboarding.viewedCardColor &&
14 | new Date(userState.installDate) < new Date('2025-04-02')
15 | )
16 | }, [userState])
17 |
18 | if (shouldShowNewFeatureTooltip) {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
38 | {loc('view_show_feature_notice')}
39 |
40 |
41 |
42 |
43 | )
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/onboarding.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom/client'
2 |
3 | import './css/index.css'
4 | import { OnboardingLayout } from './layout/onboarding'
5 |
6 | ReactDOM.createRoot(document.querySelector('#root')!).render()
7 |
--------------------------------------------------------------------------------
/src/providers/collection/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, Dispatch, PropsWithChildren, useContext, useMemo, useReducer } from 'react'
2 |
3 | import type { CollectionAction, CollectionState } from './reducer'
4 |
5 | import { collectionReducer, initCollectionState } from './reducer'
6 |
7 | export type CollectionContextProperties = {
8 | dispatch: Dispatch
9 | state: CollectionState
10 | }
11 |
12 | const CollectionContext = createContext({} as CollectionContextProperties)
13 |
14 | export const CollectionProvider = ({ children }: PropsWithChildren) => {
15 | const [state, dispatch] = useReducer(collectionReducer, initCollectionState)
16 |
17 | const memo = useMemo(() => {
18 | return { dispatch, state }
19 | }, [state, dispatch])
20 |
21 | return {children}
22 | }
23 |
24 | export const useCollection = () => useContext(CollectionContext)
25 |
--------------------------------------------------------------------------------
/src/providers/dashboard/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, Dispatch, PropsWithChildren, useContext, useMemo, useReducer } from 'react'
2 |
3 | import { DashboardAction, dashboardReducer, DashboardState, initDashboardState } from './reducer'
4 |
5 | export type DashboardContextProperties = {
6 | dispatch: Dispatch
7 | state: DashboardState
8 | }
9 |
10 | const DashboardContext = createContext({} as DashboardContextProperties)
11 |
12 | export const DashboardProvider = ({ children }: PropsWithChildren) => {
13 | const [state, dispatch] = useReducer(dashboardReducer, initDashboardState)
14 |
15 | const memo = useMemo(() => {
16 | return { dispatch, state }
17 | }, [state, dispatch])
18 |
19 | return {children}
20 | }
21 |
22 | export const useDashboard = () => useContext(DashboardContext)
23 |
--------------------------------------------------------------------------------
/src/providers/dashboard/reducer.ts:
--------------------------------------------------------------------------------
1 | import type { Collection } from 'src/types'
2 |
3 | import { DashboardLoaderData } from 'src/routes'
4 |
5 | export type DashboardAction =
6 | | { payload: boolean; type: 'set-open' }
7 | | { payload: Collection; type: 'set-collection-icon' }
8 | | { payload: Collection[]; type: 'set-collections' }
9 | | { payload: DashboardLoaderData; type: 'init' }
10 | | { type: 'reset' }
11 |
12 | export type DashboardState = {
13 | /**
14 | * The collections that are available to the user.
15 | */
16 | collections: Collection[]
17 | /**
18 | * Whether the sidebar is open or not in mobile contexts.
19 | */
20 | isOpen: boolean
21 | }
22 |
23 | export const initDashboardState: DashboardState = {
24 | collections: [],
25 | isOpen: false,
26 | }
27 |
28 | export const dashboardReducer = (state: DashboardState, action: DashboardAction) => {
29 | switch (action.type) {
30 | case 'init': {
31 | return {
32 | ...state,
33 | collections: action.payload.collections,
34 | }
35 | }
36 | case 'reset': {
37 | return initDashboardState
38 | }
39 |
40 | case 'set-collection-icon': {
41 | return {
42 | ...state,
43 | collections: state.collections.map((collection) => {
44 | if (collection.id === action.payload.id) {
45 | return { ...collection, emoji: action.payload.emoji }
46 | }
47 |
48 | return collection
49 | }),
50 | }
51 | }
52 |
53 | case 'set-collections': {
54 | return { ...state, collections: action.payload }
55 | }
56 |
57 | case 'set-open': {
58 | return { ...state, isOpen: action.payload }
59 | }
60 |
61 | default: {
62 | return initDashboardState
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/providers/details/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, Dispatch, PropsWithChildren, useContext, useMemo, useReducer } from 'react'
2 |
3 | import { DetailsAction, detailsReducer, DetailsState, initDetailsState } from './reducer'
4 |
5 | export type DetailsContextProperties = {
6 | dispatch: Dispatch
7 | state: DetailsState
8 | }
9 |
10 | const DetailsContext = createContext({} as DetailsContextProperties)
11 |
12 | export const DetailsProvider = ({ children }: PropsWithChildren) => {
13 | const [state, dispatch] = useReducer(detailsReducer, initDetailsState)
14 |
15 | const memo = useMemo(() => {
16 | return { dispatch, state }
17 | }, [state, dispatch])
18 |
19 | return {children}
20 | }
21 |
22 | export const useDetails = () => useContext(DetailsContext)
23 |
--------------------------------------------------------------------------------
/src/providers/edit/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, Dispatch, PropsWithChildren, useContext, useMemo, useReducer } from 'react'
2 |
3 | import { EditAction, editReducer, EditState, initEditState } from './reducer'
4 |
5 | export type EditContextProperties = {
6 | dispatch: Dispatch
7 | state: EditState
8 | }
9 |
10 | const EditContext = createContext({} as EditContextProperties)
11 |
12 | export const EditProvider = ({ children }: PropsWithChildren) => {
13 | const [state, dispatch] = useReducer(editReducer, initEditState)
14 |
15 | const memo = useMemo(() => {
16 | return { dispatch, state }
17 | }, [state, dispatch])
18 |
19 | return {children}
20 | }
21 |
22 | export const useEdit = () => useContext(EditContext)
23 |
--------------------------------------------------------------------------------
/src/providers/edit/reducer.ts:
--------------------------------------------------------------------------------
1 | export type EditAction =
2 | | { payload: Record; type: 'set-edit-property-value' }
3 | | { payload: string; type: 'set-edit-custom-name' }
4 | | { payload: string; type: 'set-edit-custom-value' }
5 | | { type: 'reset' }
6 |
7 | export type EditState = {
8 | /**
9 | * The custom property and value to apply to the selected SVGs.
10 | */
11 | custom: {
12 | /**
13 | * The name of the property to apply to the selected SVGs.
14 | */
15 | name: string
16 | /**
17 | * The value of the property to apply to the selected SVGs.
18 | */
19 | value: string
20 | }
21 | /**
22 | * The standard properties to apply to the selected SVGs.
23 | */
24 | standard: {
25 | /**
26 | * The class to apply to the selected SVGs.
27 | */
28 | class: string
29 | /**
30 | * The fill to apply to the selected SVGs.
31 | */
32 | fill: string
33 | /**
34 | * The height to apply to the selected SVGs.
35 | */
36 | height: string
37 | /**
38 | * The id to apply to the selected SVGs.
39 | */
40 | id: string
41 | /**
42 | * The viewBox to apply to the selected SVGs.
43 | */
44 | viewBox: string
45 | /**
46 | * The width to apply to the selected SVGs.
47 | */
48 | width: string
49 | }
50 | }
51 |
52 | export type EditStateKey = keyof EditState['standard']
53 |
54 | export const initEditState: EditState = {
55 | custom: {
56 | name: '',
57 | value: '',
58 | },
59 | standard: {
60 | class: '',
61 | fill: '',
62 | height: '',
63 | id: '',
64 | viewBox: '',
65 | width: '',
66 | },
67 | }
68 |
69 | export const editReducer = (state: EditState, action: EditAction): EditState => {
70 | switch (action.type) {
71 | case 'reset': {
72 | return initEditState
73 | }
74 |
75 | case 'set-edit-custom-name': {
76 | return {
77 | ...state,
78 | custom: {
79 | ...state.custom,
80 | name: action.payload,
81 | },
82 | }
83 | }
84 |
85 | case 'set-edit-custom-value': {
86 | return {
87 | ...state,
88 | custom: {
89 | ...state.custom,
90 | value: action.payload,
91 | },
92 | }
93 | }
94 |
95 | case 'set-edit-property-value': {
96 | return {
97 | ...state,
98 | standard: {
99 | ...state.standard,
100 | ...action.payload,
101 | },
102 | }
103 | }
104 |
105 | default: {
106 | return state
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/providers/export/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, Dispatch, PropsWithChildren, useContext, useMemo, useReducer } from 'react'
2 |
3 | import { ExportAction, exportReducer, ExportState, initExportState } from './reducer'
4 |
5 | export type ExportContextProperties = {
6 | dispatch: Dispatch
7 | state: ExportState
8 | }
9 |
10 | const ExportContext = createContext({} as ExportContextProperties)
11 |
12 | export const ExportProvider = ({ children }: PropsWithChildren) => {
13 | const [state, dispatch] = useReducer(exportReducer, initExportState)
14 |
15 | const memo = useMemo(() => {
16 | return { dispatch, state }
17 | }, [state, dispatch])
18 |
19 | return {children}
20 | }
21 |
22 | export const useExport = () => useContext(ExportContext)
23 |
--------------------------------------------------------------------------------
/src/providers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './collection'
2 | export * from './collection/reducer'
3 | export * from './dashboard'
4 | export * from './dashboard/reducer'
5 | export * from './details'
6 | export * from './details/reducer'
7 | export * from './edit'
8 | export * from './edit/reducer'
9 | export * from './export'
10 | export * from './export/reducer'
11 | export * from './user'
12 | export * from './user/reducer'
13 |
--------------------------------------------------------------------------------
/src/providers/user/index.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, Dispatch, PropsWithChildren, useContext, useMemo, useReducer } from 'react'
2 |
3 | import { initUserState, UserAction, userReducer, UserState } from './reducer'
4 |
5 | export type UserContextProperties = {
6 | dispatch: Dispatch
7 | state: UserState
8 | }
9 |
10 | const UserContext = createContext({} as UserContextProperties)
11 |
12 | export const UserProvider = ({ children }: PropsWithChildren) => {
13 | const [state, dispatch] = useReducer(userReducer, initUserState)
14 |
15 | const memo = useMemo(() => {
16 | return { dispatch, state }
17 | }, [state, dispatch])
18 |
19 | return {children}
20 | }
21 |
22 | export const useUser = () => useContext(UserContext)
23 |
--------------------------------------------------------------------------------
/src/routes/collection/index.tsx:
--------------------------------------------------------------------------------
1 | import type { Svg } from 'src/scripts'
2 | import type { CollectionData } from 'src/types'
3 |
4 | import React, { Fragment, useEffect } from 'react'
5 | import { Await, useLoaderData } from 'react-router-dom'
6 | import { EmptyState } from 'src/components'
7 | import { SvgoPlugin } from 'src/constants/svgo-plugins'
8 | import { usePastedSvg } from 'src/hooks'
9 | import { Collection } from 'src/layout/collection'
10 | import { Mainbar } from 'src/layout/collection/main-bar'
11 | import { MainPanel } from 'src/layout/collection/main-panel'
12 | import { SkeletonCollection } from 'src/layout/collection/skeleton-collection'
13 | import { TopBar } from 'src/layout/top-bar'
14 | import { useCollection, useExport, UserState, useUser } from 'src/providers'
15 |
16 | /**
17 | * This is really collection data with a promise we await for the svg data.
18 | */
19 | type LoaderData = CollectionData & {
20 | data: Promise