v0.1.20 - PostgreSQL 전환 및 Docker Compose 초안 추가
This commit is contained in:
79
src/App.vue
79
src/App.vue
@@ -16,7 +16,7 @@ import {
|
||||
updatePassword,
|
||||
updateProfile,
|
||||
} from './lib/authClient'
|
||||
import { createGoal, fetchGoals, updateGoal } from './lib/goalsApi'
|
||||
import { createGoal, deleteGoal, fetchGoals, updateGoal } from './lib/goalsApi'
|
||||
import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi'
|
||||
import {
|
||||
createInitialPlannerRecords,
|
||||
@@ -36,7 +36,6 @@ const authToken = ref('')
|
||||
const currentUser = ref(null)
|
||||
const goals = ref([])
|
||||
const goalQuery = ref('')
|
||||
const goalStatusFilter = ref('all')
|
||||
const goalBusy = ref(false)
|
||||
const goalMessage = ref('')
|
||||
const editingGoalId = ref(null)
|
||||
@@ -57,7 +56,6 @@ const goalForm = reactive({
|
||||
targetDate: '',
|
||||
activeFrom: '',
|
||||
activeUntil: '',
|
||||
status: 'active',
|
||||
})
|
||||
const profileForm = reactive({
|
||||
nickname: '',
|
||||
@@ -337,10 +335,6 @@ const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.va
|
||||
const filteredGoals = computed(() => {
|
||||
const query = goalQuery.value.trim().toLowerCase()
|
||||
return goals.value.filter((goal) => {
|
||||
if (goalStatusFilter.value !== 'all' && goal.status !== goalStatusFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return true
|
||||
}
|
||||
@@ -351,7 +345,7 @@ const filteredGoals = computed(() => {
|
||||
const activePlannerGoals = computed(() =>
|
||||
goals.value
|
||||
.filter((goal) => {
|
||||
if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) {
|
||||
if (!goal.activeFrom || !goal.activeUntil) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -713,8 +707,8 @@ function getTodayKey() {
|
||||
return toKey(new Date())
|
||||
}
|
||||
|
||||
function findOverlappingGoal({ activeFrom, activeUntil, status, excludeGoalId = null }) {
|
||||
if (!activeFrom || !activeUntil || status !== 'active') {
|
||||
function findOverlappingGoal({ activeFrom, activeUntil, excludeGoalId = null }) {
|
||||
if (!activeFrom || !activeUntil) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -723,7 +717,7 @@ function findOverlappingGoal({ activeFrom, activeUntil, status, excludeGoalId =
|
||||
return false
|
||||
}
|
||||
|
||||
if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) {
|
||||
if (!goal.activeFrom || !goal.activeUntil) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -742,7 +736,6 @@ function resetGoalForm() {
|
||||
goalForm.targetDate = ''
|
||||
goalForm.activeFrom = getTodayKey()
|
||||
goalForm.activeUntil = ''
|
||||
goalForm.status = 'active'
|
||||
editingGoalId.value = null
|
||||
}
|
||||
|
||||
@@ -850,7 +843,6 @@ function logout() {
|
||||
goals.value = []
|
||||
goalQuery.value = ''
|
||||
goalMessage.value = ''
|
||||
goalStatusFilter.value = 'all'
|
||||
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
|
||||
visible: false,
|
||||
})
|
||||
@@ -900,7 +892,6 @@ async function submitGoal() {
|
||||
const overlappedGoal = findOverlappingGoal({
|
||||
activeFrom: goalForm.activeFrom || null,
|
||||
activeUntil: goalForm.activeUntil || null,
|
||||
status: goalForm.status,
|
||||
})
|
||||
|
||||
if (overlappedGoal) {
|
||||
@@ -917,7 +908,6 @@ async function submitGoal() {
|
||||
targetDate: goalForm.targetDate,
|
||||
activeFrom: goalForm.activeFrom || null,
|
||||
activeUntil: goalForm.activeUntil || null,
|
||||
status: goalForm.status,
|
||||
})
|
||||
|
||||
await loadGoals()
|
||||
@@ -949,7 +939,6 @@ function startGoalEdit(goal) {
|
||||
goalForm.targetDate = goal.targetDate ?? ''
|
||||
goalForm.activeFrom = goal.activeFrom ?? ''
|
||||
goalForm.activeUntil = goal.activeUntil ?? ''
|
||||
goalForm.status = goal.status ?? 'active'
|
||||
goalMessage.value = ''
|
||||
}
|
||||
|
||||
@@ -976,7 +965,6 @@ async function saveGoalEdit() {
|
||||
const overlappedGoal = findOverlappingGoal({
|
||||
activeFrom: goalForm.activeFrom || null,
|
||||
activeUntil: goalForm.activeUntil || null,
|
||||
status: goalForm.status,
|
||||
excludeGoalId: editingGoalId.value,
|
||||
})
|
||||
|
||||
@@ -994,7 +982,6 @@ async function saveGoalEdit() {
|
||||
targetDate: goalForm.targetDate,
|
||||
activeFrom: goalForm.activeFrom || null,
|
||||
activeUntil: goalForm.activeUntil || null,
|
||||
status: goalForm.status,
|
||||
})
|
||||
|
||||
await loadGoals()
|
||||
@@ -1007,6 +994,30 @@ async function saveGoalEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGoal(goal) {
|
||||
const confirmed = window.confirm(`"${goal.title}" 목표를 삭제할까요? 삭제하면 과거 날짜에서도 더 이상 표시되지 않습니다.`)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
goalBusy.value = true
|
||||
goalMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await deleteGoal(authToken.value, goal.id)
|
||||
await loadGoals()
|
||||
if (editingGoalId.value === goal.id) {
|
||||
resetGoalForm()
|
||||
}
|
||||
goalMessage.value = result.message || '목표가 삭제되었습니다.'
|
||||
} catch (error) {
|
||||
goalMessage.value = error.message || '목표를 삭제하지 못했습니다.'
|
||||
} finally {
|
||||
goalBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateProfileField({ field, value }) {
|
||||
profileForm[field] = value
|
||||
}
|
||||
@@ -1236,7 +1247,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<main class="min-h-screen px-4 py-6 text-ink sm:px-6 lg:px-10 xl:h-screen xl:overflow-hidden">
|
||||
<div class="print-root mx-auto flex max-w-[1760px] flex-col gap-6 xl:h-[calc(100vh-3rem)] xl:grid xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start">
|
||||
<aside class="print-hidden rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur sm:p-6 xl:h-full xl:overflow-y-auto">
|
||||
<aside class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/70 p-5 backdrop-blur sm:p-6 xl:h-full xl:overflow-y-auto">
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.28em] text-stone-500">10 Minute Planner</p>
|
||||
@@ -1394,7 +1405,7 @@ onMounted(() => {
|
||||
<div class="min-w-0 space-y-6 xl:h-full xl:overflow-hidden">
|
||||
<section
|
||||
v-if="!isAuthenticated"
|
||||
class="print-hidden rounded-[28px] border border-white/60 bg-white/65 p-6 shadow-[0_24px_80px_rgba(28,25,23,0.08)] sm:p-8 xl:h-full xl:overflow-y-auto"
|
||||
class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/65 p-6 shadow-[0_24px_80px_rgba(28,25,23,0.08)] sm:p-8 xl:h-full xl:overflow-y-auto"
|
||||
>
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-6 text-center">
|
||||
<div class="space-y-3">
|
||||
@@ -1453,7 +1464,7 @@ onMounted(() => {
|
||||
v-else-if="screenMode === 'planner' && viewMode === 'focus'"
|
||||
class="print-hidden grid gap-6 xl:h-full xl:min-h-0 xl:grid-cols-[minmax(0,1fr)_340px]"
|
||||
>
|
||||
<div class="print-target rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-3">
|
||||
<div class="scrollbar-hide print-target rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-3">
|
||||
<PlannerPage
|
||||
:date-main="selectedDateDisplay.main"
|
||||
:date-weekday="selectedDateDisplay.weekday"
|
||||
@@ -1476,7 +1487,8 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside class="print-hidden flex flex-col gap-4 xl:h-full xl:min-h-0 xl:overflow-y-auto xl:pr-2">
|
||||
<aside class="scrollbar-hide print-hidden rounded-[28px] border border-white/60 bg-white/50 p-3 shadow-[0_18px_60px_rgba(28,25,23,0.06)] xl:h-full xl:min-h-0 xl:overflow-y-auto">
|
||||
<div class="flex flex-col gap-4 rounded-[22px] p-2">
|
||||
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
||||
<p class="mb-4 text-[11px] font-bold tracking-[0.22em] text-ink">PREV SNAPSHOT</p>
|
||||
<div class="space-y-3">
|
||||
@@ -1610,12 +1622,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="screenMode === 'planner'"
|
||||
class="print-hidden overflow-x-auto rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] sm:p-6 xl:h-full xl:overflow-y-auto"
|
||||
class="scrollbar-hide print-hidden overflow-x-auto rounded-[28px] border border-white/60 bg-white/45 p-4 shadow-[0_18px_60px_rgba(28,25,23,0.06)] sm:p-6 xl:h-full xl:overflow-y-auto"
|
||||
>
|
||||
<div class="flex min-w-[1260px] gap-6">
|
||||
<div class="print-target">
|
||||
@@ -1667,27 +1680,26 @@ onMounted(() => {
|
||||
|
||||
<GoalsDashboard
|
||||
v-else-if="screenMode === 'goals'"
|
||||
class="print-hidden xl:h-full xl:overflow-y-auto"
|
||||
class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
|
||||
:goals="filteredGoals"
|
||||
:query="goalQuery"
|
||||
:status="goalStatusFilter"
|
||||
:form="goalForm"
|
||||
:editing-goal-id="editingGoalId"
|
||||
:busy="goalBusy"
|
||||
:message="goalMessage"
|
||||
:selected-date-key="toKey(selectedDate)"
|
||||
@update:query="goalQuery = $event"
|
||||
@update:status="goalStatusFilter = $event"
|
||||
@update:form-field="updateGoalFormField"
|
||||
@submit:create="submitGoal"
|
||||
@start-edit="startGoalEdit"
|
||||
@cancel-edit="resetGoalForm(); goalMessage = ''"
|
||||
@submit:update="saveGoalEdit"
|
||||
@delete-goal="removeGoal"
|
||||
/>
|
||||
|
||||
<SettingsDashboard
|
||||
v-else-if="screenMode === 'settings'"
|
||||
class="print-hidden xl:h-full xl:overflow-y-auto"
|
||||
class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
|
||||
:user="currentUser"
|
||||
:profile-form="profileForm"
|
||||
:password-form="passwordForm"
|
||||
@@ -1703,7 +1715,7 @@ onMounted(() => {
|
||||
|
||||
<StatsDashboard
|
||||
v-else
|
||||
class="print-hidden xl:h-full xl:overflow-y-auto"
|
||||
class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
|
||||
:overview-cards="overviewCards"
|
||||
:weekly-records="weeklyRecords"
|
||||
:recent-records="recentRecords"
|
||||
@@ -1825,3 +1837,14 @@ onMounted(() => {
|
||||
</transition>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,10 +8,6 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: 'all',
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -36,12 +32,12 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:query',
|
||||
'update:status',
|
||||
'update:form-field',
|
||||
'submit:create',
|
||||
'start-edit',
|
||||
'cancel-edit',
|
||||
'submit:update',
|
||||
'delete-goal',
|
||||
])
|
||||
|
||||
function updateField(field, event) {
|
||||
@@ -109,19 +105,6 @@ function isActiveOnSelectedDate(goal) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">상태</label>
|
||||
<select
|
||||
:value="form.status"
|
||||
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"
|
||||
@change="updateField('status', $event)"
|
||||
>
|
||||
<option value="active">진행 중</option>
|
||||
<option value="done">완료</option>
|
||||
<option value="archived">보관</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3 text-[11px] font-semibold leading-5 tracking-[0.06em] text-stone-600">
|
||||
여기서 목표와 표시 기간을 설정해 두면, 플래너 작성 화면에서는 해당 날짜에 보여줄지 여부만 간단히 ON/OFF 할 수 있습니다.
|
||||
</p>
|
||||
@@ -161,7 +144,7 @@ function isActiveOnSelectedDate(goal) {
|
||||
목표가 많아져도 플래너 작성 화면이 길어지지 않도록, 전체 관리는 이 화면에서 처리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-[220px_140px]">
|
||||
<div class="grid gap-2 sm:grid-cols-[220px]">
|
||||
<input
|
||||
:value="query"
|
||||
type="text"
|
||||
@@ -169,16 +152,6 @@ function isActiveOnSelectedDate(goal) {
|
||||
placeholder="목표 검색"
|
||||
@input="emit('update:query', $event.target.value)"
|
||||
/>
|
||||
<select
|
||||
:value="status"
|
||||
class="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"
|
||||
@change="emit('update:status', $event.target.value)"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="active">진행 중</option>
|
||||
<option value="done">완료</option>
|
||||
<option value="archived">보관</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,17 +159,18 @@ function isActiveOnSelectedDate(goal) {
|
||||
<article
|
||||
v-for="goal in goals"
|
||||
:key="goal.id"
|
||||
class="rounded-[24px] border border-stone-200 bg-white px-5 py-5"
|
||||
class="rounded-[24px] border px-5 py-5 transition"
|
||||
:class="editingGoalId === goal.id ? 'border-stone-900 bg-[#f7f1e7] shadow-[0_18px_40px_rgba(28,25,23,0.10)]' : 'border-stone-200 bg-white'"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-lg font-semibold tracking-[-0.03em] text-stone-900">{{ goal.title }}</p>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.16em]"
|
||||
:class="goal.status === 'done' ? 'bg-emerald-100 text-emerald-700' : goal.status === 'archived' ? 'bg-stone-200 text-stone-600' : 'bg-stone-900 text-white'"
|
||||
v-if="editingGoalId === goal.id"
|
||||
class="rounded-full bg-stone-900 px-3 py-1 text-[10px] font-bold tracking-[0.16em] text-white"
|
||||
>
|
||||
{{ goal.status === 'done' ? '완료' : goal.status === 'archived' ? '보관' : '진행 중' }}
|
||||
수정 중
|
||||
</span>
|
||||
<span
|
||||
v-if="isActiveOnSelectedDate(goal)"
|
||||
@@ -211,13 +185,22 @@ function isActiveOnSelectedDate(goal) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-stone-300 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
|
||||
@click="emit('start-edit', goal)"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-stone-300 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-stone-600 transition hover:border-stone-500 hover:text-stone-900"
|
||||
@click="emit('start-edit', goal)"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-red-200 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-red-500 transition hover:border-red-400 hover:bg-red-50"
|
||||
@click="emit('delete-goal', goal)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ function selectYear(year) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border border-stone-200 bg-white/80 p-5">
|
||||
<section class="rounded-[24px] border border-stone-200 bg-white/82 p-5 shadow-[0_12px_36px_rgba(28,25,23,0.05)]">
|
||||
<div class="relative mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-[11px] font-bold tracking-[0.22em] text-ink">CALENDAR</h2>
|
||||
|
||||
@@ -55,3 +55,10 @@ export async function updateGoal(token, goalId, payload) {
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteGoal(token, goalId) {
|
||||
return request(`/api/goals/${goalId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user