From dd2d8eae51068a08dd1728f8be2d65e3dbc1bfc2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexandre=20Mog=C3=A8re?= <heristop@yahoo.fr>
Date: Sat, 28 Sep 2024 00:47:45 +0200
Subject: [PATCH] feat(config): added color picker

---
 app/components/AppFooter.vue            |   8 +-
 app/components/EditableLabel.vue        |  59 ++---
 app/components/ProjectPanel.vue         |   1 -
 app/components/TreeNode.vue             |  48 ++--
 app/components/config/StatusManager.vue | 318 ++++++++++++++++++------
 app/composables/store.ts                |   6 -
 app/pages/index.vue                     |   2 +-
 package.json                            |   3 +-
 pnpm-lock.yaml                          | 102 ++++++++
 9 files changed, 408 insertions(+), 139 deletions(-)

diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue
index 9e48391..33ef47d 100644
--- a/app/components/AppFooter.vue
+++ b/app/components/AppFooter.vue
@@ -2,12 +2,14 @@
 
 <template>
   <footer class="footer w-full text-center py-4">
-    <p class="flex items-center align-middle justify-center text-stone-800 dark:text-white text-sm justify-center gap-2">
-      Made by <a
+    <p class="flex items-center justify-center text-stone-800 dark:text-white text-sm gap-2">
+      <span>Made by <a
         href="https://heristop.github.io/about"
         target="_blank"
         class="text-stone-700 dark:text-stone-400 hover:text-stone-400 dark:hover:text-stone-500 transition-colors duration-300 font-bold"
-      >heristop</a> <span class="text-3xl front-bold">·</span> <span class="ml-1">Deployed on NuxtHub</span>
+      >heristop</a></span>
+      <span class="text-3xl font-bold">·</span>
+      <span>Deployed on NuxtHub</span>
     </p>
   </footer>
 </template>
diff --git a/app/components/EditableLabel.vue b/app/components/EditableLabel.vue
index fea7887..f0eb7a4 100644
--- a/app/components/EditableLabel.vue
+++ b/app/components/EditableLabel.vue
@@ -1,32 +1,50 @@
 <script setup lang="ts">
-import { ref, onMounted, nextTick } from 'vue'
+import { ref, watch } from 'vue'
 
 const props = defineProps<{
   value: string
   isEditing: boolean
+  isEditMode: boolean
 }>()
 
 const emit = defineEmits<{
   (e: 'update:value', value: string): void
   (e: 'update:isEditing', value: boolean): void
+  (e: 'double-click'): void
 }>()
 
 const inputRef = ref<HTMLInputElement | null>(null)
 const inputValue = ref(props.value)
 
-onMounted(() => {
-  if (props.isEditing) {
-    nextTick(() => {
+watch(() => props.isEditing, (newValue) => {
+  if (newValue) {
+    setTimeout(() => {
       inputRef.value?.focus()
       inputRef.value?.select()
     })
   }
 })
 
+watch(() => props.value, (newValue) => {
+  inputValue.value = newValue
+})
+
 const finishEditing = () => {
   emit('update:value', inputValue.value)
   emit('update:isEditing', false)
 }
+
+const cancelEditing = () => {
+  inputValue.value = props.value
+  emit('update:isEditing', false)
+}
+
+const handleDoubleClick = (event: MouseEvent) => {
+  if (!props.isEditMode) {
+    event.stopPropagation()
+    emit('double-click')
+  }
+}
 </script>
 
 <template>
@@ -37,42 +55,13 @@ const finishEditing = () => {
     class="edit-input"
     @blur="finishEditing"
     @keyup.enter="finishEditing"
-    @keyup.esc="finishEditing"
-    @dbclick="finishEditing"
+    @keyup.esc="cancelEditing"
   >
   <span
     v-else
     class="node-text cursor-text"
-    @click="$emit('update:isEditing', true)"
+    @dblclick="handleDoubleClick"
   >
     {{ value }}
   </span>
 </template>
-
-<style scoped>
-.edit-input {
-  text-align: center;
-  background: rgba(255, 255, 255, 0.1);
-  border: none;
-  border-bottom: 1px solid white;
-  color: white;
-  outline: none;
-  transition: all 0.3s;
-  padding: 2px 4px;
-  border-radius: 2px;
-}
-
-.edit-input:focus {
-  background: rgba(255, 255, 255, 0.2);
-  border-bottom: 2px solid white;
-  outline: none;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-.node-text {
-  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
-  max-width: 100%;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-</style>
diff --git a/app/components/ProjectPanel.vue b/app/components/ProjectPanel.vue
index 4294808..3baca01 100644
--- a/app/components/ProjectPanel.vue
+++ b/app/components/ProjectPanel.vue
@@ -506,7 +506,6 @@ onMounted(() => {
       class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
     >
       <div class="bg-white dark:bg-stone-800 p-6 rounded-lg shadow-xl w-full max-w-lg relative">
-        <!-- Bouton pour fermer la modale -->
         <button
           class="absolute top-4 right-4 text-stone-400 hover:text-stone-600 dark:text-stone-300 dark:hover:text-white transition-colors duration-300"
           aria-label="Close Modal"
diff --git a/app/components/TreeNode.vue b/app/components/TreeNode.vue
index 7c4941e..42a05e4 100644
--- a/app/components/TreeNode.vue
+++ b/app/components/TreeNode.vue
@@ -37,8 +37,11 @@ const displayContent = computed({
   },
 })
 
+const isDraggable = computed(() => !store.isEditingMode && !isEditing.value)
+
 const handleLabelUpdate = (newValue: string) => {
   displayContent.value = newValue
+  isEditing.value = false
 }
 
 watch(() => store.displayLabel, () => {
@@ -97,6 +100,12 @@ const getUniqueKeyName = (base: string, type: 'key' | 'name') => {
   return newName
 }
 
+const handleLabelDoubleClick = () => {
+  if (!store.isEditingMode) {
+    isEditing.value = true
+  }
+}
+
 const addChildNode = (event: MouseEvent) => {
   event.stopPropagation()
 
@@ -142,6 +151,11 @@ const deleteNode = (event: MouseEvent) => {
 }
 
 const handleDragStart = (event: DragEvent) => {
+  if (!isDraggable.value) {
+    event.preventDefault()
+    return
+  }
+
   if (isDragging.value) {
     return
   }
@@ -151,6 +165,11 @@ const handleDragStart = (event: DragEvent) => {
 }
 
 const handleDrop = (event: DragEvent) => {
+  if (store.isEditingMode) {
+    event.preventDefault()
+    return
+  }
+
   event.preventDefault()
   event.stopPropagation()
   const draggedKey = event.dataTransfer?.getData('text/plain')
@@ -161,22 +180,15 @@ const handleDrop = (event: DragEvent) => {
 }
 
 const handleDragOver = (event: DragEvent) => {
-  event.preventDefault()
+  if (!store.isEditingMode) {
+    event.preventDefault()
+  }
 }
 
 const handleTitleClick = (event: MouseEvent) => {
   event.stopPropagation()
 }
 
-watch(() => [props.node.status, store.statuses], () => {
-  nodeStatus.value = props.node.status || store.statuses[0]?.name || ''
-  checkIfSuccessNode(props.node)
-}, { immediate: true })
-
-onMounted(() => {
-  updateParentStatus()
-})
-
 const nodeStyle = computed(() => {
   const baseFlex = 1
   const childCount = props.node.children ? props.node.children.length : 0
@@ -193,7 +205,6 @@ const handleClick = (event: MouseEvent) => {
 
   if (!props.node.children || !props.node.children.length) {
     updateStatus()
-
     return
   }
 }
@@ -232,6 +243,15 @@ const applySuccessAnimation = (node: Section) => {
   applyToParents(node)
   isSuccessNode.value = true
 }
+
+watch(() => [props.node.status, store.statuses], () => {
+  nodeStatus.value = props.node.status || store.statuses[0]?.name || ''
+  checkIfSuccessNode(props.node)
+}, { immediate: true })
+
+onMounted(() => {
+  updateParentStatus()
+})
 </script>
 
 <template>
@@ -240,7 +260,7 @@ const applySuccessAnimation = (node: Section) => {
     :class="{ 'success-animation': isSuccessNode }"
     :style="nodeStyle"
     :data-node-key="node.key"
-    :draggable="!store.isEditingMode && !isEditing"
+    :draggable="isDraggable"
     @dragstart="handleDragStart"
     @drop="handleDrop"
     @dragover="handleDragOver"
@@ -295,12 +315,12 @@ const applySuccessAnimation = (node: Section) => {
       </span>
 
       <EditableLabel
-        :key="store.displayLabel"
         :value="displayContent"
         :is-editing="isEditing"
-        class="truncate dark:text-stone-100"
+        :is-edit-mode="store.isEditingMode"
         @update:value="handleLabelUpdate"
         @update:is-editing="isEditing = $event"
+        @double-click="handleLabelDoubleClick"
       />
 
       <div
diff --git a/app/components/config/StatusManager.vue b/app/components/config/StatusManager.vue
index 7f7765d..283cb4c 100644
--- a/app/components/config/StatusManager.vue
+++ b/app/components/config/StatusManager.vue
@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { ref, computed, reactive } from 'vue'
+import { ref, computed, reactive, nextTick } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useStore, pastelColors } from '~/composables/store'
 import { useStatusManagement } from '~/composables/status'
@@ -19,6 +19,9 @@ const store = useStore()
 const { statuses } = storeToRefs(store)
 const isDragging = ref(false)
 const editingStatus = ref<EditingStatus | null>(null)
+const showColorPicker = ref(false)
+const editingColorIndex = ref<number | null>(null)
+const newStatusInput = ref<HTMLInputElement | null>(null)
 
 withDefaults(defineProps<{
   draggable?: boolean
@@ -30,9 +33,37 @@ const safeStatuses = computed<Status[]>(() => {
   return Array.isArray(statuses.value) ? statuses.value : []
 })
 
+function getNextColor(): string {
+  const currentColors = safeStatuses.value.map((status: Status) => status.color)
+  const availableColors = pastelColors.filter(color => !currentColors.includes(color))
+
+  return availableColors.length > 0 ? availableColors[0] : pastelColors[0]
+}
+
 const newStatus = reactive<Status>({
   name: '',
-  color: getNextColor() || '#FFFFFF',
+  color: getNextColor(),
+})
+
+const currentEditingColor = computed({
+  get: () => {
+    if (editingColorIndex.value !== null) {
+      return safeStatuses.value[editingColorIndex.value].color
+    }
+    return newStatus.color
+  },
+  set: (newColor: string) => {
+    if (editingColorIndex.value !== null) {
+      store.setStatuses(
+        safeStatuses.value.map((status, i) =>
+          i === editingColorIndex.value ? { ...status, color: newColor } : status,
+        ),
+      )
+    }
+    else {
+      newStatus.color = newColor
+    }
+  },
 })
 
 const addNewStatus = () => {
@@ -40,7 +71,10 @@ const addNewStatus = () => {
     const updatedStatuses = [...safeStatuses.value, { name: newStatus.name, color: newStatus.color }]
     store.setStatuses(updatedStatuses)
     newStatus.name = ''
-    newStatus.color = getNextColor() || '#FFFFFF'
+    newStatus.color = getNextColor()
+    nextTick(() => {
+      newStatusInput.value?.focus()
+    })
   }
 }
 
@@ -99,103 +133,123 @@ const getTextColor = (backgroundColor: string): string => {
 
 const isValidColor = (color: string): boolean => /^#[0-9A-F]{6}$/i.test(color)
 
-function getNextColor(): string {
-  const currentColors = safeStatuses.value.map((status: Status) => status.color)
-  const availableColors = pastelColors.filter(color => !currentColors.includes(color))
+const startColorEditing = (index: number | null) => {
+  editingColorIndex.value = index
+  showColorPicker.value = true
+}
 
-  return availableColors.length > 0 ? availableColors[0] : pastelColors[0]
+const updateStatusColor = (color: string) => {
+  currentEditingColor.value = color
+}
+
+const closeColorPicker = () => {
+  showColorPicker.value = false
+  editingColorIndex.value = null
 }
 </script>
 
 <template>
   <div class="space-y-2">
-    <div
-      v-for="(status, index) in safeStatuses"
-      :key="status.name"
-      class="flex items-center space-x-2 p-2 bg-stone-200/50 dark:bg-stone-800 rounded-lg shadow-sm transition-all duration-300 ease-in-out"
-      :class="{ 'cursor-grab active:cursor-grabbing hover:shadow-sm': draggable && !editingStatus }"
-      :draggable="draggable && !editingStatus"
-      @dragstart="startStatusDrag($event, index)"
-      @dragend="isDragging = false"
-      @dragover.prevent
-      @drop="dropStatus($event, index)"
+    <TransitionGroup
+      name="list"
+      tag="div"
     >
-      <div class="text-stone-600 dark:text-stone-300 w-20 text-xs flex items-center">
-        <svg
-          v-if="draggable && !editingStatus"
-          xmlns="http://www.w3.org/2000/svg"
-          class="h-4 w-4 mr-1"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
-        >
-          <path
-            stroke-linecap="round"
-            stroke-linejoin="round"
-            stroke-width="2"
-            d="M4 6h16M4 12h16M4 18h16"
-          />
-        </svg>
-        {{ index + 1 }}.
-      </div>
-
-      <input
-        :value="editingStatus?.index === index ? editingStatus.name : status.name"
-        class="p-1 font-semibold rounded w-full placeholder:text-stone-300 text-xs transition-all duration-300 focus:ring-2 focus:ring-stone-500 bg-white dark:bg-stone-700 text-stone-800 dark:text-white"
-        placeholder="status"
-        @input="updateStatusName(index, ($event.target as HTMLInputElement).value)"
-        @focus="startEditing(index, status.name)"
-        @blur="stopEditing"
-      >
-      <input
-        v-model="status.color"
-        :style="{ backgroundColor: isValidColor(status.color) ? status.color : '#FFFFFF', color: isValidColor(status.color) ? getTextColor(status.color) : '#000000' }"
-        class="p-1 font-semibold rounded w-full placeholder:text-stone-300 text-xs transition-all duration-300 focus:ring-2 focus:ring-stone-500"
-        placeholder="color"
-        @focus="startEditing(index, status.name)"
-        @blur="stopEditing"
-      >
-      <button
-        :disabled="safeStatuses.length <= 1"
-        class="p-1 rounded-full bg-stone-200 dark:bg-stone-700 text-stone-600 dark:text-stone-300 hover:bg-stone-300 dark:hover:bg-stone-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 transform hover:scale-110"
-        @click="removeStatus(index)"
+      <div
+        v-for="(status, index) in safeStatuses"
+        :key="status.name"
+        class="flex items-center space-x-2 p-2 bg-stone-200/50 dark:bg-stone-800 rounded-lg shadow-sm transition-all duration-300 ease-in-out hover:shadow-md"
+        :class="{ 'cursor-grab active:cursor-grabbing': draggable && !editingStatus }"
+        :draggable="draggable && !editingStatus"
+        @dragstart="startStatusDrag($event, index)"
+        @dragend="isDragging = false"
+        @dragover.prevent
+        @drop="dropStatus($event, index)"
       >
-        <svg
-          xmlns="http://www.w3.org/2000/svg"
-          class="h-5 w-5"
-          fill="none"
-          viewBox="0 0 24 24"
-          stroke="currentColor"
+        <div class="text-stone-600 dark:text-stone-300 w-20 text-xs flex items-center">
+          <svg
+            v-if="draggable && !editingStatus"
+            xmlns="http://www.w3.org/2000/svg"
+            class="h-4 w-4 mr-1 cursor-grab"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              stroke-width="2"
+              d="M4 6h16M4 12h16M4 18h16"
+            />
+          </svg>
+          {{ index + 1 }}.
+        </div>
+
+        <input
+          :value="editingStatus?.index === index ? editingStatus.name : status.name"
+          class="p-1 font-semibold rounded w-full placeholder:text-stone-300 text-xs transition-all duration-300 focus:ring-2 focus:ring-stone-300 bg-white dark:bg-stone-700 text-stone-800 dark:text-white"
+          placeholder="status"
+          @input="updateStatusName(index, ($event.target as HTMLInputElement).value)"
+          @focus="startEditing(index, status.name)"
+          @blur="stopEditing"
         >
-          <path
-            stroke-linecap="round"
-            stroke-linejoin="round"
-            stroke-width="2"
-            d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
-          />
-        </svg>
-      </button>
-    </div>
+        <div
+          :style="{ backgroundColor: isValidColor(status.color) ? status.color : '#FFFFFF', color: isValidColor(status.color) ? getTextColor(status.color) : '#000000' }"
+          class="p-1 font-semibold rounded w-12 h-8 cursor-pointer transition-all duration-300 hover:opacity-80 focus:ring-2 focus:ring-stone-300"
+          tabindex="0"
+          role="button"
+          aria-label="Change color"
+          @click="startColorEditing(index)"
+          @keydown.enter="startColorEditing(index)"
+        />
+        <button
+          :disabled="safeStatuses.length <= 1"
+          class="p-1 rounded-full bg-stone-200 dark:bg-stone-700 text-stone-600 dark:text-stone-300 hover:bg-stone-300 dark:hover:bg-stone-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-stone-300"
+          aria-label="Remove status"
+          @click="removeStatus(index)"
+        >
+          <svg
+            xmlns="http://www.w3.org/2000/svg"
+            class="h-5 w-5"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              stroke-linecap="round"
+              stroke-linejoin="round"
+              stroke-width="2"
+              d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+            />
+          </svg>
+        </button>
+      </div>
+    </TransitionGroup>
   </div>
 
-  <div class="flex items-center space-x-2 p-2 bg-stone-200/50 dark:bg-stone-800 rounded-lg shadow-sm mt-2 hover:shadow-sm">
+  <div class="flex items-center space-x-2 p-2 bg-stone-200/50 dark:bg-stone-800 rounded-lg shadow-sm mt-2 hover:shadow-md transition-all duration-300">
     <div class="text-stone-600 dark:text-stone-300 w-20 text-xs">
       {{ safeStatuses.length + 1 }}.
     </div>
     <input
+      ref="newStatusInput"
       v-model="newStatus.name"
       class="p-1 font-semibold rounded w-full placeholder:text-stone-400 text-xs transition-all duration-300 focus:ring-2 focus:ring-stone-500 bg-white dark:bg-stone-700 text-stone-800 dark:text-white"
       placeholder="New Status"
+      @keyup.enter="addNewStatus"
     >
-    <input
-      v-model="newStatus.color"
+    <div
       :style="{ backgroundColor: isValidColor(newStatus.color) ? newStatus.color : '#FFFFFF', color: isValidColor(newStatus.color) ? getTextColor(newStatus.color) : '#000000' }"
-      class="p-1 font-semibold rounded w-full placeholder:text-stone-400 text-xs transition-all duration-300 focus:ring-2 focus:ring-stone-500"
-      :placeholder="getNextColor()"
-    >
+      class="p-1 font-semibold rounded w-12 h-8 cursor-pointer transition-all duration-300 hover:opacity-80 focus:ring-2 focus:ring-stone-300"
+      tabindex="0"
+      role="button"
+      aria-label="Choose color for new status"
+      @click="startColorEditing(null)"
+      @keydown.enter="startColorEditing(null)"
+    />
     <button
       :disabled="!newStatus.name || !newStatus.color"
-      class="p-1 rounded-full bg-stone-200 dark:bg-stone-700 text-stone-600 dark:text-stone-300 hover:bg-stone-300 dark:hover:bg-stone-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 transform hover:scale-110"
+      class="p-1 rounded-full bg-stone-200 dark:bg-stone-700 text-stone-600 dark:text-stone-300 hover:bg-stone-300 dark:hover:bg-stone-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-stone-300"
+      aria-label="Add new status"
       @click="addNewStatus"
     >
       <svg
@@ -214,4 +268,112 @@ function getNextColor(): string {
       </svg>
     </button>
   </div>
+
+  <Teleport to="body">
+    <Transition name="fade">
+      <div
+        v-if="showColorPicker"
+        class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
+      >
+        <div class="bg-stone-50 dark:bg-stone-800 p-4 rounded-lg shadow-lg m-4 max-w-sm w-full">
+          <div class="flex justify-between items-center mb-4">
+            <h3 class="text-lg font-semibold text-stone-800 dark:text-stone-200">
+              Choose a Color
+            </h3>
+            <button
+              class="text-stone-400 hover:text-stone-600 dark:text-stone-300 dark:hover:text-white transition-colors duration-300"
+              aria-label="Close Color Picker"
+              @click="closeColorPicker"
+            >
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+                class="w-6 h-6"
+              >
+                <path
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                  stroke-width="2"
+                  d="M6 18L18 6M6 6l12 12"
+                />
+              </svg>
+            </button>
+          </div>
+
+          <div class="grid grid-cols-5 gap-2 mb-4">
+            <button
+              v-for="color in pastelColors"
+              :key="color"
+              class="w-full pt-full rounded-full border border-stone-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-stone-400 transition-transform duration-200 hover:scale-110"
+              :style="{ backgroundColor: color }"
+              :aria-label="`Select color ${color}`"
+              @click="updateStatusColor(color)"
+            />
+          </div>
+
+          <div class="flex items-center space-x-2">
+            <input
+              type="color"
+              :value="currentEditingColor"
+              class="w-10 h-10 rounded cursor-pointer"
+              @input="updateStatusColor(($event.target as HTMLInputElement).value)"
+            >
+            <input
+              v-model="currentEditingColor"
+              type="text"
+              class="flex-grow p-2 text-sm border rounded focus:outline-none focus:ring-2 focus:ring-stone-300 bg-white dark:bg-stone-700 text-stone-800 dark:text-white"
+              placeholder="#FFFFFF"
+            >
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </Teleport>
 </template>
+
+<style scoped>
+.list-move,
+.list-enter-active,
+.list-leave-active {
+  transition: all 0.5s ease;
+}
+
+.list-enter-from,
+.list-leave-to {
+  opacity: 0;
+  transform: translateX(30px);
+}
+
+.list-leave-active {
+  position: absolute;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.3s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+
+.pt-full {
+  padding-top: 100%;
+}
+
+@keyframes pulse {
+  0%, 100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.5;
+  }
+}
+
+.animate-pulse {
+  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+</style>
diff --git a/app/composables/store.ts b/app/composables/store.ts
index bc60ad9..ddd7d2a 100644
--- a/app/composables/store.ts
+++ b/app/composables/store.ts
@@ -19,12 +19,6 @@ export const pastelColors = [
   '#D4A5A5', // Light Rose
   '#FFD1DC', // Light Pinkish
   '#B2B2B2', // Light Gray
-  '#FF6961', // Pastel Red
-  '#F49AC2', // Pastel Pink
-  '#77DD77', // Pastel Green
-  '#AEC6CF', // Pastel Blue
-  '#CFCFC4', // Pastel Gray
-  '#B19CD9', // Pastel Lilac
 ]
 
 type State = {
diff --git a/app/pages/index.vue b/app/pages/index.vue
index e5982c2..a3f71df 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -139,7 +139,7 @@ onMounted(() => {
           appear
         >
           <div class="text-center space-y-8 py-16">
-            <h1 class="flex flex-col items-center justify-center text-center text-4xl font-bold mb-10 drop-shadow-lg bg-clip-text text-transparent bg-gradient-to-r from-[#DD5E89] to-[#F7BB97]">
+            <h1 class="flex flex-row items-center justify-center text-center text-4xl font-bold mb-10 drop-shadow-lg bg-clip-text text-transparent bg-gradient-to-r from-[#DD5E89] to-[#F7BB97]">
               <img
                 alt="Clover Map Logo"
                 src="@/assets/logo.svg"
diff --git a/package.json b/package.json
index 45ca30f..26676e6 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,8 @@
     "nuxt": "^3.13.2",
     "nuxt-snackbar": "^1.0.4",
     "vue": "^3.5.7",
-    "vue-router": "^4.4.5"
+    "vue-router": "^4.4.5",
+    "vue3-colorpicker": "^2.3.0"
   },
   "devDependencies": {
     "@commitlint/cli": "^19.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9fbb4d1..1e655c9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
       vue-router:
         specifier: ^4.4.5
         version: 4.4.5(vue@3.5.7(typescript@5.6.2))
+      vue3-colorpicker:
+        specifier: ^2.3.0
+        version: 2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@10.11.1(vue@3.5.7(typescript@5.6.2)))(gradient-parser@1.0.2)(lodash-es@4.17.21)(tinycolor2@1.6.0)(vue-types@4.2.1(vue@3.5.7(typescript@5.6.2)))(vue@3.5.7(typescript@5.6.2))
     devDependencies:
       '@commitlint/cli':
         specifier: ^19.3.0
@@ -102,6 +105,9 @@ importers:
 
 packages:
 
+  '@aesoper/normal-utils@0.1.5':
+    resolution: {integrity: sha512-LFF/6y6h5mfwhnJaWqqxuC8zzDaHCG62kMRkd8xhDtq62TQj9dM17A9DhE87W7DhiARJsHLgcina/9P4eNCN1w==}
+
   '@alloc/quick-lru@5.2.0':
     resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
     engines: {node: '>=10'}
@@ -1109,6 +1115,9 @@ packages:
   '@polka/url@1.0.0-next.28':
     resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
 
+  '@popperjs/core@2.11.8':
+    resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+
   '@rollup/plugin-alias@5.1.0':
     resolution: {integrity: sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==}
     engines: {node: '>=14.0.0'}
@@ -1312,6 +1321,9 @@ packages:
   '@types/resolve@1.20.2':
     resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
 
+  '@types/web-bluetooth@0.0.20':
+    resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
+
   '@typescript-eslint/eslint-plugin@7.18.0':
     resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==}
     engines: {node: ^18.18.0 || >=20.0.0}
@@ -1536,6 +1548,15 @@ packages:
   '@vue/shared@3.5.7':
     resolution: {integrity: sha512-NBE1PBIvzIedxIc2RZiKXvGbJkrZ2/hLf3h8GlS4/sP9xcXEZMFWOazFkNd6aGeUCMaproe5MHVYB3/4AW9q9g==}
 
+  '@vueuse/core@10.11.1':
+    resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
+
+  '@vueuse/metadata@10.11.1':
+    resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
+
+  '@vueuse/shared@10.11.1':
+    resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
+
   JSONStream@1.3.5:
     resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
     hasBin: true
@@ -2678,6 +2699,10 @@ packages:
   graceful-fs@4.2.11:
     resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
 
+  gradient-parser@1.0.2:
+    resolution: {integrity: sha512-gR6nY33xC9yJoH4wGLQtZQMXDi6RI3H37ERu7kQCVUzlXjNedpZM7xcA489Opwbq0BSGohtWGsWsntupmxelMg==}
+    engines: {node: '>=0.10.0'}
+
   graphemer@1.4.0:
     resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
 
@@ -2900,6 +2925,10 @@ packages:
     resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==}
     engines: {node: '>=12'}
 
+  is-plain-object@5.0.0:
+    resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
+    engines: {node: '>=0.10.0'}
+
   is-reference@1.2.1:
     resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
 
@@ -3127,6 +3156,9 @@ packages:
     resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
     engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
 
+  lodash-es@4.17.21:
+    resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
+
   lodash.camelcase@4.3.0:
     resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
 
@@ -4368,6 +4400,9 @@ packages:
   tinybench@2.9.0:
     resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
 
+  tinycolor2@1.6.0:
+    resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
+
   tinyexec@0.3.0:
     resolution: {integrity: sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==}
 
@@ -4758,6 +4793,24 @@ packages:
     peerDependencies:
       vue: ^3.2.0
 
+  vue-types@4.2.1:
+    resolution: {integrity: sha512-DNQZmJuOvovLUIp0BENRkdnZHbI0V4e2mNvjAZOAXKD56YGvRchtUYOXA/XqTxdv7Ng5SJLZqRKRpAhm5NLaPQ==}
+    engines: {node: '>=12.16.0'}
+    peerDependencies:
+      vue: ^2.0.0 || ^3.0.0
+
+  vue3-colorpicker@2.3.0:
+    resolution: {integrity: sha512-e3lLmBcy7mkRrNQVeUny1DjOd6E11L8H5ok5Bx4MdXmrG+RzyacRF7KkhrEWmRYPhKAsaoUrWsFkmpPAaYnE5A==}
+    peerDependencies:
+      '@aesoper/normal-utils': ^0.1.5
+      '@popperjs/core': ^2.11.8
+      '@vueuse/core': ^10.1.2
+      gradient-parser: ^1.0.2
+      lodash-es: ^4.17.21
+      tinycolor2: ^1.4.2
+      vue: ^3.2.6
+      vue-types: ^4.1.0
+
   vue3-icon@2.1.0:
     resolution: {integrity: sha512-cnFiGAEwzp/KQKody2Yj8cBDP4Kez0AUp5mDnp052FA1fECl8a9uYUKLaeRdH0JakmZ7Jfp3tdHbpBEWF9sgBA==}
 
@@ -4888,6 +4941,8 @@ packages:
 
 snapshots:
 
+  '@aesoper/normal-utils@0.1.5': {}
+
   '@alloc/quick-lru@5.2.0': {}
 
   '@ampproject/remapping@2.3.0':
@@ -6046,6 +6101,8 @@ snapshots:
 
   '@polka/url@1.0.0-next.28': {}
 
+  '@popperjs/core@2.11.8': {}
+
   '@rollup/plugin-alias@5.1.0(rollup@4.22.2)':
     dependencies:
       slash: 4.0.0
@@ -6211,6 +6268,8 @@ snapshots:
 
   '@types/resolve@1.20.2': {}
 
+  '@types/web-bluetooth@0.0.20': {}
+
   '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.11.0(jiti@1.21.6))(typescript@5.6.2))(eslint@9.11.0(jiti@1.21.6))(typescript@5.6.2)':
     dependencies:
       '@eslint-community/regexpp': 4.11.1
@@ -6575,6 +6634,25 @@ snapshots:
 
   '@vue/shared@3.5.7': {}
 
+  '@vueuse/core@10.11.1(vue@3.5.7(typescript@5.6.2))':
+    dependencies:
+      '@types/web-bluetooth': 0.0.20
+      '@vueuse/metadata': 10.11.1
+      '@vueuse/shared': 10.11.1(vue@3.5.7(typescript@5.6.2))
+      vue-demi: 0.14.10(vue@3.5.7(typescript@5.6.2))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
+  '@vueuse/metadata@10.11.1': {}
+
+  '@vueuse/shared@10.11.1(vue@3.5.7(typescript@5.6.2))':
+    dependencies:
+      vue-demi: 0.14.10(vue@3.5.7(typescript@5.6.2))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
   JSONStream@1.3.5:
     dependencies:
       jsonparse: 1.3.1
@@ -7818,6 +7896,8 @@ snapshots:
 
   graceful-fs@4.2.11: {}
 
+  gradient-parser@1.0.2: {}
+
   graphemer@1.4.0: {}
 
   gzip-size@7.0.0:
@@ -8028,6 +8108,8 @@ snapshots:
 
   is-path-inside@4.0.0: {}
 
+  is-plain-object@5.0.0: {}
+
   is-reference@1.2.1:
     dependencies:
       '@types/estree': 1.0.6
@@ -8295,6 +8377,8 @@ snapshots:
     dependencies:
       p-locate: 6.0.0
 
+  lodash-es@4.17.21: {}
+
   lodash.camelcase@4.3.0: {}
 
   lodash.defaults@4.2.0: {}
@@ -9690,6 +9774,8 @@ snapshots:
 
   tinybench@2.9.0: {}
 
+  tinycolor2@1.6.0: {}
+
   tinyexec@0.3.0: {}
 
   tinyglobby@0.2.6:
@@ -10098,6 +10184,22 @@ snapshots:
       '@vue/devtools-api': 6.6.4
       vue: 3.5.7(typescript@5.6.2)
 
+  vue-types@4.2.1(vue@3.5.7(typescript@5.6.2)):
+    dependencies:
+      is-plain-object: 5.0.0
+      vue: 3.5.7(typescript@5.6.2)
+
+  vue3-colorpicker@2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@10.11.1(vue@3.5.7(typescript@5.6.2)))(gradient-parser@1.0.2)(lodash-es@4.17.21)(tinycolor2@1.6.0)(vue-types@4.2.1(vue@3.5.7(typescript@5.6.2)))(vue@3.5.7(typescript@5.6.2)):
+    dependencies:
+      '@aesoper/normal-utils': 0.1.5
+      '@popperjs/core': 2.11.8
+      '@vueuse/core': 10.11.1(vue@3.5.7(typescript@5.6.2))
+      gradient-parser: 1.0.2
+      lodash-es: 4.17.21
+      tinycolor2: 1.6.0
+      vue: 3.5.7(typescript@5.6.2)
+      vue-types: 4.2.1(vue@3.5.7(typescript@5.6.2))
+
   vue3-icon@2.1.0: {}
 
   vue3-snackbar@2.3.4(vue@3.5.7(typescript@5.6.2)):