├── .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 |