feat: compress uploads and serve resized image variants via /api/img
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user