feat: compress uploads and serve resized image variants via /api/img
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
class="tile"
|
||||
:class="{ 'is-uploading': item.status === 'uploading' }"
|
||||
>
|
||||
<img :src="item.url ?? item.preview" alt="" />
|
||||
<img :src="imageUrl(item.url, 400) ?? item.preview" alt="" />
|
||||
|
||||
<div v-if="item.status === 'uploading'" class="tile__progress">
|
||||
<a-progress type="circle" :percent="item.percent" :size="40" :stroke-width="8" stroke-color="#fff" trail-color="rgba(255,255,255,.3)" :show-info="false" />
|
||||
@@ -64,6 +64,8 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { CloudUploadOutlined, DeleteOutlined, EyeOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
import { checkImageFile, uploadErrorMessage } from '../../utils/upload';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -116,8 +118,9 @@ watch(
|
||||
);
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error(`«${file.name}» تصویر نیست`);
|
||||
const problem = checkImageFile(file);
|
||||
if (problem) {
|
||||
message.error(problem);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
@@ -171,10 +174,7 @@ function doUpload({ file }: { file: File & { uid?: string } }) {
|
||||
sync();
|
||||
})
|
||||
.catch((e) => {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
`بارگذاری «${file.name}» ناموفق بود`;
|
||||
message.error(msg);
|
||||
message.error(uploadErrorMessage(e, file.name));
|
||||
dropItem(uid);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
:max-count="1"
|
||||
accept="image/*"
|
||||
:custom-request="doUpload"
|
||||
:before-upload="beforeUpload"
|
||||
@remove="handleRemove"
|
||||
@preview="handlePreview"
|
||||
>
|
||||
@@ -37,9 +38,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
import { checkImageFile, uploadErrorMessage } from '../../utils/upload';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -67,6 +70,7 @@ interface UiFile {
|
||||
name: string;
|
||||
status: 'done';
|
||||
url: string;
|
||||
thumbUrl?: string;
|
||||
}
|
||||
const fileList = ref<UiFile[]>([]);
|
||||
|
||||
@@ -77,7 +81,9 @@ watch(
|
||||
if (!url) {
|
||||
fileList.value = [];
|
||||
} else if (fileList.value[0]?.url !== url) {
|
||||
fileList.value = [{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url }];
|
||||
fileList.value = [
|
||||
{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url, thumbUrl: imageUrl(url, 400) },
|
||||
];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -85,6 +91,15 @@ watch(
|
||||
|
||||
const placeholder = computed(() => props.placeholder);
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
const problem = checkImageFile(file);
|
||||
if (problem) {
|
||||
message.error(problem);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function doUpload({ file }: { file: File }) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
@@ -98,10 +113,7 @@ async function doUpload({ file }: { file: File }) {
|
||||
emit('update:modelValue', data.url);
|
||||
emit('ok', data);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'بارگذاری فایل ناموفق بود';
|
||||
message.error(msg);
|
||||
message.error(uploadErrorMessage(e, file.name));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Uploaded images through the API's resize endpoint (GET <api>/img/:file?w=).
|
||||
// Anything that is not a plain upload path (blob:, data:, bundled assets,
|
||||
// absolute URLs) is returned untouched.
|
||||
const UPLOAD_PREFIXES = ['/uploads/', '/api/uploads/'];
|
||||
|
||||
/** Must match IMAGE_WIDTHS in apps/api image-variants.service.ts. */
|
||||
export type ImageWidth = 200 | 400 | 600 | 800 | 1200 | 1600;
|
||||
|
||||
const apiBase = (() => {
|
||||
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/';
|
||||
return base.endsWith('/') ? base : `${base}/`;
|
||||
})();
|
||||
|
||||
export function imageUrl(url: string | null | undefined, width: ImageWidth): string | undefined {
|
||||
if (!url) return undefined;
|
||||
const prefix = UPLOAD_PREFIXES.find((p) => url.startsWith(p));
|
||||
if (!prefix) return url;
|
||||
const name = url.slice(prefix.length);
|
||||
if (!name || name.includes('/')) return url;
|
||||
return `${apiBase}img/${encodeURIComponent(name)}?w=${width}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Checks and error wording shared by the image uploaders. The limits mirror
|
||||
// AdminUploadsService in apps/api.
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const IMAGE_TYPES = /^image\/(jpeg|png|gif|webp|svg\+xml)$/;
|
||||
|
||||
/** Null when the file may be uploaded, otherwise the message to show. */
|
||||
export function checkImageFile(file: File): string | null {
|
||||
if (!IMAGE_TYPES.test(file.type)) {
|
||||
return `«${file.name}» پشتیبانی نمیشود؛ فقط تصویر JPG، PNG، WebP، GIF یا SVG`;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
return `حجم «${file.name}» بیشتر از ۸ مگابایت است؛ فایل کوچکتری انتخاب کنید`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a failed upload request. A 413 comes from the reverse proxy,
|
||||
* not the API, so its body is HTML rather than `{ message }`.
|
||||
*/
|
||||
export function uploadErrorMessage(e: unknown, fileName?: string): string {
|
||||
const res = (e as { response?: { status?: number; data?: unknown } })?.response;
|
||||
if (!res) return 'اتصال به سرور برقرار نشد؛ اینترنت را بررسی کنید و دوباره تلاش کنید';
|
||||
if (res.status === 413) {
|
||||
return `حجم ${fileName ? `«${fileName}»` : 'فایل'} بیشتر از حد مجاز سرور است`;
|
||||
}
|
||||
if (res.status === 403) return 'اجازهٔ بارگذاری فایل را ندارید';
|
||||
|
||||
const raw = (res.data as { message?: unknown } | undefined)?.message;
|
||||
const serverMessage = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof serverMessage === 'string' && serverMessage) return serverMessage;
|
||||
|
||||
return fileName
|
||||
? `بارگذاری «${fileName}» انجام نشد؛ دوباره تلاش کنید`
|
||||
: 'بارگذاری فایل انجام نشد؛ دوباره تلاش کنید';
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
>
|
||||
<template #description>
|
||||
<div v-for="hit in surveyMajorityHits" :key="hit.id" class="survey-majority__row">
|
||||
<img v-if="hit.imageUrl" :src="hit.imageUrl" alt="" />
|
||||
<img v-if="hit.imageUrl" :src="imageUrl(hit.imageUrl, 200)" alt="" />
|
||||
<span>
|
||||
<b>{{ hit.title || 'بدون عنوان' }}</b>
|
||||
— ▲ {{ hit.likes }} لایک (حد: {{ surveyMajorityThreshold }})
|
||||
@@ -314,6 +314,7 @@ import {
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { jdate, price } from '../utils/filters';
|
||||
import { imageUrl } from '../utils/image';
|
||||
import { useLayoutStore } from '../stores/layout';
|
||||
import { useUsersStore } from '../stores/users';
|
||||
import api from '../api/client';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="text && text.image ? text.image : noImage"
|
||||
:src="text && text.image ? imageUrl(text.image, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,7 @@ import { buildCategoryModel } from './CategoryTbl';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
|
||||
import api from '../../../api/client';
|
||||
import { imageUrl } from '../../../utils/image';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<template #image="{ record }">
|
||||
<img
|
||||
v-if="record.ProductImage && record.ProductImage.length"
|
||||
:src="record.ProductImage[0].image"
|
||||
:src="imageUrl(record.ProductImage[0].image, 200)"
|
||||
class="product-thumb"
|
||||
alt=""
|
||||
/>
|
||||
@@ -122,6 +122,7 @@ import AccountingSyncButton from '../../../components/accounting/AccountingSyncB
|
||||
import { buildProductListModel } from './productListModel';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import { isBranch } from '../../../utils';
|
||||
import { imageUrl } from '../../../utils/image';
|
||||
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
|
||||
import api from '../../../api/client';
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
>
|
||||
<template #description>
|
||||
<div v-for="hit in majorityHits" :key="hit.id" class="majority-hit">
|
||||
<img v-if="hit.imageUrl" :src="hit.imageUrl" class="thumb" alt="" />
|
||||
<img v-if="hit.imageUrl" :src="imageUrl(hit.imageUrl, 200)" class="thumb" alt="" />
|
||||
<div class="majority-hit__text">
|
||||
<b>{{ hit.title || 'بدون عنوان' }}</b>
|
||||
<div>▲ {{ hit.likes }} لایک · {{ hit.statusLabel }} · {{ hit.suggestedWeightGrams }} گرم</div>
|
||||
@@ -59,7 +59,7 @@
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'image'">
|
||||
<img v-if="record.imageUrl" :src="record.imageUrl" class="thumb" alt="" />
|
||||
<img v-if="record.imageUrl" :src="imageUrl(record.imageUrl, 200)" class="thumb" alt="" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'title'">
|
||||
{{ record.title || '—' }}
|
||||
@@ -148,6 +148,7 @@ import { onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import api from '../../../api/client';
|
||||
import { imageUrl } from '../../../utils/image';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
|
||||
type SurveyRow = {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { optionText } from '../../../utils/select';
|
||||
import { imageUrl } from '../../../utils/image';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
@@ -173,7 +174,7 @@ function getVariant(item: CartItem): CartVariant & Record<string, any> {
|
||||
}
|
||||
|
||||
function getImage(product: Record<string, any>) {
|
||||
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
|
||||
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
|
||||
}
|
||||
|
||||
/** Same as the admin builder: 0 stock means uncapped, which keeps pre-orders orderable. */
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { optionText } from '../../utils/select';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
@@ -207,7 +208,7 @@ const columns = [
|
||||
];
|
||||
|
||||
function getImage(product: Record<string, any>) {
|
||||
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
|
||||
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
|
||||
}
|
||||
|
||||
function addToCart(record: Record<string, any>) {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
</XTable>
|
||||
@@ -96,6 +96,7 @@ import { message } from 'ant-design-vue';
|
||||
import XTable from '../../components/x-table/XTable.vue';
|
||||
import XTbl, { Xtc } from '../../components/x-table/model';
|
||||
import api from '../../api/client';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
|
||||
const noImage = '/img/no-items.png';
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { optionText } from '../../utils/select';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
@@ -294,7 +295,7 @@ function getVariant(item: CartItem): CartVariant & Record<string, any> {
|
||||
}
|
||||
|
||||
function getImage(product: Record<string, any>) {
|
||||
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
|
||||
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
|
||||
}
|
||||
|
||||
/** legacy totalWeight — note it does NOT multiply by qty. Preserved. */
|
||||
|
||||
@@ -16,6 +16,8 @@ module.exports = {
|
||||
external: [
|
||||
'@prisma/client',
|
||||
'.prisma/client',
|
||||
// Native image library, loaded lazily by ImageVariantService; absent = no resizing.
|
||||
'sharp',
|
||||
'@nestjs/microservices',
|
||||
'@nestjs/microservices/*',
|
||||
'@nestjs/websockets',
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.35.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminUploadsController } from './admin-uploads.controller';
|
||||
import { PublicImagesController } from './public-images.controller';
|
||||
import { PublicUploadsController } from './public-uploads.controller';
|
||||
import { AdminUploadsService } from './admin-uploads.service';
|
||||
import { ImageVariantService } from './image-variants.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminUploadsController, PublicUploadsController],
|
||||
providers: [AdminUploadsService],
|
||||
exports: [AdminUploadsService],
|
||||
controllers: [AdminUploadsController, PublicUploadsController, PublicImagesController],
|
||||
providers: [AdminUploadsService, ImageVariantService],
|
||||
exports: [AdminUploadsService, ImageVariantService],
|
||||
})
|
||||
export class AdminUploadsModule {}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { AdminUploadsService } from './admin-uploads.service';
|
||||
import type { ImageVariantService } from './image-variants.service';
|
||||
|
||||
describe('AdminUploadsService.store', () => {
|
||||
let dir: string;
|
||||
const prevUploadDir = process.env.UPLOAD_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'uploads-'));
|
||||
process.env.UPLOAD_DIR = dir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
if (prevUploadDir === undefined) delete process.env.UPLOAD_DIR;
|
||||
else process.env.UPLOAD_DIR = prevUploadDir;
|
||||
});
|
||||
|
||||
function build(compressUpload: ImageVariantService['compressUpload']) {
|
||||
const create = jest.fn(async ({ data }) => ({ id: 'f1', ...data }));
|
||||
const prisma = { uploadedFile: { create } };
|
||||
const variants = { compressUpload, warm: jest.fn() } as unknown as ImageVariantService;
|
||||
const svc = new AdminUploadsService(prisma as never, variants);
|
||||
return { svc, create, variants };
|
||||
}
|
||||
|
||||
const file = (over: Partial<{ originalname: string; mimetype: string; size: number; buffer: Buffer }> = {}) => {
|
||||
const buffer = over.buffer ?? Buffer.alloc(2 * 1024 * 1024, 1);
|
||||
return { originalname: 'ring.jpg', mimetype: 'image/jpeg', size: buffer.length, buffer, ...over };
|
||||
};
|
||||
|
||||
it('stores the compressed WebP and records its real size and type', async () => {
|
||||
const small = Buffer.from('compressed');
|
||||
const { svc, create, variants } = build(async () => ({ buffer: small, ext: '.webp', mimeType: 'image/webp' }));
|
||||
|
||||
const row = await svc.store(file(), 'product.gallery', 'admin-1');
|
||||
|
||||
const [name] = readdirSync(dir);
|
||||
expect(name).toMatch(/^[a-f0-9]{32}\.webp$/);
|
||||
expect(readFileSync(join(dir, name)).equals(small)).toBe(true);
|
||||
expect(row).toMatchObject({
|
||||
url: `/uploads/${name}`,
|
||||
originalName: 'ring.jpg',
|
||||
mimeType: 'image/webp',
|
||||
sizeBytes: small.length,
|
||||
});
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(variants.warm).toHaveBeenCalledWith(name);
|
||||
});
|
||||
|
||||
it('stores the file as sent when compression is skipped', async () => {
|
||||
const { svc } = build(async () => null);
|
||||
const pdf = file({ originalname: 'license.pdf', mimetype: 'application/pdf' });
|
||||
|
||||
const row = await svc.store(pdf, 'business-license', null);
|
||||
|
||||
const [name] = readdirSync(dir);
|
||||
expect(name).toMatch(/\.pdf$/);
|
||||
expect(row).toMatchObject({ mimeType: 'application/pdf', sizeBytes: pdf.size });
|
||||
});
|
||||
|
||||
it('rejects oversized and unsupported files with a clear message', async () => {
|
||||
const { svc } = build(async () => null);
|
||||
|
||||
await expect(svc.store(undefined, undefined, null)).rejects.toThrow('فایلی انتخاب نشده است');
|
||||
await expect(
|
||||
svc.store(file({ buffer: Buffer.alloc(8 * 1024 * 1024 + 1) }), undefined, null),
|
||||
).rejects.toThrow('بیشتر از ۸ مگابایت');
|
||||
await expect(
|
||||
svc.store(file({ originalname: 'a.heic', mimetype: 'image/heic' }), undefined, null),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ImageVariantService } from './image-variants.service';
|
||||
import { getUploadDir } from './upload-path';
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,9 @@ import { getUploadDir } from './upload-path';
|
||||
* Deliberately NOT ported: legacy's commented-out webp re-encoding, watermarking
|
||||
* and thumbnailing. That code is dead in legacy too — every branch of it is
|
||||
* inside a `/* ... */` block, leaving only the plain `$file->move(...)` live.
|
||||
* Raster images over ~500 KB are re-encoded to WebP before storing
|
||||
* (ImageVariantService.compressUpload); PDF, SVG and GIF are stored as sent.
|
||||
* Display-sized copies are rendered separately and served from /api/img.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminUploadsService {
|
||||
@@ -32,36 +36,43 @@ export class AdminUploadsService {
|
||||
/** 8 MB. Legacy relied on PHP's upload_max_filesize; this makes it explicit. */
|
||||
private static readonly MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly variants: ImageVariantService,
|
||||
) {}
|
||||
|
||||
async store(
|
||||
file: { originalname: string; mimetype: string; size: number; buffer: Buffer } | undefined,
|
||||
purpose: string | undefined,
|
||||
actorAdminUserId: string | null,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('فایلی ارسال نشده است');
|
||||
if (!file) throw new BadRequestException('فایلی انتخاب نشده است');
|
||||
if (file.size > AdminUploadsService.MAX_BYTES) {
|
||||
throw new BadRequestException('حجم فایل بیش از حد مجاز است (۸ مگابایت)');
|
||||
throw new BadRequestException('حجم فایل بیشتر از ۸ مگابایت است؛ فایل کوچکتری انتخاب کنید');
|
||||
}
|
||||
if (!AdminUploadsService.ALLOWED.test(file.mimetype)) {
|
||||
throw new BadRequestException('نوع فایل مجاز نیست');
|
||||
throw new BadRequestException('این نوع فایل پشتیبانی نمیشود؛ فقط JPG، PNG، WebP، GIF، SVG یا PDF');
|
||||
}
|
||||
|
||||
const compressed = await this.variants.compressUpload(file.buffer, file.mimetype, file.originalname);
|
||||
const buffer = compressed?.buffer ?? file.buffer;
|
||||
|
||||
// legacy: md5(time() . original_name) . '.' . ext
|
||||
const ext = extname(file.originalname).toLowerCase();
|
||||
const ext = compressed?.ext ?? extname(file.originalname).toLowerCase();
|
||||
const stored =
|
||||
createHash('md5').update(`${Date.now()}${file.originalname}${randomUUID()}`).digest('hex') +
|
||||
ext;
|
||||
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await writeFile(join(this.dir, stored), file.buffer);
|
||||
await writeFile(join(this.dir, stored), buffer);
|
||||
this.variants.warm(stored);
|
||||
|
||||
return this.prisma.uploadedFile.create({
|
||||
data: {
|
||||
url: `/uploads/${stored}`,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
sizeBytes: file.size,
|
||||
mimeType: compressed?.mimeType ?? file.mimetype,
|
||||
sizeBytes: buffer.length,
|
||||
purpose: purpose ?? null,
|
||||
uploadedByAdminId: actorAdminUserId,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ImageVariantService } from './image-variants.service';
|
||||
|
||||
describe('ImageVariantService', () => {
|
||||
let dir: string;
|
||||
const prevUploadDir = process.env.UPLOAD_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'img-variants-'));
|
||||
process.env.UPLOAD_DIR = dir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
if (prevUploadDir === undefined) delete process.env.UPLOAD_DIR;
|
||||
else process.env.UPLOAD_DIR = prevUploadDir;
|
||||
});
|
||||
|
||||
function withoutSharp(svc: ImageVariantService) {
|
||||
(svc as unknown as { sharp: null }).sharp = null;
|
||||
return svc;
|
||||
}
|
||||
|
||||
it('snaps requested widths up to the allowed set', () => {
|
||||
expect(ImageVariantService.snapWidth('150')).toBe(200);
|
||||
expect(ImageVariantService.snapWidth(400)).toBe(400);
|
||||
expect(ImageVariantService.snapWidth('401')).toBe(600);
|
||||
expect(ImageVariantService.snapWidth('99999')).toBe(1600);
|
||||
expect(ImageVariantService.snapWidth(undefined)).toBe(800);
|
||||
expect(ImageVariantService.snapWidth('abc')).toBe(800);
|
||||
expect(ImageVariantService.snapWidth('-5')).toBe(800);
|
||||
});
|
||||
|
||||
it('returns null for missing files and traversal attempts', async () => {
|
||||
const svc = withoutSharp(new ImageVariantService());
|
||||
await expect(svc.resolve('nope.jpg', 400)).resolves.toBeNull();
|
||||
await expect(svc.resolve('../secret.jpg', 400)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('serves the original when sharp is unavailable', async () => {
|
||||
writeFileSync(join(dir, 'a.jpg'), Buffer.from([0xff, 0xd8, 0xff]));
|
||||
const svc = withoutSharp(new ImageVariantService());
|
||||
|
||||
const img = await svc.resolve('a.jpg', 600);
|
||||
expect(img).toEqual({ path: join(dir, 'a.jpg'), type: 'image/jpeg', optimized: false });
|
||||
});
|
||||
|
||||
it('never resizes svg, gif or pdf', async () => {
|
||||
writeFileSync(join(dir, 'logo.svg'), '<svg/>');
|
||||
writeFileSync(join(dir, 'anim.gif'), 'GIF89a');
|
||||
writeFileSync(join(dir, 'doc.pdf'), '%PDF');
|
||||
const svc = new ImageVariantService();
|
||||
|
||||
await expect(svc.resolve('logo.svg', 400)).resolves.toMatchObject({ type: 'image/svg+xml', optimized: false });
|
||||
await expect(svc.resolve('anim.gif', 400)).resolves.toMatchObject({ type: 'image/gif', optimized: false });
|
||||
await expect(svc.resolve('doc.pdf', 400)).resolves.toMatchObject({ type: 'application/pdf', optimized: false });
|
||||
});
|
||||
|
||||
describe('compressUpload', () => {
|
||||
const KB = 1024;
|
||||
const big = Buffer.alloc(3000 * KB);
|
||||
|
||||
/** A sharp stand-in whose output size depends on the requested quality and side. */
|
||||
function fakeSharp(svc: ImageVariantService, sizeFor: (side: number, quality: number) => number) {
|
||||
const calls: Array<{ side: number; quality: number }> = [];
|
||||
(svc as unknown as { sharp: unknown }).sharp = () => {
|
||||
let side = 0;
|
||||
let quality = 0;
|
||||
const p = {
|
||||
rotate: () => p,
|
||||
resize: (o: { width: number }) => ((side = o.width), p),
|
||||
webp: (o: { quality: number }) => ((quality = o.quality), p),
|
||||
toFile: jest.fn(),
|
||||
toBuffer: async () => {
|
||||
calls.push({ side, quality });
|
||||
return Buffer.alloc(sizeFor(side, quality));
|
||||
},
|
||||
};
|
||||
return p;
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
it('stops at the first attempt under 500 KB', async () => {
|
||||
const svc = new ImageVariantService();
|
||||
const calls = fakeSharp(svc, (_side, q) => (q >= 80 ? 900 * KB : 450 * KB));
|
||||
|
||||
const out = await svc.compressUpload(big, 'image/jpeg', 'photo.jpg');
|
||||
expect(calls).toEqual([
|
||||
{ side: 2000, quality: 82 },
|
||||
{ side: 2000, quality: 72 },
|
||||
]);
|
||||
expect(out).toMatchObject({ ext: '.webp', mimeType: 'image/webp' });
|
||||
expect(out!.buffer.length).toBe(450 * KB);
|
||||
});
|
||||
|
||||
it('keeps the smallest result when nothing reaches the target', async () => {
|
||||
const svc = new ImageVariantService();
|
||||
const calls = fakeSharp(svc, (side) => side * KB);
|
||||
|
||||
const out = await svc.compressUpload(big, 'image/png', 'photo.png');
|
||||
expect(calls).toHaveLength(5);
|
||||
expect(out!.buffer.length).toBe(1280 * KB);
|
||||
});
|
||||
|
||||
it('leaves small files, non-raster types and missing sharp alone', async () => {
|
||||
const svc = new ImageVariantService();
|
||||
const calls = fakeSharp(svc, () => 10 * KB);
|
||||
|
||||
await expect(svc.compressUpload(Buffer.alloc(400 * KB), 'image/jpeg', 'a.jpg')).resolves.toBeNull();
|
||||
await expect(svc.compressUpload(big, 'application/pdf', 'a.pdf')).resolves.toBeNull();
|
||||
await expect(svc.compressUpload(big, 'image/gif', 'a.gif')).resolves.toBeNull();
|
||||
await expect(svc.compressUpload(big, 'image/svg+xml', 'a.svg')).resolves.toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
await expect(withoutSharp(new ImageVariantService()).compressUpload(big, 'image/jpeg', 'a.jpg')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the upload when re-encoding would not make it smaller', async () => {
|
||||
const svc = new ImageVariantService();
|
||||
fakeSharp(svc, () => 4000 * KB);
|
||||
await expect(svc.compressUpload(big, 'image/webp', 'a.webp')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the original when a render fails', async () => {
|
||||
writeFileSync(join(dir, 'broken.png'), 'not really a png');
|
||||
const svc = new ImageVariantService();
|
||||
const toFile = jest.fn().mockRejectedValue(new Error('bad input'));
|
||||
const pipeline = { rotate: () => pipeline, resize: () => pipeline, webp: () => pipeline, toFile };
|
||||
(svc as unknown as { sharp: unknown }).sharp = () => pipeline;
|
||||
|
||||
const img = await svc.resolve('broken.png', 400);
|
||||
expect(toFile).toHaveBeenCalledTimes(1);
|
||||
expect(img).toMatchObject({ type: 'image/png', optimized: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { mkdir, rename, stat, unlink } from 'node:fs/promises';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
import { getUploadDir, mimeForUpload, resolveStoredUpload } from './upload-path';
|
||||
|
||||
/** Widths the image endpoint renders; anything else snaps up to the next one. */
|
||||
export const IMAGE_WIDTHS = [200, 400, 600, 800, 1200, 1600] as const;
|
||||
export type ImageWidth = (typeof IMAGE_WIDTHS)[number];
|
||||
|
||||
/** Rendered right after an upload so the first shopper never waits on a resize. */
|
||||
const WARM_WIDTHS: ImageWidth[] = [600, 1600];
|
||||
|
||||
/** GIF is skipped so animations survive; SVG and PDF are not raster. */
|
||||
const RESIZABLE = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
|
||||
const RESIZABLE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif']);
|
||||
|
||||
const WEBP_QUALITY = 78;
|
||||
|
||||
/** Stored originals aim for this size... */
|
||||
export const ORIGINAL_TARGET_BYTES = 500 * 1024;
|
||||
/** ...and only exceed this one if even the smallest attempt cannot get under it. */
|
||||
export const ORIGINAL_CEILING_BYTES = 1024 * 1024;
|
||||
|
||||
/** Tried in order until one lands under the target; the last is the smallest. */
|
||||
const ORIGINAL_ATTEMPTS = [
|
||||
{ side: 2000, quality: 82 },
|
||||
{ side: 2000, quality: 72 },
|
||||
{ side: 1600, quality: 72 },
|
||||
{ side: 1600, quality: 62 },
|
||||
{ side: 1280, quality: 60 },
|
||||
] as const;
|
||||
|
||||
interface SharpPipeline {
|
||||
rotate(): SharpPipeline;
|
||||
resize(opts: {
|
||||
width: number;
|
||||
height?: number;
|
||||
fit?: 'inside';
|
||||
withoutEnlargement: boolean;
|
||||
}): SharpPipeline;
|
||||
webp(opts: { quality: number }): SharpPipeline;
|
||||
toFile(path: string): Promise<unknown>;
|
||||
toBuffer(): Promise<Buffer>;
|
||||
}
|
||||
type SharpFactory = (input: string | Buffer, opts?: { failOn?: 'none' }) => SharpPipeline;
|
||||
|
||||
export interface CompressedUpload {
|
||||
buffer: Buffer;
|
||||
ext: '.webp';
|
||||
mimeType: 'image/webp';
|
||||
}
|
||||
|
||||
export interface ServedImage {
|
||||
path: string;
|
||||
type: string;
|
||||
/** False when the original is served because no smaller variant exists. */
|
||||
optimized: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process image work with `sharp`: compressing uploads before they are
|
||||
* stored, and resized WebP copies cached on disk under
|
||||
* `<UPLOAD_DIR>/.variants/<width>/`. The dot prefix keeps the cache out of the
|
||||
* public `/uploads/` mount (`dotfiles: 'deny'`).
|
||||
*
|
||||
* `sharp` is loaded lazily: without it (or if a render fails) uploads are
|
||||
* stored and served untouched, so a missing native binary never breaks images.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ImageVariantService {
|
||||
private readonly logger = new Logger(ImageVariantService.name);
|
||||
private readonly cacheDir = join(getUploadDir(), '.variants');
|
||||
private readonly inFlight = new Map<string, Promise<string | null>>();
|
||||
private sharp: SharpFactory | null | undefined;
|
||||
|
||||
static snapWidth(raw: unknown): ImageWidth {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return 800;
|
||||
return IMAGE_WIDTHS.find((w) => w >= n) ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
|
||||
}
|
||||
|
||||
static isResizable(filename: string): boolean {
|
||||
return RESIZABLE.has(extname(filename).toLowerCase());
|
||||
}
|
||||
|
||||
async resolve(filename: string, width: ImageWidth): Promise<ServedImage | null> {
|
||||
const original = resolveStoredUpload(filename);
|
||||
if (!original) return null;
|
||||
|
||||
const fallback: ServedImage = {
|
||||
path: original,
|
||||
type: mimeForUpload(original) ?? 'application/octet-stream',
|
||||
optimized: false,
|
||||
};
|
||||
if (!ImageVariantService.isResizable(original)) return fallback;
|
||||
|
||||
const variant = await this.variant(original, width);
|
||||
if (!variant) return fallback;
|
||||
|
||||
// An already-small upload can come out larger after re-encoding.
|
||||
const [v, o] = await Promise.all([stat(variant), stat(original)]);
|
||||
if (v.size >= o.size) return fallback;
|
||||
|
||||
return { path: variant, type: 'image/webp', optimized: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrinks an uploaded raster image before it is written to disk, so an 8 MB
|
||||
* phone photo is stored as a WebP of roughly ORIGINAL_TARGET_BYTES. Returns
|
||||
* null — store the upload as-is — when the file is already small, is not a
|
||||
* resizable format, sharp is missing, or re-encoding would not make it smaller.
|
||||
*/
|
||||
async compressUpload(
|
||||
buffer: Buffer,
|
||||
mimeType: string,
|
||||
filename: string,
|
||||
): Promise<CompressedUpload | null> {
|
||||
if (buffer.length <= ORIGINAL_TARGET_BYTES) return null;
|
||||
if (!RESIZABLE_MIME.has(mimeType)) return null;
|
||||
const sharp = this.loadSharp();
|
||||
if (!sharp) return null;
|
||||
|
||||
let smallest: Buffer | null = null;
|
||||
try {
|
||||
for (const { side, quality } of ORIGINAL_ATTEMPTS) {
|
||||
const out = await sharp(buffer, { failOn: 'none' })
|
||||
.rotate()
|
||||
.resize({ width: side, height: side, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality })
|
||||
.toBuffer();
|
||||
if (!smallest || out.length < smallest.length) smallest = out;
|
||||
if (out.length <= ORIGINAL_TARGET_BYTES) break;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`compress failed for ${filename}: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!smallest || smallest.length >= buffer.length) return null;
|
||||
if (smallest.length > ORIGINAL_CEILING_BYTES) {
|
||||
this.logger.warn(`${filename} is still ${smallest.length} bytes after compression`);
|
||||
}
|
||||
return { buffer: smallest, ext: '.webp', mimeType: 'image/webp' };
|
||||
}
|
||||
|
||||
/** Fire-and-forget pre-render for a freshly stored upload. */
|
||||
warm(filename: string): void {
|
||||
if (!ImageVariantService.isResizable(filename)) return;
|
||||
for (const width of WARM_WIDTHS) void this.resolve(filename, width);
|
||||
}
|
||||
|
||||
private async variant(original: string, width: ImageWidth): Promise<string | null> {
|
||||
const target = join(this.cacheDir, String(width), `${basename(original)}.webp`);
|
||||
if (await isFresh(target, original)) return target;
|
||||
|
||||
const pending = this.inFlight.get(target);
|
||||
if (pending) return pending;
|
||||
|
||||
const job = this.render(original, target, width).finally(() => this.inFlight.delete(target));
|
||||
this.inFlight.set(target, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
private async render(original: string, target: string, width: ImageWidth): Promise<string | null> {
|
||||
const sharp = this.loadSharp();
|
||||
if (!sharp) return null;
|
||||
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await sharp(original, { failOn: 'none' })
|
||||
.rotate()
|
||||
.resize({ width, withoutEnlargement: true })
|
||||
.webp({ quality: WEBP_QUALITY })
|
||||
.toFile(tmp);
|
||||
await rename(tmp, target);
|
||||
return target;
|
||||
} catch (err) {
|
||||
this.logger.warn(`resize failed for ${basename(original)} @${width}: ${(err as Error).message}`);
|
||||
await unlink(tmp).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadSharp(): SharpFactory | null {
|
||||
if (this.sharp !== undefined) return this.sharp;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
this.sharp = require('sharp') as SharpFactory;
|
||||
} catch {
|
||||
this.sharp = null;
|
||||
this.logger.warn('sharp is not installed; images are served without resizing');
|
||||
}
|
||||
return this.sharp;
|
||||
}
|
||||
}
|
||||
|
||||
async function isFresh(target: string, original: string): Promise<boolean> {
|
||||
try {
|
||||
const [t, o] = await Promise.all([stat(target), stat(original)]);
|
||||
return t.mtimeMs >= o.mtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { Public } from '../auth/guards';
|
||||
import { ImageVariantService } from './image-variants.service';
|
||||
|
||||
/**
|
||||
* Resized images at GET /api/img/:filename?w=<width>, for the same files
|
||||
* `/uploads/<file>` serves. Upload names are unique hashes and never rewritten,
|
||||
* so a rendered variant is cached as immutable; the untouched original (no
|
||||
* sharp, or nothing smaller to offer) keeps the shorter `/uploads` lifetime so
|
||||
* a later variant can replace it in browsers.
|
||||
*/
|
||||
@Public()
|
||||
@Controller('img')
|
||||
export class PublicImagesController {
|
||||
constructor(private readonly variants: ImageVariantService) {}
|
||||
|
||||
@Get(':filename')
|
||||
async serve(
|
||||
@Param('filename') filename: string,
|
||||
@Query('w') w: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const image = await this.variants.resolve(filename, ImageVariantService.snapWidth(w));
|
||||
if (!image) throw new NotFoundException('فایل یافت نشد');
|
||||
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
image.optimized ? 'public, max-age=31536000, immutable' : 'public, max-age=86400',
|
||||
);
|
||||
return new StreamableFile(createReadStream(image.path), {
|
||||
type: image.type,
|
||||
disposition: 'inline',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,22 @@ export class UnauthorizedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 413 is the reverse proxy refusing the body before the API sees it, so it
|
||||
* carries HTML, not `{ message }`; everything else uses the API's own wording.
|
||||
*/
|
||||
async function uploadErrorText(res: Response): Promise<string> {
|
||||
if (res.status === 413) return 'حجم فایل بیشتر از حد مجاز سرور است؛ فایل کوچکتری انتخاب کنید';
|
||||
try {
|
||||
const body = (await res.json()) as { message?: unknown };
|
||||
const raw = Array.isArray(body?.message) ? body.message[0] : body?.message;
|
||||
if (typeof raw === 'string' && raw) return raw;
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return 'بارگذاری فایل انجام نشد؛ دوباره تلاش کنید';
|
||||
}
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
@@ -760,7 +776,7 @@ export const api = {
|
||||
throw new UnauthorizedError('/customer/me/documents');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`آپلود فایل ناموفق بود (${res.status})`);
|
||||
throw new Error(await uploadErrorText(res));
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
aria-label="بزرگنمایی تصویر"
|
||||
@click="previewOpen = true"
|
||||
>
|
||||
<img v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
|
||||
<img v-lazy="imageUrl(lookup.imageUrl, 200)" :alt="lookup?.productTitle ?? ''" />
|
||||
</button>
|
||||
<div v-else class="oi__art oi__art--empty" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:diamonds" size="18" />
|
||||
@@ -63,7 +63,7 @@
|
||||
<button type="button" class="oi__previewClose" aria-label="بستن" @click="previewOpen = false">
|
||||
<Icon icon="vuesax-linear:close-circle" size="22" />
|
||||
</button>
|
||||
<img class="oi__previewImg" :src="lookup.imageUrl" :alt="lookup.productTitle ?? ''" />
|
||||
<img class="oi__previewImg" :src="imageUrl(lookup.imageUrl, 1600)" :alt="lookup.productTitle ?? ''" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
@@ -75,6 +75,7 @@ import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { orderItemLookup } from '../../composables/useOrderItemProduct'
|
||||
import type { OrderItemDetail } from '../../api/client'
|
||||
import { imageUrl } from '../../utils/image'
|
||||
|
||||
const props = defineProps<{ value: OrderItemDetail }>()
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@
|
||||
:aria-label="`تصویر ${i + 1}`"
|
||||
@click="imageIndex = i"
|
||||
>
|
||||
<img :src="s" :alt="`${title} ${i + 1}`" draggable="false" />
|
||||
<img :src="thumbs[i] ?? s" :alt="`${title} ${i + 1}`" draggable="false" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,6 +335,7 @@ import { useFiltersStore } from '../../stores/filtersStore'
|
||||
import { useCartStore, type CartItem } from '../../stores/cartStore'
|
||||
import { isUserLoggedIn } from '../../utils/checkAccess'
|
||||
import { api, type ProductSummary } from '../../api/client'
|
||||
import { imageUrl } from '../../utils/image'
|
||||
|
||||
// `initial`: the card's own ProductSummary when opened from a list (see
|
||||
// ProductMorph.vue) — lets the page render whole on its first frame.
|
||||
@@ -420,7 +421,12 @@ const categoryId = computed(() => productDetail.productRef.value?.category?.id ?
|
||||
const categoryTitle = computed(() => productDetail.productRef.value?.category?.title ?? '')
|
||||
const brandTitle = computed(() => productDetail.productRef.value?.brand?.title ?? '')
|
||||
const hasImages = computed(() => (productDetail.productRef.value?.images.length ?? 0) > 0)
|
||||
const slides = computed(() => productDetail.productRef.value?.images.map((i) => i.url) ?? [])
|
||||
const slides = computed(
|
||||
() => productDetail.productRef.value?.images.map((i) => imageUrl(i.url, 1600) ?? i.url) ?? [],
|
||||
)
|
||||
const thumbs = computed(
|
||||
() => productDetail.productRef.value?.images.map((i) => imageUrl(i.url, 200) ?? i.url) ?? [],
|
||||
)
|
||||
|
||||
// Keep the gallery index valid if the product (and so its image list) changes.
|
||||
watch(slides, (list) => {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { api } from '../../api/client'
|
||||
import { api, UnauthorizedError } from '../../api/client'
|
||||
|
||||
const props = defineProps<{
|
||||
uploadText: string
|
||||
@@ -60,6 +60,15 @@ const preview = ref<string | null>(null)
|
||||
|
||||
/** 8 MB — the same ceiling the API enforces, checked here for a fast message. */
|
||||
const MAX_BYTES = 8 * 1024 * 1024
|
||||
const ACCEPTED = /^(image\/(jpeg|png|webp)|application\/pdf)$/
|
||||
|
||||
function failureText(e: unknown): string {
|
||||
if (e instanceof UnauthorizedError) return 'برای بارگذاری، دوباره وارد حساب شوید'
|
||||
// fetch rejects with a TypeError only when the request never got a response.
|
||||
if (e instanceof TypeError) return 'اتصال به سرور برقرار نشد؛ اینترنت را بررسی کنید'
|
||||
if (e instanceof Error && e.message) return e.message
|
||||
return 'بارگذاری فایل انجام نشد؛ دوباره تلاش کنید'
|
||||
}
|
||||
|
||||
async function onPick(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
@@ -70,8 +79,12 @@ async function onPick(event: Event) {
|
||||
|
||||
error.value = null
|
||||
|
||||
if (!ACCEPTED.test(file.type)) {
|
||||
error.value = 'این نوع فایل پشتیبانی نمیشود؛ فقط تصویر JPG، PNG، WebP یا PDF'
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
error.value = 'حجم فایل بیش از حد مجاز است (۸ مگابایت)'
|
||||
error.value = 'حجم فایل بیشتر از ۸ مگابایت است؛ فایل کوچکتری انتخاب کنید'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,8 +94,8 @@ async function onPick(event: Event) {
|
||||
fileId.value = stored.id
|
||||
preview.value = file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
||||
emit('done', stored.id)
|
||||
} catch {
|
||||
error.value = 'بارگذاری انجام نشد. دوباره تلاش کنید.'
|
||||
} catch (e) {
|
||||
error.value = failureText(e)
|
||||
fileId.value = null
|
||||
preview.value = null
|
||||
} finally {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { computed, ref, unref, type Ref } from 'vue'
|
||||
import type { ProductSummary, ProductVariantSummary } from '../api/client'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useWageQuoteStore } from '../stores/wageQuoteStore'
|
||||
import { imageUrl } from '../utils/image'
|
||||
|
||||
/** A variant can be ordered when it has stock, or when it is flagged pre-order. */
|
||||
export function isVariantOrderable(v: ProductVariantSummary): boolean {
|
||||
@@ -41,7 +42,8 @@ function getCheapestVariant(variants: ProductVariantSummary[] | undefined): Prod
|
||||
|
||||
export function useProduct(productItem: ProductSummary, initialVariant?: ProductVariantSummary) {
|
||||
const product = ref<ProductSummary>(productItem)
|
||||
const image = computed(() => product.value.images?.[0]?.url)
|
||||
// Card-sized: product cards, cart lines and home panels all render this.
|
||||
const image = computed(() => imageUrl(product.value.images?.[0]?.url, 600))
|
||||
const id = computed(() => product.value.id)
|
||||
const title = computed(() => product.value.title)
|
||||
const variants = computed(() => product.value.variants)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Uploaded images through the API's resize endpoint (GET /api/img/:file?w=).
|
||||
// Anything that is not a plain upload path (blob:, data:, bundled assets,
|
||||
// absolute URLs) is returned untouched.
|
||||
const UPLOAD_PREFIXES = ['/uploads/', '/api/uploads/']
|
||||
|
||||
/** Must match IMAGE_WIDTHS in apps/api image-variants.service.ts. */
|
||||
export type ImageWidth = 200 | 400 | 600 | 800 | 1200 | 1600
|
||||
|
||||
export function imageUrl(url: string | null | undefined, width: ImageWidth): string | undefined {
|
||||
if (!url) return undefined
|
||||
const prefix = UPLOAD_PREFIXES.find((p) => url.startsWith(p))
|
||||
if (!prefix) return url
|
||||
const name = url.slice(prefix.length)
|
||||
if (!name || name.includes('/')) return url
|
||||
return `/api/img/${encodeURIComponent(name)}?w=${width}`
|
||||
}
|
||||
@@ -122,6 +122,7 @@ import type { HomeCategory } from '../stores/configStore'
|
||||
import { useFiltersStore } from '../stores/filtersStore'
|
||||
import { api, type AdminSlide, type GoldPriceQuote, type ProductSummary } from '../api/client'
|
||||
import { useCachedReads } from '../api/cachedReads'
|
||||
import { imageUrl } from '../utils/image'
|
||||
import { useCatalogStore } from '../stores/catalogStore'
|
||||
import { KEYS } from '../stores/queryCache'
|
||||
|
||||
@@ -191,7 +192,7 @@ const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
|
||||
const slides = computed<HeroSlide[]>(() =>
|
||||
(adminSlides.value ?? [])
|
||||
.filter((s) => s.image)
|
||||
.map((s, i) => ({ key: `${i}-${s.image}`, image: s.image })),
|
||||
.map((s, i) => ({ key: `${i}-${s.image}`, image: imageUrl(s.image, 1200) })),
|
||||
)
|
||||
|
||||
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<div><dt>ساعت ثبت</dt><dd dir="ltr">{{ timeOf(item.createdAt) }}</dd></div>
|
||||
</dl>
|
||||
<p v-if="item.description" class="mks__note">{{ item.description }}</p>
|
||||
<img v-if="item.image" class="mks__photo" :src="item.image" alt="" />
|
||||
<img v-if="item.image" class="mks__photo" :src="imageUrl(item.image, 400)" alt="" loading="lazy" />
|
||||
</article>
|
||||
|
||||
<section v-if="!rows.length && !loading && !failed" class="mks__empty">
|
||||
@@ -77,6 +77,7 @@ import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
import { jdate } from '../../utils/filters'
|
||||
import { api, type PreOrderRow } from '../../api/client'
|
||||
import { imageUrl } from '../../utils/image'
|
||||
|
||||
const router = useRouter()
|
||||
const STEPS = ['ثبت سفارش', 'تایید ادمین', 'تایید کارگاه', 'ارسال', 'تحویل شده']
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
|
||||
<container class="svf__body">
|
||||
<article v-for="item in rows" :key="item.id" class="svf__card">
|
||||
<img class="svf__photo" :src="item.imageUrl" alt="" />
|
||||
<img class="svf__photo" :src="imageUrl(item.imageUrl, 800)" alt="" loading="lazy" />
|
||||
<div class="svf__meta">
|
||||
<b v-if="item.title">{{ item.title }}</b>
|
||||
<span>وزن پیشنهادی: {{ item.suggestedWeightGrams }} گرم</span>
|
||||
@@ -67,6 +67,7 @@ import PageBar from '../../components/ui/PageBar.vue'
|
||||
import Spinner from '../../components/Spinner.vue'
|
||||
import { toast } from '../../composables/useToast'
|
||||
import { api, type ProductSurveyRow } from '../../api/client'
|
||||
import { imageUrl } from '../../utils/image'
|
||||
|
||||
const router = useRouter()
|
||||
const rows = ref<ProductSurveyRow[]>([])
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<section class="svm__list">
|
||||
<h2 class="svm__listTitle">ارسالهای قبلی</h2>
|
||||
<article v-for="item in rows" :key="item.id" class="svm__card">
|
||||
<img class="svm__photo" :src="item.imageUrl" alt="" />
|
||||
<img class="svm__photo" :src="imageUrl(item.imageUrl, 400)" alt="" loading="lazy" />
|
||||
<div class="svm__meta">
|
||||
<b>{{ item.title || 'بدون عنوان' }}</b>
|
||||
<span>{{ item.suggestedWeightGrams }} گرم</span>
|
||||
@@ -51,6 +51,7 @@ import Spinner from '../../components/Spinner.vue'
|
||||
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
|
||||
import { toast } from '../../composables/useToast'
|
||||
import { api, type ProductSurveyRow } from '../../api/client'
|
||||
import { imageUrl } from '../../utils/image'
|
||||
|
||||
const form = reactive({
|
||||
file_id: null as string | null,
|
||||
|
||||
Generated
+305
@@ -129,6 +129,9 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
sharp:
|
||||
specifier: ^0.35.5
|
||||
version: 0.35.5(@types/node@20.19.43)
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^10.4.5
|
||||
@@ -907,6 +910,9 @@ packages:
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
|
||||
@@ -1216,6 +1222,152 @@ packages:
|
||||
'@fast-csv/parse@4.3.6':
|
||||
resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==}
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.5':
|
||||
resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.4':
|
||||
resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.4':
|
||||
resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.4':
|
||||
resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.4':
|
||||
resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.4':
|
||||
resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.4':
|
||||
resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
|
||||
resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.5':
|
||||
resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.5':
|
||||
resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.5':
|
||||
resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.5':
|
||||
resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.5':
|
||||
resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.5':
|
||||
resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.5':
|
||||
resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.35.5':
|
||||
resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4553,6 +4705,15 @@ packages:
|
||||
shallow-equal@1.2.1:
|
||||
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
|
||||
|
||||
sharp@0.35.5:
|
||||
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '*'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6154,6 +6315,11 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -6329,6 +6495,112 @@ snapshots:
|
||||
lodash.isundefined: 3.0.1
|
||||
lodash.uniq: 4.5.0
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.5
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.5
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
@@ -10202,6 +10474,39 @@ snapshots:
|
||||
|
||||
shallow-equal@1.2.1: {}
|
||||
|
||||
sharp@0.35.5(@types/node@20.19.43):
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.5
|
||||
'@img/sharp-darwin-x64': 0.35.5
|
||||
'@img/sharp-freebsd-wasm32': 0.35.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||
'@img/sharp-linux-arm': 0.35.5
|
||||
'@img/sharp-linux-arm64': 0.35.5
|
||||
'@img/sharp-linux-ppc64': 0.35.5
|
||||
'@img/sharp-linux-riscv64': 0.35.5
|
||||
'@img/sharp-linux-s390x': 0.35.5
|
||||
'@img/sharp-linux-x64': 0.35.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.5
|
||||
'@img/sharp-linuxmusl-x64': 0.35.5
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.5
|
||||
'@img/sharp-win32-arm64': 0.35.5
|
||||
'@img/sharp-win32-ia32': 0.35.5
|
||||
'@img/sharp-win32-x64': 0.35.5
|
||||
'@types/node': 20.19.43
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
خب غای
|
||||
Reference in New Issue
Block a user