feat: compress uploads and serve resized image variants via /api/img

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-11-14 15:07:11 +03:30
co-authored by Cursor
parent b71fb678be
commit 3fe0a930c7
32 changed files with 976 additions and 52 deletions
+17 -1
View File
@@ -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)
+16
View File
@@ -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}`
}
+2 -1
View File
@@ -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,