>
33 |
34 | export type ComponentWithRef = FC<
35 | ComponentWithRefType
36 | >
37 | export type ComponentWithRefType
= Prettify<
38 | ComponentType
& {
39 | ref?: React.Ref[
40 | }
41 | >
42 |
43 | export type ComponentType] = {
44 | className?: string
45 | } & PropsWithChildren &
46 | P
47 | }
48 |
49 | declare module 'react' {
50 | export interface AriaAttributes {
51 | 'data-testid'?: string
52 | 'data-hide-in-print'?: boolean
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/hooks/common/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useDark'
2 | export * from './useInputComposition'
3 | export * from './usePrevious'
4 | export * from './useRefValue'
5 | export * from './useTitle'
6 |
--------------------------------------------------------------------------------
/src/hooks/common/useControlled.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react'
2 |
3 | import { useRefValue } from './useRefValue'
4 |
5 | export const useControlled = (
6 | value: T | undefined,
7 | defaultValue: T,
8 | onChange?: (v: T, ...args: any[]) => void,
9 | ): [T, (value: T) => void] => {
10 | const [stateValue, setStateValue] = useState(
11 | value !== undefined ? value : defaultValue,
12 | )
13 | const isControlled = value !== undefined
14 | const onChangeRef = useRefValue(onChange)
15 |
16 | const setValue = useCallback(
17 | (newValue: T) => {
18 | if (!isControlled) {
19 | setStateValue(newValue)
20 | }
21 | onChangeRef.current?.(newValue)
22 | },
23 | [isControlled, onChangeRef],
24 | )
25 |
26 | return [isControlled ? value : stateValue, setValue]
27 | }
28 |
--------------------------------------------------------------------------------
/src/hooks/common/useDark.ts:
--------------------------------------------------------------------------------
1 | import { useAtomValue } from 'jotai'
2 | import { atomWithStorage } from 'jotai/utils'
3 | import { useCallback, useLayoutEffect } from 'react'
4 | import { useMediaQuery } from 'usehooks-ts'
5 |
6 | import { nextFrame } from '~/lib/dom'
7 | import { jotaiStore } from '~/lib/jotai'
8 |
9 | const useDarkQuery = () => useMediaQuery('(prefers-color-scheme: dark)')
10 | type ColorMode = 'light' | 'dark' | 'system'
11 | const themeAtom = atomWithStorage(
12 | 'color-mode',
13 | 'system' as ColorMode,
14 | undefined,
15 | {
16 | getOnInit: true,
17 | },
18 | )
19 |
20 | function useDarkWebApp() {
21 | const systemIsDark = useDarkQuery()
22 | const mode = useAtomValue(themeAtom)
23 | return mode === 'dark' || (mode === 'system' && systemIsDark)
24 | }
25 | export const useIsDark = useDarkWebApp
26 |
27 | export const useThemeAtomValue = () => useAtomValue(themeAtom)
28 |
29 | const useSyncThemeWebApp = () => {
30 | const colorMode = useAtomValue(themeAtom)
31 | const systemIsDark = useDarkQuery()
32 | useLayoutEffect(() => {
33 | const realColorMode: Exclude =
34 | colorMode === 'system' ? (systemIsDark ? 'dark' : 'light') : colorMode
35 | document.documentElement.dataset.theme = realColorMode
36 | disableTransition(['[role=switch]>*'])()
37 | }, [colorMode, systemIsDark])
38 | }
39 |
40 | export const useSyncThemeark = useSyncThemeWebApp
41 |
42 | export const useSetTheme = () =>
43 | useCallback((colorMode: ColorMode) => {
44 | jotaiStore.set(themeAtom, colorMode)
45 | }, [])
46 |
47 | function disableTransition(disableTransitionExclude: string[] = []) {
48 | const css = document.createElement('style')
49 | css.append(
50 | document.createTextNode(
51 | `
52 | *${disableTransitionExclude.map((s) => `:not(${s})`).join('')} {
53 | -webkit-transition: none !important;
54 | -moz-transition: none !important;
55 | -o-transition: none !important;
56 | -ms-transition: none !important;
57 | transition: none !important;
58 | }
59 | `,
60 | ),
61 | )
62 | document.head.append(css)
63 |
64 | return () => {
65 | // Force restyle
66 | ;(() => window.getComputedStyle(document.body))()
67 |
68 | // Wait for next tick before removing
69 | nextFrame(() => {
70 | css.remove()
71 | })
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/hooks/common/useInputComposition.ts:
--------------------------------------------------------------------------------
1 | import type { CompositionEventHandler } from 'react'
2 | import { useCallback, useEffect, useRef } from 'react'
3 |
4 | type InputElementAttributes = React.DetailedHTMLProps<
5 | React.InputHTMLAttributes,
6 | HTMLInputElement
7 | >
8 | type TextareaElementAttributes = React.DetailedHTMLProps<
9 | React.TextareaHTMLAttributes,
10 | HTMLTextAreaElement
11 | >
12 | export const useInputComposition = (
13 | props: Pick<
14 | E extends HTMLInputElement
15 | ? InputElementAttributes
16 | : E extends HTMLTextAreaElement
17 | ? TextareaElementAttributes
18 | : never,
19 | 'onKeyDown' | 'onCompositionEnd' | 'onCompositionStart' | 'onKeyDownCapture'
20 | >,
21 | ) => {
22 | const { onKeyDown, onCompositionStart, onCompositionEnd } = props
23 |
24 | const isCompositionRef = useRef(false)
25 |
26 | const currentInputTargetRef = useRef(null)
27 |
28 | const handleCompositionStart: CompositionEventHandler = useCallback(
29 | (e) => {
30 | currentInputTargetRef.current = e.target as E
31 |
32 | isCompositionRef.current = true
33 | onCompositionStart?.(e as any)
34 | },
35 | [onCompositionStart],
36 | )
37 |
38 | const handleCompositionEnd: CompositionEventHandler = useCallback(
39 | (e) => {
40 | currentInputTargetRef.current = null
41 | isCompositionRef.current = false
42 | onCompositionEnd?.(e as any)
43 | },
44 | [onCompositionEnd],
45 | )
46 |
47 | const handleKeyDown: React.KeyboardEventHandler = useCallback(
48 | (e: any) => {
49 | // The keydown event stop emit when the composition is being entered
50 | if (isCompositionRef.current) {
51 | e.stopPropagation()
52 | return
53 | }
54 | onKeyDown?.(e)
55 |
56 | if (e.key === 'Escape') {
57 | e.preventDefault()
58 | e.stopPropagation()
59 |
60 | if (!isCompositionRef.current) {
61 | e.currentTarget.blur()
62 | }
63 | }
64 | },
65 | [onKeyDown],
66 | )
67 |
68 | // Register a global capture keydown listener to prevent the radix `useEscapeKeydown` from working
69 | useEffect(() => {
70 | const handleGlobalKeyDown = (e: KeyboardEvent) => {
71 | if (e.key === 'Escape' && currentInputTargetRef.current) {
72 | e.stopPropagation()
73 | e.preventDefault()
74 | }
75 | }
76 |
77 | document.addEventListener('keydown', handleGlobalKeyDown, { capture: true })
78 |
79 | return () => {
80 | document.removeEventListener('keydown', handleGlobalKeyDown, {
81 | capture: true,
82 | })
83 | }
84 | }, [])
85 |
86 | const ret = {
87 | onCompositionEnd: handleCompositionEnd,
88 | onCompositionStart: handleCompositionStart,
89 | onKeyDown: handleKeyDown,
90 | }
91 | Object.defineProperty(ret, 'isCompositionRef', {
92 | value: isCompositionRef,
93 | enumerable: false,
94 | })
95 | return ret as typeof ret & {
96 | isCompositionRef: typeof isCompositionRef
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/hooks/common/useMobile.ts:
--------------------------------------------------------------------------------
1 | import { useViewport } from './useViewport'
2 |
3 | export const useMobile = () => {
4 | return useViewport((v) => v.w < 1024 && v.w !== 0)
5 | }
6 |
7 | export const isMobile = () => {
8 | const w = window.innerWidth
9 | return w < 1024 && w !== 0
10 | }
11 |
--------------------------------------------------------------------------------
/src/hooks/common/usePrevious.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | export const usePrevious = (value: T): T | undefined => {
4 | const ref = useRef(void 0)
5 | useEffect(() => {
6 | ref.current = value
7 | })
8 | return ref.current
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/common/useRefValue.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useRef } from 'react'
2 |
3 | export const useRefValue = (
4 | value: S,
5 | ): Readonly<{
6 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
7 | current: S extends Function ? S : Readonly
8 | }> => {
9 | const ref = useRef(value)
10 |
11 | useLayoutEffect(() => {
12 | ref.current = value
13 | }, [value])
14 | return ref as any
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/common/useTitle.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | const titleTemplate = `%s | ${APP_NAME}`
4 | export const useTitle = (title?: Nullable) => {
5 | const currentTitleRef = useRef(document.title)
6 | useEffect(() => {
7 | if (!title) return
8 |
9 | document.title = titleTemplate.replace('%s', title)
10 | return () => {
11 | document.title = currentTitleRef.current
12 | }
13 | }, [title])
14 | }
15 |
--------------------------------------------------------------------------------
/src/hooks/common/useViewport.ts:
--------------------------------------------------------------------------------
1 | import type { ExtractAtomValue, getDefaultStore } from 'jotai'
2 | import { useAtomValue } from 'jotai'
3 | import { selectAtom } from 'jotai/utils'
4 | import { useCallback } from 'react'
5 |
6 | import { viewportAtom } from '~/atoms/viewport'
7 |
8 | export const useViewport = (
9 | selector: (value: ExtractAtomValue) => T,
10 | ): T =>
11 | useAtomValue(
12 | selectAtom(
13 | viewportAtom,
14 | useCallback((atomValue) => selector(atomValue), []),
15 | ),
16 | )
17 |
18 | type JotaiStore = ReturnType
19 | export const getViewport = (store: JotaiStore) => store.get(viewportAtom)
20 |
--------------------------------------------------------------------------------
/src/hooks/useExif.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable unicorn/prefer-node-protocol */
2 | import { Buffer } from 'buffer'
3 | import type { Exif } from 'exif-reader'
4 | import exifReader from 'exif-reader'
5 | import getRecipe from 'fuji-recipes'
6 | import * as piexif from 'piexif-ts'
7 | import { useEffect, useState } from 'react'
8 | import { toast } from 'sonner'
9 |
10 | export const useExif = (file: File | null) => {
11 | const [exif, setExif] = useState(null)
12 | const [piexifExif, setPiexifExif] = useState(null)
13 | const [fujiRecipe, setFujiRecipe] = useState | null>(null)
14 | const [isProcessing, setIsProcessing] = useState(false)
15 |
16 | useEffect(() => {
17 | if (!file) {
18 | setExif(null)
19 | setPiexifExif(null)
20 | setFujiRecipe(null)
21 | return
22 | }
23 |
24 | const processFile = async () => {
25 | setIsProcessing(true)
26 | try {
27 | const arrayBuffer = await file.arrayBuffer()
28 |
29 | // Convert ArrayBuffer to binary string for piexif.load()
30 | const uint8Array = new Uint8Array(arrayBuffer)
31 | let binaryString = ''
32 | for (const byte of uint8Array) {
33 | binaryString += String.fromCodePoint(byte)
34 | }
35 |
36 | const exifObj = piexif.load(binaryString)
37 |
38 | setPiexifExif(exifObj)
39 | const exifSegmentStr = piexif.dump(exifObj)
40 |
41 | const exifData = exifReader(Buffer.from(exifSegmentStr, 'binary'))
42 | setExif(exifData)
43 |
44 | if (exifData.Photo?.MakerNote) {
45 | try {
46 | const recipe = getRecipe(exifData.Photo?.MakerNote)
47 | setFujiRecipe(recipe)
48 | } catch (error) {
49 | console.warn('Could not parse Fuji recipe from MakerNote.', error)
50 | setFujiRecipe(null)
51 | }
52 | } else {
53 | setFujiRecipe(null)
54 | }
55 | } catch (error) {
56 | console.error('Could not read EXIF data from source image.', error)
57 | toast.error('Could not read EXIF data from source image.')
58 | setExif(null)
59 | setPiexifExif(null)
60 | setFujiRecipe(null)
61 | } finally {
62 | setIsProcessing(false)
63 | }
64 | }
65 |
66 | processFile()
67 | }, [file])
68 |
69 | return { exif, piexifExif, fujiRecipe, isProcessing }
70 | }
71 |
--------------------------------------------------------------------------------
/src/lib/cn.ts:
--------------------------------------------------------------------------------
1 | // Tremor Raw cx [v0.0.0]
2 |
3 | import type { ClassValue } from 'clsx'
4 | import clsx from 'clsx'
5 | import { twMerge } from 'tailwind-merge'
6 |
7 | export const clsxm = (...args: any[]) => {
8 | return twMerge(clsx(args))
9 | }
10 |
11 | export function cx(...args: ClassValue[]) {
12 | return twMerge(clsx(...args))
13 | }
14 |
15 | // Tremor focusInput [v0.0.2]
16 |
17 | export const focusInput = [
18 | // base
19 | 'focus:ring-2',
20 | // ring color
21 | 'focus:ring-blue-200 dark:focus:ring-blue-700/30',
22 | // border color
23 | 'focus:border-blue-500 dark:focus:border-blue-700',
24 | ]
25 |
26 | // Tremor Raw focusRing [v0.0.1]
27 |
28 | export const focusRing = [
29 | // base
30 | 'outline outline-offset-2 outline-0 focus-visible:outline-2',
31 | // outline color
32 | 'outline-blue-500 dark:outline-blue-500',
33 | ]
34 |
35 | // Tremor Raw hasErrorInput [v0.0.1]
36 |
37 | export const hasErrorInput = [
38 | // base
39 | 'ring-2',
40 | // border color
41 | 'border-red-500 dark:border-red-700',
42 | // ring color
43 | 'ring-red-200 dark:ring-red-700/30',
44 | ]
45 |
--------------------------------------------------------------------------------
/src/lib/dev.tsx:
--------------------------------------------------------------------------------
1 | declare const APP_DEV_CWD: string
2 | export const attachOpenInEditor = (stack: string) => {
3 | const lines = stack.split('\n')
4 | return lines.map((line) => {
5 | // A line like this: at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
6 | // Find the `localhost` part and open the file in the editor
7 | if (!line.includes('at ')) {
8 | return line
9 | }
10 | const match = line.match(/(http:\/\/localhost:\d+\/[^:]+):(\d+):(\d+)/)
11 |
12 | if (match) {
13 | const [o] = match
14 |
15 | // Find `@fs/`
16 | // Like: `http://localhost:5173/@fs/Users/innei/git/work/rss3/follow/node_modules/.vite/deps/chunk-RPCDYKBN.js?v=757920f2:11548:26`
17 | const realFsPath = o.split('@fs')[1]
18 |
19 | if (realFsPath) {
20 | return (
21 | // Delete `v=` hash, like `v=757920f2`
22 |
30 | {line}
31 |
32 | )
33 | } else {
34 | // at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
35 | const srcFsPath = o.split('/src')[1]
36 |
37 | if (srcFsPath) {
38 | const fs = srcFsPath.replace(/\?t=[a-f0-9]+/, '')
39 |
40 | return (
41 |
46 | {line}
47 |
48 | )
49 | }
50 | }
51 | }
52 |
53 | return line
54 | })
55 | }
56 | // http://localhost:5173/src/App.tsx?t=1720527056591:41:9
57 | const openInEditor = (file: string) => {
58 | fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}`)}`)
59 | }
60 |
--------------------------------------------------------------------------------
/src/lib/dom.ts:
--------------------------------------------------------------------------------
1 | import type { ReactEventHandler } from "react"
2 |
3 | export const stopPropagation: ReactEventHandler = (e) => e.stopPropagation()
4 |
5 | export const preventDefault: ReactEventHandler = (e) => e.preventDefault()
6 |
7 | export const nextFrame = (fn: (...args: any[]) => any) => {
8 | requestAnimationFrame(() => {
9 | requestAnimationFrame(() => {
10 | fn()
11 | })
12 | })
13 | }
14 |
15 | export const getElementTop = (element: HTMLElement) => {
16 | let actualTop = element.offsetTop
17 | let current = element.offsetParent as HTMLElement
18 | while (current !== null) {
19 | actualTop += current.offsetTop
20 | current = current.offsetParent as HTMLElement
21 | }
22 | return actualTop
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/exif-addable-tags.ts:
--------------------------------------------------------------------------------
1 | export type ExifTagDefinition = {
2 | type: 'text' | 'number' | 'datetime-local'
3 | description?: string
4 | }
5 |
6 | export type ExifSectionDefinition = Record
7 |
8 | export const addableExifTags: Record = {
9 | Image: {
10 | ImageWidth: { type: 'number' },
11 | ImageLength: { type: 'number' },
12 | BitsPerSample: { type: 'text', description: 'e.g., 8, 8, 8' },
13 | Make: { type: 'text' },
14 | Model: { type: 'text' },
15 | Orientation: { type: 'number' },
16 | XResolution: { type: 'number' },
17 | YResolution: { type: 'number' },
18 | ResolutionUnit: { type: 'number', description: '2 for inches, 3 for cm' },
19 | Software: { type: 'text' },
20 | DateTime: { type: 'datetime-local' },
21 | Artist: { type: 'text' },
22 | YCbCrPositioning: { type: 'number' },
23 | Copyright: { type: 'text' },
24 | HostComputer: { type: 'text' },
25 | ImageDescription: { type: 'text' },
26 | },
27 | Exif: {
28 | ExposureTime: { type: 'number', description: 'In seconds' },
29 | FNumber: { type: 'number' },
30 | ExposureProgram: { type: 'number' },
31 | ISOSpeedRatings: { type: 'number' },
32 | SensitivityType: { type: 'number' },
33 | DateTimeOriginal: { type: 'datetime-local' },
34 | DateTimeDigitized: { type: 'datetime-local' },
35 | OffsetTime: { type: 'text', description: 'e.g., +08:00' },
36 | OffsetTimeOriginal: { type: 'text', description: 'e.g., +08:00' },
37 | OffsetTimeDigitized: { type: 'text', description: 'e.g., +08:00' },
38 | CompressedBitsPerPixel: { type: 'number' },
39 | ShutterSpeedValue: { type: 'number' },
40 | ApertureValue: { type: 'number' },
41 | BrightnessValue: { type: 'number' },
42 | ExposureBiasValue: { type: 'number' },
43 | MaxApertureValue: { type: 'number' },
44 | MeteringMode: { type: 'number' },
45 | LightSource: { type: 'number' },
46 | Flash: { type: 'number' },
47 | FocalLength: { type: 'number' },
48 | UserComment: { type: 'text' },
49 | ColorSpace: { type: 'number' },
50 | PixelXDimension: { type: 'number' },
51 | PixelYDimension: { type: 'number' },
52 | FocalPlaneXResolution: { type: 'number' },
53 | FocalPlaneYResolution: { type: 'number' },
54 | FocalPlaneResolutionUnit: { type: 'number' },
55 | SensingMethod: { type: 'number' },
56 | CustomRendered: { type: 'number' },
57 | ExposureMode: { type: 'number' },
58 | WhiteBalance: { type: 'number' },
59 | FocalLengthIn35mmFilm: { type: 'number' },
60 | SceneCaptureType: { type: 'number' },
61 | Sharpness: { type: 'number' },
62 | SubjectDistanceRange: { type: 'number' },
63 | BodySerialNumber: { type: 'text' },
64 | LensSpecification: { type: 'text', description: 'e.g., 50, 50, 1.4, 1.4' },
65 | LensMake: { type: 'text' },
66 | LensModel: { type: 'text' },
67 | LensSerialNumber: { type: 'text' },
68 | },
69 | GPS: {
70 | GPSVersionID: { type: 'text', description: 'e.g., 2, 3, 0, 0' },
71 | GPSLatitudeRef: { type: 'text', description: 'N or S' },
72 | GPSLatitude: { type: 'text', description: 'e.g., 30, 45, 50.008' },
73 | GPSLongitudeRef: { type: 'text', description: 'E or W' },
74 | GPSLongitude: { type: 'text', description: 'e.g., 120, 44, 22.733' },
75 | GPSAltitudeRef: {
76 | type: 'number',
77 | description: '0 for above sea level, 1 for below',
78 | },
79 | GPSAltitude: { type: 'number' },
80 | GPSTimeStamp: { type: 'text', description: 'e.g., 8, 12, 42' },
81 | GPSSpeedRef: { type: 'text', description: 'K, M, or N' },
82 | GPSSpeed: { type: 'number' },
83 | GPSMapDatum: { type: 'text', description: 'e.g., WGS-84' },
84 | GPSDateStamp: { type: 'text', description: 'YYYY:MM:DD' },
85 | },
86 | Interoperability: {
87 | InteroperabilityIndex: { type: 'text', description: 'e.g., R98' },
88 | },
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/exif-converter.ts:
--------------------------------------------------------------------------------
1 | import type { Exif } from 'exif-reader'
2 | import * as piexif from 'piexif-ts'
3 |
4 | const RATIONAL_TAGS = new Set([
5 | 'ExposureTime',
6 | 'FNumber',
7 | 'ApertureValue',
8 | 'FocalLength',
9 | 'XResolution',
10 | 'YResolution',
11 | 'ExposureBiasValue',
12 | 'CompressedBitsPerPixel',
13 | 'ShutterSpeedValue',
14 | 'BrightnessValue',
15 | 'MaxApertureValue',
16 | 'SubjectDistance',
17 | 'FocalPlaneXResolution',
18 | 'FocalPlaneYResolution',
19 | 'DigitalZoomRatio',
20 | 'Gamma',
21 | 'ExposureIndex',
22 | 'GPSAltitude',
23 | 'GPSSpeed',
24 | 'GPSImgDirection',
25 | 'GPSDestBearing',
26 | 'GPSDestDistance',
27 | 'GPSHPositioningError',
28 | 'CameraElevationAngle',
29 | ])
30 |
31 | const RATIONAL_ARRAY_TAGS = new Set([
32 | 'LensSpecification',
33 | 'WhitePoint',
34 | 'PrimaryChromaticities',
35 | 'YCbCrCoefficients',
36 | 'ReferenceBlackWhite',
37 | 'GPSLatitude',
38 | 'GPSLongitude',
39 | 'GPSTimeStamp',
40 | 'GPSDestLatitude',
41 | 'GPSDestLongitude',
42 | ])
43 |
44 | const UNDEFINED_TAGS = new Set([
45 | 'ExifVersion',
46 | 'FlashpixVersion',
47 | 'ComponentsConfiguration',
48 | 'MakerNote',
49 | 'UserComment',
50 | 'FileSource',
51 | 'SceneType',
52 | 'PrintImageMatching',
53 | ])
54 | const DATETIME_TAGS = new Set([
55 | 'DateTimeOriginal',
56 | 'DateTimeDigitized',
57 | 'DateTime',
58 | ])
59 |
60 | const convertValueToPiexifFormat = (tagName: string, value: any) => {
61 | if (RATIONAL_TAGS.has(tagName) && typeof value === 'number') {
62 | const denominator = 100_000
63 | const numerator = Math.round(value * denominator)
64 | return [numerator, denominator]
65 | }
66 |
67 | if (RATIONAL_ARRAY_TAGS.has(tagName) && Array.isArray(value)) {
68 | return value.map((v) => {
69 | if (typeof v === 'number') {
70 | const denominator = 100_000
71 | const numerator = Math.round(v * denominator)
72 | return [numerator, denominator]
73 | }
74 | // It might already be in the correct format
75 | return v
76 | })
77 | }
78 |
79 | if (
80 | tagName === 'UserComment' &&
81 | typeof value === 'object' &&
82 | value !== null &&
83 | 'comment' in value
84 | ) {
85 | // piexif-ts will add ASCII prefix if it's a plain string.
86 | // This is safer than trying to reconstruct the comment block with encoding.
87 | return value.comment
88 | }
89 |
90 | if (UNDEFINED_TAGS.has(tagName)) {
91 | if (value instanceof Uint8Array) {
92 | return String.fromCodePoint(...value)
93 | }
94 | if (
95 | typeof value === 'object' &&
96 | value !== null &&
97 | value.value &&
98 | Array.isArray(value.value)
99 | ) {
100 | return String.fromCodePoint(...value.value)
101 | }
102 | // Handle MakerNote which might be an object with numeric keys from exif-reader
103 | if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
104 | const byteValues = Object.values(value)
105 | if (
106 | byteValues.length > 0 &&
107 | byteValues.every((v) => typeof v === 'number')
108 | ) {
109 | return String.fromCodePoint(...(byteValues as number[]))
110 | }
111 | }
112 | }
113 |
114 | if (DATETIME_TAGS.has(tagName) && value instanceof Date) {
115 | const year = value.getFullYear()
116 | const month = (value.getMonth() + 1).toString().padStart(2, '0')
117 | const day = value.getDate().toString().padStart(2, '0')
118 | const hours = value.getHours().toString().padStart(2, '0')
119 | const minutes = value.getMinutes().toString().padStart(2, '0')
120 | const seconds = value.getSeconds().toString().padStart(2, '0')
121 | return `${year}:${month}:${day} ${hours}:${minutes}:${seconds}`
122 | }
123 |
124 | return value
125 | }
126 |
127 | export const convertExifReaderToPiexif = (
128 | exifReaderData: Exif,
129 | originalThumbnail: string | null | undefined,
130 | ): piexif.IExif => {
131 | const piexifData: piexif.IExif = {
132 | '0th': {},
133 | Exif: {},
134 | GPS: {},
135 | '1st': {},
136 | Interop: {},
137 | thumbnail: originalThumbnail || undefined,
138 | }
139 |
140 | const findTagDetails = (
141 | tagName: string,
142 | ifdName: string,
143 | ): { code: number; ifd: string } | undefined => {
144 | const ifd = (piexif.Tags as any)[ifdName] as Record<
145 | string,
146 | { name: string; type: number }
147 | >
148 | if (!ifd) return undefined
149 | for (const code in ifd) {
150 | if (ifd[code].name === tagName) {
151 | return { code: Number(code), ifd: ifdName }
152 | }
153 | }
154 | return undefined
155 | }
156 |
157 | // Process Image data (goes to 0th or Exif)
158 | if (exifReaderData.Image) {
159 | for (const tagName in exifReaderData.Image) {
160 | const value = exifReaderData.Image[tagName]
161 | const tagDetails =
162 | findTagDetails(tagName, 'Image') ?? findTagDetails(tagName, 'Exif')
163 | if (tagDetails) {
164 | const destIfd =
165 | tagDetails.ifd === 'Image'
166 | ? piexifData['0th']
167 | : (piexifData[tagDetails.ifd as keyof piexif.IExif] as Record<
168 | number,
169 | piexif.IExifElement
170 | >)
171 | if (destIfd) {
172 | destIfd[tagDetails.code] = convertValueToPiexifFormat(tagName, value)
173 | }
174 | }
175 | }
176 | }
177 |
178 | // Process Photo data (goes to Exif)
179 | if (exifReaderData.Photo) {
180 | for (const tagName in exifReaderData.Photo) {
181 | const value = exifReaderData.Photo[tagName]
182 | const tagDetails = findTagDetails(tagName, 'Exif')
183 | if (tagDetails && piexifData.Exif) {
184 | piexifData.Exif[tagDetails.code] = convertValueToPiexifFormat(
185 | tagName,
186 | value,
187 | )
188 | }
189 | }
190 | }
191 |
192 | // Process ThumbnailTags (goes to 1st)
193 | if (exifReaderData.ThumbnailTags) {
194 | for (const tagName in exifReaderData.ThumbnailTags) {
195 | const value = exifReaderData.ThumbnailTags[tagName]
196 | const tagDetails = findTagDetails(tagName, 'FirstIFD')
197 | if (tagDetails && piexifData['1st']) {
198 | piexifData['1st'][tagDetails.code] = convertValueToPiexifFormat(
199 | tagName,
200 | value,
201 | )
202 | }
203 | }
204 | }
205 |
206 | // Process GPSInfo (goes to GPS)
207 | if (exifReaderData.GPSInfo) {
208 | for (const tagName in exifReaderData.GPSInfo) {
209 | const value = exifReaderData.GPSInfo[tagName]
210 | const tagDetails = findTagDetails(tagName, 'GPS')
211 | if (tagDetails && piexifData.GPS) {
212 | piexifData.GPS[tagDetails.code] = convertValueToPiexifFormat(
213 | tagName,
214 | value,
215 | )
216 | }
217 | }
218 | }
219 |
220 | // Process Iop (goes to Interop)
221 | if (exifReaderData.Iop) {
222 | for (const tagName in exifReaderData.Iop) {
223 | const value = exifReaderData.Iop[tagName]
224 | const tagDetails = findTagDetails(tagName, 'Interop')
225 | if (tagDetails && piexifData.Interop) {
226 | piexifData.Interop[tagDetails.code] = convertValueToPiexifFormat(
227 | tagName,
228 | value,
229 | )
230 | }
231 | }
232 | }
233 |
234 | return piexifData
235 | }
236 |
--------------------------------------------------------------------------------
/src/lib/exif-format.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | colorSpaceMap,
3 | exposureProgramMap,
4 | flashMap,
5 | fujiDynamicRangeMap,
6 | fujiFilmSimulationMap,
7 | meteringModeMap,
8 | orientationMap,
9 | whiteBalanceMap,
10 | } from '~/lib/exif-tags'
11 |
12 | import { BinaryDataViewer } from '../components/common/BinaryDataViewer'
13 |
14 | export const formatValue = (
15 | key: string,
16 | value: unknown,
17 | ): string | React.ReactNode => {
18 | if (value instanceof Date) {
19 | return value.toLocaleString()
20 | }
21 | if (value instanceof Uint8Array) {
22 | try {
23 | const str = new TextDecoder().decode(value)
24 | // eslint-disable-next-line no-control-regex
25 | if (/[\x00-\x08\x0E-\x1F]/.test(str)) {
26 | return
27 | }
28 | return str.replace(/\0+$/, '') // remove trailing null chars
29 | } catch {
30 | return
31 | }
32 | }
33 | if (Array.isArray(value)) {
34 | if (value.every((v) => typeof v === 'number')) {
35 | return value.join(', ')
36 | }
37 | return JSON.stringify(value)
38 | }
39 |
40 | // Apply specific value mappings based on the key
41 | if (typeof value === 'number') {
42 | switch (key) {
43 | case 'ExposureProgram': {
44 | return exposureProgramMap[value] || `Unknown (${value})`
45 | }
46 | case 'MeteringMode': {
47 | return meteringModeMap[value] || `Unknown (${value})`
48 | }
49 | case 'Flash': {
50 | return flashMap[value] || `Unknown (${value})`
51 | }
52 | case 'WhiteBalance': {
53 | return whiteBalanceMap[value] || `Unknown (${value})`
54 | }
55 | case 'ColorSpace': {
56 | return colorSpaceMap[value] || `Unknown (${value})`
57 | }
58 | case 'Orientation': {
59 | return orientationMap[value] || `Unknown (${value})`
60 | }
61 | }
62 | }
63 |
64 | // Format exposure time
65 | if (key === 'ExposureTime' && typeof value === 'number') {
66 | if (value < 1) {
67 | return `1/${Math.round(1 / value)}s`
68 | }
69 | return `${value}s`
70 | }
71 |
72 | // Format F-number
73 | if (key === 'FNumber' && typeof value === 'number') {
74 | return `f/${value}`
75 | }
76 |
77 | // Format focal length
78 | if (key === 'FocalLength' && typeof value === 'number') {
79 | return `${value}mm`
80 | }
81 |
82 | // Format ISO
83 | if (
84 | (key === 'ISOSpeedRatings' ||
85 | key === 'ISO' ||
86 | key === 'PhotographicSensitivity') &&
87 | typeof value === 'number'
88 | ) {
89 | return `ISO ${value}`
90 | }
91 |
92 | // Format exposure bias
93 | if (key === 'ExposureBiasValue' && typeof value === 'number') {
94 | const sign = value >= 0 ? '+' : ''
95 | return `${sign}${value.toFixed(1)} EV`
96 | }
97 |
98 | // Format resolution
99 | if (
100 | (key === 'XResolution' || key === 'YResolution') &&
101 | typeof value === 'number'
102 | ) {
103 | return `${value} dpi`
104 | }
105 |
106 | if (typeof value === 'object' && value !== null) {
107 | return JSON.stringify(value)
108 | }
109 |
110 | return String(value)
111 | }
112 |
113 | // Format Fuji Recipe values for better readability
114 | export const formatFujiRecipeValue = (
115 | key: string,
116 | value: unknown,
117 | ): string | React.ReactNode => {
118 | if (typeof value === 'string') {
119 | // Film simulation mapping
120 | if (
121 | key.toLowerCase().includes('film') ||
122 | key.toLowerCase().includes('simulation')
123 | ) {
124 | return fujiFilmSimulationMap[value] || value
125 | }
126 | // Dynamic range mapping
127 | if (
128 | key.toLowerCase().includes('dynamic') ||
129 | key.toLowerCase().includes('range')
130 | ) {
131 | return fujiDynamicRangeMap[value] || value
132 | }
133 | }
134 |
135 | if (typeof value === 'number') {
136 | // Format specific numeric values
137 | if (key.toLowerCase().includes('temperature') && value > 1000) {
138 | return `${value}K`
139 | }
140 | if (
141 | key.toLowerCase().includes('tint') ||
142 | key.toLowerCase().includes('fine')
143 | ) {
144 | const sign = value >= 0 ? '+' : ''
145 | return `${sign}${value}`
146 | }
147 | }
148 |
149 | return formatValue(key, value)
150 | }
151 |
152 | const reverseMap = (map: Record): Record => {
153 | const reversed: Record = {}
154 | for (const [key, value] of Object.entries(map)) {
155 | reversed[value] = Number(key)
156 | }
157 | return reversed
158 | }
159 |
160 | const reversedExposureProgramMap = reverseMap(exposureProgramMap)
161 | const reversedMeteringModeMap = reverseMap(meteringModeMap)
162 | const reversedFlashMap = reverseMap(flashMap)
163 | const reversedWhiteBalanceMap = reverseMap(whiteBalanceMap)
164 | const reversedColorSpaceMap = reverseMap(colorSpaceMap)
165 | const reversedOrientationMap = reverseMap(orientationMap)
166 | const reversedFujiFilmSimulationMap = reverseMap(fujiFilmSimulationMap)
167 | const reversedFujiDynamicRangeMap = reverseMap(fujiDynamicRangeMap)
168 |
169 | export const unformatValue = (
170 | key: string,
171 | value: string,
172 | originalValue: any,
173 | ) => {
174 | if (typeof value !== 'string') return originalValue
175 | if (reversedExposureProgramMap[value])
176 | return reversedExposureProgramMap[value]
177 | if (reversedMeteringModeMap[value]) return reversedMeteringModeMap[value]
178 | if (reversedFlashMap[value]) return reversedFlashMap[value]
179 | if (reversedWhiteBalanceMap[value]) return reversedWhiteBalanceMap[value]
180 | if (reversedColorSpaceMap[value]) return reversedColorSpaceMap[value]
181 | if (reversedOrientationMap[value]) return reversedOrientationMap[value]
182 | if (reversedFujiFilmSimulationMap[value])
183 | return reversedFujiFilmSimulationMap[value]
184 | if (reversedFujiDynamicRangeMap[value])
185 | return reversedFujiDynamicRangeMap[value]
186 |
187 | if (key === 'ExposureTime') {
188 | if (value.startsWith('1/')) {
189 | return 1 / Number(value.slice(2, -1))
190 | }
191 | return Number(value.slice(0, -1))
192 | }
193 | if (key === 'FNumber') {
194 | return Number(value.slice(2))
195 | }
196 | if (key === 'FocalLength') {
197 | return Number(value.slice(0, -2))
198 | }
199 | if (key.startsWith('ISO')) {
200 | return Number(value.slice(4))
201 | }
202 | if (key === 'ExposureBiasValue') {
203 | return Number(value.split(' ')[0])
204 | }
205 | if (key.endsWith('Resolution')) {
206 | return Number(value.split(' ')[0])
207 | }
208 |
209 | if (originalValue instanceof Date) {
210 | return new Date(value)
211 | }
212 |
213 | if (typeof originalValue === 'number') {
214 | return Number(value)
215 | }
216 |
217 | return value
218 | }
219 |
--------------------------------------------------------------------------------
/src/lib/jotai.ts:
--------------------------------------------------------------------------------
1 | import type { Atom, PrimitiveAtom } from 'jotai'
2 | import { createStore, useAtom, useAtomValue, useSetAtom } from 'jotai'
3 | import { selectAtom } from 'jotai/utils'
4 | import { useCallback } from 'react'
5 |
6 | export const jotaiStore = createStore()
7 |
8 | export const createAtomAccessor = (atom: PrimitiveAtom) =>
9 | [
10 | () => jotaiStore.get(atom),
11 | (value: T) => jotaiStore.set(atom, value),
12 | ] as const
13 |
14 | const options = { store: jotaiStore }
15 | /**
16 | * @param atom - jotai
17 | * @returns - [atom, useAtom, useAtomValue, useSetAtom, jotaiStore.get, jotaiStore.set]
18 | */
19 | export const createAtomHooks = (atom: PrimitiveAtom) =>
20 | [
21 | atom,
22 | () => useAtom(atom, options),
23 | () => useAtomValue(atom, options),
24 | () => useSetAtom(atom, options),
25 | ...createAtomAccessor(atom),
26 | ] as const
27 |
28 | export const createAtomSelector = (atom: Atom) => {
29 | const useHook = (selector: (a: T) => R, deps: any[] = []) =>
30 | useAtomValue(
31 | selectAtom(
32 | atom,
33 | useCallback((a) => selector(a as T), deps),
34 | ),
35 | )
36 |
37 | useHook.__atom = atom
38 | return useHook
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/ns.ts:
--------------------------------------------------------------------------------
1 | const ns = 'app'
2 | export const getStorageNS = (key: string) => `${ns}:${key}`
3 |
4 | export const clearStorage = () => {
5 | for (let i = 0; i < localStorage.length; i++) {
6 | const key = localStorage.key(i)
7 | if (key && key.startsWith(ns)) {
8 | localStorage.removeItem(key)
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/query-client.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from '@tanstack/react-query'
2 | import { FetchError } from 'ofetch'
3 |
4 | const queryClient = new QueryClient({
5 | defaultOptions: {
6 | queries: {
7 | gcTime: Infinity,
8 | retryDelay: 1000,
9 | retry(failureCount, error) {
10 | console.error(error)
11 | if (error instanceof FetchError && error.statusCode === undefined) {
12 | return false
13 | }
14 |
15 | return !!(3 - failureCount)
16 | },
17 | // throwOnError: import.meta.env.DEV,
18 | },
19 | },
20 | })
21 |
22 | export { queryClient }
23 |
--------------------------------------------------------------------------------
/src/lib/route-builder.ts:
--------------------------------------------------------------------------------
1 | import { get, omit } from 'es-toolkit/compat'
2 | import { Fragment } from 'react/jsx-runtime'
3 | import type { RouteObject } from 'react-router'
4 |
5 | type NestedStructure = { [key: string]: NestedStructure }
6 |
7 | const MainGroupSegment = '(main)'
8 |
9 | function nestPaths(paths: string[]): NestedStructure {
10 | const result: NestedStructure = {}
11 |
12 | paths.forEach((path) => {
13 | // Remove the './pages' prefix and the '.tsx' suffix
14 | const trimmedPath = path.replace('./pages/', '').replace('.tsx', '')
15 | const parts = trimmedPath.split('/')
16 |
17 | let currentLevel = result
18 | for (const part of parts) {
19 | if (!currentLevel[part]) {
20 | currentLevel[part] = {}
21 | }
22 | currentLevel = currentLevel[part]
23 | }
24 | })
25 |
26 | return result
27 | }
28 |
29 | export function buildGlobRoutes(
30 | glob: Record Promise>,
31 | ): RouteObject[] {
32 | const keys = Object.keys(glob)
33 | const paths = nestPaths(keys)
34 | const pathGetterSet = new Set()
35 |
36 | const routeObject: RouteObject[] = []
37 |
38 | function dtsRoutes(
39 | parentKey: string,
40 | children: RouteObject[],
41 | paths: NestedStructure,
42 |
43 | parentPath = '',
44 | ) {
45 | const pathKeys = Object.keys(paths)
46 | // sort `layout` to the start, and `index` to the end
47 | pathKeys.sort((a, b) => {
48 | if (a === 'layout') {
49 | return -1
50 | }
51 | if (b === 'layout') {
52 | return 1
53 | }
54 | if (a === 'index') {
55 | return 1
56 | }
57 | if (b === 'index') {
58 | return -1
59 | }
60 | return a.localeCompare(b)
61 | })
62 |
63 | // sort, if () group, then move to the end
64 | pathKeys.sort((a, b) => {
65 | if (a.startsWith('(') && a.endsWith(')')) {
66 | return 1
67 | }
68 | if (b.startsWith('(') && b.endsWith(')')) {
69 | return -1
70 | }
71 | return 0
72 | })
73 |
74 | // TODO biz priority
75 | // move `(main)` to the top
76 | const mainIndex = pathKeys.indexOf(MainGroupSegment)
77 | if (mainIndex !== -1) {
78 | pathKeys.splice(mainIndex, 1)
79 | pathKeys.unshift(MainGroupSegment)
80 | }
81 |
82 | for (const key of pathKeys) {
83 | const isGroupedRoute = key.startsWith('(') && key.endsWith(')')
84 |
85 | const segmentPathKey = parentKey + key
86 |
87 | if (isGroupedRoute) {
88 | const accessPath = `${segmentPathKey}/layout.tsx`
89 | const globGetter = get(glob, accessPath) || (() => Fragment)
90 | if (pathGetterSet.has(accessPath)) {
91 | // throw new Error(`duplicate path: ` + accessPath)
92 |
93 | console.error(`duplicate path: ${accessPath}`)
94 | }
95 | pathGetterSet.add(accessPath)
96 |
97 | // if (!globGetter) {
98 | // throw new Error("grouped route must have a layout file")
99 | // }
100 |
101 | const childrenChildren: RouteObject[] = []
102 | dtsRoutes(
103 | `${segmentPathKey}/`,
104 | childrenChildren,
105 | paths[key],
106 | parentPath,
107 | )
108 | children.push({
109 | path: '',
110 | lazy: globGetter,
111 | children: childrenChildren,
112 | handle: {
113 | fs: segmentPathKey,
114 | fullPath: parentPath,
115 | },
116 | })
117 | } else if (key === 'layout') {
118 | // if parent key is grouped routes, the layout is handled, so skip this logic
119 | if (parentKey.endsWith(')/')) {
120 | continue
121 | }
122 | // if `key` is `layout`, then it's a grouped route
123 | const accessPath = `${segmentPathKey}.tsx`
124 | const globGetter = get(glob, accessPath)
125 |
126 | const childrenChildren: RouteObject[] = []
127 | // should omit layout, because layout is already handled
128 | dtsRoutes(
129 | parentKey,
130 | childrenChildren,
131 | omit(paths, 'layout') as NestedStructure,
132 | parentPath,
133 | )
134 | children.push({
135 | path: '',
136 | lazy: globGetter,
137 | children: childrenChildren,
138 | handle: {
139 | fs: segmentPathKey,
140 | fullPath: parentPath,
141 | },
142 | })
143 | break
144 | } else {
145 | const content = paths[key]
146 | const hasChild = Object.keys(content).length > 0
147 |
148 | const normalizeKey = normalizePathKey(key)
149 |
150 | if (!hasChild) {
151 | const accessPath = `${segmentPathKey}.tsx`
152 | const globGetter = get(glob, accessPath)
153 |
154 | if (pathGetterSet.has(`${segmentPathKey}.tsx`)) {
155 | // throw new Error(`duplicate path: ` + accessPath)
156 | console.error(`duplicate path: ${accessPath}`)
157 | // continue
158 | }
159 | pathGetterSet.add(accessPath)
160 |
161 | children.push({
162 | path: normalizeKey,
163 | lazy: globGetter,
164 | handle: {
165 | fs: `${segmentPathKey}/${normalizeKey}`,
166 | fullPath: `${parentPath}/${normalizeKey}`,
167 | },
168 | })
169 | } else {
170 | const childrenChildren: RouteObject[] = []
171 | const fullPath = `${parentPath}/${normalizeKey}`
172 | dtsRoutes(
173 | `${segmentPathKey}/`,
174 | childrenChildren,
175 | paths[key],
176 | fullPath,
177 | )
178 | children.push({
179 | path: normalizeKey,
180 | children: childrenChildren,
181 | handle: {
182 | fs: `${segmentPathKey}/${normalizeKey}`,
183 | fullPath,
184 | },
185 | })
186 | }
187 | }
188 | }
189 | }
190 |
191 | dtsRoutes('./pages/', routeObject, paths)
192 | return routeObject
193 | }
194 |
195 | const normalizePathKey = (key: string) => {
196 | if (key === 'index') {
197 | return ''
198 | }
199 |
200 | if (key.startsWith('[') && key.endsWith(']')) {
201 | return `:${key.slice(1, -1)}`
202 | }
203 | return key
204 | }
205 |
--------------------------------------------------------------------------------
/src/lib/spring.ts:
--------------------------------------------------------------------------------
1 | import type { Spring } from 'motion/react'
2 |
3 | /**
4 | * A smooth spring with a predefined duration and no bounce.
5 | */
6 | const smoothPreset: Spring = {
7 | type: 'spring',
8 | duration: 0.4,
9 | bounce: 0,
10 | }
11 |
12 | /**
13 | * A spring with a predefined duration and small amount of bounce that feels more snappy.
14 | */
15 | const snappyPreset: Spring = {
16 | type: 'spring',
17 | duration: 0.4,
18 | bounce: 0.15,
19 | }
20 |
21 | /**
22 | * A spring with a predefined duration and higher amount of bounce.
23 | */
24 | const bouncyPreset: Spring = {
25 | type: 'spring',
26 | duration: 0.4,
27 | bounce: 0.3,
28 | }
29 | class SpringPresets {
30 | smooth = smoothPreset
31 | snappy = snappyPreset
32 | bouncy = bouncyPreset
33 | }
34 | class SpringStatic {
35 | presets = new SpringPresets()
36 |
37 | /**
38 | * A smooth spring with a predefined duration and no bounce that can be tuned.
39 | *
40 | * @param duration The perceptual duration, which defines the pace of the spring.
41 | * @param extraBounce How much additional bounce should be added to the base bounce of 0.
42 | */
43 | smooth(duration = 0.4, extraBounce = 0): Spring {
44 | return {
45 | type: 'spring',
46 | duration,
47 | bounce: extraBounce,
48 | }
49 | }
50 |
51 | /**
52 | * A spring with a predefined duration and small amount of bounce that feels more snappy.
53 | */
54 | snappy(duration = 0.4, extraBounce = 0): Spring {
55 | return {
56 | type: 'spring',
57 | duration,
58 | bounce: 0.15 + extraBounce,
59 | }
60 | }
61 |
62 | /**
63 | * A spring with a predefined duration and higher amount of bounce that can be tuned.
64 | */
65 | bouncy(duration = 0.4, extraBounce = 0): Spring {
66 | return {
67 | type: 'spring',
68 | duration,
69 | bounce: 0.3 + extraBounce,
70 | }
71 | }
72 | }
73 |
74 | const SpringClass = new SpringStatic()
75 | export { SpringClass as Spring }
76 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import type { ClassValue } from 'clsx'
2 | import { clsx } from 'clsx'
3 | import { twMerge } from 'tailwind-merge'
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs))
7 | }
8 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import './styles/index.css'
2 |
3 | import * as React from 'react'
4 | import { createRoot } from 'react-dom/client'
5 | import { RouterProvider } from 'react-router'
6 |
7 | import { router } from './router'
8 |
9 | const $container = document.querySelector('#root') as HTMLElement
10 |
11 | if (import.meta.env.DEV) {
12 | const { start } = await import('react-scan')
13 | start()
14 | }
15 | createRoot($container).render(
16 |
17 |
18 | ,
19 | )
20 |
--------------------------------------------------------------------------------
/src/pages/editor/index.tsx:
--------------------------------------------------------------------------------
1 | export { Component } from '../reader'
2 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'react-router'
2 |
3 | export const loader = () => {
4 | return redirect('/transfer')
5 | }
6 |
--------------------------------------------------------------------------------
/src/pages/reader/index.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from '@radix-ui/react-label'
2 | /* eslint-disable unicorn/prefer-node-protocol */
3 | import { Buffer } from 'buffer'
4 | import type { Exif } from 'exif-reader'
5 | import exifReader from 'exif-reader'
6 | import * as piexif from 'piexif-ts'
7 | import { useEffect, useRef, useState } from 'react'
8 |
9 | import { ExifDisplay } from '~/components/common/ExifDisplay'
10 | import type { ImageState } from '~/components/common/ImageUploader'
11 | import { ImageUploader } from '~/components/common/ImageUploader'
12 | import { Button } from '~/components/ui/button'
13 | import { Checkbox } from '~/components/ui/checkbox'
14 | import { useExif } from '~/hooks/useExif'
15 | import { convertExifReaderToPiexif } from '~/lib/exif-converter'
16 |
17 | export const Component = () => {
18 | const [image, setImage] = useState(null)
19 | const { exif, piexifExif, fujiRecipe } = useExif(image?.file || null)
20 | const [editableExif, setEditableExif] = useState(null)
21 | const [originalPiexifExif, setOriginalPiexifExif] =
22 | useState(null)
23 | const [removeGps, setRemoveGps] = useState(true)
24 | const fileInputRef = useRef(null)
25 |
26 | useEffect(() => {
27 | if (exif) {
28 | setEditableExif(exif)
29 | }
30 | if (piexifExif) {
31 | setOriginalPiexifExif(piexifExif)
32 | }
33 | }, [exif, piexifExif])
34 |
35 | const handleImageChange = (file: File) => {
36 | setImage({ file, previewUrl: URL.createObjectURL(file) })
37 | }
38 |
39 | const handleDownload = () => {
40 | if (!image || !image.file || !editableExif) return
41 | const { file } = image
42 | const reader = new FileReader()
43 | reader.onload = (e) => {
44 | const dataUrl = e.target?.result as string
45 | const exifObj = convertExifReaderToPiexif(
46 | editableExif,
47 | originalPiexifExif?.thumbnail,
48 | )
49 |
50 | if (removeGps) {
51 | exifObj.GPS = {}
52 | }
53 | const exifStr = piexif.dump(exifObj)
54 | const newDataUrl = piexif.insert(exifStr, dataUrl)
55 | const link = document.createElement('a')
56 | link.href = newDataUrl
57 | link.download = file.name
58 | document.body.append(link)
59 | link.click()
60 | link.remove()
61 | }
62 | reader.readAsDataURL(file)
63 | }
64 |
65 | const handleExifChange = (newExif: Exif) => {
66 | setEditableExif(newExif)
67 | }
68 |
69 | const handleExportJson = () => {
70 | if (!editableExif) return
71 |
72 | const jsonData = JSON.stringify(editableExif, null, 2)
73 | const blob = new Blob([jsonData], { type: 'application/json' })
74 | const url = URL.createObjectURL(blob)
75 | const link = document.createElement('a')
76 | link.href = url
77 | link.download = `exif-data-${Date.now()}.json`
78 | document.body.append(link)
79 | link.click()
80 | link.remove()
81 | URL.revokeObjectURL(url)
82 | }
83 |
84 | const handleImportJson = () => {
85 | fileInputRef.current?.click()
86 | }
87 |
88 | const handleImportJSONSelect = (
89 | event: React.ChangeEvent,
90 | ) => {
91 | const file = event.target.files?.[0]
92 | if (!file) return
93 |
94 | if (file.type !== 'application/json') {
95 | alert('Please select a valid JSON file')
96 | return
97 | }
98 |
99 | const reader = new FileReader()
100 | reader.onload = (e) => {
101 | try {
102 | const jsonContent = e.target?.result as string
103 | const rawJsonData = JSON.parse(jsonContent)
104 |
105 | // Restore Buffer objects from serialized JSON
106 | const parsedExif = restoreBuffersFromJson(rawJsonData) as Exif
107 |
108 | // Convert the JSON EXIF data to piexif format first
109 | const piexifObj = convertExifReaderToPiexif(
110 | parsedExif,
111 | originalPiexifExif?.thumbnail,
112 | )
113 |
114 | // Then convert back to exif-reader format for consistency
115 | const exifSegmentStr = piexif.dump(piexifObj)
116 | const normalizedExif = exifReader(Buffer.from(exifSegmentStr, 'binary'))
117 |
118 | setEditableExif(normalizedExif)
119 | } catch (error) {
120 | alert(
121 | 'Failed to parse JSON file. Please ensure it contains valid EXIF data.',
122 | )
123 | console.error('JSON parse error:', error)
124 | }
125 | }
126 | // eslint-disable-next-line unicorn/prefer-blob-reading-methods
127 | reader.readAsText(file)
128 |
129 | // Reset the input value so the same file can be selected again
130 | event.target.value = ''
131 | }
132 |
133 | return (
134 |
135 |
136 |
144 |
145 |
146 |
147 |
153 |
154 |
155 |
156 | setRemoveGps(Boolean(checked))}
160 | />
161 |
162 |
163 |
164 |
167 |
174 |
181 |
182 |
189 |
190 |
191 |
192 |
197 |
198 |
199 |
200 |
201 | )
202 | }
203 |
204 | // Function to restore Buffer objects from JSON
205 | const restoreBuffersFromJson = (obj: any): any => {
206 | if (obj === null || typeof obj !== 'object') {
207 | return obj
208 | }
209 |
210 | // Check if this is a serialized Buffer
211 | if (
212 | obj.type === 'Buffer' &&
213 | Array.isArray(obj.data) &&
214 | obj.data.every((item: any) => typeof item === 'number')
215 | ) {
216 | return Buffer.from(obj.data)
217 | }
218 |
219 | // Check if this is a Uint8Array that was serialized
220 | if (obj.type === 'Uint8Array' && Array.isArray(obj.data)) {
221 | return new Uint8Array(obj.data)
222 | }
223 |
224 | // Recursively process arrays
225 | if (Array.isArray(obj)) {
226 | return obj.map((item) => restoreBuffersFromJson(item))
227 | }
228 |
229 | // Recursively process object properties
230 | const result: any = {}
231 | for (const [key, value] of Object.entries(obj)) {
232 | // Check if this is a date string that should be converted back to Date
233 | if (
234 | typeof value === 'string' &&
235 | (key === 'DateTimeOriginal' ||
236 | key === 'DateTimeDigitized' ||
237 | key === 'DateTime' ||
238 | key.includes('Date') ||
239 | key.includes('Time')) &&
240 | /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?$/.test(value)
241 | ) {
242 | result[key] = new Date(value)
243 | } else {
244 | result[key] = restoreBuffersFromJson(value)
245 | }
246 | }
247 | return result
248 | }
249 |
--------------------------------------------------------------------------------
/src/pages/transfer/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable unicorn/prefer-node-protocol */
2 | import { Buffer } from 'buffer'
3 | import exifReader from 'exif-reader'
4 | import getRecipe from 'fuji-recipes'
5 | import * as piexif from 'piexif-ts'
6 | import { useState } from 'react'
7 | import { toast } from 'sonner'
8 |
9 | import { ExifDisplay } from '~/components/common/ExifDisplay'
10 | import type { ImageState } from '~/components/common/ImageUploader'
11 | import { ImageUploader } from '~/components/common/ImageUploader'
12 | import { Button } from '~/components/ui/button'
13 | import { Checkbox } from '~/components/ui/checkbox'
14 | import { useExif } from '~/hooks/useExif'
15 | import { convertExifReaderToPiexif } from '~/lib/exif-converter'
16 |
17 | export const Component = () => {
18 | const [sourceImage, setSourceImage] = useState(null)
19 | const [targetImage, setTargetImage] = useState(null)
20 | const [newImageUrl, setNewImageUrl] = useState(null)
21 |
22 | const {
23 | exif: sourceExif,
24 | piexifExif: sourcePiexifExif,
25 | fujiRecipe: sourceFujiRecipe,
26 | } = useExif(sourceImage?.file || null)
27 |
28 | const [targetExif, setTargetExif] = useState(null)
29 | const [targetFujiRecipe, setTargetFujiRecipe] = useState | null>(null)
33 | const [removeGps, setRemoveGps] = useState(true)
34 |
35 | const handleSourceImageChange = (file: File) => {
36 | setSourceImage({ file, previewUrl: URL.createObjectURL(file) })
37 | setTargetExif(null)
38 | setTargetFujiRecipe(null)
39 | }
40 |
41 | const handleTargetImageChange = (file: File) => {
42 | setTargetImage({ file, previewUrl: URL.createObjectURL(file) })
43 | setTargetExif(null)
44 | setTargetFujiRecipe(null)
45 | setNewImageUrl(null)
46 | }
47 |
48 | const handleTransfer = async () => {
49 | if (!sourceImage?.file || !targetImage?.file || !sourceExif) {
50 | toast.error('Please select both source and target images.')
51 | return
52 | }
53 |
54 | const exifObj = convertExifReaderToPiexif(
55 | sourceExif,
56 | sourcePiexifExif?.thumbnail,
57 | )
58 |
59 | if (removeGps) {
60 | exifObj.GPS = {}
61 | }
62 | const exifStr = piexif.dump(exifObj)
63 |
64 | const readerTarget = new FileReader()
65 | readerTarget.onload = (e) => {
66 | const targetDataUrl = e.target?.result as string
67 | const newImageDataUrl = piexif.insert(exifStr, targetDataUrl)
68 | setNewImageUrl(newImageDataUrl)
69 |
70 | try {
71 | const newExifObj = piexif.load(newImageDataUrl)
72 | const newExifSegmentStr = piexif.dump(newExifObj)
73 |
74 | const newExif = exifReader(Buffer.from(newExifSegmentStr, 'binary'))
75 | setTargetExif(newExif)
76 | if ((newExif as any).MakerNote) {
77 | try {
78 | const recipe = getRecipe((newExif as any).MakerNote)
79 | setTargetFujiRecipe(recipe)
80 | } catch (error) {
81 | console.warn(
82 | 'Could not parse Fuji recipe from transferred MakerNote.',
83 | error,
84 | )
85 | setTargetFujiRecipe(null)
86 | }
87 | } else {
88 | setTargetFujiRecipe(null)
89 | }
90 | } catch (error) {
91 | console.error('Could not read EXIF from new image.', error)
92 | setTargetExif(null)
93 | setTargetFujiRecipe(null)
94 | }
95 |
96 | // Also update the target image preview to show the new image with exif
97 | setTargetImage((prev) =>
98 | prev ? { ...prev, previewUrl: newImageDataUrl } : null,
99 | )
100 |
101 | toast.success('EXIF data transferred successfully!', {
102 | description: 'You can now download the image.',
103 | })
104 | }
105 | readerTarget.readAsDataURL(targetImage.file!)
106 | }
107 |
108 | const handleDownload = () => {
109 | if (!newImageUrl) {
110 | toast.error('No new image to download.', {
111 | description: 'Please transfer EXIF data first.',
112 | })
113 | return
114 | }
115 | const link = document.createElement('a')
116 | link.href = newImageUrl
117 | link.download = `processed-${targetImage?.file?.name || 'image.jpg'}`
118 | document.body.append(link)
119 | link.click()
120 | link.remove()
121 | }
122 |
123 | return (
124 |
125 |
126 |
134 |
135 |
136 |
142 |
143 |
149 |
150 |
151 |
152 | setRemoveGps(checked === true)}
156 | />
157 |
163 |
164 |
165 |
166 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 | )
182 | }
183 |
--------------------------------------------------------------------------------
/src/providers/context-menu-provider.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, memo, useCallback, useEffect, useRef } from 'react'
2 |
3 | import type { FollowMenuItem } from '~/atoms/context-menu'
4 | import {
5 | MenuItemSeparator,
6 | MenuItemType,
7 | useContextMenuState,
8 | } from '~/atoms/context-menu'
9 | import {
10 | ContextMenu,
11 | ContextMenuCheckboxItem,
12 | ContextMenuContent,
13 | ContextMenuItem,
14 | ContextMenuPortal,
15 | ContextMenuSeparator,
16 | ContextMenuSub,
17 | ContextMenuSubContent,
18 | ContextMenuSubTrigger,
19 | ContextMenuTrigger,
20 | } from '~/components/ui/context-menu'
21 | import { clsxm as cn } from '~/lib/cn'
22 | import { nextFrame, preventDefault } from '~/lib/dom'
23 |
24 | export const ContextMenuProvider: Component = ({ children }) => (
25 | <>
26 | {children}
27 |
28 | >
29 | )
30 |
31 | const Handler = () => {
32 | const ref = useRef(null)
33 | const [contextMenuState, setContextMenuState] = useContextMenuState()
34 |
35 | useEffect(() => {
36 | if (!contextMenuState.open) return
37 | const triggerElement = ref.current
38 | if (!triggerElement) return
39 | // [ContextMenu] Add ability to control
40 | // https://github.com/radix-ui/primitives/issues/1307#issuecomment-1689754796
41 | triggerElement.dispatchEvent(
42 | new MouseEvent('contextmenu', {
43 | bubbles: true,
44 | cancelable: true,
45 | clientX: contextMenuState.position.x,
46 | clientY: contextMenuState.position.y,
47 | }),
48 | )
49 | }, [contextMenuState])
50 |
51 | const handleOpenChange = useCallback(
52 | (state: boolean) => {
53 | if (state) return
54 | if (!contextMenuState.open) return
55 | setContextMenuState({ open: false })
56 | contextMenuState.abortController.abort()
57 | },
58 | [contextMenuState, setContextMenuState],
59 | )
60 |
61 | return (
62 |
63 |
64 |
65 | {contextMenuState.open &&
66 | contextMenuState.menuItems.map((item, index) => {
67 | const prevItem = contextMenuState.menuItems[index - 1]
68 | if (
69 | prevItem instanceof MenuItemSeparator &&
70 | item instanceof MenuItemSeparator
71 | ) {
72 | return null
73 | }
74 |
75 | if (!prevItem && item instanceof MenuItemSeparator) {
76 | return null
77 | }
78 | const nextItem = contextMenuState.menuItems[index + 1]
79 | if (!nextItem && item instanceof MenuItemSeparator) {
80 | return null
81 | }
82 | return
83 | })}
84 |
85 |
86 | )
87 | }
88 |
89 | const Item = memo(({ item }: { item: FollowMenuItem }) => {
90 | const onClick = useCallback(() => {
91 | if ('click' in item) {
92 | // Here we need to delay one frame,
93 | // so it's two raf's, in order to have `point-event: none` recorded by RadixOverlay after modal is invoked in a certain scenario,
94 | // and the page freezes after modal is turned off.
95 | nextFrame(() => {
96 | item.click?.()
97 | })
98 | }
99 | }, [item])
100 | const itemRef = useRef(null)
101 |
102 | switch (item.type) {
103 | case MenuItemType.Separator: {
104 | return
105 | }
106 | case MenuItemType.Action: {
107 | const hasSubmenu = item.submenu.length > 0
108 | const Wrapper = hasSubmenu
109 | ? ContextMenuSubTrigger
110 | : typeof item.checked === 'boolean'
111 | ? ContextMenuCheckboxItem
112 | : ContextMenuItem
113 |
114 | const Sub = hasSubmenu ? ContextMenuSub : Fragment
115 |
116 | return (
117 |
118 |
127 | {!!item.icon && (
128 |
129 | {item.icon}
130 |
131 | )}
132 | {item.label}
133 |
134 | {hasSubmenu && (
135 |
136 |
137 | {item.submenu.map((subItem, index) => (
138 |
139 | ))}
140 |
141 |
142 | )}
143 |
144 | )
145 | }
146 | default: {
147 | return null
148 | }
149 | }
150 | })
151 |
--------------------------------------------------------------------------------
/src/providers/event-provider.tsx:
--------------------------------------------------------------------------------
1 | import { throttle } from 'es-toolkit/compat'
2 | import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'
3 | import { useStore } from 'jotai'
4 | import type { FC } from 'react'
5 |
6 | import { viewportAtom } from '~/atoms/viewport'
7 |
8 | export const EventProvider: FC = () => {
9 | const store = useStore()
10 | useIsomorphicLayoutEffect(() => {
11 | const readViewport = throttle(() => {
12 | const { innerWidth: w, innerHeight: h } = window
13 | const sm = w >= 640
14 | const md = w >= 768
15 | const lg = w >= 1024
16 | const xl = w >= 1280
17 | const _2xl = w >= 1536
18 | store.set(viewportAtom, {
19 | sm,
20 | md,
21 | lg,
22 | xl,
23 | '2xl': _2xl,
24 | h,
25 | w,
26 | })
27 |
28 | const isMobile = window.innerWidth < 1024
29 | document.documentElement.dataset.viewport = isMobile
30 | ? 'mobile'
31 | : 'desktop'
32 | }, 16)
33 |
34 | readViewport()
35 |
36 | window.addEventListener('resize', readViewport)
37 | return () => {
38 | window.removeEventListener('resize', readViewport)
39 | }
40 | }, [])
41 |
42 | return null
43 | }
44 |
--------------------------------------------------------------------------------
/src/providers/root-providers.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClientProvider } from '@tanstack/react-query'
2 | import { Provider } from 'jotai'
3 | import { LazyMotion, MotionConfig } from 'motion/react'
4 | import type { FC, PropsWithChildren } from 'react'
5 |
6 | import { Toaster } from '~/components/ui/sonner'
7 | import { jotaiStore } from '~/lib/jotai'
8 | import { queryClient } from '~/lib/query-client'
9 | import { Spring } from '~/lib/spring'
10 |
11 | import { ContextMenuProvider } from './context-menu-provider'
12 | import { EventProvider } from './event-provider'
13 | import { SettingSync } from './setting-sync'
14 | import { StableRouterProvider } from './stable-router-provider'
15 |
16 | const loadFeatures = () =>
17 | import('../framer-lazy-feature').then((res) => res.default)
18 | export const RootProviders: FC = ({ children }) => (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 |
32 |
33 | )
34 |
--------------------------------------------------------------------------------
/src/providers/setting-sync.tsx:
--------------------------------------------------------------------------------
1 | import { useSyncThemeark } from '~/hooks/common'
2 |
3 | const useUISettingSync = () => {
4 | useSyncThemeark()
5 | }
6 |
7 | export const SettingSync = () => {
8 | useUISettingSync()
9 |
10 | return null
11 | }
12 |
--------------------------------------------------------------------------------
/src/providers/stable-router-provider.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect } from 'react'
2 | import type { NavigateFunction } from 'react-router'
3 | import {
4 | useLocation,
5 | useNavigate,
6 | useParams,
7 | useSearchParams,
8 | } from 'react-router'
9 |
10 | import { setNavigate, setRoute } from '~/atoms/route'
11 |
12 | declare global {
13 | export const router: {
14 | navigate: NavigateFunction
15 | }
16 | interface Window {
17 | router: typeof router
18 | }
19 | }
20 | window.router = {
21 | navigate() {},
22 | }
23 |
24 | /**
25 | * Why this.
26 | * Remix router always update immutable object when the router has any changes, lead to the component which uses router hooks re-render.
27 | * This provider is hold a empty component, to store the router hooks value.
28 | * And use our router hooks will not re-render the component when the router has any changes.
29 | * Also it can access values outside of the component and provide a value selector
30 | */
31 | export const StableRouterProvider = () => {
32 | const [searchParams] = useSearchParams()
33 | const params = useParams()
34 | const nav = useNavigate()
35 | const location = useLocation()
36 |
37 | // NOTE: This is a hack to expose the navigate function to the window object, avoid to import `router` circular issue.
38 | useLayoutEffect(() => {
39 | window.router.navigate = nav
40 |
41 | setRoute({
42 | params,
43 | searchParams,
44 | location,
45 | })
46 | setNavigate({ fn: nav })
47 | }, [searchParams, params, location, nav])
48 |
49 | return null
50 | }
51 |
--------------------------------------------------------------------------------
/src/router.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserRouter } from 'react-router'
2 |
3 | import { App } from './App'
4 | import { ErrorElement } from './components/common/ErrorElement'
5 | import { NotFound } from './components/common/NotFound'
6 | import { buildGlobRoutes } from './lib/route-builder'
7 |
8 | const globTree = import.meta.glob('./pages/**/*.tsx')
9 | const tree = buildGlobRoutes(globTree)
10 |
11 | export const router = createBrowserRouter([
12 | {
13 | path: '/',
14 | element: ,
15 | children: tree,
16 | errorElement: ,
17 | },
18 | {
19 | path: '*',
20 | element: ,
21 | },
22 | ])
23 |
--------------------------------------------------------------------------------
/src/store/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Innei/exif-tools/b677f7e85ebefb8386354a823192b276653c0760/src/store/.gitkeep
--------------------------------------------------------------------------------
/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @import './tailwind.css';
2 |
--------------------------------------------------------------------------------
/src/styles/tailwind.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @plugin "@tailwindcss/typography";
3 | @plugin '@egoist/tailwindcss-icons';
4 | @plugin "tailwind-scrollbar";
5 | @plugin 'tailwindcss-animate';
6 | @plugin 'tailwindcss-safe-area';
7 |
8 | @import 'tailwindcss-uikit-colors/v4/macos.css';
9 |
10 | @source "./src/**/*.{js,jsx,ts,tsx}";
11 | @custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
12 |
13 | [data-theme='light'] {
14 | --radius: 0.5rem;
15 | --border: 20 5.9% 90%;
16 |
17 | --accent: #1e90ff;
18 | --accent-foreground: #f5f5f7;
19 | --background: #f5f5f7;
20 | --foreground: #1c1c1e;
21 | --background-secondary: #efefef;
22 | --background-tertiary: #e8e8e8;
23 | --background-quaternary: #e0e0e0;
24 | }
25 |
26 | [data-theme='dark'] {
27 | --radius: 0.5rem;
28 | --border: 0 0% 22.1%;
29 | --accent: #0f52ba;
30 | --accent-foreground: #f5f5f7;
31 | --background: #100d08;
32 | --foreground: #f5f5f7;
33 | --background-secondary: #1a1610;
34 | --background-tertiary: #242018;
35 | --background-quaternary: #2e2920;
36 | }
37 |
38 | [data-hand-cursor='true'] {
39 | --cursor-button: pointer;
40 | --cursor-select: text;
41 | --cursor-checkbox: pointer;
42 | --cursor-link: pointer;
43 | --cursor-menu: pointer;
44 | --cursor-radio: pointer;
45 | --cursor-switch: pointer;
46 | --cursor-card: pointer;
47 | }
48 |
49 | :root {
50 | --cursor-button: default;
51 | --cursor-select: text;
52 | --cursor-checkbox: default;
53 | --cursor-link: pointer;
54 | --cursor-menu: default;
55 | --cursor-radio: default;
56 | --cursor-switch: default;
57 | --cursor-card: default;
58 | }
59 |
60 | :root,
61 | body {
62 | @apply bg-background text-foreground;
63 | @apply font-sans;
64 | @apply text-base leading-normal;
65 | @apply antialiased;
66 | @apply selection:bg-accent selection:text-white;
67 | }
68 |
69 | /* Theme configuration */
70 | @theme {
71 | /* Container */
72 | --container-padding: 2rem;
73 | --container-max-width-2xl: 1400px;
74 |
75 | /* Custom cursors */
76 | --cursor-button: var(--cursor-button);
77 | --cursor-select: var(--cursor-select);
78 | --cursor-checkbox: var(--cursor-checkbox);
79 | --cursor-link: var(--cursor-link);
80 | --cursor-menu: var(--cursor-menu);
81 | --cursor-radio: var(--cursor-radio);
82 | --cursor-switch: var(--cursor-switch);
83 | --cursor-card: var(--cursor-card);
84 |
85 | /* Colors */
86 | --color-border: hsl(var(--border));
87 | --color-accent: var(--accent);
88 | --color-accent-foreground: var(--accent-foreground);
89 | --color-background: var(--background);
90 | --color-foreground: var(--foreground);
91 | --color-background-secondary: var(--background-secondary);
92 | --color-background-tertiary: var(--background-tertiary);
93 | --color-background-quaternary: var(--background-quaternary);
94 |
95 | /* Blur */
96 | --blur-background: 70px;
97 |
98 | /* Shadow */
99 | --shadow-context-menu:
100 | 0px 0px 1px rgba(0, 0, 0, 0.12), 0px 0px 1.5px rgba(0, 0, 0, 0.04),
101 | 0px 7px 22px rgba(0, 0, 0, 0.08);
102 |
103 | /* Font */
104 | --text-large-title: 1.625rem;
105 | --text-large-title--line-height: 2rem;
106 |
107 | --text-title1: 1.375rem;
108 | --text-title1--line-height: 1.625rem;
109 |
110 | --text-title2: 1.0625rem;
111 | --text-title2--line-height: 1.375rem;
112 |
113 | --text-title3: 0.9375rem;
114 | --text-title3--line-height: 1.25rem;
115 |
116 | --text-headline: 0.8125rem;
117 | --text-headline--line-height: 1rem;
118 |
119 | --text-body: 0.8125rem;
120 | --text-body--line-height: 1rem;
121 |
122 | --text-callout: 0.75rem;
123 | --text-callout--line-height: 0.9375rem;
124 |
125 | --text-subheadline: 0.6875rem;
126 | --text-subheadline--line-height: 0.875rem;
127 |
128 | --text-footnote: 0.625rem;
129 | --text-footnote--line-height: 0.8125rem;
130 |
131 | --text-caption: 0.625rem;
132 | --text-caption--line-height: 0.8125rem;
133 |
134 | /* Font families */
135 | --font-sans: 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
136 | --font-serif:
137 | 'Noto Serif CJK SC', 'Noto Serif SC', var(--font-serif),
138 | 'Source Han Serif SC', 'Source Han Serif', source-han-serif-sc, SongTi SC,
139 | SimSum, 'Hiragino Sans GB', system-ui, -apple-system, Segoe UI, Roboto,
140 | Helvetica, 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif;
141 | --font-mono:
142 | 'OperatorMonoSSmLig Nerd Font', 'Cascadia Code PL',
143 | 'FantasqueSansMono Nerd Font', 'operator mono', JetBrainsMono,
144 | 'Fira code Retina', 'Fira code', Consolas, Monaco, 'Hannotate SC',
145 | monospace, -apple-system;
146 |
147 | /* Custom screens */
148 | --screen-light-mode: (prefers-color-scheme: light);
149 | --screen-dark-mode: (prefers-color-scheme: dark);
150 |
151 | /* Width and max-width */
152 | --width-screen: 100vw;
153 | --max-width-screen: 100vw;
154 |
155 | /* Height and max-height */
156 | --height-screen: 100vh;
157 | --max-height-screen: 100vh;
158 | }
159 |
160 | @layer base {
161 | .container {
162 | margin-left: auto;
163 | margin-right: auto;
164 | padding: var(--container-padding);
165 | }
166 | @media (min-width: 1536px) {
167 | .container {
168 | max-width: var(--container-max-width-2xl);
169 | }
170 | }
171 | }
172 |
173 | html {
174 | @apply font-sans;
175 | }
176 |
177 | html body {
178 | @apply max-w-screen overflow-x-hidden;
179 | }
180 |
181 | *:not(input):not(textarea):not([contenteditable='true']):focus-visible {
182 | outline: 0 !important;
183 | }
184 |
185 | @font-face {
186 | font-family: 'Geist Sans';
187 | src: url('../assets/fonts/GeistVF.woff2') format('woff2');
188 | font-style: normal;
189 | font-weight: 100 200 300 400 500 600 700 800 900;
190 | }
191 |
192 | body {
193 | font-feature-settings:
194 | 'rlig' 1,
195 | 'calt' 1;
196 | }
197 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "DOM",
6 | "DOM.Iterable",
7 | "ESNext"
8 | ],
9 | "allowJs": false,
10 | "skipLibCheck": true,
11 | "esModuleInterop": false,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "ESNext",
16 | "moduleResolution": "Node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "skipDefaultLibCheck": true,
20 | "noImplicitAny": false,
21 | "noEmit": true,
22 | "jsx": "preserve",
23 | "paths": {
24 | "~/*": [
25 | "./src/*"
26 | ],
27 | "@pkg": [
28 | "./package.json"
29 | ]
30 | },
31 | },
32 | "include": [
33 | "./src/**/*",
34 | ]
35 | }
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | { "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] }
2 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import tailwindcss from '@tailwindcss/vite'
2 | import reactRefresh from '@vitejs/plugin-react'
3 | import { codeInspectorPlugin } from 'code-inspector-plugin'
4 | import { defineConfig } from 'vite'
5 | import { checker } from 'vite-plugin-checker'
6 | import tsconfigPaths from 'vite-tsconfig-paths'
7 |
8 | import PKG from './package.json'
9 |
10 | // https://vitejs.dev/config/
11 | export default defineConfig({
12 | plugins: [
13 | reactRefresh(),
14 | tsconfigPaths(),
15 | checker({
16 | typescript: true,
17 | enableBuild: true,
18 | }),
19 | codeInspectorPlugin({
20 | bundler: 'vite',
21 | hotKeys: ['altKey'],
22 | }),
23 | tailwindcss(),
24 | ],
25 | define: {
26 | APP_DEV_CWD: JSON.stringify(process.cwd()),
27 | APP_NAME: JSON.stringify(PKG.name),
28 | },
29 | })
30 |
--------------------------------------------------------------------------------