Merge branch 'search'
This commit is contained in:
commit
22f5eced3c
@ -5,9 +5,16 @@
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=042e55278939bfa9163a67a6fe4021c0&libraries=services,clusterer,drawing"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,15 +1,10 @@
|
||||
import { localAxios } from "@/utils/http-commons";
|
||||
import { localAxios } from '@/utils/http-commons';
|
||||
|
||||
const local = localAxios;
|
||||
|
||||
function getArticles(contentTypeId, sidoCode, gugunCode, title, success, fail) {
|
||||
function searchAttarctions(queryString, success, fail) {
|
||||
//list
|
||||
local
|
||||
.get(
|
||||
`/attraction/search?contentTypeId=${contentTypeId}&sidoCode=${sidoCode}&gugunCode=${gugunCode}&title=${title}`
|
||||
)
|
||||
.then(success)
|
||||
.catch(fail);
|
||||
local.get(`/attraction/search?contentTypeId=12&${queryString}`).then(success).catch(fail);
|
||||
}
|
||||
|
||||
export { getArticles };
|
||||
export { searchAttarctions };
|
||||
|
@ -19,6 +19,8 @@
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
@ -118,6 +120,10 @@ textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
*::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
margin: 0 auto;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ const logout = () => {
|
||||
<RouterLink :to="{ name: 'home' }">EnjoyTrip</RouterLink>
|
||||
</div>
|
||||
<div class="menuWrapper">
|
||||
<div class="item"><RouterLink :to="{ name: 'home' }">검색</RouterLink></div>
|
||||
<div class="item"><RouterLink :to="{ name: 'search' }">검색</RouterLink></div>
|
||||
<div class="item"><RouterLink :to="{ name: 'board' }">게시판</RouterLink></div>
|
||||
|
||||
<template v-if="isLogin">
|
||||
@ -53,10 +53,10 @@ header {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background-color: var(--color-background-soft);
|
||||
background-color: var(--color-background);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: 0px 4px 4px #0003;
|
||||
box-sizing: border-box;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
header a {
|
||||
|
@ -8,11 +8,15 @@ const { type, primary } = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :class="primary ? 'primary' : ''">
|
||||
<button :disabled="disabled" :type="type" :class="primary ? 'primary' : ''">
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
@ -31,6 +35,10 @@ button {
|
||||
transform 0.25s;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: initial;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-background);
|
||||
@ -46,10 +54,6 @@ button.primary {
|
||||
}
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
button.primary:active {
|
||||
background-color: var(--color-primary-soft);
|
||||
}
|
||||
|
27
src/components/common/KakaoMap.vue
Normal file
27
src/components/common/KakaoMap.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<!-- eslint-disable no-undef -->
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const mapDiv = ref(null);
|
||||
const map = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
const options = {
|
||||
center: new kakao.maps.LatLng(33.450701, 126.570667),
|
||||
level: 3,
|
||||
};
|
||||
|
||||
map.value = new kakao.maps.Map(mapDiv.value, options);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="map" ref="mapDiv" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#map {
|
||||
width: calc(100% - 400px);
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
166
src/components/common/Select.vue
Normal file
166
src/components/common/Select.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import FilledButton from './FilledButton.vue';
|
||||
|
||||
const { placeholder, options, optionName, disabled } = defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '----',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
optionName: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
modelValue: {
|
||||
sidoCode: Number,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const updateModel = defineEmits(['update:modelValue']);
|
||||
|
||||
const body = document.querySelector('body');
|
||||
const buttonRef = ref(null);
|
||||
const isOpen = ref(false);
|
||||
const menuStyle = ref('');
|
||||
const selected = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
const rect = buttonRef.value.$el.getBoundingClientRect();
|
||||
|
||||
menuStyle.value = `transform: translate(${rect.left}px, ${rect.bottom + 4}px)`;
|
||||
});
|
||||
|
||||
const selectedName = computed(() => selected.value?.[optionName] ?? selected.value);
|
||||
|
||||
function handleOpen() {
|
||||
isOpen.value = true;
|
||||
body.style.pointerEvents = 'none';
|
||||
body.style.overflow = 'hidden';
|
||||
document.addEventListener('pointerdown', handleClose, { once: true });
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
isOpen.value = false;
|
||||
body.removeAttribute('style');
|
||||
}
|
||||
|
||||
function handleClose({ target }) {
|
||||
if (target && target?.tagName !== 'HTML') {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleSelect(option) {
|
||||
selected.value = option;
|
||||
updateModel('update:modelValue', option);
|
||||
closeMenu();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FilledButton
|
||||
:disabled="disabled"
|
||||
:class="'select' + (isOpen ? ' active' : '')"
|
||||
ref="buttonRef"
|
||||
@mousedown="handleOpen"
|
||||
>
|
||||
<span>{{ selectedName ?? placeholder }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden=""
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</FilledButton>
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<div class="menu-wrapper" :style="menuStyle">
|
||||
<ul class="menu">
|
||||
<li
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
@click.stop="handleSelect(option)"
|
||||
:class="'menu-item' + (selected === option ? ' selected' : '')"
|
||||
>
|
||||
{{ option[optionName] ?? option }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes show {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -8px, 0) scale3d(0.95, 0.95, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.select svg {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
min-width: max-content;
|
||||
pointer-events: auto;
|
||||
will-change: transform;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.menu {
|
||||
background-color: var(--color-background);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
animation: show 0.25s;
|
||||
padding: 4px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.25s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
.menu-item.selected {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
@ -8,11 +8,15 @@ const { type, primary } = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :class="primary ? 'primary' : ''">
|
||||
<button :disabled="disabled" :type="type" :class="primary ? 'primary' : ''">
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
90
src/components/search/AttractionItem.vue
Normal file
90
src/components/search/AttractionItem.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
const { place } = defineProps({
|
||||
place: {
|
||||
contentId: Number,
|
||||
first_image: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
addr1: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
addr2: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
mapx: {
|
||||
type: Number,
|
||||
default: 33,
|
||||
},
|
||||
mapy: {
|
||||
type: Number,
|
||||
default: 126,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
console.log(place.contentId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="handleClick" class="attraction">
|
||||
<img :src="place.first_image" alt="image" />
|
||||
<div class="info">
|
||||
<h3 class="title">{{ place.title }}</h3>
|
||||
<address>{{ place.addr1 }}</address>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attraction {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
/* box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); */
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
address {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
img[src='/'],
|
||||
img[src=''] {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
152
src/components/search/SearchBar.vue
Normal file
152
src/components/search/SearchBar.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import TextButton from '../common/TextButton.vue';
|
||||
import axios from 'axios';
|
||||
import Select from '../common/Select.vue';
|
||||
import { useAttractionStore } from '@/stores/attractionStore';
|
||||
|
||||
const contentTypeList = [
|
||||
{ value: 12, name: '관광지' },
|
||||
{ value: 14, name: '문화시설' },
|
||||
{ value: 15, name: '축제공연행사' },
|
||||
{ value: 25, name: '여행코스' },
|
||||
{ value: 28, name: '레포츠' },
|
||||
{ value: 32, name: '숙박' },
|
||||
{ value: 38, name: '쇼핑' },
|
||||
{ value: 39, name: '음식점' },
|
||||
];
|
||||
const { search } = useAttractionStore();
|
||||
const sidoList = ref([]);
|
||||
const gugunList = ref([]);
|
||||
const sido = ref({ sidoCode: 0 });
|
||||
const gugun = ref({ gugunCode: 0 });
|
||||
const contentType = ref(contentTypeList[0]);
|
||||
const keyword = ref('');
|
||||
const searchQuery = computed(() => {
|
||||
const { sidoCode } = sido.value;
|
||||
const { gugunCode } = gugun.value;
|
||||
const areaQuery = `sidoCode=${sidoCode}` + (sidoCode ? `&gugunCode=${gugunCode}` : '');
|
||||
const keywordQuery = `title=${keyword.value}`;
|
||||
|
||||
return [areaQuery, keywordQuery].join('&');
|
||||
});
|
||||
|
||||
watch(sido, ({ sidoCode }) => {
|
||||
console.log(`sido : ${sidoCode}`);
|
||||
axios.get(`//localhost:8000/area/gugun?sidoCode=${sidoCode}`).then(({ data }) => {
|
||||
gugunList.value = data;
|
||||
console.log(gugunList.value.length);
|
||||
});
|
||||
// TODO: API call
|
||||
});
|
||||
|
||||
if (sidoList.value.length === 0) {
|
||||
axios.get('//localhost:8000/area/sido').then(({ data }) => {
|
||||
sidoList.value = data;
|
||||
});
|
||||
// TODO: API call
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
search(searchQuery.value);
|
||||
// TODO: API call;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-wrapper">
|
||||
<nav class="select-wrapper">
|
||||
<!-- <Dropdown v-model="sido" :options="sidoList" option-label="sidoName" placeholder="시/도">
|
||||
</Dropdown> -->
|
||||
<Select placeholder="시/도" v-model="sido" optionName="sidoName" :options="sidoList" />
|
||||
<Select
|
||||
placeholder="시/군/구"
|
||||
v-model="gugun"
|
||||
:options="gugunList"
|
||||
:disabled="gugunList.length === 0"
|
||||
optionName="gugunName"
|
||||
/>
|
||||
<Select
|
||||
placeholder="종류"
|
||||
v-model="contentType"
|
||||
:options="contentTypeList"
|
||||
optionName="name"
|
||||
/>
|
||||
</nav>
|
||||
<form @submit.prevent="handleSubmit" class="search-box">
|
||||
<input type="text" name="keyword" v-model.lazy="keyword" placeholder="검색어를 입력하세요" />
|
||||
<TextButton @click="handleSubmit">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
class="bi bi-search"
|
||||
viewBox="0 0 16 16"
|
||||
part="svg"
|
||||
>
|
||||
<path
|
||||
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</TextButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.select-wrapper > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* .p-dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
.p-dropdown-items {
|
||||
padding: 8px;
|
||||
}
|
||||
.p-dropdown-item {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
.p-dropdown-filter-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
} */
|
||||
</style>
|
40
src/components/search/SideBar.vue
Normal file
40
src/components/search/SideBar.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { useAttractionStore } from '@/stores/attractionStore';
|
||||
import AttractionItem from './AttractionItem.vue';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const attractionStore = useAttractionStore();
|
||||
const { attractionList } = storeToRefs(attractionStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<SearchBar />
|
||||
<ul class="resultList">
|
||||
<li v-for="attraction in attractionList" :key="attraction.title">
|
||||
<AttractionItem :place="attraction" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 400px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.resultList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 0 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
@ -79,6 +79,17 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
component: () => import('@/views/SearchView.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'search',
|
||||
component: () => import('@/components/common/KakaoMap.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
16
src/stores/attractionStore.js
Normal file
16
src/stores/attractionStore.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { searchAttarctions } from '@/api/attraction';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useAttractionStore = defineStore('attraction', () => {
|
||||
const attractionList = ref([]);
|
||||
|
||||
function search(queryString) {
|
||||
console.log(searchAttarctions);
|
||||
searchAttarctions(queryString, ({ data }) => {
|
||||
attractionList.value = data;
|
||||
});
|
||||
}
|
||||
|
||||
return { attractionList, search };
|
||||
});
|
24
src/views/SearchView.vue
Normal file
24
src/views/SearchView.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import AppHeader from '@/components/AppHeader.vue';
|
||||
import KakaoMap from '@/components/common/KakaoMap.vue';
|
||||
import SideBar from '@/components/search/SideBar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppHeader />
|
||||
<main>
|
||||
<SideBar />
|
||||
<KakaoMap />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user