검증게시판 기능 추가

This commit is contained in:
Macbook
2026-05-27 15:41:19 +09:00
parent 7a0af36b9b
commit 63693d47c0
57 changed files with 4480 additions and 14 deletions
+183
View File
@@ -0,0 +1,183 @@
/**
* 클립보드·붙여넣기 이미지 → 업로드용 File 정규화
*/
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
]);
/** 클립보드 항목에서 우선 선택할 MIME (macOS TIFF 등 제외) */
const CLIPBOARD_PREFERRED = [
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
'image/bmp',
];
function normalizeMime(raw: string | undefined): string | null {
if (!raw) return null;
const base = raw.split(';')[0]?.trim().toLowerCase() ?? '';
if (base === 'image/jpg' || base === 'image/pjpeg') {
return 'image/jpeg';
}
if (base === 'image/x-png') {
return 'image/png';
}
return ALLOWED_MIME.has(base) ? base : null;
}
function extForMime(mime: string): string {
switch (mime) {
case 'image/jpeg': return 'jpg';
case 'image/png': return 'png';
case 'image/gif': return 'gif';
case 'image/webp': return 'webp';
case 'image/bmp': return 'bmp';
default: return 'png';
}
}
function guessMimeFromName(name: string): string | undefined {
const lower = name.toLowerCase();
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.bmp')) return 'image/bmp';
return undefined;
}
/** File MIME·이름 정규화 (클립보드·붙여넣기 대응) */
export function normalizeImageFile(file: File): File | null {
const mime = normalizeMime(file.type)
?? normalizeMime(guessMimeFromName(file.name))
?? 'image/png';
if (!ALLOWED_MIME.has(mime)) return null;
if (file.size === 0) return null;
const ext = extForMime(mime);
const name = file.name && !file.name.startsWith('blob')
? file.name
: `clipboard-${Date.now()}.${ext}`;
if (file.type === mime && file.name === name) return file;
return new File([file], name, { type: mime, lastModified: file.lastModified });
}
/** FileList / File[] → 허용 이미지만 */
export function pickImageFiles(list: FileList | File[]): File[] {
return Array.from(list)
.map(normalizeImageFile)
.filter((f): f is File => f != null);
}
/** 백엔드 제한(5MB) 초과 시 JPEG로 축소 */
export async function compressImageIfNeeded(
file: File,
maxBytes = 5 * 1024 * 1024,
): Promise<File> {
if (file.size <= maxBytes) return file;
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
throw new Error('IMAGE_READ_FAILED');
}
let w = bitmap.width;
let h = bitmap.height;
const maxDim = 2560;
if (w > maxDim || h > maxDim) {
const scale = Math.min(maxDim / w, maxDim / h);
w = Math.max(1, Math.round(w * scale));
h = Math.max(1, Math.round(h * scale));
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
throw new Error('IMAGE_READ_FAILED');
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
let quality = 0.9;
let blob: Blob | null = null;
for (let i = 0; i < 8; i++) {
blob = await new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve, 'image/jpeg', quality);
});
if (blob && blob.size <= maxBytes) break;
quality -= 0.1;
}
if (!blob || blob.size > maxBytes) {
throw new Error('IMAGE_TOO_LARGE');
}
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
return new File([blob], `${base}.jpg`, { type: 'image/jpeg', lastModified: Date.now() });
}
/** 업로드 전 이미지 정규화·용량 조정 */
export async function prepareImagesForUpload(files: File[]): Promise<File[]> {
const normalized = pickImageFiles(files);
const out: File[] = [];
for (const f of normalized) {
out.push(await compressImageIfNeeded(f));
}
return out;
}
/** ClipboardEvent / DataTransfer 에서 이미지 File 추출 */
export function filesFromDataTransfer(data: DataTransfer): File[] {
const out: File[] = [];
for (const item of Array.from(data.items)) {
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (!file) continue;
const normalized = normalizeImageFile(file);
if (normalized) out.push(normalized);
}
if (out.length === 0) {
return pickImageFiles(data.files);
}
return out;
}
/** navigator.clipboard.read() 결과 → File[] */
export async function filesFromClipboardRead(): Promise<File[]> {
if (!navigator.clipboard?.read) {
throw new Error('CLIPBOARD_UNSUPPORTED');
}
const items = await navigator.clipboard.read();
const files: File[] = [];
for (const item of items) {
const type = CLIPBOARD_PREFERRED.find(t => item.types.includes(t));
if (!type) continue;
const blob = await item.getType(type);
if (!blob || blob.size === 0) continue;
const mime = normalizeMime(blob.type) ?? type;
const ext = extForMime(mime);
files.push(new File(
[blob],
`clipboard-${Date.now()}.${ext}`,
{ type: mime },
));
}
return files;
}