fix. 컴포넌트 폴더 구조 변경
This commit is contained in:
27
layers/components/blocks/HybridLink.vue
Normal file
27
layers/components/blocks/HybridLink.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
to: string
|
||||
target?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
target: '',
|
||||
class: '',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="props.target === '_blank'"
|
||||
v-bind="$attrs"
|
||||
:href="props.to"
|
||||
:target="props.target"
|
||||
:class="props.class"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
<AtomsLocaleLink v-else v-bind="$attrs" :to="props.to" :class="props.class">
|
||||
<slot />
|
||||
</AtomsLocaleLink>
|
||||
</template>
|
||||
52
layers/components/blocks/LanguageSwitcher.vue
Normal file
52
layers/components/blocks/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="bg-white">
|
||||
<select
|
||||
v-model="selectedLocale"
|
||||
class="text-black px-2 py-1 rounded-md"
|
||||
@change="switchLanguage"
|
||||
>
|
||||
<option
|
||||
v-for="localeOption in availableLanguages"
|
||||
:key="localeOption"
|
||||
:value="localeOption"
|
||||
>
|
||||
{{ localeOption }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const gameDataStore = useGameDataStore()
|
||||
|
||||
// 사용 가능한 언어 목록
|
||||
const availableLanguages = computed(() => {
|
||||
return gameDataStore.gameData?.lang_codes || ['ko']
|
||||
})
|
||||
|
||||
const { locale } = useI18n()
|
||||
const switchLocalePath = useSwitchLocalePath()
|
||||
const router = useRouter()
|
||||
|
||||
const selectedLocale = ref(locale.value)
|
||||
|
||||
// 언어 변경 함수
|
||||
const switchLanguage = async () => {
|
||||
console.log(
|
||||
'🚀 ~ switchLanguage ~ selectedLocale.value:',
|
||||
selectedLocale.value
|
||||
)
|
||||
if (selectedLocale.value) {
|
||||
// URL 경로를 통해 언어 변경
|
||||
const path = switchLocalePath(selectedLocale.value)
|
||||
if (path) {
|
||||
await router.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// locale이 변경될 때 selectedLocale도 동기화
|
||||
watch(locale, newLocale => {
|
||||
selectedLocale.value = newLocale
|
||||
})
|
||||
</script>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRegistry } from '#layers/composables/useTemplateRegistry'
|
||||
import type { PageDataTemplate } from '#layers/types/api/pageData'
|
||||
|
||||
const props = defineProps<{ templates: PageDataTemplate[] }>()
|
||||
const registry = useTemplateRegistry() as Record<string, { component: any }>
|
||||
|
||||
const isShowTemplate = (template: PageDataTemplate) => {
|
||||
return template?.components && Object.keys(template.components).length > 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<template
|
||||
v-for="(template, index) in props.templates"
|
||||
:key="template.template_code ?? index"
|
||||
>
|
||||
<component
|
||||
:is="registry[template.template_code]?.component"
|
||||
v-if="isShowTemplate(template)"
|
||||
:components="template.components"
|
||||
/>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
61
layers/components/blocks/StoveGnb.vue
Normal file
61
layers/components/blocks/StoveGnb.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div id="header-stove" class="relative z-[5]" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGameDataStore } from '#layers/stores/useGameDataStore'
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const { locale, availableLocales } = useI18n()
|
||||
const { gameData } = useGameDataStore()
|
||||
|
||||
const stoveInflowPath = runtimeConfig.public.stoveInflowPath
|
||||
const stoveGameNo = runtimeConfig.public.stoveGameNo
|
||||
const gnbData = gameData?.stove_gnb
|
||||
|
||||
const languageCodes = computed(() => {
|
||||
if (Array.isArray(availableLocales)) {
|
||||
return availableLocales.map(
|
||||
(localeCode: any) => localeCode.code || localeCode
|
||||
)
|
||||
}
|
||||
return [locale]
|
||||
})
|
||||
|
||||
function loadGnb(locale: string) {
|
||||
locale = locale.toLowerCase()
|
||||
|
||||
const gnbOption = {
|
||||
wrapper: '#header-stove',
|
||||
isResponsive: true,
|
||||
skin: gnbData?.skin_type || 'gnb-dark-mini',
|
||||
widget: {
|
||||
gameListAndService: false,
|
||||
languageSelect: false,
|
||||
notification: false,
|
||||
stoveDownload: false,
|
||||
},
|
||||
global: {
|
||||
userGds: true,
|
||||
defaultSelectedLanguage: locale || 'en',
|
||||
languageCoverages: languageCodes.value,
|
||||
},
|
||||
loginMethod: {
|
||||
params: {
|
||||
inflow_path: stoveInflowPath,
|
||||
game_no: stoveGameNo,
|
||||
show_play_button: gnbData?.stove_install_button_visible || 'Y',
|
||||
},
|
||||
redirectCurrentPage: true,
|
||||
windowTitle: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
const cpHeader = new (window as any).cp.Header(gnbOption)
|
||||
cpHeader.render()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGnb(locale.value)
|
||||
})
|
||||
</script>
|
||||
32
layers/components/blocks/VisualContent.vue
Normal file
32
layers/components/blocks/VisualContent.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
tag: string
|
||||
text: string
|
||||
imageSrc?: any
|
||||
imageClass?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="tag" v-bind="$attrs">
|
||||
<template v-if="imageSrc && 'mobileSrc' in imageSrc">
|
||||
<!-- 모바일 이미지 (sm 미만) -->
|
||||
<img
|
||||
v-if="imageSrc.mobileSrc"
|
||||
:src="imageSrc.mobileSrc"
|
||||
:alt="text"
|
||||
:class="`${props.imageClass} sm:hidden`"
|
||||
/>
|
||||
<!-- PC 이미지 (sm 이상) -->
|
||||
<img
|
||||
v-if="imageSrc.pcSrc"
|
||||
:src="imageSrc.pcSrc"
|
||||
:alt="text"
|
||||
:class="`${props.imageClass} hidden sm:block`"
|
||||
/>
|
||||
</template>
|
||||
<span v-else v-html="text?.replace(/\n/g, '<br/>') || ''" />
|
||||
</component>
|
||||
</template>
|
||||
@@ -1,257 +0,0 @@
|
||||
<template>
|
||||
<footer id="footer" ref="footerRef" class="bg-black">
|
||||
<div
|
||||
class="inner relative max-w-7xl mx-auto px-10 py-8 text-[12px] text-gray-400 md:px-4 md:py-7 md:text-[12px]"
|
||||
>
|
||||
<div class="menu-area">
|
||||
<ul class="flex items-center flex-wrap md:gap-1.5">
|
||||
<li
|
||||
v-for="(footerMenuItem, index) in footerLinks"
|
||||
:key="index"
|
||||
class="text-sm md:text-[11px] md:tracking-[-0.5px] relative flex items-center"
|
||||
:class="{
|
||||
'before:content-[\'\'] before:inline-block before:bg-gray-500 before:h-2 before:w-px before:mx-1.5 before:mt-1 before:align-top md:before:mt-1':
|
||||
index > 0,
|
||||
'md:before:hidden': index === 4,
|
||||
}"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="footerMenuItem.link"
|
||||
:target="footerMenuItem.target"
|
||||
:class="[
|
||||
footerMenuItem.active === 'y' && 'text-[#e04600]',
|
||||
index === 2 && 'text-[#e04600]',
|
||||
'hover:text-gray-600 transition-colors',
|
||||
]"
|
||||
>
|
||||
{{ footerMenuItem.title }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2.5 md:flex-col md:mt-1.5">
|
||||
<img
|
||||
:src="footerData.game_rating_image_url"
|
||||
alt="게임등급"
|
||||
class="w-10 h-14 md:w-10 md:h-12 md:order-1"
|
||||
/>
|
||||
|
||||
<dl
|
||||
class="grid grid-cols-[110px_auto_110px_auto] w-full max-w-[490px] ml-5 border-t border-l border-gray-600 tracking-tight md:grid-cols-[66px_auto_84px_auto] md:max-w-[358px] md:m-0 md:mb-2.5"
|
||||
>
|
||||
<dt
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
|
||||
>
|
||||
게임명
|
||||
</dt>
|
||||
<dd
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
|
||||
>
|
||||
{{ footerData.game_rating_info.title }}
|
||||
</dd>
|
||||
<dt
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
|
||||
>
|
||||
상호
|
||||
</dt>
|
||||
<dd
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
|
||||
>
|
||||
{{ footerData.game_rating_info.company_name }}
|
||||
</dd>
|
||||
<dt
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
|
||||
>
|
||||
이용등급
|
||||
</dt>
|
||||
<dd
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
|
||||
>
|
||||
{{ footerData.game_rating_info.reg_no }}
|
||||
</dd>
|
||||
<dt
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
|
||||
>
|
||||
등급분류번호
|
||||
</dt>
|
||||
<dd
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
|
||||
>
|
||||
{{ footerData.game_rating_info.rating_grade }}
|
||||
</dd>
|
||||
<dt
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
|
||||
>
|
||||
제작년월일
|
||||
</dt>
|
||||
<dd
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
|
||||
>
|
||||
{{ footerData.game_rating_info.prod_date }}
|
||||
</dd>
|
||||
<dt
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 bg-[#1a1a1a] md:p-0.5 md:px-1.5"
|
||||
>
|
||||
신고(등록)번호
|
||||
</dt>
|
||||
<dd
|
||||
class="p-1.5 px-4 border-r border-b border-gray-600 text-gray-500 md:p-0.5 md:px-1.5"
|
||||
>
|
||||
{{ footerData.game_rating_info.rating_class_no }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="address-area mt-6">
|
||||
<address class="not-italic text-gray-500">
|
||||
<div class="row my-1.5">
|
||||
<span
|
||||
v-dompurify-html="footerData.footer_info"
|
||||
class="[&_a]:cursor-pointer [&_a]:text-blue-500 [&_a]:underline"
|
||||
></span>
|
||||
</div>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<div class="copyright-area mt-5 text-gray-500 md:mt-4">
|
||||
<span>© Smilegate. All rights reserved</span>
|
||||
</div>
|
||||
|
||||
<div class="logo-area flex mt-3 md:mt-2.5">
|
||||
<a
|
||||
:href="
|
||||
locale === 'ja'
|
||||
? 'https://www.smilegate.com/jp'
|
||||
: 'https://www.smilegate.com/en'
|
||||
"
|
||||
target="_blank"
|
||||
class="smilegate"
|
||||
>
|
||||
<img
|
||||
:src="footerData.dev_ci_url"
|
||||
alt="스마일게이트 로고"
|
||||
class="w-auto h-auto"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.nx3games.com"
|
||||
target="_blank"
|
||||
class="nx3 ml-2.5 md:ml-4"
|
||||
>
|
||||
<img
|
||||
:src="footerData.dev_ci_url2"
|
||||
alt="NX3 로고"
|
||||
class="w-auto h-auto"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="language-area absolute bottom-7 right-10 text-white md:bottom-5.5 md:right-4"
|
||||
>
|
||||
<MoleculesLanguageSwitcher />
|
||||
|
||||
<!-- <SelectLanguage /> -->
|
||||
<!-- <AtomsLanguageSwitcher /> -->
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { locale } = useI18n()
|
||||
interface FooterMenuType {
|
||||
id: string
|
||||
title: string
|
||||
link: string
|
||||
target: string
|
||||
active: string
|
||||
highlight?: string
|
||||
}
|
||||
|
||||
const footerLinks = ref<FooterMenuType[]>([
|
||||
{
|
||||
id: 'company',
|
||||
title: '회사소개',
|
||||
link: '#',
|
||||
target: '_blank',
|
||||
active: 'n',
|
||||
},
|
||||
{
|
||||
id: 'terms',
|
||||
title: '이용약관',
|
||||
link: 'https://common.game.onstove.com/terms/index?gameType=SG&termsType=1&langCode=@m{Terms_Lang_Code}',
|
||||
target: '_blank',
|
||||
active: 'n',
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
title: '개인정보처리방침',
|
||||
link: 'https://clause.onstove.com/stove/terms?category=privacy',
|
||||
target: '_blank',
|
||||
active: 'y',
|
||||
},
|
||||
{
|
||||
id: 'operation',
|
||||
title: '운영정책',
|
||||
link: 'https://common.game.onstove.com/terms/index?gameType=CZN&termsType=3&langCode=@m{Terms_Lang_Code}',
|
||||
target: '_blank',
|
||||
active: 'n',
|
||||
},
|
||||
{
|
||||
id: 'fund',
|
||||
title: '청소년보호정책',
|
||||
link: 'https://common.game.onstove.com/terms/index?gameType=CZN&termsType=6&langCode=ja',
|
||||
target: '_blank',
|
||||
active: 'n',
|
||||
},
|
||||
{
|
||||
id: 'customerService',
|
||||
title: '게임 이용 등급',
|
||||
link: 'https://cs.onstove.com/@m{Terms_Lang_Code}/service/STOVE_CHAOSZERO',
|
||||
target: '_blank',
|
||||
active: 'n',
|
||||
},
|
||||
] as FooterMenuType[])
|
||||
|
||||
const footerData = ref({
|
||||
dev_ci_url:
|
||||
'https://static-pubcomm.gate8.com/local/template/l9/common/logo_smilegate.png',
|
||||
dev_ci_url2:
|
||||
'https://static-pubcomm.gate8.com/local/template/l9/common/logo_nx3.png',
|
||||
game_rating_image_url:
|
||||
'https://static-pubcomm.gate8.com/local/template/l9/common/grades_age/Type15.svg',
|
||||
use_dev_ci_url: true,
|
||||
fund_display_yn: true,
|
||||
use_game_rating: true,
|
||||
fund_display_url: 'https://testgame.com/law/fund-ko',
|
||||
game_rating_info: {
|
||||
title: '테스트 게임',
|
||||
reg_no: 'R-2024-7890',
|
||||
prod_date: '2024-05-01',
|
||||
rating_type: '15',
|
||||
company_name: '테스트엔터테인먼트',
|
||||
content_info: '1,2,3,',
|
||||
rating_grade: '15세 이용가',
|
||||
rating_class_no: '2024-123456',
|
||||
},
|
||||
footer_info:
|
||||
"(주)스마일게이트홀딩스 메가포트지점 대표: 성준호<br>주소: 경기도 성남시 분당구 분당로 55, 7층 (서현동 분당 퍼스트타워)<br>통신판매업 신고번호: 제2023-성남분당A-0145호<br>사업자등록번호: 813-85-02492<br>E-mail: <a href='mailto:help@smilegate.com'>help@smilegate.com</a><br>TEL: 1670-0399",
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 태국어 폰트 크기 조정 */
|
||||
@media (max-width: 411px) {
|
||||
:global(.lang-th) .menu-area li {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 321px) {
|
||||
:global(.lang-th) .menu-area li {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,96 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useGameDataStore } from '#layers/stores/useGameDataStore'
|
||||
import type { GameDataValue, GameDataGnb } from '#layers/types/api/gameData'
|
||||
|
||||
const gameDataStore = useGameDataStore()
|
||||
|
||||
const gameData = gameDataStore.gameData as GameDataValue
|
||||
const gnbList = gameData?.gnb?.menus as GameDataGnb['menus']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="bg-theme-foreground text-theme-foreground-reversal relative z-50"
|
||||
>
|
||||
<MoleculesStoveGnb />
|
||||
<div
|
||||
data-name="header-game"
|
||||
class="px-[40px] h-16 flex items-center whitespace-nowrap"
|
||||
>
|
||||
<!-- 로고 -->
|
||||
<div data-name="header-logo" class="mr-[40px]">
|
||||
<AtomsLocaleLink to="/brand">
|
||||
<img
|
||||
:src="gameData?.gnb?.bi_path"
|
||||
:alt="gameData?.game_name"
|
||||
class="h-[30px]"
|
||||
/>
|
||||
</AtomsLocaleLink>
|
||||
</div>
|
||||
|
||||
<!-- 메인 네비게이션 -->
|
||||
<nav data-name="header-nav" class="flex items-center space-x-[32px]">
|
||||
<template v-if="gnbList">
|
||||
<div
|
||||
v-for="(gnbItem, key) in gnbList"
|
||||
:key="key"
|
||||
class="relative group"
|
||||
>
|
||||
<!-- Link 컴포넌트 사용 -->
|
||||
<MoleculesHybridLink
|
||||
:to="gnbItem.url_path"
|
||||
:target="gnbItem.link_target"
|
||||
class="relative flex items-center h-[64px]"
|
||||
>
|
||||
{{ gnbItem.menu_name }}
|
||||
<AtomsIconsArrowDown v-if="gnbItem.children" class="ml-1" />
|
||||
<span
|
||||
class="absolute bottom-0 left-0 w-full h-2 border-b-2 border-transparent transition-border-color group-hover:border-theme-foreground-reversal group-active:border-theme-foreground-reversal-10"
|
||||
/>
|
||||
</MoleculesHybridLink>
|
||||
<div
|
||||
v-if="gnbItem.children"
|
||||
class="absolute top-full left-[-28px] min-w-[190px] pt-[4px] pointer-events-none group-hover:pointer-events-auto"
|
||||
>
|
||||
<ul
|
||||
class="bg-theme-foreground-10 rounded-[20px] shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 p-3"
|
||||
>
|
||||
<li v-for="child in gnbItem.children" :key="child.menu_name">
|
||||
<!-- Link 컴포넌트 사용 -->
|
||||
<MoleculesHybridLink
|
||||
:to="child.url_path"
|
||||
:target="child.link_target"
|
||||
class="flex items-center px-4 py-[9px] rounded-[12px] transition-background hover:bg-theme-foreground-reversal-40 active:bg-theme-foreground-reversal-70"
|
||||
>
|
||||
{{ child.menu_name }}
|
||||
<AtomsIconsLinkOut
|
||||
v-if="child.link_target === '_blank'"
|
||||
class="ml-1"
|
||||
/>
|
||||
</MoleculesHybridLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 구분선 -->
|
||||
<div class="w-px h-4 bg-theme-foreground-reversal-30" />
|
||||
|
||||
<!-- 이벤트 -->
|
||||
<a href="#" class="flex items-center space-x-[3px] text-gradient-pink">
|
||||
<AtomsIconsStar />
|
||||
<span>이벤트</span>
|
||||
<AtomsIconsStar />
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 오른쪽 영역 -->
|
||||
<div data-name="header-right" class="ml-auto">
|
||||
<div class="relative group">
|
||||
<AtomsButton size="small">게임 시작</AtomsButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
45
layers/components/blocks/loading/Full.vue
Normal file
45
layers/components/blocks/loading/Full.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { useLoadingStore } from '#layers/stores/useLoadingStore'
|
||||
|
||||
const loadingStore = useLoadingStore()
|
||||
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>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
55
layers/components/blocks/loading/Local.vue
Normal file
55
layers/components/blocks/loading/Local.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useLoadingStore } from '#layers/stores/useLoadingStore'
|
||||
|
||||
const loadingStore = useLoadingStore()
|
||||
const localLoadings = computed(() => Object.entries(loadingStore.localLoadings))
|
||||
const canTeleport = (localId: string) => {
|
||||
if (!import.meta.client) {
|
||||
return false
|
||||
}
|
||||
return !!document.getElementById(localId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
134
layers/components/blocks/modal/YouTube.vue
Normal file
134
layers/components/blocks/modal/YouTube.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
|
||||
@click="handleBackdropClick"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div v-if="isOpen" class="relative w-full max-w-4xl mx-4" @click.stop>
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class="p-1 text-white rounded-full transition-colors"
|
||||
aria-label="모달 닫기"
|
||||
@click="closeModal"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 유튜브 영상 컨테이너 -->
|
||||
<div class="relative w-full" :style="{ paddingBottom: '56.25%' }">
|
||||
<iframe
|
||||
v-if="youtubeId"
|
||||
:src="`https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0`"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
title="YouTube video player"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
youtubeId: string
|
||||
title?: string
|
||||
description?: string
|
||||
closeOnBackdrop?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'update:isOpen', value: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
description: '',
|
||||
closeOnBackdrop: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.isOpen) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// 배경 클릭으로 모달 닫기
|
||||
const handleBackdropClick = () => {
|
||||
if (props.closeOnBackdrop) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 닫기 함수
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
emit('update:isOpen', false)
|
||||
}
|
||||
|
||||
// 키보드 이벤트 리스너 등록/해제
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// 모달이 열릴 때 body 스크롤 방지
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
isOpen => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 컴포넌트 언마운트 시 body 스크롤 복원
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user