단어 삭제

2023년 3월 13일

intro

이번에는 localStorage에 저장된 단어를 삭제하는 기능을 구현하려고 한다.
삭제 기능은 localWords에서 단어를 지우면 그 단어를 localTrashCan에서 저장했다가 2주 뒤 완전히 삭제하는 방식
완전히 삭제되기 전에 복원, 미리 삭제가 가능하다. 또한 WordList에서 wordArrlocalTrashCan의 단어를 토글 버튼을 이용해 전환하면서 보여준다.

구현할 기능 미리 보기

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()를 호출


댓글