feat. GR_CONTENTS_01 템플릿 제작

This commit is contained in:
clkim
2025-10-24 10:03:54 +09:00
parent 1d3cd18ada
commit 3729a2bcb7
22 changed files with 385 additions and 164 deletions

View File

@@ -17,9 +17,9 @@ const imagePaths = computed(() => {
if (!props.resourcesData?.res_path) return null
const pcPath =
props.resourcesData.res_path.path_pc || props.resourcesData.res_path.path_mo
props.resourcesData.res_path.path_pc ?? props.resourcesData.res_path.path_mo
const moPath =
props.resourcesData.res_path.path_mo || props.resourcesData.res_path.path_pc
props.resourcesData.res_path.path_mo ?? props.resourcesData.res_path.path_pc
return {
pc: pcPath ? getResolvedHost(pcPath) : '',
@@ -45,8 +45,8 @@ const sanitizedContent = computed(() => {
<template>
<!-- 이미지 -->
<picture v-if="isTypeImage(resourcesData?.resource_type) && imagePaths">
<source media="(min-width: 1024px)" :srcset="imagePaths.pc" />
<source media="(max-width: 1023px)" :srcset="imagePaths.mo" />
<source :srcset="imagePaths.pc" media="(min-width: 1024px)" />
<source :srcset="imagePaths.mo" media="(max-width: 1023px)" />
<img
:src="imagePaths.pc"
:alt="alt || displayText"

View File

@@ -6,40 +6,27 @@ const { fullLoading } = storeToRefs(loadingStore)
</script>
<template>
<Transition
enter-active-class="transition-opacity duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="fullLoading"
class="fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]"
>
<!-- 메인 스피너 -->
<div class="relative w-16 h-16">
<!-- 외부 -->
<div
class="absolute inset-0 border-4 border-transparent border-t-blue-500 rounded-full animate-spin"
/>
<!-- 중간 -->
<div
class="absolute inset-1 border-4 border-transparent border-t-purple-500 rounded-full animate-spin"
style="animation-delay: -0.3s"
/>
<!-- 내부 -->
<div
class="absolute inset-2 border-4 border-transparent border-t-cyan-500 rounded-full animate-spin"
style="animation-delay: -0.6s"
/>
<!-- 중심 -->
<div
class="absolute inset-3 border-4 border-transparent border-t-emerald-500 rounded-full animate-spin"
style="animation-delay: -0.9s"
/>
</div>
<Transition name="fade">
<div v-if="fullLoading" class="spinner-container">
<div class="spinner-line"></div>
</div>
</Transition>
</template>
<style scoped>
.spinner-container {
@apply fixed inset-0 bg-black/90 flex items-center justify-center z-[900];
}
.spinner {
@apply w-[80px] h-[80px] bg-cover bg-center bg-no-repeat bg-[url('/images/common/publisning_template_loader_black.png')];
}
[data-theme='light'] {
.spinner-container {
@apply bg-white/90;
}
.spinner {
@apply bg-[url('/images/common/publisning_template_loader_white.png')];
}
}
</style>

View File

@@ -14,42 +14,29 @@ const canTeleport = (localId: string) => {
<template>
<template v-for="[localId, loadingInfo] in localLoadings" :key="localId">
<Teleport v-if="canTeleport(localId)" :to="`#${localId}`">
<Transition
enter-active-class="transition-opacity duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="loadingInfo.active"
class="fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]"
>
<!-- 메인 스피너 -->
<div class="relative w-16 h-16">
<!-- 외부 -->
<div
class="absolute inset-0 border-4 border-transparent border-t-blue-500 rounded-full animate-spin"
/>
<!-- 중간 -->
<div
class="absolute inset-1 border-4 border-transparent border-t-purple-500 rounded-full animate-spin"
style="animation-delay: -0.3s"
/>
<!-- 내부 -->
<div
class="absolute inset-2 border-4 border-transparent border-t-cyan-500 rounded-full animate-spin"
style="animation-delay: -0.6s"
/>
<!-- 중심 -->
<div
class="absolute inset-3 border-4 border-transparent border-t-emerald-500 rounded-full animate-spin"
style="animation-delay: -0.9s"
/>
</div>
<Transition name="fade">
<div v-if="loadingInfo.active" class="spinner-container">
<div class="spinner-line"></div>
</div>
</Transition>
</Teleport>
</template>
</template>
<style scoped>
.spinner-container {
@apply fixed inset-0 bg-black/90 flex items-center justify-center z-[900];
}
.spinner {
@apply w-[80px] h-[80px] bg-cover bg-center bg-no-repeat bg-[url('/images/common/publisning_template_loader_black.png')];
}
[data-theme='light'] {
.spinner-container {
@apply bg-white/90;
}
.spinner {
@apply bg-[url('/images/common/publisning_template_loader_white.png')];
}
}
</style>

View File

@@ -7,7 +7,7 @@ import { useSplideArrow } from '#layers/composables/useSplideArrow'
interface Props {
slideItemSize: SlideItemSize
slideItemLength?: number
autoplay?: boolean | string
autoplay?: boolean
arrows?: boolean
pagination?: boolean
class?: string
@@ -24,9 +24,7 @@ const emit = defineEmits(['mounted', 'move', 'arrowClick'])
// Splide 화살표 로직을 위한 composable 사용
const { addArrowClickListeners } = useSplideArrow()
const isMultipleItems = computed(() => {
return props.slideItemLength > 1
})
const isMultipleItems = computed(() => (props.slideItemLength ?? 0) > 1)
const options = computed((): ResponsiveOptions => {
return {
@@ -106,7 +104,7 @@ const handleMove = (
</script>
<template>
<div :class="`center-highlight ${props.class || ''}`" :style="style">
<div :class="`center-focus ${props.class || ''}`" :style="style">
<Splide
:options="options"
@splide:mounted="handleSplideMounted"
@@ -118,58 +116,58 @@ const handleMove = (
</template>
<style scoped>
.center-highlight {
.center-focus {
@apply w-full;
}
.center-highlight:deep(.splide__slide) {
.center-focus:deep(.splide__slide) {
@apply flex items-center justify-center;
width: var(--banner-width-mo);
height: var(--banner-height-mo-active);
margin-right: var(--banner-gap-mo);
opacity: 0.5;
}
.center-highlight:deep(.splide__slide) .slide-inner {
.center-focus:deep(.splide__slide) .slide-inner {
width: var(--banner-width-mo);
height: var(--banner-height-mo);
opacity: 0.5;
}
.center-highlight:deep(.splide__slide.is-active) {
.center-focus:deep(.splide__slide.is-active) {
width: var(--banner-width-mo-container);
opacity: 1;
}
.center-highlight:deep(.splide__slide.is-active) .slide-inner {
.center-focus:deep(.splide__slide.is-active) .slide-inner {
width: var(--banner-width-mo-active);
height: var(--banner-height-mo-active);
opacity: 1;
transition: all 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
.center-highlight:deep(.splide__slide.is-next),
.center-highlight:deep(.splide__slide.is-prev) {
.center-focus:deep(.splide__slide.is-next),
.center-focus:deep(.splide__slide.is-prev) {
opacity: 1;
}
/* PC 스타일 */
@media (min-width: 1024px) {
.center-highlight:deep(.splide__slide) {
.center-focus:deep(.splide__slide) {
width: var(--banner-width-pc);
height: var(--banner-height-pc-active);
margin-right: var(--banner-gap-pc);
}
.center-highlight:deep(.splide__slide) .slide-inner {
.center-focus:deep(.splide__slide) .slide-inner {
width: var(--banner-width-pc);
height: var(--banner-height-pc);
}
.center-highlight:deep(.splide__slide.is-active) {
.center-focus:deep(.splide__slide.is-active) {
width: var(--banner-width-pc-container);
}
.center-highlight:deep(.splide__slide.is-active) .slide-inner {
.center-focus:deep(.splide__slide.is-active) .slide-inner {
width: var(--banner-width-pc-active);
height: var(--banner-height-pc-active);
}
.center-highlight:deep(.arrow-prev) {
.center-focus:deep(.arrow-prev) {
left: 28px;
}
.center-highlight:deep(.arrow-next) {
.center-focus:deep(.arrow-next) {
right: 28px;
}
}

View File

@@ -6,7 +6,7 @@ import type { SlideItemSize } from '#layers/types/components/slide'
interface Props {
slideItemSize: SlideItemSize
slideItemLength?: number
autoplay?: boolean | string
autoplay?: boolean
arrows?: boolean
pagination?: boolean
class?: string
@@ -23,9 +23,7 @@ const emit = defineEmits(['mounted', 'move', 'arrowClick'])
// Splide 화살표 로직을 위한 composable 사용
const { addArrowClickListeners } = useSplideArrow()
const isMultipleItems = computed(() => {
return props.slideItemLength > 1
})
const isMultipleItems = computed(() => (props.slideItemLength ?? 0) > 1)
const options = computed((): ResponsiveOptions => {
return {

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { Splide } from '@splidejs/vue-splide'
import type { Splide as SplideType, ResponsiveOptions } from '@splidejs/splide'
import type { PageDataResourceGroups } from '#layers/types/api/pageData'
interface Props {
type?: ResponsiveOptions['type']
slideItemLength?: number
autoplay?: boolean
arrows?: boolean
pagination?: boolean
paginationData?: PageDataResourceGroups
breakpoints?: ResponsiveOptions['breakpoints']
}
const props = withDefaults(defineProps<Props>(), {
autoplay: false,
arrows: true,
pagination: true,
})
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
// Splide 화살표 로직을 위한 composable 사용
const { addArrowClickListeners } = useSplideArrow()
const isMultipleItems = computed(() => (props.slideItemLength ?? 0) > 1)
const resolvedType = computed<ResponsiveOptions['type']>(() => {
if (props.type) return props.type
return isMultipleItems.value ? 'loop' : 'slide'
})
const options = computed((): ResponsiveOptions => {
return {
type: resolvedType.value,
autoWidth: true,
autoHeight: true,
speed: 500,
updateOnMove: true,
autoplay: props.autoplay,
arrows: props.arrows && isMultipleItems.value,
pagination: props.pagination && isMultipleItems.value,
classes: {
arrows: 'splide-arrows',
arrow: 'splide-arrow',
prev: 'arrow-prev',
next: 'arrow-next',
pagination: 'splide-pagination-bullets',
page: 'splide-pagination-bullet',
},
...(props.breakpoints ? { breakpoints: props.breakpoints } : {}),
}
})
const handleSplideMounted = (splide: SplideType) => {
emit('mounted', splide)
// 화살표 버튼 클릭 이벤트 리스너 추가
nextTick(() => {
addArrowClickListeners(splide, (direction, targetIndex) => {
emit('arrowClick', direction, targetIndex)
})
})
}
const handleMove = (
splide: SplideType,
newIndex: number,
oldIndex: number,
destIndex: number
) => {
emit('move', splide, newIndex, oldIndex, destIndex)
}
</script>
<template>
<Splide
:options="options"
class="w-full"
:style="getPaginationClass(props.paginationData)"
@splide:mounted="handleSplideMounted"
@splide:move="handleMove"
>
<slot />
</Splide>
</template>

View File

@@ -6,11 +6,10 @@ import type { Splide as SplideType, ResponsiveOptions } from '@splidejs/splide'
import type { PageDataResourceGroups } from '#layers/types/api/pageData'
interface Props {
autoplay?: boolean | string
autoplay?: boolean
arrows?: boolean
pagination?: boolean
paginationData?: PageDataResourceGroups
class?: string
}
const props = withDefaults(defineProps<Props>(), {