├── .cursor └── rules │ └── color.mdc ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc.mjs ├── .vscode └── settings.json ├── eslint.config.mjs ├── index.html ├── package.json ├── plugins └── eslint-recursive-sort.js ├── pnpm-lock.yaml ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico └── logo.jpg ├── readme.md ├── renovate.json ├── src ├── App.tsx ├── assets │ └── fonts │ │ └── GeistVF.woff2 ├── atoms │ ├── context-menu.ts │ ├── route.ts │ └── viewport.ts ├── components │ ├── common │ │ ├── AddFieldForm.tsx │ │ ├── BinaryDataViewer.tsx │ │ ├── ErrorElement.tsx │ │ ├── ExifDisplay.tsx │ │ ├── ExifField.tsx │ │ ├── ExifSection.tsx │ │ ├── Footer.tsx │ │ ├── GpsDisplay.tsx │ │ ├── Header.tsx │ │ ├── ImageUploader.tsx │ │ ├── KeyParameters.tsx │ │ ├── LoadRemixAsyncComponent.tsx │ │ ├── NotFound.tsx │ │ └── ProviderComposer.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── background │ │ ├── StarBackground.tsx │ │ └── index.ts │ │ ├── button │ │ ├── Button.tsx │ │ ├── IconButton.tsx │ │ ├── MotionButton.tsx │ │ └── index.ts │ │ ├── checkbox │ │ ├── Checkbox.tsx │ │ └── index.ts │ │ ├── context-menu │ │ ├── context-menu.tsx │ │ └── index.ts │ │ ├── divider │ │ ├── Divider.tsx │ │ └── index.ts │ │ ├── hover-card.tsx │ │ ├── input │ │ ├── Input.tsx │ │ └── index.ts │ │ ├── loading.tsx │ │ ├── portal │ │ ├── RootPortal.tsx │ │ ├── index.ts │ │ └── useRootPortal.tsx │ │ ├── select │ │ ├── ResponsiveSelect.tsx │ │ ├── Select.tsx │ │ └── index.ts │ │ ├── slider │ │ ├── Slider.tsx │ │ └── index.ts │ │ ├── sonner.tsx │ │ └── tooltip │ │ └── Tooltip.tsx ├── framer-lazy-feature.ts ├── global.d.ts ├── hooks │ ├── common │ │ ├── index.ts │ │ ├── useControlled.ts │ │ ├── useDark.ts │ │ ├── useInputComposition.ts │ │ ├── useMobile.ts │ │ ├── usePrevious.ts │ │ ├── useRefValue.ts │ │ ├── useTitle.ts │ │ └── useViewport.ts │ └── useExif.ts ├── lib │ ├── cn.ts │ ├── dev.tsx │ ├── dom.ts │ ├── exif-addable-tags.ts │ ├── exif-converter.ts │ ├── exif-format.tsx │ ├── exif-tags.ts │ ├── jotai.ts │ ├── ns.ts │ ├── query-client.ts │ ├── route-builder.ts │ ├── spring.ts │ └── utils.ts ├── main.tsx ├── pages │ ├── editor │ │ └── index.tsx │ ├── index.tsx │ ├── reader │ │ └── index.tsx │ └── transfer │ │ └── index.tsx ├── providers │ ├── context-menu-provider.tsx │ ├── event-provider.tsx │ ├── root-providers.tsx │ ├── setting-sync.tsx │ └── stable-router-provider.tsx ├── router.tsx ├── store │ └── .gitkeep ├── styles │ ├── index.css │ └── tailwind.css └── vite-env.d.ts ├── tsconfig.json ├── vercel.json └── vite.config.ts /.cursor/rules/color.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # UIKit Colors for Tailwind CSS 7 | 8 | You should use @https://github.com/Innei/apple-uikit-colors/blob/main/packages/uikit-colors/macos.ts TailwindCSS atom classname. 9 | 10 | ## System Colors 11 | red 12 | orange 13 | yellow 14 | green 15 | mint 16 | teal 17 | cyan 18 | blue 19 | indigo 20 | purple 21 | pink 22 | brown 23 | gray 24 | 25 | ## Fill Colors 26 | fill 27 | fill-secondary 28 | fill-tertiary 29 | fill-quaternary 30 | fill-quinary 31 | fill-vibrant 32 | fill-vibrant-secondary 33 | fill-vibrant-tertiary 34 | fill-vibrant-quaternary 35 | fill-vibrant-quinary 36 | 37 | ## Text Colors 38 | text 39 | text-secondary 40 | text-tertiary 41 | text-quaternary 42 | text-quinary 43 | text-vibrant 44 | text-vibrant-secondary 45 | text-vibrant-tertiary 46 | text-vibrant-quaternary 47 | text-vibrant-quinary 48 | 49 | ## Material Colors 50 | material-ultra-thick 51 | material-thick 52 | material-medium 53 | material-thin 54 | material-ultra-thin 55 | material-opaque 56 | 57 | ## Control Colors 58 | control-enabled 59 | control-disabled 60 | 61 | ## Interface Colors 62 | menu 63 | popover 64 | titlebar 65 | sidebar 66 | selection-focused 67 | selection-focused-fill 68 | selection-unfocused 69 | selection-unfocused-fill 70 | header-view 71 | tooltip 72 | under-window-background 73 | 74 | 75 | ## Applied Colors 76 | All above tailwind atom will match this colors. 77 | 78 | ```css 79 | @media (prefers-color-scheme: light) { 80 | html { 81 | --color-red: 255 69 58; 82 | --color-orange: 255 149 0; 83 | --color-yellow: 255 204 0; 84 | --color-green: 40 205 65; 85 | --color-mint: 0 199 190; 86 | --color-teal: 89 173 196; 87 | --color-cyan: 85 190 240; 88 | --color-blue: 0 122 255; 89 | --color-indigo: 88 86 214; 90 | --color-purple: 175 82 222; 91 | --color-pink: 255 45 85; 92 | --color-brown: 162 132 94; 93 | --color-gray: 142 142 147; 94 | --color-fill: 0 0 0 / 0.1; 95 | --color-fillSecondary: 0 0 0 / 0.08; 96 | --color-fillTertiary: 0 0 0 / 0.05; 97 | --color-fillQuaternary: 0 0 0 / 0.03; 98 | --color-fillQuinary: 0 0 0 / 0.02; 99 | --color-fillVibrant: 217 217 217; 100 | --color-fillVibrantSecondary: 230 230 230; 101 | --color-fillVibrantTertiary: 242 242 242; 102 | --color-fillVibrantQuaternary: 247 247 247; 103 | --color-fillVibrantQuinary: 251 251 251; 104 | --color-text: 0 0 0 / 0.85; 105 | --color-textSecondary: 0 0 0 / 0.5; 106 | --color-textTertiary: 0 0 0 / 0.25; 107 | --color-textQuaternary: 0 0 0 / 0.1; 108 | --color-textQuinary: 0 0 0 / 0.05; 109 | --color-textVibrant: 76 76 76; 110 | --color-textVibrantSecondary: 128 128 128; 111 | --color-textVibrantTertiary: 191 191 191; 112 | --color-textVibrantQuaternary: 230 230 230; 113 | --color-textVibrantQuinary: 242 242 242; 114 | --color-materialUltraThick: 246 246 246 / 0.84; 115 | --color-materialThick: 246 246 246 / 0.72; 116 | --color-materialMedium: 246 246 246 / 0.6; 117 | --color-materialThin: 246 246 246 / 0.48; 118 | --color-materialUltraThin: 246 246 246 / 0.36; 119 | --color-materialOpaque: 246 246 246; 120 | --color-controlEnabled: 251 251 251; 121 | --color-controlDisabled: 243 243 243; 122 | --color-menu: 40 40 40 / 0.58; 123 | --color-popover: 0 0 0 / 0.28; 124 | --color-titlebar: 234 234 234 / 0.8; 125 | --color-sidebar: 234 234 234 / 0.84; 126 | --color-selectionFocused: 10 130 255 / 0.75; 127 | --color-selectionFocusedFill: 10 130 255; 128 | --color-selectionUnfocused: 0 0 0 / 0.1; 129 | --color-selectionUnfocusedFill: 246 246 246 / 0.84; 130 | --color-headerView: 255 255 255 / 0.8; 131 | --color-tooltip: 246 246 246 / 0.6; 132 | --color-underWindowBackground: 246 246 246 / 0.84; 133 | } 134 | } 135 | @media (prefers-color-scheme: dark) { 136 | html { 137 | --color-red: 255 69 58; 138 | --color-orange: 255 159 10; 139 | --color-yellow: 255 214 10; 140 | --color-green: 50 215 75; 141 | --color-mint: 106 196 220; 142 | --color-teal: 106 196 220; 143 | --color-cyan: 90 200 245; 144 | --color-blue: 10 132 255; 145 | --color-indigo: 94 92 230; 146 | --color-purple: 191 90 242; 147 | --color-pink: 255 55 95; 148 | --color-brown: 172 142 104; 149 | --color-gray: 152 152 157; 150 | --color-fill: 255 255 255 / 0.1; 151 | --color-fillSecondary: 255 255 255 / 0.08; 152 | --color-fillTertiary: 255 255 255 / 0.05; 153 | --color-fillQuaternary: 255 255 255 / 0.03; 154 | --color-fillQuinary: 255 255 255 / 0.02; 155 | --color-fillVibrant: 36 36 36; 156 | --color-fillVibrantSecondary: 20 20 20; 157 | --color-fillVibrantTertiary: 13 13 13; 158 | --color-fillVibrantQuaternary: 9 9 9; 159 | --color-fillVibrantQuinary: 7 7 7; 160 | --color-text: 255 255 255 / 0.85; 161 | --color-textSecondary: 255 255 255 / 0.5; 162 | --color-textTertiary: 255 255 255 / 0.25; 163 | --color-textQuaternary: 255 255 255 / 0.1; 164 | --color-textQuinary: 255 255 255 / 0.05; 165 | --color-textVibrant: 229 229 229; 166 | --color-textVibrantSecondary: 124 124 124; 167 | --color-textVibrantTertiary: 65 65 65; 168 | --color-textVibrantQuaternary: 35 35 35; 169 | --color-textVibrantQuinary: 17 17 17; 170 | --color-materialUltraThick: 40 40 40 / 0.84; 171 | --color-materialThick: 40 40 40 / 0.72; 172 | --color-materialMedium: 40 40 40 / 0.6; 173 | --color-materialThin: 40 40 40 / 0.48; 174 | --color-materialUltraThin: 40 40 40 / 0.36; 175 | --color-materialOpaque: 40 40 40; 176 | --color-controlEnabled: 255 255 255 / 0.2; 177 | --color-controlDisabled: 255 255 255 / 0.1; 178 | --color-menu: 246 246 246 / 0.72; 179 | --color-popover: 246 246 246 / 0.6; 180 | --color-titlebar: 60 60 60 / 0.8; 181 | --color-sidebar: 0 0 0 / 0.45; 182 | --color-selectionFocused: 10 130 255 / 0.75; 183 | --color-selectionFocusedFill: 10 130 255; 184 | --color-selectionUnfocused: 255 255 255 / 0.1; 185 | --color-selectionUnfocusedFill: 40 40 40 / 0.65; 186 | --color-headerView: 30 30 30 / 0.8; 187 | --color-tooltip: 0 0 0 / 0.35; 188 | --color-underWindowBackground: 0 0 0 / 0.45; 189 | } 190 | } 191 | ``` 192 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [main, master] 9 | pull_request: 10 | branches: [main, master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [lts/*] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Cache pnpm modules 28 | uses: actions/cache@v4 29 | env: 30 | cache-name: cache-pnpm-modules 31 | with: 32 | path: ~/.pnpm-store 33 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 34 | restore-keys: | 35 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}- 36 | 37 | - name: Setup pnpm 38 | uses: pnpm/action-setup@v4 39 | with: 40 | run_install: true 41 | - run: pnpm run build 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | import { factory } from '@innei/prettier' 2 | 3 | export default factory({ 4 | importSort: false, 5 | }) 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]": { 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | } 7 | }, 8 | "tailwindCSS.experimental.classRegex": [ 9 | [ 10 | "([\"'`][^\"'`]*.*?[\"'`])", 11 | "[\"'`]([^\"'`]*).*?[\"'`]" 12 | ] 13 | ], 14 | // If you do not want to autofix some rules on save 15 | // You can put this in your user settings or workspace settings 16 | "eslint.codeActionsOnSave.rules": [ 17 | "!unused-imports/no-unused-imports", 18 | "*" 19 | ], 20 | // If you want to silent stylistic rules 21 | // You can put this in your user settings or workspace settings 22 | "eslint.rules.customizations": [ 23 | { 24 | "rule": "@stylistic/*", 25 | "severity": "off", 26 | "fixable": true 27 | }, 28 | { 29 | "rule": "antfu/consistent-list-newline", 30 | "severity": "off" 31 | }, 32 | { 33 | "rule": "hyoban/jsx-attribute-spacing", 34 | "severity": "off" 35 | }, 36 | { 37 | "rule": "simple-import-sort/*", 38 | "severity": "off" 39 | }, 40 | { 41 | "rule": "unused-imports/no-unused-imports", 42 | "severity": "off" 43 | } 44 | ], 45 | "exportall.config.relExclusion": [ 46 | "/src/hooks/common/index.ts" 47 | ] 48 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig } from 'eslint-config-hyoban' 3 | 4 | export default defineConfig( 5 | { 6 | formatting: false, 7 | lessOpinionated: true, 8 | preferESM: false, 9 | react: true, 10 | tailwindCSS: false, 11 | }, 12 | { 13 | settings: { 14 | tailwindcss: { 15 | whitelist: ['center'], 16 | }, 17 | }, 18 | rules: { 19 | 'unicorn/prefer-math-trunc': 'off', 20 | '@eslint-react/no-clone-element': 0, 21 | '@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 0, 22 | // NOTE: Disable this temporarily 23 | 'react-compiler/react-compiler': 0, 24 | 'no-restricted-syntax': 0, 25 | 'no-restricted-globals': [ 26 | 'error', 27 | { 28 | name: 'location', 29 | message: 30 | "Since you don't use the same router instance in electron and browser, you can't use the global location to get the route info. \n\n" + 31 | 'You can use `useLocaltion` or `getReadonlyRoute` to get the route info.', 32 | }, 33 | ], 34 | }, 35 | }, 36 | { 37 | files: ['**/*.tsx'], 38 | rules: { 39 | '@stylistic/jsx-self-closing-comp': 'error', 40 | }, 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Exif Editor & Transfer 8 | 39 | 40 | 41 |
42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exif-transfer", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "packageManager": "pnpm@10.11.1", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/innei/exif-transfer" 9 | }, 10 | "scripts": { 11 | "build": "tsc && vite build", 12 | "dev": "vite", 13 | "format": "prettier --write \"src/**/*.ts\" ", 14 | "lint": "eslint --fix", 15 | "prepare": "simple-git-hooks", 16 | "serve": "vite preview" 17 | }, 18 | "dependencies": { 19 | "@headlessui/react": "2.2.4", 20 | "@radix-ui/react-accordion": "1.2.11", 21 | "@radix-ui/react-checkbox": "1.3.2", 22 | "@radix-ui/react-context-menu": "2.2.15", 23 | "@radix-ui/react-hover-card": "1.1.14", 24 | "@radix-ui/react-label": "2.1.7", 25 | "@radix-ui/react-select": "2.2.5", 26 | "@radix-ui/react-slider": "1.3.5", 27 | "@radix-ui/react-slot": "1.2.3", 28 | "@radix-ui/react-tooltip": "1.2.7", 29 | "@remixicon/react": "4.6.0", 30 | "@tanstack/react-query": "5.80.6", 31 | "buffer": "6.0.3", 32 | "clsx": "2.1.1", 33 | "es-toolkit": "1.39.3", 34 | "exif-reader": "2.0.2", 35 | "foxact": "0.2.45", 36 | "fuji-recipes": "1.0.2", 37 | "immer": "10.1.1", 38 | "jotai": "2.12.5", 39 | "leaflet": "1.9.4", 40 | "lucide-react": "0.513.0", 41 | "motion": "12.16.0", 42 | "ofetch": "1.4.1", 43 | "piexif-ts": "npm:@innei/piexif-ts@2.1.2", 44 | "react": "19.1.0", 45 | "react-dom": "19.1.0", 46 | "react-leaflet": "5.0.0", 47 | "react-router": "7.6.2", 48 | "react-scan": "0.3.4", 49 | "sonner": "2.0.5", 50 | "tailwind-merge": "3.3.0", 51 | "tailwindcss-uikit-colors": "1.0.0-alpha.1", 52 | "usehooks-ts": "3.1.1" 53 | }, 54 | "devDependencies": { 55 | "@egoist/tailwindcss-icons": "1.9.0", 56 | "@iconify-json/mingcute": "1.2.3", 57 | "@innei/prettier": "^0.15.0", 58 | "@tailwindcss/container-queries": "0.1.1", 59 | "@tailwindcss/postcss": "4.1.8", 60 | "@tailwindcss/typography": "0.5.16", 61 | "@tailwindcss/vite": "4.1.8", 62 | "@types/leaflet": "1.9.18", 63 | "@types/node": "22.15.30", 64 | "@types/react": "19.1.6", 65 | "@types/react-dom": "19.1.6", 66 | "@vitejs/plugin-react": "^4.5.1", 67 | "autoprefixer": "10.4.21", 68 | "code-inspector-plugin": "0.20.12", 69 | "eslint": "9.28.0", 70 | "eslint-config-hyoban": "4.0.8", 71 | "lint-staged": "16.1.0", 72 | "postcss": "8.5.4", 73 | "postcss-import": "16.1.0", 74 | "postcss-js": "4.0.1", 75 | "prettier": "3.5.3", 76 | "simple-git-hooks": "2.13.0", 77 | "tailwind-scrollbar": "4.0.2", 78 | "tailwind-variants": "1.0.0", 79 | "tailwindcss": "4.1.8", 80 | "tailwindcss-animate": "1.0.7", 81 | "tailwindcss-safe-area": "0.6.0", 82 | "typescript": "5.8.3", 83 | "vite": "6.3.5", 84 | "vite-plugin-checker": "0.9.3", 85 | "vite-tsconfig-paths": "5.1.4" 86 | }, 87 | "simple-git-hooks": { 88 | "pre-commit": "pnpm exec lint-staged" 89 | }, 90 | "lint-staged": { 91 | "*.{js,jsx,ts,tsx}": [ 92 | "prettier --ignore-path ./.gitignore --write " 93 | ], 94 | "*.{js,ts,cjs,mjs,jsx,tsx,json}": [ 95 | "eslint --fix" 96 | ] 97 | } 98 | } -------------------------------------------------------------------------------- /plugins/eslint-recursive-sort.js: -------------------------------------------------------------------------------- 1 | const sortObjectKeys = (obj) => { 2 | if (typeof obj !== 'object' || obj === null) { 3 | return obj 4 | } 5 | 6 | if (Array.isArray(obj)) { 7 | return obj.map((element) => sortObjectKeys(element)) 8 | } 9 | 10 | return Object.keys(obj) 11 | .sort() 12 | .reduce((acc, key) => { 13 | acc[key] = sortObjectKeys(obj[key]) 14 | return acc 15 | }, {}) 16 | } 17 | /** 18 | * @type {import("eslint").ESLint.Plugin} 19 | */ 20 | export default { 21 | rules: { 22 | 'recursive-sort': { 23 | meta: { 24 | type: 'layout', 25 | fixable: 'code', 26 | }, 27 | create(context) { 28 | return { 29 | Program(node) { 30 | if (context.getFilename().endsWith('.json')) { 31 | const sourceCode = context.getSourceCode() 32 | const text = sourceCode.getText() 33 | 34 | try { 35 | const json = JSON.parse(text) 36 | const sortedJson = sortObjectKeys(json) 37 | const sortedText = JSON.stringify(sortedJson, null, 2) 38 | 39 | if (text.trim() !== sortedText.trim()) { 40 | context.report({ 41 | node, 42 | message: 'JSON keys are not sorted recursively', 43 | fix(fixer) { 44 | return fixer.replaceText(node, sortedText) 45 | }, 46 | }) 47 | } 48 | } catch (error) { 49 | context.report({ 50 | node, 51 | message: `Invalid JSON: ${error.message}`, 52 | }) 53 | } 54 | } 55 | }, 56 | } 57 | }, 58 | }, 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innei/exif-tools/b677f7e85ebefb8386354a823192b276653c0760/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innei/exif-tools/b677f7e85ebefb8386354a823192b276653c0760/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innei/exif-tools/b677f7e85ebefb8386354a823192b276653c0760/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innei/exif-tools/b677f7e85ebefb8386354a823192b276653c0760/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innei/exif-tools/b677f7e85ebefb8386354a823192b276653c0760/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innei/exif-tools/b677f7e85ebefb8386354a823192b276653c0760/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Innei/exif-tools/b677f7e85ebefb8386354a823192b276653c0760/public/logo.jpg -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # EXIF Transfer & Editor 2 | 3 | A modern web application for reading, editing, and transferring EXIF metadata between images. Built with React, TypeScript, and TailwindCSS, featuring an intuitive drag-and-drop interface for photographers and image processing workflows. 4 | 5 | ## ✨ Features 6 | 7 | ### 📸 EXIF Data Management 8 | 9 | - **Read & Edit EXIF Data** - View and modify comprehensive metadata from images with an interactive editor 10 | - **Transfer EXIF Data** - Copy metadata between different images seamlessly 11 | - **Preserve Image Quality** - Lossless EXIF operations without image degradation 12 | - **Multiple Format Support** - Works with JPEG, PNG and other common formats 13 | - **Detailed EXIF Display** - View organized metadata with human-readable labels and collapsible sections 14 | - **GPS Data Control** - Option to preserve or remove GPS location information 15 | - **Fuji Film Simulation Support** - Special handling for Fujifilm camera recipes and film simulations 16 | 17 | ### 🎨 Modern Interface 18 | 19 | - **Dual Mode Operation** - Separate modes for EXIF editing and data transfer 20 | - **Drag & Drop Upload** - Intuitive file upload experience with visual feedback 21 | - **Real-time Preview** - Instant image and EXIF data visualization 22 | - **Interactive EXIF Editor** - Click-to-edit functionality for modifying metadata values 23 | - **Dark/Light Theme** - System preference aware theme switching 24 | - **Responsive Design** - Mobile-first approach optimized for all devices 25 | - **Export Options** - Download processed images or export EXIF data as JSON 26 | 27 | ### 🚀 Technical Stack 28 | 29 | - **Vite 6** - Lightning-fast build tool with Hot Module Replacement 30 | - **React 19** - Latest React with concurrent features 31 | - **TypeScript** - Full type safety and IntelliSense support 32 | - **TailwindCSS 4** - Utility-first CSS with modern design system 33 | - **EXIF Libraries** - `exif-reader` and `piexif-ts` for robust metadata handling 34 | - **Fuji Recipes** - `fuji-recipes` library for Fujifilm film simulation support 35 | 36 | ### 🛠️ Developer Experience 37 | 38 | - **ESLint + Prettier** - Consistent code formatting and linting 39 | - **Git Hooks** - Pre-commit hooks with lint-staged for quality assurance 40 | - **Modern Tooling** - Optimized development workflow with hot reloading 41 | - **TypeScript Config** - Strict type checking with path mapping support 42 | 43 | ## 🚀 Quick Start 44 | 45 | ### Prerequisites 46 | 47 | - Node.js 18+ 48 | - pnpm (recommended package manager) 49 | 50 | ### Installation 51 | 52 | ```bash 53 | # Clone the repository 54 | git clone https://github.com/innei/exif-transfer 55 | cd exif-transfer 56 | 57 | # Install dependencies 58 | pnpm install 59 | 60 | # Start development server 61 | pnpm dev 62 | ``` 63 | 64 | ## 🎯 How to Use 65 | 66 | ### EXIF Editor Mode (`/editor`) 67 | 68 | 1. **Upload Image** - Drag and drop or click to select an image with EXIF data 69 | 2. **View & Edit** - Browse organized EXIF sections and click on values to edit them 70 | 3. **GPS Control** - Choose to preserve or remove GPS location data 71 | 4. **Export Options** - Download the modified image or export EXIF data as JSON 72 | 73 | ### EXIF Transfer Mode (`/transfer`) 74 | 75 | 1. **Upload Source Image** - Select an image with EXIF data you want to copy 76 | 2. **Upload Target Image** - Select the image you want to add EXIF data to 77 | 3. **Transfer EXIF** - Click "Transfer EXIF" to copy metadata from source to target 78 | 4. **View Results** - Inspect the transferred EXIF data in the organized display 79 | 5. **Download** - Save the processed image with embedded EXIF metadata 80 | 81 | ## 📁 Project Structure 82 | 83 | ``` 84 | src/ 85 | ├── components/ # Reusable UI components 86 | │ ├── ui/ # Base UI components (buttons, accordion, etc.) 87 | │ └── common/ # App-specific components 88 | │ ├── ExifDisplay.tsx # Interactive EXIF metadata display 89 | │ ├── ImageUploader.tsx # Drag & drop image uploader 90 | │ └── GpsDisplay.tsx # GPS coordinate display 91 | ├── pages/ # Application pages 92 | │ ├── reader/ # EXIF editor interface 93 | │ └── transfer/ # EXIF transfer interface 94 | ├── hooks/ # Custom React hooks 95 | │ └── useExif.ts # EXIF data extraction hook 96 | ├── lib/ # Utility functions and configurations 97 | │ ├── exif-tags.ts # EXIF tag mapping and translations 98 | │ ├── exif-converter.ts # EXIF format conversion utilities 99 | │ └── cn.ts # Class name utilities 100 | ├── providers/ # Context providers 101 | ├── atoms/ # Jotai state management 102 | └── styles/ # Global styles and Tailwind config 103 | ``` 104 | 105 | ## 🔧 Key Components 106 | 107 | ### Interactive EXIF Display (`ExifDisplay.tsx`) 108 | 109 | - **Organized Sections** - Collapsible sections for different EXIF categories 110 | - **Click-to-Edit** - Interactive editing of EXIF values with proper formatting 111 | - **Human-Readable Labels** - Friendly names for technical EXIF tags 112 | - **Value Formatting** - Automatic formatting for exposure, ISO, GPS coordinates, etc. 113 | - **Fuji Recipe Support** - Special handling for Fujifilm film simulation data 114 | 115 | ### Image Uploader (`ImageUploader.tsx`) 116 | 117 | - **Drag & Drop** - Visual feedback for file operations 118 | - **Image Preview** - Immediate preview with proper aspect ratio 119 | - **File Validation** - Support for JPEG, PNG, and other common formats 120 | - **Error Handling** - Graceful handling of invalid or corrupted files 121 | 122 | ### EXIF Processing 123 | 124 | - **Robust Extraction** - Uses `exif-reader` for comprehensive metadata reading 125 | - **Metadata Injection** - `piexif-ts` library for embedding EXIF data 126 | - **Format Conversion** - Seamless conversion between different EXIF formats 127 | - **Binary Data Handling** - Proper encoding/decoding of complex EXIF structures 128 | 129 | ## 🎨 EXIF Data Support 130 | 131 | The application handles comprehensive EXIF metadata including: 132 | 133 | - **Camera Settings** - Aperture (f/stop), shutter speed, ISO sensitivity, focal length 134 | - **Image Technical** - Resolution, color space, orientation, compression 135 | - **GPS Information** - Latitude, longitude, altitude, timestamp 136 | - **Timestamps** - Original capture time, modification dates 137 | - **Equipment Details** - Camera make/model, lens information, firmware 138 | - **Exposure Data** - Exposure mode, metering mode, white balance, flash settings 139 | - **Fujifilm Specific** - Film simulation recipes, dynamic range settings 140 | 141 | ### Supported Formats & Values 142 | 143 | - **Exposure Time** - Displayed as fractional seconds (e.g., "1/250s") 144 | - **Aperture** - F-number format (e.g., "f/2.8") 145 | - **ISO** - Standard ISO notation (e.g., "ISO 800") 146 | - **Focal Length** - Millimeter notation (e.g., "50mm") 147 | - **GPS Coordinates** - Decimal degrees with hemisphere indicators 148 | - **Timestamps** - Localized date/time formatting 149 | 150 | ## 🤝 Contributing 151 | 152 | 1. Fork the repository 153 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 154 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 155 | 4. Push to the branch (`git push origin feature/amazing-feature`) 156 | 5. Open a Pull Request 157 | 158 | ## 📄 License 159 | 160 | This project is open source and available under the [MIT License](LICENSE). 161 | 162 | --- 163 | 164 | **Built with ❤️ for photographers and image processing enthusiasts** 165 | 166 | 2025 © Innei, Released under the MIT License. 167 | 168 | > [Personal Website](https://innei.in/) · GitHub [@Innei](https://github.com/innei/) 169 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { Outlet } from 'react-router' 3 | 4 | import { Footer } from './components/common/Footer' 5 | import { Header } from './components/common/Header' 6 | import { RootProviders } from './providers/root-providers' 7 | 8 | export const App: FC = () => { 9 | return ( 10 | 11 |
12 | 13 |