Merge branch 'search'

This commit is contained in:
jhynsoo 2024-05-20 16:49:01 +09:00
commit 22f5eced3c
15 changed files with 561 additions and 18 deletions

View File

@ -5,9 +5,16 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title> <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> </head>
<body> <body>
<div id="app"></div> <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> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@ -1,15 +1,10 @@
import { localAxios } from "@/utils/http-commons"; import { localAxios } from '@/utils/http-commons';
const local = localAxios; const local = localAxios;
function getArticles(contentTypeId, sidoCode, gugunCode, title, success, fail) { function searchAttarctions(queryString, success, fail) {
//list //list
local local.get(`/attraction/search?contentTypeId=12&${queryString}`).then(success).catch(fail);
.get(
`/attraction/search?contentTypeId=${contentTypeId}&sidoCode=${sidoCode}&gugunCode=${gugunCode}&title=${title}`
)
.then(success)
.catch(fail);
} }
export { getArticles }; export { searchAttarctions };

View File

@ -19,6 +19,8 @@
--vt-c-text-light-2: rgba(60, 60, 60, 0.66); --vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white); --vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64); --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 */ /* semantic color variables for this project */
@ -118,6 +120,10 @@ textarea {
font-size: 16px; font-size: 16px;
} }
*::placeholder {
color: var(--color-text-secondary);
}
p { p {
white-space: pre; white-space: pre;
} }

View File

@ -1,6 +1,7 @@
@import './base.css'; @import './base.css';
#app { #app {
height: 100vh;
margin: 0 auto; margin: 0 auto;
font-weight: normal; font-weight: normal;
} }

View File

@ -22,7 +22,7 @@ const logout = () => {
<RouterLink :to="{ name: 'home' }">EnjoyTrip</RouterLink> <RouterLink :to="{ name: 'home' }">EnjoyTrip</RouterLink>
</div> </div>
<div class="menuWrapper"> <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> <div class="item"><RouterLink :to="{ name: 'board' }">게시판</RouterLink></div>
<template v-if="isLogin"> <template v-if="isLogin">
@ -53,10 +53,10 @@ header {
left: 0; left: 0;
width: 100%; width: 100%;
height: 80px; height: 80px;
background-color: var(--color-background-soft); background-color: var(--color-background);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
box-shadow: 0px 4px 4px #0003;
box-sizing: border-box; box-sizing: border-box;
z-index: 9999;
} }
header a { header a {

View File

@ -8,11 +8,15 @@ const { type, primary } = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disabled: {
type: Boolean,
default: false,
},
}); });
</script> </script>
<template> <template>
<button :type="type" :class="primary ? 'primary' : ''"> <button :disabled="disabled" :type="type" :class="primary ? 'primary' : ''">
<slot></slot> <slot></slot>
</button> </button>
</template> </template>
@ -31,6 +35,10 @@ button {
transform 0.25s; transform 0.25s;
} }
button:disabled {
cursor: initial;
}
button.primary { button.primary {
background-color: var(--color-primary); background-color: var(--color-primary);
color: var(--color-background); color: var(--color-background);
@ -46,10 +54,6 @@ button.primary {
} }
} }
button:active {
transform: scale(0.95);
}
button.primary:active { button.primary:active {
background-color: var(--color-primary-soft); background-color: var(--color-primary-soft);
} }

View 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>

View 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>

View File

@ -8,11 +8,15 @@ const { type, primary } = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disabled: {
type: Boolean,
default: false,
},
}); });
</script> </script>
<template> <template>
<button :type="type" :class="primary ? 'primary' : ''"> <button :disabled="disabled" :type="type" :class="primary ? 'primary' : ''">
<slot></slot> <slot></slot>
</button> </button>
</template> </template>

View 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>

View 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>

View 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>

View File

@ -79,6 +79,17 @@ const router = createRouter({
}, },
], ],
}, },
{
path: '/search',
component: () => import('@/views/SearchView.vue'),
children: [
{
path: '',
name: 'search',
component: () => import('@/components/common/KakaoMap.vue'),
},
],
},
], ],
}); });

View 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
View 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>