Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
192 lines
5.9 KiB
TypeScript
192 lines
5.9 KiB
TypeScript
import { texts, format } from '@/texts'
|
|
|
|
const IMAGE_SIGNATURES: Record<string, (bytes: Uint8Array) => boolean> = {
|
|
'image/jpeg': (bytes) => bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff,
|
|
'image/png': (bytes) => [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((byte, index) => bytes[index] === byte),
|
|
'image/webp': (bytes) =>
|
|
new TextDecoder().decode(bytes.slice(0, 4)) === 'RIFF' && new TextDecoder().decode(bytes.slice(8, 12)) === 'WEBP',
|
|
}
|
|
|
|
const HEIC_BRANDS = new Set(['heic', 'heif', 'mif1', 'msf1', 'hevx', 'heim', 'heis', 'hevm', 'hevs'])
|
|
|
|
const HEIC_MIME_TYPES = new Set(['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'])
|
|
const IMAGE_PROCESSING_TIMEOUT_MS = 30_000
|
|
|
|
/** Accept list for `<input type="file">` — includes HEIC so iPhone Photos picker works. */
|
|
export const SAFE_IMAGE_ACCEPT = [...Object.keys(IMAGE_SIGNATURES), 'image/heic', 'image/heif', '.heic', '.heif'].join(',')
|
|
|
|
function decodeAscii(bytes: Uint8Array, start: number, end: number): string {
|
|
return new TextDecoder().decode(bytes.slice(start, end))
|
|
}
|
|
|
|
function isHeicBytes(bytes: Uint8Array): boolean {
|
|
if (bytes.length < 12) return false
|
|
if (decodeAscii(bytes, 4, 8) !== 'ftyp') return false
|
|
|
|
return HEIC_BRANDS.has(decodeAscii(bytes, 8, 12))
|
|
}
|
|
|
|
function isHeicMime(mime: string): boolean {
|
|
return HEIC_MIME_TYPES.has(mime.toLowerCase())
|
|
}
|
|
|
|
function isHeicFileName(name: string): boolean {
|
|
return /\.hei[cf]$/i.test(name)
|
|
}
|
|
|
|
/** Prefer magic bytes over `file.type` (often empty on iOS Safari). */
|
|
export function detectImageMime(bytes: Uint8Array, declaredType = ''): string | null {
|
|
for (const [mime, matches] of Object.entries(IMAGE_SIGNATURES)) {
|
|
if (matches(bytes)) return mime
|
|
}
|
|
|
|
if (isHeicBytes(bytes) || isHeicMime(declaredType)) return 'image/heic'
|
|
|
|
return null
|
|
}
|
|
|
|
function extensionForMime(mime: string): string {
|
|
switch (mime) {
|
|
case 'image/png':
|
|
return 'png'
|
|
case 'image/gif':
|
|
return 'gif'
|
|
case 'image/webp':
|
|
return 'webp'
|
|
case 'image/heic':
|
|
return 'heic'
|
|
case 'image/heif':
|
|
return 'heif'
|
|
default:
|
|
return 'jpg'
|
|
}
|
|
}
|
|
|
|
function withMimeType(file: File, mime: string): File {
|
|
if (file.type === mime) return file
|
|
|
|
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
|
|
const nextName = `${baseName}.${extensionForMime(mime)}`
|
|
|
|
return new File([file], nextName, { type: mime, lastModified: file.lastModified })
|
|
}
|
|
|
|
/**
|
|
* Converts HEIC → JPEG in-browser when possible (Safari/WebKit on iPhone).
|
|
* Returns `null` — instead of throwing — when the browser can't decode HEIC
|
|
* (Chrome/Edge/Firefox, notably on Windows), so the caller can fall back to
|
|
* uploading the original HEIC file for the backend to convert.
|
|
*/
|
|
async function convertHeicToJpeg(file: File): Promise<File | null> {
|
|
let bitmap: ImageBitmap
|
|
|
|
try {
|
|
bitmap = await createImageBitmap(file)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const canvas = document.createElement('canvas')
|
|
|
|
canvas.width = bitmap.width
|
|
canvas.height = bitmap.height
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
if (!ctx) throw new Error(texts.common.imagePrepareFailed)
|
|
|
|
ctx.drawImage(bitmap, 0, 0)
|
|
|
|
const blob = await withImageProcessingTimeout(
|
|
new Promise<Blob>((resolve, reject) => {
|
|
canvas.toBlob(
|
|
(result) => {
|
|
if (result) resolve(result)
|
|
else reject(new Error(texts.common.imageConvertFailed))
|
|
},
|
|
'image/jpeg',
|
|
0.92
|
|
)
|
|
})
|
|
)
|
|
|
|
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
|
|
|
|
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg', lastModified: Date.now() })
|
|
} finally {
|
|
bitmap.close()
|
|
}
|
|
}
|
|
|
|
function withImageProcessingTimeout<T>(operation: Promise<T>): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = window.setTimeout(() => {
|
|
reject(new Error(texts.common.imageProcessingTimedOut))
|
|
}, IMAGE_PROCESSING_TIMEOUT_MS)
|
|
|
|
operation.then(
|
|
(value) => {
|
|
window.clearTimeout(timeout)
|
|
resolve(value)
|
|
},
|
|
(error: unknown) => {
|
|
window.clearTimeout(timeout)
|
|
reject(error instanceof Error ? error : new Error(texts.common.imagePrepareFailed))
|
|
}
|
|
)
|
|
})
|
|
}
|
|
|
|
function sizeError(file: File, maxSizeMb: number): string | null {
|
|
if (file.size === 0) return texts.common.imageEmpty
|
|
if (file.size > maxSizeMb * 1024 * 1024) {
|
|
return format(texts.common.imageTooLarge, { maxMb: maxSizeMb })
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Normalize an image picked on any device (esp. iPhone) into an upload-ready File:
|
|
* - sniffs magic bytes when `file.type` is empty
|
|
* - converts HEIC/HEIF → JPEG when the browser can decode it
|
|
*
|
|
* Returns the File, or a Persian error string.
|
|
*/
|
|
export async function prepareImageForUpload(file: File, maxSizeMb = 6): Promise<File | string> {
|
|
const tooLarge = sizeError(file, maxSizeMb)
|
|
|
|
if (tooLarge) return tooLarge
|
|
|
|
const bytes = new Uint8Array(await withImageProcessingTimeout(file.slice(0, 16).arrayBuffer()))
|
|
const sniffed = detectImageMime(bytes, file.type) ?? (isHeicFileName(file.name) ? 'image/heic' : null)
|
|
|
|
if (!sniffed) {
|
|
return texts.common.imageTypeNotAllowed
|
|
}
|
|
|
|
try {
|
|
if (sniffed === 'image/heic') {
|
|
const jpeg = await convertHeicToJpeg(file)
|
|
|
|
// Browser couldn't decode HEIC itself (e.g. Chrome/Edge on Windows) —
|
|
// send the original file as-is; the backend converts it server-side.
|
|
if (!jpeg) return withMimeType(file, sniffed)
|
|
|
|
const afterConvert = sizeError(jpeg, maxSizeMb)
|
|
|
|
return afterConvert ?? jpeg
|
|
}
|
|
|
|
return withMimeType(file, sniffed)
|
|
} catch (error) {
|
|
return error instanceof Error ? error.message : texts.common.imagePrepareFailed
|
|
}
|
|
}
|
|
|
|
export async function validateImageFile(file: File, maxSizeMb = 6): Promise<string | null> {
|
|
const prepared = await prepareImageForUpload(file, maxSizeMb)
|
|
|
|
return typeof prepared === 'string' ? prepared : null
|
|
}
|