단어 삭제
2023년 3월 13일
intro
이번에는 localStorage에 저장된 단어를 삭제하는 기능을 구현하려고 한다.
삭제 기능은 localWords
에서 단어를 지우면 그 단어를 localTrashCan
에서 저장했다가 2주 뒤 완전히 삭제하는 방식
완전히 삭제되기 전에 복원, 미리 삭제가 가능하다. 또한 WordList
에서 wordArr
와 localTrashCan
의 단어를 토글 버튼을 이용해 전환하면서 보여준다.
구현할 기능 미리 보기
code
단어 삭제 기능을 구현하기 전에 단어를 삭제하는 버튼이 있을 모달과 localTrashCan
에 있는 단어를 복원, 미리 삭제하는 버튼이 있는 모달을 구현
stores/DetailWord.js
stores/DetailWord.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useModalStore } from './modal';
import { useMainStore } from './Main';
export const useDetailWordStore = defineStore('detailWord', () => {
const mainStore = useMainStore();
const modalStore = useModalStore();
const detailWord = ref(null);
const trashCanDetailWord = ref(null);
function detailWordOpen(targetWord) {
detailWord.value = mainStore.wordDetail(targetWord);
modalStore.detailModal = true;
}
function trashCanDetailWordOpen(targetWord) {
trashCanDetailWord.value = mainStore.trashCanWordDetail(targetWord);
modalStore.trashCanWordModal = true;
}
function trashCanDetailWordModalCreate() {
return trashCanDetailWord.value;
}
function detailWordModalCreate() {
return detailWord.value;
}
return {
detailWordOpen,
detailWordModalCreate,
trashCanDetailWordOpen,
trashCanDetailWordModalCreate
};
});
- 저장되어 있는 단어를 클릭했을 때 나올 모달과 관련된 스토어
function detailWordOpen()
,function trashCanWordOpen()
: 단어를 클릭했을 때 호출되는 함수, 각각 스토어의detailWord, trashCanDetailWord
에 클릭한 단어에 관련된 데이터를 저장function detailWordModalCreate
,function trashCanDetailWordModalCreate()
: 각 상황에 맞는 모달이 화면에 렌더링 될 때 호출되는 함수, 관련된 데이터를 컴포넌트에 반환- 실행 방식: 단어 클릭 -> DetailWord.js의 WordOpen() -> 모달 컴포넌트 렌더링 시작(DetailWord.js의 ModalCreate() 호출) -> 받은 데이터가 모달 slot에 들어가고 화면에 렌더링 됨
components/ModalWordDetail.vue
components/ModalWordDetail.vue
<script setup>
import { useMainStore } from '../stores/Main';
import { useModalStore } from '../stores/modal';
import { useDetailWordStore } from '../stores/DetailWord';
import { ref } from 'vue';
import Modal from './TheModal.vue';
const mainStore = useMainStore();
const modalStore = useModalStore();
const detailWordStore = useDetailWordStore();
const detailWord = ref(null);
detailWord.value = {...detailWordStore.detailWordModalCreate()};
</script>
<template>
<Modal>
<template #word>
{{ detailWord.word }}
</template>
<template #means>
<li v-for="mean in detailWord.means" :key="mean" class="modal_means">
{{ mean }}
</li>
</template>
<template #footer>
<div class="flex justify-end modal_timetext">{{ detailWord.time }}</div>
<div class="flex gap-x-4">
<button @click="modalStore.modalExit" class="modal_btn bg-neutral-400 hover:bg-neutral-500">cancel</button>
<button @click="mainStore.wordDelete(detailWord.word)" class="modal_btn bg-rose-400 hover:bg-rose-600">delete</button>
</div>
</template>
</Modal>
</template>
- 메인 스토어에 있는
wordArr
의 단어를 클릭했을 때 화면에 나타나는 모달 - 내용: 클릭한 단어, 단어의 뜻, 저장한 날짜, 취소, 삭제 버튼
components/ModalTrashCanWordDetail.vue
components/ModalTrashCanWordDetail.vue
<script setup>
import TheModal from './TheModal.vue';
import { useMainStore } from '../stores/Main';
import { ref } from 'vue';
import { useDetailWordStore } from '../stores/DetailWord';
const mainStore = useMainStore();
const detailWordStore = useDetailWordStore();
const detailTrashWord = ref(null);
detailTrashWord.value = {...detailWordStore.trashCanDetailWordModalCreate()};
</script>
<template>
<TheModal>
<template #word>
{{ detailTrashWord.word }}
</template>
<template #means>
<li v-for="mean in detailTrashWord.means" :key="mean" class="modal_means">
{{ mean }}
</li>
</template>
<template #footer>
<div class="modal_timetext">
<div>삭제한 날짜: {{ detailTrashWord.time }}</div>
<div>삭제될 날짜: {{ detailTrashWord.afterTime }}</div>
</div>
<div class="flex gap-x-4 justify-end">
<button @click="mainStore.trashCanWordRestore(detailTrashWord.word)" class="modal_btn bg-emerald-400 hover:bg-emerald-500">restore</button>
<button @click="mainStore.trashCanWordKill(detailTrashWord.word)" class="modal_btn bg-rose-400 hover:bg-rose-600">delete</button>
</div>
</template>
</TheModal>
</template>
- 메인 스토어에 있는
localTrashCan
의 단어를 클릭했을 때 화면에 나타나는 모달 - 내용: 클릭한 단어, 단어의 뜻, 삭제한 날짜, 삭제될 날짜, 복원, 미리 삭제 버튼
components/WordTrashCan.vue
components/WordTrashCan.vue
<script setup>
const props = defineProps({
word: String,
means: Array,
time: String,
afterTime: String,
})
const emits = defineEmits(['trashCanWordDetail']);
function trashCanWordDetail() {
emits('trashCanWordDetail', props.word);
}
</script>
<template>
<div>
<!-- word -->
<span class="card_word" @click="trashCanWordDetail">
{{ props.word }}
</span>
<!-- 삭제한 날 -->
<div class="card_content">
삭제한 날짜 : {{ props.time }}
</div>
<!-- 휴지통에서 지워질 날 -->
<div class="card_content">
완전히 삭제될 날짜 : {{ props.afterTime }}
</div>
</div>
</template>
WordList.vue
에서 화면이 휴지통 상태일 때localTrashCan.vue
의 단어들을 보여줄 컴포넌트- 단어를 클릭하면 emits을 통해
WordList.vue
에 단어를 보내고WordList.vue
에서 detailWord의trashCanWordOpen()
을 호출
components/WordCard.vue
components/WordCard.vue
<script setup>
import { Icon } from '@iconify/vue';
import { useMainStore } from '../stores/Main';
import { ref } from 'vue';
const props = defineProps({
word: String,
means: String,
check: Boolean,
index: Number,
})
const emits = defineEmits(['wordDetail']);
const mainStore = useMainStore();
const word = ref(props.word);
const means = ref(props.means);
const check = ref(props.check);
const index = ref(props.index);
function transmit() {
emits('wordDetail', word.value);
}
function checkBtnClick() {
check.value = !check.value;
mainStore.wordCheck(word.value, check.value, index.value);
}
</script>
<template>
<div class="flex flex-col">
<div class="flex gap-x-1">
<!-- check Icon -->
<button>
<Icon v-if="check === false" class="flex items-center" @click="checkBtnClick" icon="carbon:checkbox" width="37" height="37"></Icon>
<Icon v-else class="flex items-center" @click="checkBtnClick" icon="carbon:checkbox-checked" width="37" height="37"></Icon>
</button>
<!-- word -->
<span class="card_word" :class="{ wordcheck_active: check }" @click="transmit" >{{ word }}</span>
</div>
<!-- mean -->
<div :class="{ wordcheck_active: check }">
<li v-for="mean in means.split(',')" :key="mean" class="card_content">
{{ mean }}
</li>
</div>
</div>
</template>
<style scoped>
.wordcheck_active {
@apply opacity-60 line-through decoration-2;
}
</style>
WordTrashCan.vue
처럼 emits 추가- 단어를 클릭하면 emits를 통해
WordList.vue
에 단어를 보내고WordList.vue
에서 detailWord의detailWordOpen()
호출
stores/Modal.js
stores/Modal.js
import { defineStore } from 'pinia';
export const useModalStore = defineStore('modal', {
state: () => ({
inputModal: false,
inputNotExistModal: false,
inputSimilarModal: false,
detailModal: false,
trashCanWordModal: false,
}),
actions: {
modalExit() {
this.inputModal = false;
this.inputNotExistModal = false;
this.inputSimilarModal = false;
this.detailModal = false;
this.trashCanWordModal = false;
}
}
});
- 추가된 모달들의 state 추가
components/WordList.vue
components/WordList.vue
<script setup>
import { useMainStore } from '../stores/Main';
import { useModalStore } from '../stores/modal';
import { useDetailWordStore } from '../stores/DetailWord';
import WordCard from './WordCard.vue';
import WordTrashCan from './WordTrashCan.vue';
import ModalTrashCanWordDetail from './ModalTrashCanWordDetail.vue';
import ModalWordDetail from './ModalWordDetail.vue';
const mainStore = useMainStore();
const modalStore = useModalStore();
const detailStore = useDetailWordStore();
</script>
<template>
<div class="md:ml-10 lg:ml-32 xl:ml-36 2xl:ml-36">
<template v-if="mainStore.screenTransition === 0">
<div class="grid md:grid-cols-2 md:gap-x-36">
<div v-for="(word, index) in mainStore.wordArr" :key="word" class="mb-[32px]">
<WordCard :word="word.word" :means="word.means" :check="word.check" :index="index" @wordDetail="(targetWord) => detailStore.detailWordOpen(targetWord)"></WordCard>
</div>
</div>
</template>
<template v-if="mainStore.screenTransition === 1">
<div>
<div v-for="word in mainStore.localTrashCan.values()" :key="word" class="mb-[32px]">
<WordTrashCan :word="word.word" :means="word.means.split(',')" :time="word.time" :afterTime="word.afterTime" @trashCanWordDetail="(targetWord) => detailStore.trashCanDetailWordOpen(targetWord)"></WordTrashCan>
</div>
</div>
</template>
<Teleport to="body">
<Transition name="slide-fade">
<ModalWordDetail v-if="modalStore.detailModal" />
</Transition>
<Transition name="slide-fade">
<ModalTrashCanWordDetail v-if="modalStore.trashCanWordModal" />
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.slide-fade-enter-active {
transition: all 0.3s ease;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translatex(-20px);
opacity: 0;
}
</style>
@wordDetail
: 자식 컴포넌트인WordCard.vue
에서 단어를 클릭하면 'wordDetail' 키워드와 단어가 emits되어 DetailWord 스토어의detailWordOpen()
을 호출@trashCanWordDetail
: 자식 컴포넌트인WordTrashCan.vue
에서 단어를 클릭하면 'trashCanWordDetail' 키워드와 단어가 emits 되어 DetailWord 스토어의trashCanDetailWordOpen()
호출
stores/Main.js
stores/Main.js
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core';
import { useModalStore } from './Modal';
export const useMainStore = defineStore('main', () => {
const modalStore = useModalStore();
// 휴지통, 메인화면 전환 0: 메인, 1: 휴지통
const screenTransition = ref(0);
const localWords = useStorage('mapWords', new Map(), localStorage);
const localTrashCan = useStorage('trashCan', new Map(), localStorage);
const wordArr = ref([]);
function getTimeAndTimestamp() {
const date = new Date();
const nowTime = `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}`;
const timestamp = date.getTime();
return { date, nowTime, timestamp };
}
function getTimeAndTimestampAfterDay(day) {
const { date, nowTime, timestamp} = getTimeAndTimestamp();
const afterDate = new Date(date.setDate(date.getDate() + day));
const afterTimestamp = afterDate.getTime();
const afterTime = `${afterDate.getFullYear()}-${afterDate.getMonth()+1}-${afterDate.getDate()} ${afterDate.getHours()}:${afterDate.getMinutes()}`;
return { nowTime, timestamp, afterTime, afterTimestamp };
}
function wordArrUpdate() {
const localWordsToArr = Array.from(localWords.value, (item) => {
return { ...item[1] };
} );
wordArr.value = [...localWordsToArr].reverse();
}
// init: localStorage에 있는 단어들 set
function setWordDic() {
wordArrUpdate();
// 휴지통 갱신
const { timestamp } = getTimeAndTimestamp();
for (const [key, value] of localTrashCan.value) {
if (value.afterTimestamp <= timestamp) {
localTrashCan.value.delete(key);
}
}
}
// word add
function wordAdd(word, means) {
const { nowTime, timestamp } = getTimeAndTimestamp();
const item = { word, means: means.toString(), timestamp, time: nowTime, check: false };
// 이미 단어가 존재하면 지웠다가 저장
if (localWords.value.has(word)) {
localWords.value.delete(word);
}
localWords.value.set(word, item);
wordArrUpdate();
modalStore.modalExit();
}
// word check
function wordCheck(targetWord, ch, index) {
wordArr.value[index].check = ch;
const checkedWord = localWords.value.get(targetWord);
checkedWord.check = ch
localWords.value.set(targetWord, checkedWord);
}
// word delete
function wordDelete(targetWord) {
const { nowTime, timestamp, afterTime, afterTimestamp} = getTimeAndTimestampAfterDay(14);
const deleteWord = { word: targetWord, means: localWords.value.get(targetWord).means, time: nowTime, timestamp, afterTimestamp, afterTime };
localWords.value.delete(targetWord);
wordArrUpdate();
// 휴지통에 이미 존재하는 단어면 지운뒤 저장
if (localTrashCan.value.has(targetWord)) {
localTrashCan.value.delete(targetWord);
}
localTrashCan.value.set(targetWord, deleteWord);
modalStore.modalExit();
}
// 화면간 단어 <-> 휴지통 교체
function contentChange() {
screenTransition.value = screenTransition.value === 0 ? 1 : 0;
}
// word detail
function wordDetail(targetWord) {
const { word, means, time } = localWords.value.get(targetWord);
const detailWord = { word, means: means.split(','), time };
return detailWord;
}
// trashCan word detail
function trashCanWordDetail(targetWord) {
const { word, means, afterTime, time } = localTrashCan.value.get(targetWord);
const detailTrashCanWord = { word, means: means.split(','), time, afterTime };
return detailTrashCanWord;
}
// trashCan word kill
function trashCanWordKill(targetWord) {
localTrashCan.value.delete(targetWord);
modalStore.modalExit();
}
// trashCan word restore
function trashCanWordRestore(targetWord) {
const { word, means } = localTrashCan.value.get(targetWord);
wordAdd(word, means);
trashCanWordKill(word);
}
return {
wordArr,
localTrashCan,
screenTransition,
setWordDic,
wordAdd,
wordCheck,
wordDelete,
contentChange,
wordDetail,
trashCanWordDetail,
trashCanWordKill,
trashCanWordRestore,
};
})
- ModalWordDetail, ModalTrashCanWordDetail 컴포넌트 추가로 모달 상태 값 추가
- 단어 삭제와 휴지통 단어 삭제, 복원 기능 구현에 함수, 변수 추가
localTrashCan
:localWords
에서 삭제된 단어들이 저장되는 곳. 휴지통, 최근 삭제된 단어일수록 아래에 위치screenTransition
: 이 값을 이용해WordList.vue
의 내용이wordArr
인지localTrashCan
인지 결정function getTimeAndTimestampAfterDay()
: 현재 날짜, 타임스탬프, 14일 뒤 날짜와 타임스탬프 리턴 완전히 삭제될 날짜를 얻기 위해 호출function setWordDic()
: 휴지통 갱신 기능 추가, 현재 날짜보다localTrashCan
에 있는 단어의 삭제될 날짜(afterTimestamp)가 작거나 같으면 삭제function wordDelete(targetWord)
:localWords
의 타켓 단어를 삭제 후wordArrUpdate()
호출 =>localTrashCan
에 저장,localTrashCan
에 이미 있는 단어면 순서를 위해 지운 뒤 저장function contentChange()
:wordList
의 내용물을 토글 (wordArr
<=>localTrashCan
)function WordDetail, trashCanWordDetail
: 모달에 필요한 데이터들을 local에서 가지고 와 리턴function trashCanWordRestore, trashCanWordKill
:localTrashCan
의 단어를 복원, 완전히 삭제하는 함수, 복원은wordAdd()
를 호출 후 완전히 삭제
components/TheHeader.vue
components/TheHeader.vue
<script setup>
import { Icon } from '@iconify/vue';
import { useMainStore } from '@/stores/Main';
import { useModalStore } from '@/stores/modal';
import WordInput from './WordInput.vue';
import ModalCaseNomal from './ModalCaseNomal.vue';
import ModalCaseNotExist from './ModalCaseNotExist.vue';
import ModalCaseSimilar from './ModalCaseSimilar.vue';
const mainStore = useMainStore();
const modalStore = useModalStore();
function contentChangeClick() {
mainStore.contentChange();
}
</script>
<template>
<div class="fixed top-0 z-20 flex items-center w-full h-16 min-h-16 max-h-16 justify-between py-2 px-2 xs:px-6 sm:px-10 md:px-24 lg:px-48 2xl:px-80 bg-[#2E4559]">
<!-- logo -->
<div class="text-2xl md:text-4xl font-bold text-white">voca</div>
<!-- input -->
<div class="relative w-3/5 md:w-1/2">
<WordInput />
</div>
<!-- btn -->
<button @click="contentChangeClick">
<Icon icon="ph:trash" width="34" height="34" color="#e4e4e7" :class="mainStore.screenTransition === 0 ? 'block' : 'hidden'" />
<Icon icon="ph:notepad" width="34" height="36" color="#e4e4e7" :class="mainStore.screenTransition === 1 ? 'block' : 'hidden'"/>
</button>
<!-- modal -->
<Teleport to="body">
<Transition name="slide-fade">
<ModalCaseNomal v-if="modalStore.inputModal" />
</Transition>
<Transition name="slide-fade">
<ModalCaseSimilar v-if="modalStore.inputSimilarModal" />
</Transition>
<Transition name="slide-fade">
<ModalCaseNotExist v-if="modalStore.inputNotExistModal" />
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.slide-fade-enter-active {
transition: all 0.3s ease;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translatex(-20px);
opacity: 0;
}
</style>
function contentChangeClick()
: 휴지통 아이콘을 클릭하면 Main 스토어의contentChange()
를 호출