feat: add fullscreen viewer

This commit is contained in:
2025-08-08 00:25:27 +03:00
parent d9fd26d354
commit 4ae8d59328
11 changed files with 271 additions and 147 deletions

View File

@@ -56,4 +56,22 @@ class ImageTool implements ImageToolInterface
return rtrim($this->siteUrl, '/') . '/image/' . str_replace($this->imageDir, '', $fullNewPath); return rtrim($this->siteUrl, '/') . '/image/' . str_replace($this->imageDir, '', $fullNewPath);
} }
public function getUrl(string $path): string
{
return rtrim($this->siteUrl, '/') . '/image/' . str_replace($this->imageDir, '', $path);
}
public function getRealSize(string $path): array
{
$fullPath = $this->imageDir . $path;
if (! is_file($fullPath)) {
throw new \RuntimeException('Image file not found: ' . $path);
}
$img = $this->manager->make($fullPath);
return [$img->getWidth(), $img->getHeight()];
}
} }

View File

@@ -4,6 +4,9 @@ namespace Openguru\OpenCartFramework\ImageTool;
interface ImageToolInterface interface ImageToolInterface
{ {
public function getUrl(string $path): string;
public function getRealSize(string $path): array;
public function resize( public function resize(
string $path, string $path,
int $width, int $width,

View File

@@ -230,20 +230,20 @@ class ProductsHandler
->where('products_images.product_id', '=', $productId) ->where('products_images.product_id', '=', $productId)
->get(); ->get();
$images = []; $imagePaths = [];
$images[] = [ $imagePaths[] = $product['product_image'];
'url' => $this->ocImageTool->resize(
$product['product_image'],
$imageWidth,
$imageHeight,
'placeholder.png'
),
'alt' => $product['product_name'],
];
foreach ($productsImages as $item) { foreach ($productsImages as $item) {
$imagePaths[] = $item['image'];
}
$images = [];
foreach ($imagePaths as $imagePath) {
[$width, $height] = $this->ocImageTool->getRealSize($imagePath);
$images[] = [ $images[] = [
'url' => $this->ocImageTool->resize($item['image'], $imageWidth, $imageHeight, 'placeholder.png'), 'thumbnailURL' => $this->ocImageTool->resize($imagePath, $imageWidth, $imageHeight, 'placeholder.png'),
'largeURL' => $this->ocImageTool->getUrl($imagePath),
'width' => $width,
'height' => $height,
'alt' => $product['product_name'], 'alt' => $product['product_name'],
]; ];
} }

View File

@@ -0,0 +1,67 @@
<template>
<div class="fixed z-200 top-0 inset-0 flex justify-center align-center flex-col h-full bg-black">
<Swiper
:zoom="true"
:navigation="true"
:pagination="{
type: 'fraction',
}"
:activeIndex="activeIndex"
:modules="[Zoom, Navigation, Pagination]"
class="mySwiper w-full h-full"
@slider-move="vibrate"
>
<SwiperSlide v-for="image in images">
<div class="swiper-zoom-container">
<img :src="image.largeURL" :alt="image.alt"/>
</div>
</SwiperSlide>
</Swiper>
<button
class="absolute z-50 text-white text-xl right-5 cursor-pointer"
style="top: calc(var(--tg-safe-area-inset-top, 5px) + var(--tg-content-safe-area-inset-top, 5px))"
@click="onClose"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-10">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<script setup>
import {Navigation, Pagination, Zoom} from "swiper/modules";
import {Swiper, SwiperSlide} from "swiper/vue";
import {onMounted} from "vue";
const props = defineProps({
images: {
type: Array,
default: () => [],
},
activeIndex: {
type: Number,
default: 0,
},
});
const emits = defineEmits(['close']);
let canVibrate = true;
function vibrate() {
if (!canVibrate) return;
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
canVibrate = false;
setTimeout(() => {
canVibrate = true;
}, 50);
}
function onClose() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
emits('close');
}
</script>

View File

@@ -1,100 +1,49 @@
<template> <template>
<div class="aspect-w-4 aspect-h-3"> <swiper-container ref="swiperEl" pagination="true">
<swiper <swiper-slide
:lazy="true" v-for="image in images"
:pagination="pagination" lazy
:navigation="true"
:modules="modules"
class="mySwiper w-full min-h-[200px]"
@touchMove="onTouchMove"
> >
<swiper-slide v-for="image in images">
<img <img
:src="image.url" :src="image.url"
:alt="image.alt" :alt="image.alt"
loading="lazy" loading="lazy"
/> />
<div
class="swiper-lazy-preloader swiper-lazy-preloader-white"
></div>
</swiper-slide> </swiper-slide>
</swiper> </swiper-container>
</div>
</template> </template>
<script> <script setup>
import {Swiper, SwiperSlide} from 'swiper/vue'; import {onMounted, ref} from "vue";
import 'swiper/css'; const props = defineProps({
import 'swiper/css/pagination';
import {Pagination} from 'swiper/modules';
export default {
components: {
Swiper,
SwiperSlide,
},
props: {
images: { images: {
type: Array, type: Array,
default: () => [], default: () => [],
}
}, },
});
setup() { const swiperEl = ref(null);
const throttle = (func, delay) => {
let lastCall = 0;
return (...args) => {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func(...args);
}
};
};
const hapticTick = throttle(() => { let haptic = true;
const haptic = window?.Telegram?.WebApp?.HapticFeedback;
if (haptic?.selectionChanged) {
haptic.selectionChanged();
} else if (haptic?.impactOccurred) {
haptic.impactOccurred('light');
}
}, 100);
const onTouchMove = () => { onMounted(async () => {
hapticTick(); swiperEl.value?.addEventListener('swiperactiveindexchange', (event) => {
}; window.Telegram.WebApp.HapticFeedback.selectionChanged();
});
return { swiperEl.value?.addEventListener('swiperprogress', (num) => {
pagination: { if (haptic === false) return;
clickable: true, window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
dynamicBullets: true, haptic = false;
}, setTimeout(() => haptic = true, 50);
modules: [Pagination], });
onTouchMove,
}; swiperEl.value?.addEventListener('swiperreachbeginning', (event) => {
}, window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
}; });
swiperEl.value?.addEventListener('swiperreachend', (event) => {
window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
});
});
</script> </script>
<style scoped>
.product-swiper {
width: 100%;
height: auto;
}
.swiper-slide {
text-align: center;
}
img {
width: 100%;
display: block;
object-fit: contain;
}
</style>

View File

@@ -11,6 +11,12 @@ import ApplicationError from "@/ApplicationError.vue";
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts"; import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
import {injectYaMetrika} from "@/utils/yaMetrika.js"; import {injectYaMetrika} from "@/utils/yaMetrika.js";
import { register } from 'swiper/element/bundle';
import 'swiper/element/bundle';
import 'swiper/css/bundle';
register();
const pinia = createPinia(); const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
app app

View File

@@ -10,7 +10,10 @@ html, body, #app {
html { html {
--swiper-pagination-color: var(--color-primary); --swiper-pagination-color: var(--color-primary);
--swiper-navigation-color: var(--color-primary);
--swiper-pagination-bullet-inactive-color: var(--color-base-content); --swiper-pagination-bullet-inactive-color: var(--color-base-content);
--swiper-pagination-fraction-color: var(--color-neutral-content);
--swiper-pagination-bottom: var(--tg-safe-area-inset-bottom);
} }
.swiper-pagination-bullets { .swiper-pagination-bullets {

View File

@@ -27,28 +27,33 @@
:class="item.stock === false ? 'border-error' : ''" :class="item.stock === false ? 'border-error' : ''"
> >
<div class="card-body"> <div class="card-body">
<div class="avatar"> <div class="flex">
<div class="avatar mr-5">
<div class="w-16 rounded"> <div class="w-16 rounded">
<img :src="item.thumb"/> <img :src="item.thumb"/>
</div> </div>
</div> </div>
<div>
<h2 class="card-title">{{ item.name }} <span v-if="! item.stock" class="text-error font-bold">***</span></h2> <h2 class="card-title">{{ item.name }} <span v-if="! item.stock" class="text-error font-bold">***</span></h2>
<p class="text-sm font-bold">{{ item.total }}</p> <p class="text-sm font-bold">{{ item.total }}</p>
<p>{{ item.price }}/ед</p> <p>{{ item.price }}/ед</p>
<div> <div>
<div v-for="option in item.option"> <div v-for="option in item.option">
<p><span class="font-bold">{{ option.name }}</span>: {{ option.value }}</p> <p><span class="font-bold">{{ option.name }}</span>: {{ option.value }}</p>
<!-- <component--> <!-- <component-->
<!-- v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"--> <!-- v-if="SUPPORTED_OPTION_TYPES.includes(option.type) && componentMap[option.type]"-->
<!-- :is="componentMap[option.type]"--> <!-- :is="componentMap[option.type]"-->
<!-- :option="option"--> <!-- :option="option"-->
<!-- />--> <!-- />-->
<!-- <div v-else class="text-sm text-error">--> <!-- <div v-else class="text-sm text-error">-->
<!-- Тип опции "{{ option.type }}" не поддерживается.--> <!-- Тип опции "{{ option.type }}" не поддерживается.-->
<!-- </div>--> <!-- </div>-->
</div> </div>
</div> </div>
</div>
</div>
<div class="card-actions justify-between"> <div class="card-actions justify-between">
<Quantity <Quantity
:disabled="cart.isLoading" :disabled="cart.isLoading"

View File

@@ -1,7 +1,27 @@
<template> <template>
<div class="pb-10"> <div class="pb-10">
<div> <div>
<ProductImageSwiper :images="product.images"/> <swiper-container
pagination="true"
>
<swiper-slide
v-for="(image, index) in product.images"
lazy="true"
>
<img
:src="image.thumbnailURL"
:alt="image.alt"
@click="showFullScreen(index)"
/>
</swiper-slide>
</swiper-container>
<FullScreenImageViewer
v-if="isFullScreen"
:images="product.images"
:activeIndex="initialFullScreenIndex"
@close="closeFullScreen"
/>
<!-- Product info --> <!-- Product info -->
<div <div
@@ -92,11 +112,10 @@
</template> </template>
<script setup> <script setup>
import {computed, onMounted, ref} from "vue"; import {computed, onMounted, onUnmounted, ref} from "vue";
import {useRoute, useRouter} from 'vue-router' import {useRoute, useRouter} from 'vue-router'
import ProductOptions from "../components/ProductOptions/ProductOptions.vue"; import ProductOptions from "../components/ProductOptions/ProductOptions.vue";
import {useCartStore} from "../stores/CartStore.js"; import {useCartStore} from "../stores/CartStore.js";
import ProductImageSwiper from "../components/ProductImageSwiper.vue";
import Quantity from "../components/Quantity.vue"; import Quantity from "../components/Quantity.vue";
import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js"; import {SUPPORTED_OPTION_TYPES} from "@/constants/options.js";
import {apiFetch} from "@/utils/ftch.js"; import {apiFetch} from "@/utils/ftch.js";
@@ -111,6 +130,11 @@ const router = useRouter();
const isInCart = ref(false); const isInCart = ref(false);
const btnText = computed(() => isInCart.value ? 'В корзине' : 'Купить'); const btnText = computed(() => isInCart.value ? 'В корзине' : 'Купить');
const isFullScreen = ref(false);
const initialFullScreenIndex = ref(0);
import FullScreenImageViewer from "@/components/FullScreenImageViewer.vue";
const canAddToCart = computed(() => { const canAddToCart = computed(() => {
if (!product.value || product.value.options === undefined || product.value.options?.length === 0) { if (!product.value || product.value.options === undefined || product.value.options?.length === 0) {
@@ -126,6 +150,19 @@ const canAddToCart = computed(() => {
return required.length === 0; return required.length === 0;
}); });
function showFullScreen(index) {
window.Telegram.WebApp.HapticFeedback.selectionChanged();
isFullScreen.value = true;
initialFullScreenIndex.value = index;
document.body.style.overflow = 'hidden';
history.pushState({ fullscreen: true }, '');
}
function closeFullScreen() {
isFullScreen.value = false;
document.body.style.overflow = '';
}
async function actionBtnClick() { async function actionBtnClick() {
try { try {
error.value = ''; error.value = '';
@@ -150,8 +187,35 @@ function setQuantity(newQuantity) {
window.Telegram.WebApp.HapticFeedback.selectionChanged(); window.Telegram.WebApp.HapticFeedback.selectionChanged();
} }
let canVibrate = true;
function onPopState() {
if (isFullScreen.value) {
closeFullScreen();
} else {
// пусть Vue Router сам обработает
router.back();
}
}
onUnmounted(() => {
window.removeEventListener('popstate', onPopState);
});
onMounted(async () => { onMounted(async () => {
const {data} = await apiFetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`); const {data} = await apiFetch(`/index.php?route=extension/tgshop/handle&api_action=product_show&id=${productId.value}`);
product.value = data; product.value = data;
window.addEventListener('popstate', onPopState);
const swiperEl = document.querySelector('swiper-container');
swiperEl.addEventListener('swiperslidermove', (event) => {
if (!canVibrate) return;
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft');
canVibrate = false;
setTimeout(() => {
canVibrate = true;
}, 50);
});
}); });
</script> </script>

View File

@@ -1,10 +1,19 @@
import { defineConfig } from "vite"; import {defineConfig} from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import {fileURLToPath, URL} from 'node:url'; import {fileURLToPath, URL} from 'node:url';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), vue()], plugins: [
tailwindcss(),
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag.startsWith('swiper-'),
},
},
}),
],
base: '/image/catalog/tgshopspa/', base: '/image/catalog/tgshopspa/',
build: { build: {