v0.1.43 - 플래너 라벨과 가이드 동작 정리
This commit is contained in:
@@ -45,12 +45,12 @@ function updateField(field, event) {
|
||||
<div class="w-full max-w-md rounded-[28px] border border-white/60 bg-[#f6f1e8] p-6 shadow-2xl sm:p-7">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Account</p>
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">계정 시작</p>
|
||||
<h2 class="text-2xl font-semibold tracking-[-0.04em] text-stone-900">
|
||||
{{ mode === 'login' ? '로그인' : '회원가입' }}
|
||||
</h2>
|
||||
<p class="text-sm leading-6 text-stone-600">
|
||||
{{ mode === 'login' ? '저장된 플래너를 다시 이어서 볼 수 있습니다.' : '사용자별 기록과 통계를 연결하기 위한 계정을 만듭니다.' }}
|
||||
{{ mode === 'login' ? '작성하던 플래너를 이어서 기록하세요.' : '나만의 플래너와 통계를 안전하게 보관할 계정을 만듭니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -82,7 +82,7 @@ function updateField(field, event) {
|
||||
:value="form.email"
|
||||
:type="mode === 'login' ? 'text' : 'email'"
|
||||
class="w-full rounded-2xl border border-stone-300 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-stone-500"
|
||||
:placeholder="mode === 'login' ? 'zenn@example.com 또는 planner-admin' : 'zenn@example.com'"
|
||||
:placeholder="mode === 'login' ? '이메일 또는 아이디를 입력해 주세요.' : 'you@example.com'"
|
||||
@input="updateField('email', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -110,7 +110,7 @@ function updateField(field, event) {
|
||||
class="w-full rounded-full bg-stone-900 px-5 py-3 text-xs font-bold tracking-[0.18em] text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||
:disabled="busy"
|
||||
>
|
||||
{{ busy ? '처리 중...' : mode === 'login' ? 'LOGIN' : 'SIGN UP' }}
|
||||
{{ busy ? '처리 중...' : mode === 'login' ? '로그인하기' : '가입하기' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
90
src/components/GuideTooltip.vue
Normal file
90
src/components/GuideTooltip.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['dismiss'])
|
||||
|
||||
const open = ref(false)
|
||||
const rootRef = ref(null)
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!props.visible) {
|
||||
return
|
||||
}
|
||||
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function closeFromOutside(event) {
|
||||
if (!open.value || rootRef.value?.contains(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
emit('dismiss')
|
||||
close()
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pointerdown', closeFromOutside)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointerdown', closeFromOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="visible"
|
||||
ref="rootRef"
|
||||
class="relative inline-flex"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border border-stone-300 bg-white text-[10px] font-bold text-stone-500 transition hover:border-stone-500 hover:text-stone-900 focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2"
|
||||
aria-label="가이드 보기"
|
||||
:aria-expanded="open"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
<span
|
||||
v-if="open"
|
||||
class="absolute left-0 top-7 z-50 w-64 rounded-2xl border border-stone-200 bg-white p-4 text-left shadow-[0_18px_50px_rgba(28,25,23,0.16)]"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.2em] text-stone-500">{{ title }}</span>
|
||||
<span class="mt-2 block text-[11px] font-semibold leading-5 tracking-[0.04em] text-stone-700">{{ description }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-full border border-stone-200 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
|
||||
@click="dismiss"
|
||||
>
|
||||
더 이상 보지 않기
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
dateMain: {
|
||||
@@ -57,12 +57,15 @@ const emit = defineEmits([
|
||||
'update:task-label',
|
||||
'update:task-title',
|
||||
'toggle:task',
|
||||
'clear:tasks',
|
||||
'update:memo-label',
|
||||
'update:memo',
|
||||
'update:timetable',
|
||||
])
|
||||
|
||||
let dragState = null
|
||||
let taskSelectionDrag = null
|
||||
const selectedTaskIndexes = ref(new Set())
|
||||
|
||||
function shouldShowTaskPlaceholder(index) {
|
||||
return index === 0 && props.tasks.every((task) => !task.title.trim())
|
||||
@@ -111,9 +114,114 @@ function stopTimetableDrag() {
|
||||
dragState = null
|
||||
}
|
||||
|
||||
function isTaskSelectionBlockedTarget(target) {
|
||||
return Boolean(target.closest('button, textarea, [contenteditable="true"]'))
|
||||
}
|
||||
|
||||
function getTaskSelectionRange(startIndex, endIndex) {
|
||||
const rangeStart = Math.min(startIndex, endIndex)
|
||||
const rangeEnd = Math.max(startIndex, endIndex)
|
||||
|
||||
return new Set(Array.from({ length: rangeEnd - rangeStart + 1 }, (_, offset) => rangeStart + offset))
|
||||
}
|
||||
|
||||
function isTaskSelected(index) {
|
||||
return selectedTaskIndexes.value.has(index)
|
||||
}
|
||||
|
||||
function clearTaskSelection() {
|
||||
selectedTaskIndexes.value = new Set()
|
||||
taskSelectionDrag = null
|
||||
}
|
||||
|
||||
function clearTaskSelectionOnFocus() {
|
||||
if (taskSelectionDrag) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTaskSelection()
|
||||
}
|
||||
|
||||
function startTaskSelection(index, event) {
|
||||
if (event.button !== 0 || isTaskSelectionBlockedTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedTaskIndexes.value = new Set()
|
||||
taskSelectionDrag = {
|
||||
startIndex: index,
|
||||
isSelecting: false,
|
||||
}
|
||||
}
|
||||
|
||||
function moveTaskSelection(index) {
|
||||
if (!taskSelectionDrag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!taskSelectionDrag.isSelecting && index === taskSelectionDrag.startIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!taskSelectionDrag.isSelecting) {
|
||||
taskSelectionDrag.isSelecting = true
|
||||
document.activeElement?.blur?.()
|
||||
window.getSelection()?.removeAllRanges?.()
|
||||
}
|
||||
|
||||
selectedTaskIndexes.value = getTaskSelectionRange(taskSelectionDrag.startIndex, index)
|
||||
}
|
||||
|
||||
function moveTaskSelectionFromPointer(event) {
|
||||
if (!taskSelectionDrag) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskRow = document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-task-index]')
|
||||
const index = Number(taskRow?.dataset.taskIndex)
|
||||
|
||||
if (Number.isInteger(index)) {
|
||||
moveTaskSelection(index)
|
||||
}
|
||||
}
|
||||
|
||||
function stopTaskSelection() {
|
||||
if (taskSelectionDrag && !taskSelectionDrag.isSelecting) {
|
||||
selectedTaskIndexes.value = new Set()
|
||||
}
|
||||
|
||||
taskSelectionDrag = null
|
||||
}
|
||||
|
||||
function clearSelectedTasks(event) {
|
||||
if (selectedTaskIndexes.value.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
clearTaskSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (!['Backspace', 'Delete'].includes(event.key)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
emit('clear:tasks', [...selectedTaskIndexes.value])
|
||||
clearTaskSelection()
|
||||
}
|
||||
|
||||
window.addEventListener('pointerup', stopTimetableDrag)
|
||||
window.addEventListener('pointermove', moveTaskSelectionFromPointer)
|
||||
window.addEventListener('pointerup', stopTaskSelection)
|
||||
window.addEventListener('keydown', clearSelectedTasks)
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointerup', stopTimetableDrag)
|
||||
window.removeEventListener('pointermove', moveTaskSelectionFromPointer)
|
||||
window.removeEventListener('pointerup', stopTaskSelection)
|
||||
window.removeEventListener('keydown', clearSelectedTasks)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -123,17 +231,23 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="planner-sheet__meta flex flex-col gap-4 py-3 sm:py-[18px]">
|
||||
<div class="planner-sheet__meta-top flex flex-col gap-3 sm:gap-4" :class="props.showDday ? 'sm:flex-row' : ''">
|
||||
<div class="relative min-h-[82px] border-t border-ink px-[10px] pt-[10px]" :class="props.showDday ? 'w-full sm:w-[394px] sm:flex-1' : 'w-full flex-1'">
|
||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">YEAR / MONTH / DAY</span>
|
||||
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">
|
||||
<div class="planner-field min-h-[82px] px-[10px] pt-[10px]" :class="props.showDday ? 'w-full sm:w-[394px] sm:flex-1' : 'w-full flex-1'">
|
||||
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||
<span class="shrink-0">YEAR / MONTH / DAY</span>
|
||||
<span class="h-px flex-1 bg-ink"></span>
|
||||
</div>
|
||||
<p class="pt-6 text-[11px] tracking-[0.2em] text-ink sm:text-sm">
|
||||
<span>{{ dateMain }}</span>
|
||||
<span class="ml-1" :class="dateWeekdayTone">{{ dateWeekday }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="props.showDday" class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] sm:w-[210px]">
|
||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">D-DAY</span>
|
||||
<div v-if="props.showDday" class="planner-field min-h-[82px] w-full px-[10px] pt-[10px] sm:w-[210px]">
|
||||
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||
<span class="shrink-0">D-DAY</span>
|
||||
<span class="h-px flex-1 bg-ink"></span>
|
||||
</div>
|
||||
<p
|
||||
class="pt-5 text-[11px] tracking-[0.14em] text-ink sm:pt-6 sm:text-sm"
|
||||
class="pt-6 text-[11px] tracking-[0.14em] text-ink sm:text-sm"
|
||||
style="
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
@@ -148,39 +262,61 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="planner-sheet__meta-bottom flex flex-col gap-3 border-b border-ink pb-3 sm:gap-4 sm:pb-[18px] lg:flex-row">
|
||||
<div class="relative min-h-[82px] w-full flex-1 border-t border-ink px-[10px] pt-[10px] lg:w-[394px]">
|
||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">COMMENT</span>
|
||||
<div class="planner-field min-h-[82px] w-full flex-1 px-[10px] pt-[10px] lg:w-[394px]">
|
||||
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||
<span class="shrink-0">COMMENT</span>
|
||||
<span class="h-px flex-1 bg-ink"></span>
|
||||
</div>
|
||||
<textarea
|
||||
:value="comment"
|
||||
rows="3"
|
||||
class="mt-3 h-[54px] w-full resize-none bg-transparent pt-2 text-[11px] font-semibold normal-case tracking-[0.06em] text-stone-700 outline-none placeholder:text-stone-400 sm:mt-4 sm:text-xs"
|
||||
class="mt-3 h-[54px] w-full resize-none bg-transparent pt-2 text-[11px] font-semibold normal-case tracking-[0.06em] text-stone-700 outline-none placeholder:text-stone-400 sm:text-xs"
|
||||
placeholder="오늘의 코멘트를 적어 주세요."
|
||||
@input="emit('update:comment', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative min-h-[82px] w-full border-t border-ink px-[10px] pt-[10px] lg:w-[210px]">
|
||||
<span class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">총 시간</span>
|
||||
<p class="pt-5 text-[11px] tracking-[0.2em] text-ink sm:pt-6 sm:text-sm">{{ totalTime }}</p>
|
||||
<div class="planner-field min-h-[82px] w-full px-[10px] pt-[10px] lg:w-[210px]">
|
||||
<div class="planner-field__header flex items-center gap-2 text-muted">
|
||||
<span class="shrink-0">FOCUSED TIME</span>
|
||||
<span class="h-px flex-1 bg-ink"></span>
|
||||
</div>
|
||||
<p class="pt-6 text-[11px] tracking-[0.2em] text-ink sm:text-sm">{{ totalTime }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="planner-sheet__body flex flex-col gap-5 py-[10px] lg:flex-row lg:gap-4">
|
||||
<div class="planner-sheet__lists flex w-full flex-1 flex-col gap-7 sm:gap-9 lg:w-[394px]">
|
||||
<section class="relative">
|
||||
<div class="absolute -top-[9px] left-0 bg-paper px-[2px] text-muted">TASKS</div>
|
||||
<div class="border-t border-ink">
|
||||
<section>
|
||||
<div class="flex items-center gap-2 text-muted">
|
||||
<span class="shrink-0">TASKS</span>
|
||||
<span
|
||||
v-if="selectedTaskIndexes.size > 0"
|
||||
class="shrink-0 rounded-full border border-ink/30 px-2 py-[1px] text-[8px] tracking-[0.12em] text-ink"
|
||||
>
|
||||
선택 {{ selectedTaskIndexes.size }}개 · DEL 삭제 · ESC 취소
|
||||
</span>
|
||||
<span class="h-px flex-1 bg-ink"></span>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="(task, index) in tasks"
|
||||
:key="task.id"
|
||||
class="flex min-h-[38px] items-center border-b"
|
||||
:class="index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line'"
|
||||
:key="task.id ?? index"
|
||||
:data-task-index="index"
|
||||
class="flex min-h-[38px] select-none items-center border-b transition-colors"
|
||||
:class="[
|
||||
index % 5 === 4 || index === tasks.length - 1 ? 'border-ink' : 'border-line',
|
||||
isTaskSelected(index) ? 'bg-amber-100/55 ring-1 ring-inset ring-amber-500/40' : '',
|
||||
]"
|
||||
@pointerdown="startTaskSelection(index, $event)"
|
||||
@pointerenter="moveTaskSelection(index)"
|
||||
>
|
||||
<div class="h-full w-[52px] shrink-0 border-r border-dashed border-ink px-1.5 py-[7px] sm:w-[62px] sm:px-2">
|
||||
<input
|
||||
:value="task.label"
|
||||
type="text"
|
||||
class="w-full bg-transparent text-center text-[9px] font-semibold tracking-[0.08em] text-stone-500 outline-none placeholder:text-stone-300"
|
||||
@focus="clearTaskSelectionOnFocus"
|
||||
@input="emit('update:task-label', { index, value: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
@@ -190,13 +326,14 @@ onBeforeUnmount(() => {
|
||||
type="text"
|
||||
class="w-full truncate bg-transparent text-[10px] font-semibold normal-case tracking-[0.04em] text-stone-800 outline-none placeholder:text-stone-400 sm:text-[11px] sm:tracking-[0.06em]"
|
||||
:placeholder="shouldShowTaskPlaceholder(index) ? '할 일을 입력해 주세요.' : ''"
|
||||
@focus="clearTaskSelectionOnFocus"
|
||||
@input="emit('update:task-title', { index, value: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex h-full w-[36px] shrink-0 items-center justify-center p-[8px] sm:w-[42px] sm:p-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-full items-center justify-center border border-dashed transition"
|
||||
class="flex h-full w-full items-center justify-center border border-dashed transition focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
:class="task.checked ? 'border-ink bg-stone-100 text-ink' : 'border-ink/60 text-transparent'"
|
||||
@click="emit('toggle:task', index)"
|
||||
>
|
||||
@@ -207,9 +344,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="relative">
|
||||
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">MEMO</div>
|
||||
<div class="border-t border-ink">
|
||||
<section>
|
||||
<div class="flex items-center gap-2 text-muted">
|
||||
<span class="shrink-0">MEMO</span>
|
||||
<span class="h-px flex-1 bg-ink"></span>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="(memoItem, index) in memo"
|
||||
:key="`memo-${index}`"
|
||||
@@ -237,10 +377,13 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="planner-sheet__timetable relative w-full shrink-0 lg:w-[210px]">
|
||||
<div class="absolute -top-2 left-0 bg-paper px-[2px] text-muted">TIME TABLE</div>
|
||||
<section class="planner-sheet__timetable w-full shrink-0 lg:w-[210px]">
|
||||
<div class="flex items-center gap-2 text-muted">
|
||||
<span class="shrink-0">TIME TABLE</span>
|
||||
<span class="h-px flex-1 bg-ink"></span>
|
||||
</div>
|
||||
<div class="planner-sheet__timetable-scroll overflow-x-auto pb-1">
|
||||
<div class="planner-sheet__timetable-grid min-w-[210px] border-t border-ink">
|
||||
<div class="planner-sheet__timetable-grid min-w-[210px]">
|
||||
<div
|
||||
v-for="(hour, index) in hours"
|
||||
:key="`${hour}-${index}`"
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
guideTooltipResetMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -37,6 +41,7 @@ const emit = defineEmits([
|
||||
'update:password-field',
|
||||
'submit:profile',
|
||||
'submit:password',
|
||||
'reset-guide-tooltips',
|
||||
])
|
||||
|
||||
const initials = computed(() =>
|
||||
@@ -77,6 +82,26 @@ function updatePasswordField(field, event) {
|
||||
...
|
||||
</p>
|
||||
</div> -->
|
||||
|
||||
<div class="mt-6 rounded-[24px] border border-stone-200 bg-white/80 p-4">
|
||||
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">GUIDE TOOLTIPS</p>
|
||||
<p class="mt-3 text-sm font-semibold leading-6 text-stone-700">
|
||||
숨긴 가이드 툴팁을 다시 표시합니다.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded-full border border-stone-900 px-4 py-3 text-xs font-bold tracking-[0.14em] text-stone-900 transition hover:bg-stone-900 hover:text-white"
|
||||
@click="emit('reset-guide-tooltips')"
|
||||
>
|
||||
가이드 다시 보기
|
||||
</button>
|
||||
<p
|
||||
v-if="guideTooltipResetMessage"
|
||||
class="mt-3 rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-xs font-semibold leading-5 text-stone-600"
|
||||
>
|
||||
{{ guideTooltipResetMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="grid gap-6">
|
||||
|
||||
Reference in New Issue
Block a user