macbook 에서 리눅스로 이동
This commit is contained in:
95
frontend/app/pages/admin/index.vue
Normal file
95
frontend/app/pages/admin/index.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useDashboardStore } from '~/stores/dashboard'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const { kpiCards, trendBars, runningProjects } = storeToRefs(dashboardStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-indigo-950/70 to-slate-900 px-5 py-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-cyan-300">대시보드</p>
|
||||
<div class="mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-100">운영 대시보드</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">진행중 프로젝트, 전체 프로젝트, 신규 리드를 가로형 위젯으로 확인하세요.</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
to="/admin/projects"
|
||||
class="rounded-md border border-cyan-300/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-semibold text-cyan-100 hover:bg-cyan-500/20"
|
||||
>
|
||||
프로젝트 더 보기
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="grid gap-3 xl:grid-cols-[2fr_1fr]">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<article
|
||||
v-for="card in kpiCards"
|
||||
:key="card.label"
|
||||
class="rounded-xl border bg-gradient-to-br from-slate-900 to-slate-950 p-5"
|
||||
:class="[card.border]"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.16em] text-slate-200">{{ card.label }}</p>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<p class="text-3xl font-black" :class="card.color">{{ card.value }}</p>
|
||||
<span class="inline-flex h-2.5 w-2.5 rounded-full bg-gradient-to-r" :class="card.band"></span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-400">{{ card.note }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="rounded-xl border border-slate-800 bg-gradient-to-b from-slate-900 to-indigo-950/50 p-5">
|
||||
<h2 class="text-sm font-semibold">리드 추이(샘플)</h2>
|
||||
<div class="mt-3 h-36 rounded-lg border border-cyan-300/20 bg-slate-950/80">
|
||||
<div class="flex h-full items-end gap-2 px-4 pb-4">
|
||||
<span v-for="bar in trendBars" :key="bar" class="w-full bg-gradient-to-t from-cyan-300 to-indigo-300/80" :style="{ height: `${bar}%` }"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-3 xl:grid-cols-[1fr_1fr]">
|
||||
<article class="rounded-xl border border-slate-800 bg-gradient-to-b from-slate-900 to-slate-950 p-5">
|
||||
<h2 class="text-lg font-semibold">진행중 프로젝트 목록</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">운영 중인 핵심 프로젝트</p>
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-slate-800">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-900">
|
||||
<tr class="text-slate-300">
|
||||
<th class="px-4 py-3">프로젝트</th>
|
||||
<th class="px-4 py-3">도메인</th>
|
||||
<th class="px-4 py-3">상태</th>
|
||||
<th class="px-4 py-3 text-right">리드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="project in runningProjects" :key="project.domain" class="border-t border-slate-800 text-slate-200">
|
||||
<td class="px-4 py-3">{{ project.name }}</td>
|
||||
<td class="px-4 py-3">{{ project.domain }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-900/40 px-2.5 py-0.5 text-xs font-medium text-indigo-200">
|
||||
{{ project.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">{{ project.leads }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="rounded-xl border border-slate-800 bg-gradient-to-b from-slate-900 to-indigo-950/40 p-5">
|
||||
<h2 class="text-lg font-semibold">빠른 액션</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">바로 이동해 바로 시작</p>
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<NuxtLink to="/admin/projects" class="rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white">새 프로젝트 만들기</NuxtLink>
|
||||
<NuxtLink to="/admin/pages" class="rounded-md border border-indigo-300/30 bg-indigo-950/20 px-4 py-2 text-sm text-indigo-100 hover:bg-indigo-900/30">페이지 바로가기</NuxtLink>
|
||||
<NuxtLink to="/admin/leads" class="rounded-md border border-cyan-300/30 bg-cyan-950/20 px-4 py-2 text-sm text-cyan-100 hover:bg-cyan-900/30">리드 조회</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
213
frontend/app/pages/admin/leads.vue
Normal file
213
frontend/app/pages/admin/leads.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface LeadRecord {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
campaign: string
|
||||
page: string
|
||||
channel: string
|
||||
status: '새리드' | '검토중' | '완료'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const leads = ref<LeadRecord[]>([
|
||||
{
|
||||
id: 'ld-1001',
|
||||
name: '김하늘',
|
||||
phone: '010-1111-2222',
|
||||
email: 'haneul@example.com',
|
||||
campaign: 'Q3 마케팅 캠페인',
|
||||
page: '메인 랜딩',
|
||||
channel: 'Google',
|
||||
status: '새리드',
|
||||
createdAt: '2026-03-04T09:12:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'ld-1002',
|
||||
name: '이준수',
|
||||
phone: '010-2222-3333',
|
||||
email: 'junsoo@example.com',
|
||||
campaign: '블랙프라이데이 2023',
|
||||
page: '구글 전용 랜딩',
|
||||
channel: 'Meta',
|
||||
status: '검토중',
|
||||
createdAt: '2026-03-03T17:44:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'ld-1003',
|
||||
name: '박소영',
|
||||
phone: '010-3333-4444',
|
||||
email: 'soyoung@example.com',
|
||||
campaign: 'Product Hunt 런칭',
|
||||
page: '리타겟 랜딩',
|
||||
channel: 'Instagram',
|
||||
status: '완료',
|
||||
createdAt: '2026-03-02T21:13:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'ld-1004',
|
||||
name: '최민재',
|
||||
phone: '010-4444-5555',
|
||||
email: 'minjae@example.com',
|
||||
campaign: 'SaaS 웨비나 시리즈',
|
||||
page: '오퍼 페이지 B',
|
||||
channel: 'Naver',
|
||||
status: '새리드',
|
||||
createdAt: '2026-03-01T11:08:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'ld-1005',
|
||||
name: '정연우',
|
||||
phone: '010-5555-6666',
|
||||
email: 'yeonwoo@example.com',
|
||||
campaign: 'Q3 마케팅 캠페인',
|
||||
page: '메인 랜딩',
|
||||
channel: 'Google',
|
||||
status: '검토중',
|
||||
createdAt: '2026-03-01T03:03:00.000Z'
|
||||
}
|
||||
])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref<'all' | LeadRecord['status']>('all')
|
||||
const channelFilter = ref<'all' | string>('all')
|
||||
const campaignFilter = ref<'all' | string>('all')
|
||||
|
||||
const channelOptions = ['Google', 'Meta', 'Instagram', 'Naver']
|
||||
const campaigns = computed(() => {
|
||||
const names = [...new Set(leads.value.map((lead) => lead.campaign))]
|
||||
return names
|
||||
})
|
||||
|
||||
const filteredLeads = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
|
||||
return leads.value
|
||||
.filter((lead) => {
|
||||
const target = `${lead.name} ${lead.phone} ${lead.email} ${lead.campaign} ${lead.page} ${lead.channel} ${lead.status}`.toLowerCase()
|
||||
const matchesQuery = query === '' || target.includes(query)
|
||||
const matchesStatus = statusFilter.value === 'all' || lead.status === statusFilter.value
|
||||
const matchesChannel = channelFilter.value === 'all' || lead.channel === channelFilter.value
|
||||
const matchesCampaign = campaignFilter.value === 'all' || lead.campaign === campaignFilter.value
|
||||
|
||||
return matchesQuery && matchesStatus && matchesChannel && matchesCampaign
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
})
|
||||
|
||||
const statusLabel = (status: LeadRecord['status']) => {
|
||||
if (status === '완료') return '완료'
|
||||
if (status === '검토중') return '검토중'
|
||||
return '새 리드'
|
||||
}
|
||||
|
||||
const statusClass = (status: LeadRecord['status']) => {
|
||||
if (status === '완료') {
|
||||
return 'text-emerald-200 bg-emerald-400/10'
|
||||
}
|
||||
|
||||
if (status === '검토중') {
|
||||
return 'text-amber-200 bg-amber-400/10'
|
||||
}
|
||||
|
||||
return 'text-indigo-200 bg-indigo-400/10'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-slate-900 to-indigo-950/70 px-5 py-4 shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm text-slate-400">홈 <span class="px-2">></span> 리드 조회</p>
|
||||
<h1 class="mt-1 text-3xl font-black tracking-tight text-slate-100">리드 조회</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">캠페인/페이지 기반으로 리드를 확인하고 상태를 관리하세요.</p>
|
||||
</div>
|
||||
<button class="rounded-xl bg-gradient-to-r from-indigo-500 to-cyan-500 px-5 py-2.5 text-base font-semibold text-white shadow-lg shadow-cyan-950/40 transition hover:from-indigo-400 hover:to-cyan-400">
|
||||
리드 엑셀 내보내기
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="rounded-2xl border border-indigo-400/20 bg-gradient-to-br from-slate-900/80 via-slate-950 to-slate-900/90 p-5 shadow-[0_12px_30px_rgba(0,0,0,0.3)]">
|
||||
<div class="flex flex-nowrap items-end justify-between gap-4">
|
||||
<label class="min-w-0 space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">검색</span>
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-cyan-300">⌕</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="이름, 전화번호, 이메일, 캠페인, 페이지"
|
||||
class="w-full min-w-[420px] rounded-xl border border-indigo-500/20 bg-slate-950/80 px-9 py-3 text-sm text-slate-100 outline-none ring-0 transition focus:border-cyan-400 focus:shadow-[0_0_0_1px_rgba(103,232,249,0.35)]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="ml-auto flex shrink-0 items-end gap-3">
|
||||
<label class="flex flex-col space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">상태</span>
|
||||
<select v-model="statusFilter" class="rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400">
|
||||
<option value="all">전체</option>
|
||||
<option value="새리드">새 리드</option>
|
||||
<option value="검토중">검토중</option>
|
||||
<option value="완료">완료</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">채널</span>
|
||||
<select v-model="channelFilter" class="rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400">
|
||||
<option value="all">전체</option>
|
||||
<option v-for="ch in channelOptions" :key="ch" :value="ch">
|
||||
{{ ch }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">캠페인</span>
|
||||
<select v-model="campaignFilter" class="rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400">
|
||||
<option value="all">전체 캠페인</option>
|
||||
<option v-for="name in campaigns" :key="name" :value="name">{{ name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/80">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-900 text-slate-300">
|
||||
<tr>
|
||||
<th class="px-4 py-3">이름</th>
|
||||
<th class="px-4 py-3">연락처</th>
|
||||
<th class="px-4 py-3">이메일</th>
|
||||
<th class="px-4 py-3">캠페인</th>
|
||||
<th class="px-4 py-3">페이지</th>
|
||||
<th class="px-4 py-3">채널</th>
|
||||
<th class="px-4 py-3">상태</th>
|
||||
<th class="px-4 py-3">수집일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="lead in filteredLeads" :key="lead.id" class="border-t border-slate-800 text-slate-200">
|
||||
<td class="px-4 py-3">{{ lead.name }}</td>
|
||||
<td class="px-4 py-3">{{ lead.phone }}</td>
|
||||
<td class="px-4 py-3 text-cyan-200">{{ lead.email }}</td>
|
||||
<td class="px-4 py-3">{{ lead.campaign }}</td>
|
||||
<td class="px-4 py-3">{{ lead.page }}</td>
|
||||
<td class="px-4 py-3">{{ lead.channel }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold tracking-[0.07em]" :class="statusClass(lead.status)">
|
||||
{{ statusLabel(lead.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ new Date(lead.createdAt).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
375
frontend/app/pages/admin/pages/[id]/builder.vue
Normal file
375
frontend/app/pages/admin/pages/[id]/builder.vue
Normal file
@@ -0,0 +1,375 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePagesStore } from '~/stores/pages'
|
||||
|
||||
type BuilderBlockType = 'html' | 'image' | 'form' | 'kakao'
|
||||
|
||||
type BuilderBlock = {
|
||||
id: string
|
||||
type: BuilderBlockType
|
||||
label: string
|
||||
html?: string
|
||||
src?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const pagesStore = usePagesStore()
|
||||
const { campaignOptions } = storeToRefs(pagesStore)
|
||||
|
||||
const pageId = computed(() => String(route.params.id || ''))
|
||||
const selectedPage = computed(() => pagesStore.pages.find((page) => page.id === pageId.value))
|
||||
|
||||
const pageTitle = ref('Landing Page')
|
||||
const metaDescription = ref('landing page description')
|
||||
const metaKeywords = ref('landing, ads')
|
||||
const iconUrl = ref('https://avatars.githubusercontent.com/u/9919?s=200&v=4')
|
||||
const headCode = ref('<meta property="og:type" content="website" />')
|
||||
const bodyScript = ref("console.log('landing admin builder loaded')")
|
||||
const footerHtml = ref('')
|
||||
const isDragOver = ref(false)
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const htmlDraft = ref('<p>새 HTML 블록입니다.</p>')
|
||||
const imageAlt = ref('이미지')
|
||||
|
||||
const generatedHtml = ref('')
|
||||
const isSaved = ref(false)
|
||||
const objectUrlMap = ref<Record<string, string>>({})
|
||||
|
||||
const blockList = ref<BuilderBlock[]>([
|
||||
{ id: `b-${Date.now()}`, type: 'html', label: '커스텀 HTML', html: '<p>커스텀 HTML 블록입니다.</p>' },
|
||||
])
|
||||
|
||||
const campaignName = computed(() => {
|
||||
if (!selectedPage.value) return '-'
|
||||
return campaignOptions.value.find((campaign) => campaign.id === selectedPage.value?.campaignId)?.name || '-'
|
||||
})
|
||||
|
||||
const pageUrl = computed(() => {
|
||||
if (!selectedPage.value) return ''
|
||||
return `https://${selectedPage.value.domain}${selectedPage.value.routePath}`
|
||||
})
|
||||
|
||||
const previewHeader = computed(() => selectedPage.value?.name || '새 페이지')
|
||||
|
||||
const finalHeadHtml = computed(() => {
|
||||
const pieces = [
|
||||
'<meta charset="UTF-8" />',
|
||||
`<title>${escapeHtml(pageTitle.value)}</title>`,
|
||||
`<meta name="description" content="${escapeHtml(metaDescription.value)}" />`,
|
||||
`<meta name="keywords" content="${escapeHtml(metaKeywords.value)}" />`,
|
||||
`<link rel="icon" href="${escapeHtml(iconUrl.value)}" />`,
|
||||
headCode.value.trim(),
|
||||
]
|
||||
|
||||
return pieces.filter(Boolean).join('\n ')
|
||||
})
|
||||
|
||||
const blockHtmls = computed(() => blockList.value.map((block) => {
|
||||
if (block.type === 'image' && block.src) {
|
||||
return `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt || '')}" style="max-width:100%;border-radius:12px;display:block;" />`
|
||||
}
|
||||
|
||||
return block.html ? block.html : '<p>빈 HTML</p>'
|
||||
}))
|
||||
|
||||
const previewBlockHtmls = computed(() => blockList.value.map((block) => {
|
||||
if (block.type === 'image' && block.src) {
|
||||
return `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt || '')}" style="max-width:320px; width:100%; max-height:220px; object-fit:contain;display:block;" />`
|
||||
}
|
||||
|
||||
return block.html ? block.html : '<p>빈 HTML</p>'
|
||||
}))
|
||||
|
||||
const finalBodyHtml = computed(() => {
|
||||
const bodyOpen = '<' + 'script>'
|
||||
const bodyClose = '<' + '/script>'
|
||||
const safeBodyScript = bodyScript.value.replace(/<\/script/gi, '<\\/script')
|
||||
const body = [
|
||||
...blockHtmls.value,
|
||||
footerHtml.value,
|
||||
`${bodyOpen}${safeBodyScript}${bodyClose}`,
|
||||
].join('\n')
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
${finalHeadHtml.value}
|
||||
</head>
|
||||
<body>
|
||||
<div style="padding:16px;">${body}</div>
|
||||
</body>
|
||||
</html>`
|
||||
})
|
||||
|
||||
const previewBodyHtml = computed(() => {
|
||||
const body = [
|
||||
...previewBlockHtmls.value,
|
||||
footerHtml.value,
|
||||
].join('\n')
|
||||
|
||||
return `<div style=\"padding:16px;\">${body}</div>`
|
||||
})
|
||||
|
||||
const addHtmlBlock = () => {
|
||||
const draft = htmlDraft.value.trim() || '<p>빈 HTML</p>'
|
||||
blockList.value = [
|
||||
{
|
||||
id: `b-html-${Date.now()}`,
|
||||
type: 'html',
|
||||
label: `HTML 블록 ${blockList.value.length + 1}`,
|
||||
html: draft,
|
||||
},
|
||||
...blockList.value,
|
||||
]
|
||||
htmlDraft.value = '<p>새 HTML 블록입니다.</p>'
|
||||
}
|
||||
|
||||
const addImageFromFiles = (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const toAdd = Array.from(files)
|
||||
const createdBlocks: BuilderBlock[] = toAdd.map((file, idx) => {
|
||||
const localUrl = URL.createObjectURL(file)
|
||||
const blockId = `b-image-${Date.now()}-${idx}`
|
||||
objectUrlMap.value[blockId] = localUrl
|
||||
return {
|
||||
id: blockId,
|
||||
type: 'image',
|
||||
label: `이미지 블록 ${blockList.value.length + idx + 1}`,
|
||||
src: localUrl,
|
||||
alt: imageAlt.value.trim() || '이미지',
|
||||
}
|
||||
})
|
||||
|
||||
blockList.value = [...createdBlocks, ...blockList.value]
|
||||
imageAlt.value = '이미지'
|
||||
}
|
||||
|
||||
const addImageFromUpload = (evt: Event) => {
|
||||
const target = evt.target as HTMLInputElement
|
||||
const targetFiles = target.files
|
||||
addImageFromFiles(targetFiles)
|
||||
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
const openImagePicker = () => {
|
||||
imageInputRef.value?.click()
|
||||
}
|
||||
|
||||
const onImageDragOver = (evt: DragEvent) => {
|
||||
evt.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const onImageDragLeave = (evt: DragEvent) => {
|
||||
evt.preventDefault()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onImageDrop = (evt: DragEvent) => {
|
||||
evt.preventDefault()
|
||||
isDragOver.value = false
|
||||
addImageFromFiles(evt.dataTransfer?.files || null)
|
||||
}
|
||||
|
||||
const moveUp = (index: number) => {
|
||||
if (index === 0) return
|
||||
const next = [...blockList.value]
|
||||
;[next[index - 1], next[index]] = [next[index], next[index - 1]]
|
||||
blockList.value = next
|
||||
}
|
||||
|
||||
const moveDown = (index: number) => {
|
||||
if (index === blockList.value.length - 1) return
|
||||
const next = [...blockList.value]
|
||||
;[next[index], next[index + 1]] = [next[index + 1], next[index]]
|
||||
blockList.value = next
|
||||
}
|
||||
|
||||
const removeBlock = (id: string) => {
|
||||
if (objectUrlMap.value[id]) {
|
||||
URL.revokeObjectURL(objectUrlMap.value[id])
|
||||
delete objectUrlMap.value[id]
|
||||
}
|
||||
blockList.value = blockList.value.filter((block) => block.id !== id)
|
||||
}
|
||||
|
||||
const saveBuilder = () => {
|
||||
generatedHtml.value = finalBodyHtml.value
|
||||
isSaved.value = true
|
||||
setTimeout(() => {
|
||||
isSaved.value = false
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const copyFinalHtml = async () => {
|
||||
if (!generatedHtml.value) {
|
||||
generatedHtml.value = finalBodyHtml.value
|
||||
}
|
||||
|
||||
await navigator.clipboard?.writeText(generatedHtml.value)
|
||||
}
|
||||
|
||||
const backToList = () => {
|
||||
router.push('/admin/pages')
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<header class="rounded-xl border border-slate-700/80 bg-slate-900 px-5 py-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400">홈 > 페이지 > 빌더</p>
|
||||
<h1 class="mt-1 text-2xl font-black tracking-tight text-white">페이지 빌더</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">선택한 페이지의 블록을 관리하고 HTML을 생성합니다.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink to="/admin/pages" class="rounded-md border border-slate-700 px-3 py-2 text-sm">목록으로</NuxtLink>
|
||||
<button type="button" @click="backToList" class="rounded-md border border-slate-700 px-3 py-2 text-sm">닫기</button>
|
||||
<button type="button" @click="saveBuilder" class="rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-medium text-white">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section v-if="selectedPage" class="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-950 p-5">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-bold text-white">{{ previewHeader }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{{ campaignName }}</p>
|
||||
<p class="mt-1 text-sm text-slate-300">{{ pageUrl }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto w-[360px] rounded-xl border border-slate-700 bg-black/20 p-4">
|
||||
<div class="rounded-lg bg-slate-900/80 p-2 min-h-[120px]" v-html="previewBodyHtml"></div>
|
||||
<div v-if="blockHtmls.length === 0" class="rounded-lg border border-slate-700 bg-slate-900/80 p-2 mt-2 text-xs text-slate-300">블록이 없습니다.</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="rounded-2xl border border-slate-800 bg-slate-950 p-5 text-sm">
|
||||
<h2 class="text-lg font-semibold text-white">빌더 설정</h2>
|
||||
<p class="mt-1 text-slate-400">HTML/이미지 블록과 헤더/아이콘/Body Script/푸터 HTML을 설정하세요.</p>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<label class="block space-y-1">
|
||||
<span class="text-slate-400">페이지 제목</span>
|
||||
<input v-model="pageTitle" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2" />
|
||||
</label>
|
||||
<label class="block space-y-1">
|
||||
<span class="text-slate-400">메타 설명</span>
|
||||
<textarea v-model="metaDescription" rows="2" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
|
||||
</label>
|
||||
<label class="block space-y-1">
|
||||
<span class="text-slate-400">메타 키워드</span>
|
||||
<input v-model="metaKeywords" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2" />
|
||||
</label>
|
||||
<label class="block space-y-1">
|
||||
<span class="text-slate-400">파비콘 URL</span>
|
||||
<input v-model="iconUrl" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2" />
|
||||
</label>
|
||||
<label class="block space-y-1">
|
||||
<span class="text-slate-400">HEAD 추가 코드</span>
|
||||
<textarea v-model="headCode" rows="3" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
|
||||
</label>
|
||||
<label class="block space-y-1">
|
||||
<span class="text-slate-400">BODY Script</span>
|
||||
<textarea v-model="bodyScript" rows="3" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
|
||||
</label>
|
||||
<label class="block space-y-1">
|
||||
<span class="text-slate-400">Footer HTML</span>
|
||||
<textarea v-model="footerHtml" rows="3" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<h3 class="font-semibold text-white">블록 추가</h3>
|
||||
<label class="block space-y-1">
|
||||
<span class="text-slate-400">HTML 블록</span>
|
||||
<textarea v-model="htmlDraft" rows="3" class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2"></textarea>
|
||||
</label>
|
||||
<button type="button" @click="addHtmlBlock" class="mt-1 rounded-md bg-cyan-900/60 px-3 py-2 text-sm text-white">HTML 블록 추가</button>
|
||||
|
||||
<div
|
||||
class="mt-3 rounded-lg border-2 border-dashed border-slate-700 bg-slate-900/40 p-5 text-center text-slate-300"
|
||||
:class="isDragOver ? 'border-cyan-400 bg-slate-900' : 'border-slate-700'"
|
||||
@dragover.prevent="onImageDragOver"
|
||||
@dragleave.prevent="onImageDragLeave"
|
||||
@drop.prevent="onImageDrop"
|
||||
@click="openImagePicker"
|
||||
>
|
||||
<p class="text-sm text-slate-200 font-semibold">이미지 파일을 드래그해서 올려주세요</p>
|
||||
<p class="mt-1 text-xs text-slate-500">갯수 제한 없이 한 번에 여러 장 추가됩니다.</p>
|
||||
<span class="text-xs text-slate-500 mt-2 block">참고: 업로드한 파일은 현재 페이지 미리보기/블록으로 즉시 반영됩니다.</span>
|
||||
<input
|
||||
ref="imageInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="addImageFromUpload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div v-for="(block, idx) in blockList" :key="block.id" class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2">
|
||||
<div class="mb-1 flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<img
|
||||
v-if="block.type === 'image' && block.src"
|
||||
:src="block.src"
|
||||
:alt="block.alt || '이미지'"
|
||||
class="h-12 w-16 rounded-none object-cover"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="text-slate-100">{{ block.label }}</p>
|
||||
<p class="text-xs text-slate-400">{{ block.type }}</p>
|
||||
<p class="text-xs text-slate-300" v-if="block.type === 'html'">{{ block.html }}</p>
|
||||
<p class="text-xs text-slate-300" v-else-if="block.type === 'image'">{{ block.alt || '이미지 블록' }}</p>
|
||||
<p class="text-xs text-slate-300" v-else>{{ block.html || '사용자 블록' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="rounded-md border border-slate-700 px-2 py-1 text-xs" @click="moveUp(idx)">↑</button>
|
||||
<button type="button" class="rounded-md border border-slate-700 px-2 py-1 text-xs" @click="moveDown(idx)">↓</button>
|
||||
<button type="button" class="rounded-md border border-red-500/50 px-2 py-1 text-xs text-red-200" @click="removeBlock(block.id)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs text-slate-400">{{ blockList.length }}개 블록</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else class="rounded-2xl border border-dashed border-slate-700 bg-slate-900 p-6 text-slate-300">
|
||||
<p>선택한 페이지를 찾을 수 없습니다.</p>
|
||||
<NuxtLink to="/admin/pages" class="mt-4 inline-flex rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-100">페이지 목록으로 이동</NuxtLink>
|
||||
</section>
|
||||
|
||||
<section v-if="generatedHtml" class="rounded-2xl border border-slate-700 bg-slate-900 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-100">생성 HTML</h3>
|
||||
<button type="button" @click="copyFinalHtml" class="rounded-md border border-cyan-300/40 px-3 py-1.5 text-xs text-cyan-100">클립보드 복사</button>
|
||||
</div>
|
||||
<pre class="mt-3 max-h-64 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-300">{{ generatedHtml }}</pre>
|
||||
</section>
|
||||
|
||||
<p v-if="isSaved" class="rounded-md border border-emerald-300/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">저장(HTML 생성)되었습니다.</p>
|
||||
</section>
|
||||
</template>
|
||||
224
frontend/app/pages/admin/pages/[id]/variants.vue
Normal file
224
frontend/app/pages/admin/pages/[id]/variants.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePagesStore } from '~/stores/pages'
|
||||
import { useVariantsStore } from '~/stores/variants'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const pagesStore = usePagesStore()
|
||||
const variantsStore = useVariantsStore()
|
||||
|
||||
const pageId = computed(() => String(route.params.id || ''))
|
||||
const page = computed(() => pagesStore.pages.find((item) => item.id === pageId.value) ?? null)
|
||||
const variants = computed(() => variantsStore.listByPage(pageId.value))
|
||||
const hasPage = computed(() => page.value !== null)
|
||||
|
||||
const totalLeads = computed(() => variants.value.reduce((sum, item) => sum + item.leadCount, 0))
|
||||
const totalTraffic = computed(() => variants.value.reduce((sum, item) => sum + item.trafficWeight, 0))
|
||||
|
||||
const newVariantName = ref('')
|
||||
const newVariantDescription = ref('')
|
||||
const newVariantWeight = ref(10)
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (!page.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `${page.value.name} 변형 관리`
|
||||
})
|
||||
|
||||
const backToPages = () => {
|
||||
router.push('/admin/pages')
|
||||
}
|
||||
|
||||
const toBuilder = () => {
|
||||
if (hasPage.value) {
|
||||
router.push(`/admin/pages/${pageId.value}/builder`)
|
||||
}
|
||||
}
|
||||
|
||||
const createVariant = () => {
|
||||
const name = newVariantName.value.trim()
|
||||
if (!hasPage.value || name.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
variantsStore.createVariant({
|
||||
pageId: pageId.value,
|
||||
name,
|
||||
description: newVariantDescription.value,
|
||||
trafficWeight: Number(newVariantWeight.value) || 10
|
||||
})
|
||||
|
||||
newVariantName.value = ''
|
||||
newVariantDescription.value = ''
|
||||
newVariantWeight.value = 10
|
||||
}
|
||||
|
||||
const removeVariant = (variantId: string) => {
|
||||
variantsStore.removeVariant(pageId.value, variantId)
|
||||
}
|
||||
|
||||
const toggleVariant = (variantId: string) => {
|
||||
variantsStore.toggleActive(pageId.value, variantId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-indigo-950/70 to-slate-900 px-5 py-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-cyan-300">페이지</p>
|
||||
<div class="mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-100">{{ hasPage ? pageTitle : '페이지를 찾을 수 없습니다' }}</h1>
|
||||
<p v-if="page" class="mt-1 text-sm text-slate-400">
|
||||
{{ page.domain }}{{ page.routePath }} · {{ page.leadCount }} 리드
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-cyan-300/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-semibold text-cyan-100 hover:bg-cyan-500/20"
|
||||
@click="toBuilder"
|
||||
>
|
||||
빌더 열기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-700 px-3 py-1.5 text-sm text-slate-300 hover:bg-slate-900"
|
||||
@click="backToPages"
|
||||
>
|
||||
페이지 목록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!hasPage" class="rounded-xl border border-slate-800 bg-slate-900/70 px-6 py-8">
|
||||
<h2 class="text-xl font-bold text-slate-100">해당 페이지를 찾을 수 없습니다.</h2>
|
||||
<p class="mt-1 text-sm text-slate-300">ID가 변경되었거나 삭제된 페이지일 수 있습니다.</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
|
||||
@click="backToPages"
|
||||
>
|
||||
페이지 목록으로 이동
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="grid gap-4 md:grid-cols-2">
|
||||
<article class="rounded-xl border border-slate-800 bg-slate-900/70 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-200">변형 메트릭</h2>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
|
||||
<p class="text-xs text-slate-400">총 변형 수</p>
|
||||
<p class="mt-2 text-2xl font-black text-slate-100">{{ variants.length }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
|
||||
<p class="text-xs text-slate-400">누적 리드</p>
|
||||
<p class="mt-2 text-2xl font-black text-slate-100">{{ totalLeads }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
|
||||
<p class="text-xs text-slate-400">총 트래픽 비율</p>
|
||||
<p class="mt-2 text-2xl font-black text-slate-100">{{ totalTraffic }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="rounded-xl border border-slate-800 bg-slate-900/70 p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-200">새 변형 추가</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-medium text-slate-300">변형 이름</span>
|
||||
<input
|
||||
v-model="newVariantName"
|
||||
type="text"
|
||||
placeholder="예: 주말 자극형"
|
||||
class="w-full rounded-lg border border-indigo-400/30 bg-slate-950/80 px-3 py-2 text-sm text-slate-100 outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-medium text-slate-300">메모</span>
|
||||
<input
|
||||
v-model="newVariantDescription"
|
||||
type="text"
|
||||
placeholder="변형 설명"
|
||||
class="w-full rounded-lg border border-indigo-400/30 bg-slate-950/80 px-3 py-2 text-sm text-slate-100 outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-medium text-slate-300">트래픽 비율 (%)</span>
|
||||
<input
|
||||
v-model.number="newVariantWeight"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-full rounded-lg border border-indigo-400/30 bg-slate-950/80 px-3 py-2 text-sm text-slate-100 outline-none"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
|
||||
@click="createVariant"
|
||||
>
|
||||
변형 생성
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-4 py-3 text-sm font-semibold text-slate-100">
|
||||
등록 변형 목록
|
||||
</div>
|
||||
<article
|
||||
v-for="variant in variants"
|
||||
:key="variant.id"
|
||||
class="rounded-xl border border-slate-800 bg-slate-900/70 p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-100">{{ variant.name }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">{{ variant.description || '메모 없음' }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleVariant(variant.id)"
|
||||
:class="variant.isActive ? 'bg-emerald-500/20 text-emerald-100' : 'bg-slate-700 text-slate-300'"
|
||||
class="rounded-md border border-slate-700 px-3 py-1.5 text-xs font-semibold"
|
||||
>
|
||||
{{ variant.isActive ? '활성' : '비활성' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
|
||||
<p class="text-xs text-slate-400">상태</p>
|
||||
<p class="mt-1 text-base font-semibold text-slate-100">
|
||||
{{ variant.isActive ? '노출' : '중단' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
|
||||
<p class="text-xs text-slate-400">트래픽 비율</p>
|
||||
<p class="mt-1 text-base font-semibold text-slate-100">{{ variant.trafficWeight }}%</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-950/60 p-3">
|
||||
<p class="text-xs text-slate-400">리드 수</p>
|
||||
<p class="mt-1 text-base font-semibold text-slate-100">{{ variant.leadCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-red-300/40 bg-red-900/20 px-3 py-1.5 text-sm text-red-100"
|
||||
@click="removeVariant(variant.id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
273
frontend/app/pages/admin/pages/index.vue
Normal file
273
frontend/app/pages/admin/pages/index.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePagesStore, type PageStatus, type PageSortBy } from '~/stores/pages'
|
||||
import { useVariantsStore } from '~/stores/variants'
|
||||
import CreatePageModal from '~/components/admin/CreatePageModal.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const pagesStore = usePagesStore()
|
||||
const variantsStore = useVariantsStore()
|
||||
const { filteredPages, searchQuery, campaignFilter, statusFilter, sortBy, campaignOptions } = storeToRefs(pagesStore)
|
||||
const pages = computed(() =>
|
||||
filteredPages.value.map((page) => ({
|
||||
...page,
|
||||
variantCount: variantsStore.countByPage(page.id)
|
||||
}))
|
||||
)
|
||||
const showCreatePage = ref(false)
|
||||
|
||||
const initialCampaign = computed(() => {
|
||||
const value = route.query.campaignId
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value
|
||||
}
|
||||
|
||||
return 'all'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => initialCampaign.value,
|
||||
(next) => {
|
||||
pagesStore.setCampaignFilter(next)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'live', label: '라이브' },
|
||||
{ value: 'draft', label: '초안' },
|
||||
{ value: 'paused', label: '일시정지' }
|
||||
] as const
|
||||
|
||||
const sortOptions: Array<{ value: PageSortBy; label: string }> = [
|
||||
{ value: 'updated', label: '최근 수정순' },
|
||||
{ value: 'name', label: '이름 오름차순' },
|
||||
{ value: 'leads', label: '리드' },
|
||||
{ value: 'visitors', label: '방문자' },
|
||||
{ value: 'variants', label: '변형 수' }
|
||||
]
|
||||
|
||||
const campaignItems = computed(() => [
|
||||
{ value: 'all', label: '전체 캠페인' },
|
||||
...campaignOptions.value.map((campaign) => ({
|
||||
value: campaign.id,
|
||||
label: campaign.name
|
||||
}))
|
||||
])
|
||||
|
||||
const statusLabel = (status: PageStatus) => {
|
||||
const { label } = pagesStore.statusClass(status)
|
||||
return label
|
||||
}
|
||||
|
||||
const statusClass = (status: PageStatus) => {
|
||||
const { chipClass } = pagesStore.statusClass(status)
|
||||
return chipClass
|
||||
}
|
||||
|
||||
const statusDotClass = (status: PageStatus) => {
|
||||
const { dotClass } = pagesStore.statusClass(status)
|
||||
return dotClass
|
||||
}
|
||||
|
||||
const openCreatePage = () => {
|
||||
showCreatePage.value = true
|
||||
}
|
||||
|
||||
const closeCreatePage = () => {
|
||||
showCreatePage.value = false
|
||||
}
|
||||
|
||||
const submitCreatePage = (payload: { name: string; campaignId: string; domain: string; routePath: string }) => {
|
||||
pagesStore.createPage(payload)
|
||||
showCreatePage.value = false
|
||||
}
|
||||
|
||||
const openBuilder = (pageId: string) => {
|
||||
router.push(`/admin/pages/${pageId}/builder`)
|
||||
}
|
||||
|
||||
const onDelete = (pageId: string) => {
|
||||
pagesStore.removePage(pageId)
|
||||
}
|
||||
|
||||
const campaignLabel = (campaignId: string) => pagesStore.campaignName(campaignId)
|
||||
|
||||
const pageUrl = (domain: string, routePath: string) => `https://${domain}${routePath}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-slate-900 to-indigo-950/70 px-5 py-4 shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm text-slate-400">홈 <span class="px-2">></span> 페이지</p>
|
||||
<h1 class="mt-1 text-3xl font-black tracking-tight text-slate-100">페이지 관리</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">캠페인 기준으로 페이지를 관리하고 운영하세요.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openCreatePage"
|
||||
class="rounded-xl bg-gradient-to-r from-indigo-500 to-cyan-500 px-5 py-2.5 text-base font-semibold text-white shadow-lg shadow-cyan-950/40 transition hover:from-indigo-400 hover:to-cyan-400"
|
||||
>
|
||||
+ 새 페이지 만들기
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="rounded-2xl border border-indigo-400/20 bg-gradient-to-br from-slate-900/80 via-slate-950 to-slate-900/90 p-5 shadow-[0_12px_30px_rgba(0,0,0,0.3)]">
|
||||
<div class="flex flex-nowrap items-end justify-between gap-4">
|
||||
<label class="min-w-0 space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">검색</span>
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-cyan-300">⌕</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="페이지명, 도메인, 경로로 검색"
|
||||
class="w-full min-w-[520px] rounded-xl border border-indigo-500/20 bg-slate-950/80 px-9 py-3 text-sm text-slate-100 outline-none ring-0 transition focus:border-cyan-400 focus:shadow-[0_0_0_1px_rgba(103,232,249,0.35)]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="ml-auto flex shrink-0 items-end gap-3">
|
||||
<label class="flex flex-col space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">캠페인</span>
|
||||
<select
|
||||
v-model="campaignFilter"
|
||||
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
|
||||
>
|
||||
<option
|
||||
v-for="campaign in campaignItems"
|
||||
:key="campaign.value"
|
||||
:value="campaign.value"
|
||||
>
|
||||
{{ campaign.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">상태</span>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
|
||||
>
|
||||
<option v-for="status in statusOptions" :key="status.value" :value="status.value">
|
||||
{{ status.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">정렬</span>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
|
||||
>
|
||||
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<article
|
||||
v-for="page in pages"
|
||||
:key="page.id"
|
||||
class="relative overflow-hidden rounded-2xl border border-slate-800 bg-gradient-to-b from-slate-900/95 to-slate-950/95 p-5 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-cyan-300/40"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-100">{{ page.name }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{{ campaignLabel(page.campaignId) }}</p>
|
||||
<p class="mt-1 text-sm text-slate-300">
|
||||
{{ pageUrl(page.domain, page.routePath) }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-semibold tracking-[0.08em]" :class="statusClass(page.status)" style="font-size: 9px; line-height: 1.1;">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(page.status)" />
|
||||
{{ statusLabel(page.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-4 gap-3">
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/80 p-3">
|
||||
<p class="text-xs tracking-wider text-slate-400">도메인</p>
|
||||
<p class="mt-1 text-lg font-bold text-slate-100">{{ page.domain }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/80 p-3">
|
||||
<p class="text-xs tracking-wider text-slate-400">리드</p>
|
||||
<p class="mt-1 text-lg font-bold text-slate-100">{{ page.leadCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/80 p-3">
|
||||
<p class="text-xs tracking-wider text-slate-400">방문자</p>
|
||||
<p class="mt-1 text-lg font-bold text-slate-100">{{ page.visitorCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-950/80 p-3">
|
||||
<p class="text-xs tracking-wider text-slate-400">변형</p>
|
||||
<p class="mt-1 text-lg font-bold text-slate-100">{{ page.variantCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between border-t border-slate-700/60 pt-4">
|
||||
<p class="text-sm text-slate-400">마지막 수정: {{ new Date(page.updatedAt).toLocaleString() }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-700/80 bg-slate-900 px-3 py-1.5 text-sm text-slate-200 transition hover:bg-slate-800/70"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="`/admin/pages/${page.id}/variants`"
|
||||
class="rounded-md border border-indigo-400/40 bg-indigo-950/50 px-3 py-1.5 text-sm text-indigo-100 transition hover:bg-indigo-900/50"
|
||||
>
|
||||
변형 관리
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
@click="openBuilder(page.id)"
|
||||
class="rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-3 py-1.5 text-sm font-medium text-white hover:from-indigo-500 hover:to-cyan-400"
|
||||
>
|
||||
빌더 열기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="onDelete(page.id)"
|
||||
class="rounded-md border border-red-300/30 bg-red-900/30 px-3 py-1.5 text-sm text-red-100 transition hover:bg-red-900/50"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flex min-h-[220px] items-center justify-center rounded-2xl border border-dashed border-cyan-300/30 bg-gradient-to-b from-slate-900 to-indigo-950/60 p-5">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-r from-indigo-400/20 to-cyan-400/20 text-2xl text-cyan-200">+</div>
|
||||
<p class="text-2xl font-bold text-slate-100">페이지가 없습니다</p>
|
||||
<p class="mt-2 text-slate-400">필터를 조정하거나 새 페이지를 먼저 만들어보세요.</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mx-auto mt-4 rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
|
||||
@click="openCreatePage"
|
||||
>
|
||||
바로 만들기
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<CreatePageModal
|
||||
v-if="showCreatePage"
|
||||
:campaigns="campaignOptions"
|
||||
@close="closeCreatePage"
|
||||
@submit="submitCreatePage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
259
frontend/app/pages/admin/projects.vue
Normal file
259
frontend/app/pages/admin/projects.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useProjectsStore, type ProjectStatus } from '~/stores/projects'
|
||||
import { usePagesStore } from '~/stores/pages'
|
||||
import { ref } from 'vue'
|
||||
import CreateProjectModal from '~/components/admin/CreateProjectModal.vue'
|
||||
|
||||
const projectsStore = useProjectsStore()
|
||||
const pagesStore = usePagesStore()
|
||||
const { filteredProjects, searchQuery, statusFilter, sortBy } = storeToRefs(projectsStore)
|
||||
const projects = filteredProjects
|
||||
const showCreateProject = ref(false)
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'active', label: '진행중' },
|
||||
{ value: 'archived', label: '종료' },
|
||||
{ value: 'draft', label: '임시저장' }
|
||||
] as const
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'newest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'most-leads', label: '리드 많은순' },
|
||||
{ value: 'most-variants', label: '변형 많은순' }
|
||||
] as const
|
||||
|
||||
const statusLabel = (status: ProjectStatus) => {
|
||||
if (status === 'active') {
|
||||
return '진행중'
|
||||
}
|
||||
|
||||
if (status === 'archived') {
|
||||
return '종료'
|
||||
}
|
||||
|
||||
return '임시저장'
|
||||
}
|
||||
|
||||
const statusClass = (status: ProjectStatus) => {
|
||||
if (status === 'active') {
|
||||
return 'text-emerald-200 bg-emerald-400/10'
|
||||
}
|
||||
|
||||
if (status === 'archived') {
|
||||
return 'text-slate-300 bg-slate-500/10'
|
||||
}
|
||||
|
||||
return 'text-amber-200 bg-amber-400/10'
|
||||
}
|
||||
|
||||
const statusDotClass = (status: ProjectStatus) => {
|
||||
if (status === 'active') {
|
||||
return 'bg-emerald-300'
|
||||
}
|
||||
|
||||
if (status === 'archived') {
|
||||
return 'bg-slate-400'
|
||||
}
|
||||
|
||||
return 'bg-amber-300'
|
||||
}
|
||||
|
||||
const cardAccent = (status: ProjectStatus) => {
|
||||
if (status === 'active') {
|
||||
return 'before:bg-emerald-400'
|
||||
}
|
||||
|
||||
if (status === 'archived') {
|
||||
return 'before:bg-slate-400'
|
||||
}
|
||||
|
||||
return 'before:bg-amber-300'
|
||||
}
|
||||
|
||||
const getVariantPageId = (projectId: string) => {
|
||||
return pagesStore.pages.find((page) => page.campaignId === projectId)?.id ?? ''
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
showCreateProject.value = true
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateProject.value = false
|
||||
}
|
||||
|
||||
const submitCreateProject = (payload: { name: string; campaignName: string }) => {
|
||||
projectsStore.createProject(payload)
|
||||
showCreateProject.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<header class="rounded-xl border border-slate-700/80 bg-gradient-to-r from-slate-900 via-slate-900 to-indigo-950/70 px-5 py-4 shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm text-slate-400">홈 <span class="px-2">></span> 프로젝트</p>
|
||||
<h1 class="mt-1 text-3xl font-black tracking-tight text-slate-100">프로젝트 그룹</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openCreateModal"
|
||||
class="rounded-xl bg-gradient-to-r from-indigo-500 to-cyan-500 px-5 py-2.5 text-base font-semibold text-white shadow-lg shadow-cyan-950/40 transition hover:from-indigo-400 hover:to-cyan-400"
|
||||
>
|
||||
+ 새 프로젝트 만들기
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="rounded-2xl border border-indigo-400/20 bg-gradient-to-br from-slate-900/80 via-slate-950 to-slate-900/90 p-5 shadow-[0_12px_30px_rgba(0,0,0,0.3)]">
|
||||
<div class="flex flex-nowrap items-end justify-between gap-4">
|
||||
<label class="min-w-0 space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">검색</span>
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-cyan-300">⌕</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="이름 또는 태그로 검색"
|
||||
v-model="searchQuery"
|
||||
class="w-full min-w-[520px] rounded-xl border border-indigo-500/20 bg-slate-950/80 px-9 py-3 text-sm text-slate-100 outline-none ring-0 transition focus:border-cyan-400 focus:shadow-[0_0_0_1px_rgba(103,232,249,0.35)]"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="ml-auto flex shrink-0 items-end gap-3">
|
||||
<label class="flex flex-col space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">상태</span>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
|
||||
>
|
||||
<option
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
{{ status.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col space-y-2">
|
||||
<span class="px-1 text-xs font-semibold tracking-[0.12em] text-cyan-200">정렬</span>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="shrink-0 rounded-xl border border-indigo-500/25 bg-slate-950/80 px-5 py-3 text-sm font-medium text-slate-200 outline-none transition hover:border-cyan-400"
|
||||
>
|
||||
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-xl border border-cyan-300/30 bg-gradient-to-r from-cyan-500/20 via-indigo-500/20 to-purple-500/20 px-5 py-3 text-sm font-semibold whitespace-nowrap text-cyan-50 transition hover:bg-slate-800 hover:border-cyan-200/50"
|
||||
>
|
||||
필터
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="relative overflow-hidden rounded-2xl border border-slate-800 bg-gradient-to-b from-slate-900/95 to-slate-950/95 p-5 shadow-lg shadow-black/20 transition hover:-translate-y-0.5 hover:border-cyan-300/40 before:absolute before:bottom-0 before:left-0 before:top-0 before:w-[3px] before:rounded-l-2xl"
|
||||
:class="cardAccent(project.status)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-100">{{ project.title }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{{ project.subtitle }}</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-semibold tracking-[0.08em]" :class="statusClass(project.status)" style="font-size: 10px; line-height: 1.1;">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(project.status)" />
|
||||
{{ statusLabel(project.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 gap-3" style="display:grid;grid-template-columns:1fr 1fr;">
|
||||
<div class="min-w-0 rounded-xl border border-slate-800 bg-slate-950/80 p-3">
|
||||
<p class="text-xs tracking-wider text-slate-400">총 리드</p>
|
||||
<p class="mt-1 text-3xl font-black text-slate-100">{{ project.leads }}</p>
|
||||
</div>
|
||||
<div class="min-w-0 rounded-xl border border-slate-800 bg-slate-950/80 p-3">
|
||||
<p class="text-xs tracking-wider text-slate-400">{{ project.metricLabel }}</p>
|
||||
<p class="mt-1 text-3xl font-black text-slate-100">{{ project.metricValue }}</p>
|
||||
<p class="mt-1 text-sm" :class="project.trendUp ? 'text-emerald-300' : 'text-slate-400'">{{ project.trend }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-slate-700/60 pt-4"
|
||||
style="margin-top: 28px;"
|
||||
>
|
||||
<p class="text-sm text-slate-400">{{ project.variants }}개 변형 테스트</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/admin/pages?campaignId=${project.id}`"
|
||||
class="rounded-md border border-slate-700/80 bg-slate-900 px-3 py-1.5 text-sm text-slate-200 transition hover:bg-slate-800/70"
|
||||
>
|
||||
페이지 관리
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="getVariantPageId(project.id)"
|
||||
:to="`/admin/pages/${getVariantPageId(project.id)}/variants`"
|
||||
class="rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-3 py-1.5 text-sm font-medium text-white hover:from-indigo-500 hover:to-cyan-400"
|
||||
>
|
||||
변형 관리
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="rounded-md bg-slate-700 px-3 py-1.5 text-sm text-slate-300"
|
||||
disabled
|
||||
>
|
||||
변형 없음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
class="flex min-h-[320px] items-center justify-center rounded-2xl border border-dashed border-cyan-300/30 bg-gradient-to-b from-slate-900 to-indigo-950/60 p-5"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-r from-indigo-400/20 to-cyan-400/20 text-2xl text-cyan-200">+</div>
|
||||
<p class="text-2xl font-bold text-slate-100">새 그룹 만들기</p>
|
||||
<p class="mt-2 text-slate-400">새 A/B 테스트나 캠페인을 시작하세요</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mx-auto mt-4 rounded-md bg-gradient-to-r from-indigo-600 to-cyan-500 px-4 py-2 text-sm font-semibold text-white"
|
||||
@click="openCreateModal"
|
||||
>
|
||||
바로 생성하기
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<footer class="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 pt-4">
|
||||
<p class="text-slate-400">총 12개 중 1~4개 표시</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900">이전</button>
|
||||
<button class="rounded-lg border border-indigo-300/30 bg-indigo-900/50 px-3 py-1.5 text-cyan-100">1</button>
|
||||
<button class="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900">2</button>
|
||||
<button class="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900">3</button>
|
||||
<button class="rounded-lg border border-slate-700 px-3 py-1.5 text-slate-300 hover:bg-slate-900">다음</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<CreateProjectModal
|
||||
v-if="showCreateProject"
|
||||
@close="closeCreateModal"
|
||||
@submit="submitCreateProject"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user