import { useEffect, useMemo, useRef, useState } from 'react'
import { faker } from '@faker-js/faker'

type Person = {
	id: number
	// userId: string
	firstName: string
	lastName: string
	age: number
}

const range = (len: number) => {
	const arr: number[] = []
	for (let i = 0; i < len; i++) {
		arr.push(i)
	}
	return arr
}

const newPerson = (num: number): Person => {
	return {
		id: num,
		// userId: faker.string.uuid(),
		firstName: faker.person.firstName(),
		lastName: faker.person.lastName(),
		age: faker.number.int(40),
	}
}

const ROW_HEIGHT = 35

const rows = range(2000).map((index): Person => newPerson(index))
const rowCount = rows.length

export const VirtualScroll = () => {
	const gridRef = useRef<HTMLDivElement>(null)

	const [scrollTop, setScrollTop] = useState(0)
	const [clientHeight, setClientHeight] = useState(0)

	const handleScroll = ({ currentTarget: { scrollTop } }: React.UIEvent<HTMLDivElement>) => setScrollTop(scrollTop)

	useEffect(() => {
		if (!gridRef.current) return
		const handleResize = () => {
			if (!gridRef.current) return
			setClientHeight(gridRef.current.clientHeight)
		}

		const { scrollTop, clientHeight } = gridRef.current

		setScrollTop(scrollTop)
		setClientHeight(clientHeight)
		window.addEventListener('resize', handleResize)

		return () => {
			window.removeEventListener('resize', handleResize)
		}
	}, [])

	const { startIndex, endIndex } = useMemo(() => {
		const viewportHeight = clientHeight
		const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT))
		const visibleRowCount = Math.min(rowCount - startIndex, Math.ceil(viewportHeight / ROW_HEIGHT))
		const endIndex = startIndex + visibleRowCount

		return { startIndex, endIndex }
	}, [clientHeight, scrollTop])

	const visibleRows = rows.slice(startIndex, endIndex)

	return (
		<div style={{ flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
			{/* <div style={{ display: 'flex', gap: '1em' }}>
				<span>scrollTop: {scrollTop.toLocaleString()}px</span>
				<span>clientHeight: {clientHeight.toLocaleString()}px</span>
				<span>startNode: {startIndex.toLocaleString()}</span>
				<span>visibleNodesCount: {endIndex - startIndex}</span>
			</div> */}
			<div
				ref={gridRef}
				style={{
					display: 'grid',
					blockSize: 350,
					flexGrow: 1,
					gridTemplateRows: `repeat(${rows.length}, 35px)`,
					overflow: 'auto',
					border: '1px solid gray',
				}}
				onScroll={handleScroll}
			>
				{visibleRows.map((item, i: number) => (
					<div
						key={item.id}
						style={{
							gridRowStart: startIndex + i + 1,
							display: 'flex',
							gap: '1em',
							height: ROW_HEIGHT,
							borderBottom: '1px solid gray',
						}}
					>
						<span>{i + 1}</span>
						<span>{item.id}</span>
						{/* <span>{item.userId}</span> */}
						<span>{item.firstName}</span>
						<span>{item.lastName}</span>
						<span>{item.age}</span>
					</div>
				))}
			</div>
		</div>
	)
}

브라우저 resizing 까지는 대응했지만, debounce 미적용이라...

다양한 루트로 얻은 소스를 짬뽕한거라 일관성은 없지만 대략 사용방법을 터득하기에는 괜찮을 것 같아서 정리해봄

최종 채택은 2번으로 했지만 나머지도 기록 삼아 남김

 

1. 클라이언트 - heic2any: webp 바로 전환은 안되서 png 거쳐서 변환. 갤럭시 울트라 200M 사진은 처리불가

'use client'

import { useState } from 'react'

export function FileUpload() {
	const [file, setFile] = useState<any>()

	// 파일 선택 시 처리
	const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return
		
		const isHeic =
			file.type === 'image/heic' ||
			file.name.toLowerCase().endsWith('.heic') ||
			file.name.toLowerCase().endsWith('.heif')

		if (isHeic) {
			try {
				const heic2any = (await import('heic2any')).default
				// 1. HEIC/HEIF 파일을 PNG Blob으로 변환
				const pngBlob = await heic2any({
					blob: file,
					toType: 'image/png',
				})

				// 2. PNG Blob을 File 객체로 변환 및 WebP로 Canvas 변환
				const convertedFile = await convertToWebP(pngBlob, file.name)
				setFile(convertedFile)
			} catch (error) {
				console.error('### HEIC 변환 실패:', error)
				alert('이미지 변환에 실패했습니다. 다른 이미지를 시도해주세요.')
			}
		} else {
			setFile(file)
		}
	}

	// Blob을 WebP 포맷 File 객체로 변환하는 함수
	const convertToWebP = (blob: Blob | Blob[], originalName: string): Promise<File> => {
		return new Promise((resolve, reject) => {
			const img = new Image()
			const url = URL.createObjectURL(Array.isArray(blob) ? blob[0] : blob)

			img.onload = () => {
				URL.revokeObjectURL(url)
				const { width, height } = imageResize(img.width, img.height)

				// Canvas 생성
				const canvas = document.createElement('canvas')
				canvas.width = width
				canvas.height = height
				const ctx = canvas.getContext('2d')
				if (!ctx) throw new Error('Canvas context를 생성할 수 없습니다.')
				ctx.drawImage(img, 0, 0)
				// ctx.drawImage(img, 0, 0, targetWidth, targetHeight)

				canvas.toBlob(
					(webpBlob) => {
						if (!webpBlob) throw new Error('WebP 변환에 실패했습니다.')
						const newName = originalName.replace(/\.(heic|heif)$/i, '.webp')
						const webpFile = new File([webpBlob], newName, { type: 'image/webp' })
						resolve(webpFile)
					},
					'image/webp',
					0.8,
				)
			}
			img.onerror = (err) => reject(err)
			img.src = url
		})
	}

	// 폼 전송
	const handleSubmit = async (e) => {
		e.preventDefault()
		if (!file) return alert('파일을 선택해주세요.')

		const formData = new FormData()
		formData.append('image', file)
	}

	return (
		<form onSubmit={handleSubmit}>
			<input type="file" accept="image/*" onChange={handleFileChange} />
			<button type="submit">전송</button>
		</form>
	)
}

 

2. 클라이언트 - heic-decode: heic2any가 100%라면 60% 정도의 속도로 빠르게 처리하나 빌드 후 라이브러리 용량이 다소 나감. 갤럭시 울트라 200M 사진은 처리불가

'use client'

import { useState } from 'react'

export function FileUpload() {
	const [loading, setLoading] = useState(false)

	const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return

		setLoading(true)
		let fileToSend: File | Blob = file

		const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif')

		if (isHeic) {
			try {
				const arrayBuffer = await file.arrayBuffer()

				// 1. HEIC 내부의 원본 픽셀 데이터(RGBA) 추출
				const decode = (await import('heic-decode')).default // 399.78 KB => 910.64 KB
				const { width, height, data } = await decode({ buffer: new Uint8Array(arrayBuffer) })
				const { width: targetWidth, height: targetHeight } = imageResize(width, height)

				// 3. 임시 오프스크린 캔버스 생성하여 리사이징 처리
				const canvas = document.createElement('canvas')
				canvas.width = targetWidth
				canvas.height = targetHeight
				const ctx = canvas.getContext('2d')

				if (!ctx) throw new Error('Canvas context를 생성할 수 없습니다.')

				// 원본 거대 데이터를 임시 ImageData 객체로 만듦
				const originalImageData = new ImageData(new Uint8ClampedArray(data), width, height)

				if (width === targetWidth && height === targetHeight) {
					// 크기가 동일하면 그대로 그림
					ctx.putImageData(originalImageData, 0, 0)
				} else {
					// 임시 캔버스를 거쳐 브라우저 하드웨어 가속을 이용해 부드럽게 축소(Downscale)합니다.
					const tempCanvas = document.createElement('canvas')
					tempCanvas.width = width
					tempCanvas.height = height
					tempCanvas.getContext('2d')?.putImageData(originalImageData, 0, 0)

					// 최종 캔버스에 축소해서 그리기
					ctx.drawImage(tempCanvas, 0, 0, targetWidth, targetHeight)
				}

				// 4. 🔥 [수정] 캔버스 내용을 WebP Blob으로 변환
				const webpBlob = await new Promise<Blob | null>((resolve) => {
					canvas.toBlob((blob) => resolve(blob), 'image/webp', 0.8)
				})

				if (!webpBlob) throw new Error('WebP 변환에 실패했습니다.')

				// 5. 🔥 [수정] 서버 전송용 File 객체 확장자를 .webp로 재포장
				const newFileName = file.name.replace(/\.(heic|heif)$/i, '.webp')
				fileToSend = new File([webpBlob], newFileName, { type: 'image/webp' })
			} catch (error) {
				console.error('브라우저 내 초고해상도 변환 중 에러 발생:', error)
				alert('이미지가 너무 커서 변환에 실패했습니다. 일반 JPG/PNG를 사용해주세요.')
				setLoading(false)
				return
			}
		}

		// 6. Next.js Route Handler로 FormData 전송
		try {
			const formData = new FormData()
			formData.append('file', fileToSend)
		} catch (err) {
			console.error(err)
		} finally {
			setLoading(false)
		}
	}
	return (
		<>
			<div>
				<input type="file" accept=".heic,.heif" onChange={handleFileChange} disabled={loading} />
				{loading && <p>이미지 최적화 및 변환 중... 잠시만 기다려주세요 (화면을 끄지 마세요)</p>}
			</div>
		</>
	)
}

 

3. 클라이언트 - @imagemagick/magick-wasm: 갤럭시 울트라 200M 사진도 처리가 가능하나 속도가 압도적으로 느리다.

'use client'
import NextImage from 'next/image'
import { useState } from 'react'

async function initMagick() {
    const { initializeImageMagick } = await import('@imagemagick/magick-wasm')
	const response = await fetch('/magick.wasm') // public 경로. 해당 파일은 \node_modules\@imagemagick\magick-wasm\dist 안에 있음
	const wasmBuffer = await response.arrayBuffer()
	await initializeImageMagick(wasmBuffer)
}
initMagick()

export function FileUpload() {
	const [outputUrl, setOutputUrl] = useState({ src: '', width: 0, height: 0 })

	const handleConvert = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return
		const { ImageMagick } = await import('@imagemagick/magick-wasm')

		// 2. Read file as Uint8Array
		const arrayBuffer = await file.arrayBuffer()
		const data = new Uint8Array(arrayBuffer)

		// 3. Perform transformation
		ImageMagick.read(data, (image) => {
			const { width, height } = imageResize(image.width, image.height)
			image.resize(width, height)
			image.format = 'WEBP'

			// 4. Write back to a Blob for display
			image.write((outputData) => {
				const url = window.URL.createObjectURL(new Blob([new Uint8Array(outputData).buffer], { type: 'image/webp' }))
				setOutputUrl({ src: url, width, height })
			})
		})
	}

	return (
		<div>
			<input type="file" onChange={handleConvert} />
			{outputUrl.src && <NextImage {...outputUrl} alt="Converted" />}
		</div>
	)
}

function imageResize(width: number, height: number) {
	const MAX_WIDTH = 4000
	const MAX_HEIGHT = 4000
	let targetWidth = width
	let targetHeight = height

	if (width > MAX_WIDTH || height > MAX_HEIGHT) {
		if (width > height) {
			targetWidth = MAX_WIDTH
			targetHeight = Math.round((height * MAX_WIDTH) / width)
		} else {
			targetHeight = MAX_HEIGHT
			targetWidth = Math.round((width * MAX_HEIGHT) / height)
		}
	}
	return { width: targetWidth, height: targetHeight }
}

 

4. 서버 - heic-convert, sharp: 갤럭시 울트라 200M 사진은 처리불가

import { NextRequest, NextResponse } from 'next/server';
import convert from 'heic-convert'
import sharp from 'sharp'

async function handler(req: NextRequest) {


	// ...전략
	
		const formdata = await req.formData();

		const newFormData = new FormData()

		for (const [key, value] of formdata.entries()) {
			if (!value) {
				newFormData.append(key, '')
			} else if (value instanceof File === false) {
				newFormData.append(key, value)
			} else {
				const file = value
				const newFileName = file.name.split('.')
				const ext = newFileName.pop()

				if (!ext || ['heic', 'heif'].includes(ext.toLowerCase()) === false) {
					newFormData.append(key, value)
				} else {
					const outputBuffer = await convert({
						buffer: new Uint8Array(await file.arrayBuffer()) as unknown as ArrayBuffer,
						format: 'PNG',
					})
					// 2. 변환된 버퍼를 sharp로 처리 (리사이징 등)
					const webpBuffer = await sharp(outputBuffer).webp({ quality: 80 }).toBuffer()

					// 3. Web API의 File 객체 형태로 만들어 FormData에 추가
					const finalFile = new File([webpBuffer as any], `${newFileName}.webp`, { type: 'image/webp' })
					newFormData.append(key, finalFile)
				}
			}
		}

// ...후략		
}

export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const OPTIONS = handler;

 

공통함수

export function imageResize(width: number, height: number) {
	const MAX_WIDTH = 4000
	const MAX_HEIGHT = 4000
	let targetWidth = width
	let targetHeight = height

	if (width > MAX_WIDTH || height > MAX_HEIGHT) {
		if (width > height) {
			targetWidth = MAX_WIDTH
			targetHeight = Math.round((height * MAX_WIDTH) / width)
		} else {
			targetHeight = MAX_HEIGHT
			targetWidth = Math.round((width * MAX_HEIGHT) / height)
		}
	}
	return { width: targetWidth, height: targetHeight }
}

 

 

에디터를 결정하기에 앞서 여러가지를 테스트해봤다.

- slate: 데이터가 json 형태라 기존 데이터와 호환성 문제로 fail

- react-draft: 데이터가 객체형태라 viewer로 따로 써야하고 어쩌고 하는데 무엇보다 반응속도라 느려서 fail

- quill: toolbar를 세부항목까지 자유롭게 커스텀이 가능한 점은 좋았으나 표나 이미지 업로드 기능같은걸 내가 구현해야한다는 점에서 내가 게을러서 fail

- jodit: 무료는 로고 존재감도 쎄고 문서가 불친절해서 보류했다가 tiny mce로 결정해서 fail

- tiny mce: 문서가 매우 잘 되어있고 커뮤니티 버전으로도 어지간한 기능은 다 있어서 최종 결정하게 되었다.

 

import dynamic from 'next/dynamic'
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react'

const Editor = dynamic(() => import('@tinymce/tinymce-react').then((mode) => mode.Editor), {
	ssr: false,
})

// plugin 기능 import 역할
const tinymcePlugins = [
	'link',
	'lists', // 기본 ol, ul
	'image',
	'autoresize',
	'autolink',
	'advlist', // ol, ul 불릿이 좀 더 여러가지가 생김
	'table',
	'preview',
	'searchreplace', // 치환기능
	'visualblocks', // 태그를 실루엣으로 보여주어 태그가 꼬일경우 편집하는데 도움됨
	'code', // 코드삽입기능ㄴㄴ html 태그로 보여주는 기능임
	'fullscreen',
	'media', // 동영상
	'accordion', // 티스토리 기준 접은글 기능
	'autosave', // localstrage에 저장함
]

// toolbar에 해당 기능을 나열함. plugin이 필요한 기능의 경우 위에도 아래에도 둘 다 들어가야함
const tinymceToolbar =
	'undo redo | blocks fontsize |' +
	'bold italic underline strikethrough forecolor backcolor |' +
	'bullist numlist blockquote|' +
	'alignleft aligncenter alignright alignjustify | outdent indent |' +
	'link image table accordion media | removeformat visualblocks searchreplace restoredraft | fullscreen preview code'

// 온갖 설정은 여기에 다 넣는다고 보면 됨
const editorInitOption = {
    language: 'ko_KR', // 한국어팩
    plugins: tinymcePlugins,
    toolbar: tinymceToolbar,
    color_default_background: '#E67E23', // fontBackColor 기본 선택은 되지만 "@tinymce/tinymce-react": "^4.3.0" 기준 pallete에서 선택은 안되어 있다.
	color_default_foreground: window.matchMedia('(prefers-color-scheme: dark)').matches ? '#fff' : '#000', // fontColor 기본 선택
    
    // dark skin 적용
    // toolbar css 변경 방법은 찾고 있다.
    skin: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'oxide-dark' : 'oxide',
    // dark 대신 css 파일 경로를 넣어줄수도 있다.
    // dark 적용후 import 되는 css 파일을 받아서 dark 기본색인 #222f3를 수정후 사용하면 좋다.
    content_css: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default',
    
    autosave_interval: '20s',
    autosave_restore_when_empty: true, // 빈 편집기에 마지막 저장데이터 복원여부 확인
    autosave_retention: '30m', // 30분 넘은 저장데이터는 삭제
    autosave_prefix: 'tinymce-autosave-{path}{query}-{id}-',
    // min_height: 250,
    // max_height: document.querySelector('#__next').offsetHeight - 250,
    menubar: false,
    branding: false,
    statusbar: false,
    
    // automatic_uploads: false, // true가 기본값
    // 이미지를 업로드할 서버 api 주소. 
    // 다음과 같은 형태로 반환해야 정상적으로 동작한다. 
    // { "location": "folder/sub-folder/new-location.png" }
    images_upload_url: '/api/image-uploads',  
    images_upload_base_path: '/api/files', // 업로드한 파일의 중복경로
    images_upload_credentials: true, // 인증정보가 cookie면 true
    file_picker_callback: (callback, value, meta) => {	
        const input = document.createElement('input')
        input.setAttribute('accept', 'image/*')
        input.setAttribute('type', 'file')
        input.onchange = function () {
            const file = this.files[0]
            const reader = new FileReader()
            reader.onload = function () {
                const id = 'blobid' + new Date().getTime()
                const blobCache = tinymce.activeEditor.editorUpload.blobCache
                const base64 = reader.result.split(',')[1]
                const blobInfo = blobCache.create(id, file, base64)
                blobCache.add(blobInfo)
                callback(blobInfo.blobUri(), { alt: file.name })
            }
            reader.readAsDataURL(file)
        }
        input.click()
    },
    
    // 이미지 업로드 중 커스텀 기능을 넣어야한다면 이걸로 처리해야 함
    // 위에서 설정한 항목중 images_upload_url, images_upload_base_path, images_upload_credentials를 쓰지 않고 내부 코드을 사용함
    // convert_urls: false, // 아래에서 resolve가 절대경로를 반환하는 거라면 false 기본값은 true임
    // images_upload_handler: (blobInfo, progress) => 
    //   
    // 	new Promise((resolve, reject) => {
    //		const images_upload_url = '/api/image-uploads' // 이미지 업로드 api 주소
    // 		const xhr = new XMLHttpRequest()
    // 		xhr.withCredentials = true // 인증정보가 cookie면 true
    // 		xhr.open('POST', images_upload_url)

    // 		xhr.upload.onprogress = (e) => {
    // 			progress((e.loaded / e.total) * 100)
    // 		}

    // 		xhr.onload = () => {
    // 			if (xhr.status === 403) {
    // 				reject({ message: 'HTTP Error: ' + xhr.status, remove: true })
    // 				return
    // 			}
    // 			if (xhr.status < 200 || xhr.status >= 300) {
    // 				reject('HTTP Error: ' + xhr.status)
    // 				return
    // 			}

    // 			const json = JSON.parse(xhr.responseText)
    // 			if (!json || typeof json.location != 'string') {
    // 				reject('Invalid JSON: ' + xhr.responseText)
    // 				return
    // 			}
    
    //			/* 커스텀 설정 넣을 곳 */

    // 			resolve(json.location) // 경로
    // 		}

    // 		xhr.onerror = () => {
    // 			reject('Image upload failed due to a XHR Transport error. Code: ' + xhr.status)
    // 		}

    // 		const formData = new FormData()
    // 		formData.append('file', blobInfo.blob(), blobInfo.filename())

    // 		xhr.send(formData)
    // 	}),
}

export default function Editor() {
	const editorRef = useRef(null)
    
    // editorRef.current.getContent()
    
	return <Editor
        onInit={(e, editor) => (editorRef.current = editor)}
        apiKey="API키"
        initialValue={에디터에 출력할 기본값이 있다면 여기}
        init={editorInitOption}
    />
}

toolbar css는 일단.... _doucument 파일에다 <NextScript /> 아래에 <link rel="stylesheet" href="" /> 태그를 삽입하는 방식으로 일단 처리하려고 한다. 대신에 @media (prefers-color-scheme: dark) { } 로 감싸주었다.

필요한 라이브러리를 찾았는데, 혹은 정착한 라이브러리라 하더라도 더 좋은게 있는데 놓치고 있진 않는가 확인하는 용도로 http://npmtrends.com 사이트를 이용하곤 한다.

어쩌다 다국어 관련하여 https://npmtrends.com/next-i18next-vs-next-translate 이걸 확인해보니 애정(?)하는 next-translate의 이용자 수가 미미한 것에 자극받아 귀차니즘을 무릎쓰고 이 글을 쓰게 되었다.

 

1. next-i18next

next.js 기반에서 가장 많이 쓰는 라이브러리고, 위의 두번째 링크를 타고 가서 봐도 알 수 있듯이 사용자 수가 급속히 늘고 있다. 나도 실제로 가장 처음으로 도입했었었다. 그렇게 몇 달 쓰다가 매 페이지마다 serverSideTranslations 이런거 넣어주는거에 현타가 와서 next-translate 테스트 후 바로 넘어가게 되었다. 

import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import { useTranslation } from "next-i18next"

function Page () {
  const { t } = useTranslation("button")

  return (
    <div>
      <p>{t("ok")}</p>
    </div>
  );
};

export default Page

// 이거 페이지마다 해줘야 함
export const getStaticProps = async ({ locale }) => ({
  props: {
    ...(await serverSideTranslations(locale, ["common", "button", ... ])), // 해당 페이지에서 쓸 파일명 나열
  },
});

그러다보니 next-i18next에 대해서 많이 잊었다가 이 글을 쓰기 위해 리마인드하기 위해 검색하다 보니 url에 /ko/ /en/ 이런것도 붙었었던 것 같기도 하고 아무튼 뭔가 많이 너저분했었던 기억만 남아있을 정도랄까?

 

2. next-translate

 next.config.js에서 

const nextTranslate = require('next-translate-plugin')
const nextConfig = {
	.
    .
    .
	...nextTranslate(),
}
module.exports = nextConfig

이 설정과 

/locale/에 언어별로 json  파일을 두고,

i18n.json에서는 pages라는 항목에 경로별로 어떤 파일을 적용할껀지 설정해준다. 여기가 바로 핵심적으로 감동했던 부분이었다.

{
	"locales": [
		"ko",
		"en",
        .
        .
        .
	],
	"defaultLocale": "ko",
	"pages": {
		"*": [
			"common",
			"button",
			"navigation",
            .
            .
            .
		]
	}
}
// "*" 은 경로=1.x 버전일때 저렇게 경로를 안 잡으면 components 폴더 안의 파일들이 안되길래 급한 마음에 저렇게 하고 그 후 테스트를 놨었는데 다른 해결책이 있을 수도 있다.
// []에는 /locales/ 하위 언어별 파일명

마지막으로 매 페이지에서는 다음과 같은 방식으로 쓰기만하면 된다.

import useTranslation from 'next-translate/useTranslation'
import getT from 'next-translate/getT'

// Front
function Page () {
	const { t } = useTranslation('') // '' 안에 locale의 특정 파일명을 넣을 수도 있지만 비워두면 다 불러옴
	return 
    	<>
            <button>{t('button:back')}</button>
            <button>{t('button:confirm.ok')}</button>
            <button>{t('button:confirm.cancel')}</button>
    	</>
}
// button => 파일명
// back, confirm.ok, confirm.cancel => json key

export default Page

// Server
export async function getServerSideProps(context) {
     const { locale } = context
    const t = await getT(locale, '') // '' 안에 특정 파일명 가능
    
    console.log(t('button:back');
    
    return {
        props: {}
    }
}

1.xx버전때 설정한 이후로 쭉 쓰고 있는데 초기 설정 당시 useTranslation를 매 페이지에서 하니 부하가 있는 듯해 react의 context에서 한번 한 후 꺼내 쓰는 방식으로 해서 속도를 드라마틱하게 개선했었는데 그 후 관련 테스트를 안해서 요즘은 모르겠지만 아무튼 사용성이 편해서 잘 쓰고 있다.

 

결론: next-translate 쓰세요!

인증이 된 사용자만 파일 다운로드가 가능해야하는 상황이라서 일반적인 a href를 쓸 수 없었다. 이런 경우 이런 식으로 파일 다운로드를 진행해야하는 것 같았다. 

간단해보이는 소스지만, 이 조합을 찾아내기까지 하루이상의 시간이 소요되었기 때문에 다음에는 헤매지 않기 위해 기록해둔다.

 

1. Express

import { join } from 'path';

...

router.get('/subpath/:id', async (req, res, next) => {
    const id = req.params.id;

    const filePath = join(__dirname, `서버에 저장된하위경로`);
    const filename = '파일명.확장자';
    
    // 1안
    res.setHeader('Content-Disposition', `attachment; filename=다른파일명가능`); // 필수
    res.sendFile(filePath + filename);

    // 2안
    res.download(filePath + filename); // 서버에 저장된 파일명으로만 가능

    // 3안
    res.setHeader('Content-Disposition', `attachment; filename=다른파일명가능`); // 필수
    const stream = fs.createReadStream(filePath + filename);
    stream.pipe(res);
});

출처: 불특정 다수

 

2. Front

const authHeader = 'token값'
const url = `/api/subpath/${id}`
let filename = '' // 서버에서 보내는 파일명이 담길 변수

fetch(url, { headers: { 'X-Auth-Token': authHeader } })
    .then((response) => {
        if (!response.ok) {
            throw Error(response.statusText)
        }

        const header = response.headers.get('Content-Disposition')
        const parts = header.split(';')
        filename = parts[1].split('=')[1].replaceAll('"', '')

        return response.blob()
    })
    .then((blob) => {
        if (blob != null) {
            var url = window.URL.createObjectURL(blob)
            var a = document.createElement('a')
            a.href = url
            a.download = filename
            document.body.appendChild(a)
            a.click()
            a.remove()
        }
    })
    .catch((err) => {
        console.log(err)
    })

출처: https://christosmonogios.com/2022/05/10/Use-the-Fetch-API-to-download-files-with-their-original-filename-and-the-proper-way-to-catch-server-errors/

 

front의 경우 이거 말고도 new ReadableStream에 new Response 동원해서 하는 복잡한 소스도 있었는데 위가 더 심플하고 속도도 체감될 정도로 빨라서 이 걸로 낙찰했다.

 

'Javascript' 카테고리의 다른 글

정렬 sort  (0) 2021.04.01
jQuery to VanillaJS  (0) 2021.03.16
키보드를 이용한 Input text 포커스 이동  (0) 2021.03.15
String to Date / Date to String  (0) 2020.12.17
숫자에 콤마와 언콤마를 편하게  (0) 2016.06.27

trigger를 통해서 값이 insert되면 외부 프로그램, 혹은 API를 호출하고 싶었다.

그때마다 검색되는 UDF인 sys_exec() 는 my_global.h가 없어 컴파일이 안되어 사용할 수 없는 상황이었다.

(MySQL 5에서는 있었지만 8 혹은 마이너 버전부터 제외한다는 글을 본 것 같다.)

mysql 내부에서 해결하는 것은 포기하고 돌도 돌다 node.js에 binlog를 이용하여 테이블을 감시하는 것을 찾았다.

https://www.npmjs.com/package/@vlasky/mysql-live-select

원래는 목적으로는 특정 레코드의 컬럼까지 감시가 가능한 것 같지만, 나는 테이블만 감시하는 목적으로 사용하려고 했고, 제공된 예제를 아래와 같이 수정하니 목적을 달성할 수 있게 되었다.

const LiveMysql = require('@vlasky/mysql-live-select');
const LiveMysqlKeySelector = require('@vlasky/mysql-live-select/lib/LiveMysqlKeySelector');

const settings = {
  host: '호스트주소',
  user: '아이디',
  password: '비밀번호',
  database: 'DB명'
};

const liveConnection = new LiveMysql(settings);

liveConnection
  .select('select version()', [], LiveMysqlKeySelector.Index(), [
    {
      table: '테이블명',
      condition: function (row, newRow, rowDeleted) {
        if (row && newRow === null) {
          if (rowDeleted === false) {
            // insert 상황            
          } else {
            // delete 상황
          }
        } else {
          // update 상황
        }
        return true;
      }
    }
  ]);

설치 innologica.github.io/vue2-daterange-picker/#installation :무사히 완료

npm i vue2-daterange-picker --save
or
yarn add vue2-daterange-picker

 

가장 기본 용법인 이건 된다.

// 컴포넌트 등록
import DateRangePicker from 'vue2-daterange-picker'
import 'vue2-daterange-picker/dist/vue2-daterange-picker.css'

Vue.component('DateRangePicker', DateRangePicker)


// 실사용
<template>
  <date-range-picker
    v-model="daterange" 
    :locale-data="{
      direction: 'ltr',
      firstDay: 0,
      format: 'yyyy-mm-dd',
      separator: ' - ',
      applyLabel: '확인',
      cancelLabel: '취소',
      weekLabel: '주',
      customRangeLabel: '기간',

      daysOfWeek: ['일', '월', '화', '수', '목', '금', '토'],
      monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
     }"
  />
</template>
<script>
import DateRangePicker from 'vue2-daterange-picker'
</script>

근데, 나는 props의 디폴트 값이 마음에 들지 않았다.

그리고 혼자 오해를 했다. 용법 innologica.github.io/vue2-daterange-picker/#usage : Register the component 이걸 하면 default props를 overriding 해줄거라고

내 잘못이 맞긴 한데 내가 파악한 구조상으로는 default props를 설정하려면 DateRangePicker.vue를 새로 만들던가 해야 할 것 같다. 기존 거 고쳐봤자 업데이트하면 날라갈 것 같으니 말이다.

그리고 매뉴얼이 진짜 불친절하다 props이름이랑 밑에 설명이랑 매칭이 바로 바로 안되서 일일히 _넣어서 확인해봐야했다.

시간도 버렸고 안 쓰기로 했지만 또 언젠가 쓰게 될지도 모르니까 오늘 정리한 것은 기록으로 남겨두겠다.

 

node_modules\vue2-daterange-picker\dist\vue2-daterange-picker.css에서 나는 문제가 있어서 수정한 부분은 아래와 같다.

// original
.daterangepicker.openscenter[data-v-7d8c7845]{
    ...
    left:50%;
    ...
}

// modify
.daterangepicker.openscenter[data-v-7d8c7845]{
    ...
    left:100%;
    ...
}

 

매뉴얼 중 props 관련하여 이름순 말고 관련있는 설정끼리 묶어봤다.

/** 날짜 설정과 관련된 옵션 **/
:minDate [String, Date], // 최소
:maxDate [String, Date], // 최대
:localeData Object,  // 로케일 데이터 포함한 객체
:dateRange [Object], // v-model prop에서 사용함 startDate와 endDate 를 포함하는 객체여야며 Date로 파싱할 수 있는 문자열이어야 함
:dateFormat Function, 
// function(classes, date) - 2 개의 매개 변수를받는 특수한 prop 유형 함수:
// "classes" - 컴포넌트의 로직이 정의한 클래스,
// "date" - 현재 처리 된 날짜.
// 렌더링 된 날짜에 적용 할 Vue 클래스 객체를 반환해야 함.


/** input 상태 관련 **/
:disabled Boolean, // 비활성화 상태
:readonly Boolean, // readonly 여부


/** input class명 관련 **/
:controlContainerClass [Object, String], // class명

/** 달력 모양 관련 **/
:ranges [Object, Boolean], // 오늘, 어제, 이번달, 올해, 지날달 범위 선택으로 뜨는것. 숨기려면 false로 설정할 수 있음
:opens String, // :ranges 목록의 위치를 지정하는 것임 "center", "left", "right", "inline"라는데 제대로 동작 안한다

:showWeekNumbers Boolean, // 주수표시
:showDropdowns Boolean, // 달력 위에 월 및 연도 선택에 대한 드롭 다운을 표시
:alwaysShowCalendars Boolean, // false로 설정하고 미리 정의 된 범위 중 하나를 선택하면 달력이 숨겨진다고 함
:singleDatePicker [Boolean, String], // 단일 캘린더만 표시

:timePicker Boolean, // 달력 아래에 시간 (시간 / 분) 선택에 대한 드롭 다운을 표시
:timePickerIncrement Number, // 분 드롭 다운에서 분 단위
:timePicker24Hour Boolean, // 24시간제 여부
:timePickerSeconds Boolean, // 시/분을 제외한 초를 선택

:appendToBody Boolean, // 드롭 다운 요소를 본문 끝에 추가하고 동적으로 크기 / 위치를 지정할지 여부
:calculatePosition Function, // appendToBody true일 경우 설정함


/** 달력 이벤트 관련 **/
:linkedCalendars Boolean, // 달력선택 연동
:autoApply Boolean, // 선택한 범위를 자동으로 적용
:closeOnEsc Boolean, // esc키로 드롭다운을 닫을 지 여부

여기까지만 봐서 정리도 여기까지만 한다.

 

// 숫자 배열

const arr = [5, 2, 6, 11, 8, 20];

// 오름차순
arr.sort(function(a, b) {
    return a - b;
});

// 내림차순
arr.sort(function(a, b) {
    return b - a;
});

 

// 문자 배열

const arr = ['cherry', 'peach', 'strawberry', 'apple'];

// 오름차순
arr.sort();

// 내림차순
arr.sort(function(a, b) {
	return (a > b)? -1 : ((a < b)? 1 : 0);
});

 

// 객체 배열

const arr = [
  { key: 10, value: '휴대폰' },
  { key: 5, value: '이어폰' },
  { key: 21, value: '가방' },
  { key: 8, value: '보조배터리' },
  { key: 16, value: '전화기' },
];

// 객체 내 숫자 기준
arr.sort(function(a, b) {
    return a.key - b.key;
});

// 객체 내 문자열 기준
arr.sort(function(a, b) {
	return (a.value > b.value)? -1 : ((a.value < b.value)? 1 : 0);
});

 

1. 선택자

// 기본
$(".box");

document.querySelector(".box"); // 첫번째만 반환함 
document.querySelectorAll(".box"); // 모두 반환함 nodeList 형태라고 하는데 배열이라고 생각하면 편함


// find
var container = $(".container");
container.find(".box");

var container = document.querySelector(".container");
container.querySelector(".box");

2. 기능실행

$(".box").hide();

// 한개
document.querySelector(".box").style.display = "none";
// 여러개
document.querySelectorAll(".box").forEach(box => { box.style.display = "none" })

3. 탐색

$(".box").next();
$(".box").prev();
$(".box").parent();

var box = document.querySelector(".box");
box.nextElementSibling;
box.previousElementSibling;
box.parentElement;

4. 요소

// 요소 추가
$("<div/>");
$("<span/>");

document.createElement("div");
document.createElement("span");


// 요소에 텍스트 추가
var element = document.createElement("div");
element.textContent = "Text"

var text = document.createTextNode("div");
element.appendChild(text);


// 요소의 속성 수정 
$(".button").text("New text");

document.querySelector(".button").textContent = "New text";

// 요소의 속성값 가져오기
$(".button").text();

document.querySelector(".button").textContent;


// 요소에 하위 요소 추가
$(".container").append($("<div/>"));

var element = document.createElement("div");
document.querySelector(".container").appendChild(element);

4. 이벤트 추가

// 기본
$(".button").click(function(e) { /* handle click event */ });
$(".button").mouseenter(function(e) {  /* handle click event */ });
$(document).keyup(function(e) {  /* handle key up event */  });

document.querySelector(".button").addEventListener("click", (e) => { /* ... */ });
document.querySelector(".button").addEventListener("mouseenter", (e) => { /* ... */ });
document.addEventListener("keyup", (e) => { /* ... */ });


// 동적으로 추가하는 요소에도 이벤트 추가
$(".search-container").on("click", ".search-result", handleClick); // 기존 요소와 추가될 요소까지

var searchElement = document.createElement("div");
document.querySelector(".search-container").appendChild(searchElement);
searchElement.addEventListener("click", handleClick); // 추가한 요소에 이벤트 추가하기


// Trigger
$(document).trigger("myEvent");
$(".box").trigger("myEvent");

document.dispatchEvent(new Event("myEvent"));
document.querySelector(".box").dispatchEvent(new Event("myEvent"));

5. Style (CSS)

// 하나라면
$(".box").css("color", "#000");

document.querySelector(".box").style.color = "#000";


// 여러개라면
$(".box").css({
  "color": "#000",
  "background-color": "red"
});

var box = document.querySelector(".box");
box.style.color = "#000";
box.style.backgroundColor = "red";

// Set all styles at once (and override any existing styles)
box.style.cssText = "color: #000; background-color: red";


// Class명 추가/삭제/토글
$(".box").addClass("focus");
$(".box").removeClass("focus");
$(".box").toggleClass("focus");

// 한개
var box = document.querySelector(".box");
box.classList.add("focus");
box.classList.remove("focus");
box.classList.toggle("focus");

// 여러개
var box = document.querySelector(".box");
box.classList.add("focus", "highlighted");
box.classList.remove("focus", "highlighted");

// 상호 베타적인 토글용 2개의 Class를 번갈아가며 써야 한다면
document.querySelector(".box").classList.replace("focus", "blurred");


// Class명을 가지고 있는지 확인
$(".box").hasClass("focus");

document.querySelector(".box").classList.contains("focus");

6. Document Ready

$(document).ready(function() { 
	// 소스
});

var ready = (callback) => {
  if (document.readyState != "loading") callback();
  else document.addEventListener("DOMContentLoaded", callback);
}

ready(() => { 
	// 소스
});

7. Ajax

$.ajax({
	url: "data.json"
}).done(function(data) {
	// ...
}).fail(function() {
	// Handle error
});


fetch("data.json").then(data => {
	// Handle data
}).catch(error => {
	// Handle error
});

 

출처를 기반으로 다시 정리함

출처: tobiasahlin.com/blog/move-from-jquery-to-vanilla-javascript/

 

Cheat sheet for moving from jQuery to vanilla JavaScript

This reference guide will help you convert jQuery's most common patterns to vanilla JavaScript

tobiasahlin.com

 

'Javascript' 카테고리의 다른 글

fetch로 file download (feat. express)  (0) 2023.05.15
정렬 sort  (0) 2021.04.01
키보드를 이용한 Input text 포커스 이동  (0) 2021.03.15
String to Date / Date to String  (0) 2020.12.17
숫자에 콤마와 언콤마를 편하게  (0) 2016.06.27

방향키와 페이지 업다운 키를 이용하여 포커스 이동을 구현해보았다.

<!doctype html>
<html lang="ko">
<head>
	<meta charset="utf-8">
	<title>키보드를 이용한 포커스 이동</title>
</head>
<body>
	<h1>키보드로 이동하기</h1>
	
	<form name="frm">
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a"><input name="b"><input name="c"><br>
		<input name="a">
		
		<button>전송</button>
	</form>
	
	
</body>
<script>
	const inputs = document.querySelectorAll("input");
	const total = inputs.length;

	const cols = 3; // 컬럼수
	//const rows = Math.ceil(total/cols); // 줄수

	function keyevent(event) {
		const keycode = event.keyCode;
		const idx = Array.from(document.querySelectorAll('input')).indexOf(event.target);

		switch (keycode) {
			case (13) : // Enter
				if(idx===(total-1)){ //enter      
					//alert("폼 전송");
				}else{
					document.querySelectorAll('input')[idx+1].focus();
				}
				break;

			case(33) : // PageUp
				document.querySelectorAll('input')[0].focus();
				break;

			case(34) : // PageDown
				document.querySelectorAll('input')[total-1].focus();
				break;
			case(37) : // Left
				if(idx===0){
					return false;      
				}else{  
					document.querySelectorAll('input')[idx-1].focus();
				}
				break;
			case(38) : // Up
				if(idx < cols){
					return false;      
				}else{
					document.querySelectorAll('input')[idx-cols].focus();
				} 
				break;
			case(39) : // Right
				if(idx===(total-1)){ 
					return false;      
				}else{      
					document.querySelectorAll('input')[idx+1].focus();
				} 
				break;

			case (40) : // Down
				if(idx+cols >= total){
					return false;       
				}else{
					document.querySelectorAll('input')[idx+cols].focus();
				}
				break;

			default : 
				return false;
				break;
		}
	}


	
	function formCheck(e) {
		// 문제가 있으면
		// e.preventDefault();
	}
	
	document.querySelector("form").addEventListener('submit', formCheck);
	for(const item of inputs) {
		item.addEventListener('keydown', keyevent);
	}

</script>
</html>

 

 

'Javascript' 카테고리의 다른 글

정렬 sort  (0) 2021.04.01
jQuery to VanillaJS  (0) 2021.03.16
String to Date / Date to String  (0) 2020.12.17
숫자에 콤마와 언콤마를 편하게  (0) 2016.06.27
정규식으로 괄호 안의 문자 추출과 치환하기  (0) 2015.07.09

+ Recent posts