v0.1.20 - PostgreSQL 전환 및 Docker Compose 초안 추가

This commit is contained in:
2026-04-22 10:48:24 +09:00
parent 9b788406ea
commit 8ff4c979fa
24 changed files with 614 additions and 248 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
})
}