Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="inline-nickname-editor" :class="{ editable, editing, [playerColor || 'neutral']: true }"> | |
| <!-- Display mode --> | |
| <div | |
| v-if="!editing" | |
| class="nickname-display" | |
| :title="editable ? 'Click to edit nickname' : ''" | |
| @click="startEdit" | |
| > | |
| <span class="stone-icon" :class="playerColor"></span> | |
| <span class="nickname-text">{{ displayNickname }}</span> | |
| <span v-if="editable" class="edit-icon">✏️</span> | |
| </div> | |
| <!-- Edit mode --> | |
| <div v-else class="nickname-edit"> | |
| <span class="stone-icon" :class="playerColor"></span> | |
| <input | |
| ref="inputRef" | |
| v-model="editValue" | |
| type="text" | |
| class="nickname-input" | |
| :class="{ error: hasError }" | |
| :maxlength="20" | |
| :title="inputTooltip" | |
| @keydown.enter="confirmEdit" | |
| @keydown.esc="cancelEdit" | |
| @blur="confirmEdit" | |
| /> | |
| <span v-if="hasError" class="error-icon" :title="errorMessage">⚠️</span> | |
| </div> | |
| <!-- Character count and help text when editing --> | |
| <div v-if="editing" class="edit-info"> | |
| <span class="help-text">Enter to save • Esc to cancel</span> | |
| <span class="char-count">{{ editValue.length }}/20</span> | |
| </div> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { ref, computed, watch, nextTick } from "vue"; | |
| const props = defineProps<{ | |
| nickname: string; | |
| editable: boolean; | |
| playerColor: "black" | "white" | null; | |
| }>(); | |
| const emit = defineEmits<{ | |
| (e: "update", nickname: string): void; | |
| }>(); | |
| const editing = ref(false); | |
| const editValue = ref(props.nickname); | |
| const errorMessage = ref(""); | |
| const inputRef = ref<HTMLInputElement | null>(null); | |
| const displayNickname = computed(() => props.nickname || "Guest"); | |
| const hasError = computed(() => errorMessage.value !== ""); | |
| const inputTooltip = computed(() => | |
| "3-20 characters, letters/numbers/spaces only" | |
| ); | |
| function validateNickname(nickname: string): { valid: boolean; error: string } { | |
| const trimmed = nickname.trim(); | |
| if (trimmed.length < 3) return { valid: false, error: "Must be 3+ chars" }; | |
| if (trimmed.length > 20) return { valid: false, error: "Max 20 chars" }; | |
| if (!/^[a-zA-Z0-9 ]+$/.test(trimmed)) return { valid: false, error: "Letters, numbers, spaces only" }; | |
| if (trimmed !== nickname) return { valid: false, error: "No leading/trailing spaces" }; | |
| return { valid: true, error: "" }; | |
| } | |
| function startEdit() { | |
| if (!props.editable) return; | |
| editing.value = true; | |
| editValue.value = props.nickname; | |
| errorMessage.value = ""; | |
| nextTick(() => { | |
| inputRef.value?.focus(); | |
| inputRef.value?.select(); | |
| }); | |
| } | |
| function confirmEdit() { | |
| if (!editing.value) return; | |
| const trimmed = editValue.value.trim(); | |
| if (trimmed === props.nickname) { | |
| cancelEdit(); | |
| return; | |
| } | |
| const validation = validateNickname(trimmed); | |
| if (!validation.valid) { | |
| errorMessage.value = validation.error; | |
| return; | |
| } | |
| editing.value = false; | |
| errorMessage.value = ""; | |
| emit("update", trimmed); | |
| } | |
| function cancelEdit() { | |
| editing.value = false; | |
| editValue.value = props.nickname; | |
| errorMessage.value = ""; | |
| } | |
| watch( | |
| () => props.nickname, | |
| (newNickname) => { | |
| if (!editing.value) { | |
| editValue.value = newNickname; | |
| } | |
| } | |
| ); | |
| </script> | |
| <style lang="scss" scoped> | |
| .inline-nickname-editor { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.25rem; | |
| &.editable .nickname-display { | |
| cursor: pointer; | |
| transition: background-color 0.2s ease; | |
| &:hover { | |
| background-color: rgba(255, 255, 255, 0.05); | |
| } | |
| } | |
| .nickname-display, | |
| .nickname-edit { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 0.5rem; | |
| border-radius: 6px; | |
| background-color: rgba(0, 0, 0, 0.2); | |
| } | |
| .stone-icon { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| &.black { | |
| background-color: #2c2c2c; | |
| border: 2px solid #fff; | |
| } | |
| &.white { | |
| background-color: #f0f0f0; | |
| border: 2px solid #000; | |
| } | |
| } | |
| .nickname-text { | |
| font-weight: 600; | |
| color: #e0e0e0; | |
| } | |
| .edit-icon { | |
| opacity: 0; | |
| transition: opacity 0.2s ease; | |
| font-size: 0.8rem; | |
| } | |
| &.editable:hover .edit-icon { | |
| opacity: 0.6; | |
| } | |
| .nickname-input { | |
| flex: 1; | |
| background-color: rgba(255, 255, 255, 0.1); | |
| border: 2px solid #60a5fa; | |
| border-radius: 4px; | |
| padding: 0.25rem 0.5rem; | |
| color: #e0e0e0; | |
| font-weight: 600; | |
| font-size: 0.95rem; | |
| outline: none; | |
| &.error { | |
| border-color: #ef4444; | |
| } | |
| &:focus { | |
| background-color: rgba(255, 255, 255, 0.15); | |
| } | |
| } | |
| .error-icon { | |
| font-size: 1rem; | |
| cursor: help; | |
| } | |
| .edit-info { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 0 0.5rem; | |
| font-size: 0.75rem; | |
| } | |
| .help-text { | |
| color: #60a5fa; | |
| font-style: italic; | |
| } | |
| .char-count { | |
| color: #9ca3af; | |
| } | |
| } | |
| </style> | |