редактирование и удаление привычек

This commit is contained in:
Alexander Andreev 2026-01-05 21:32:02 +03:00
parent b2b1ba078e
commit 8e1d026fd4
6 changed files with 399 additions and 25 deletions

View File

@ -69,6 +69,16 @@ Adhering to these conventions is critical for maintaining project stability.
- Group API endpoints by domain (e.g., `/api/habits`, `/api/village`).
- All business logic should reside in the backend.
### Data Conventions
- **`daysOfWeek` Field:** This array represents the days a habit is active. The week starts on **Monday**.
- Monday: `0`
- Tuesday: `1`
- Wednesday: `2`
- Thursday: `3`
- Friday: `4`
- Saturday: `5`
- Sunday: `6`
### AI / Gemini Usage Rules
- **DO NOT** allow the AI to change the Node.js version, upgrade Prisma, alter the Prisma configuration, or modify the core project structure.
- **ALLOWED:** The AI can be used to add or modify Prisma schema models, generate new API endpoints, and implement business logic within the existing architectural framework.

View File

@ -0,0 +1,94 @@
<template>
<div v-if="show" class="modal-overlay" @click.self="$emit('cancel')">
<div class="modal-content">
<h4>{{ title }}</h4>
<p>{{ message }}</p>
<div class="modal-actions">
<button @click="$emit('cancel')" class="btn-cancel">{{ cancelText }}</button>
<button @click="$emit('confirm')" class="btn-confirm">{{ confirmText }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
show: {
type: Boolean,
required: true,
},
title: {
type: String,
default: 'Подтверждение',
},
message: {
type: String,
required: true,
},
confirmText: {
type: String,
default: 'Да',
},
cancelText: {
type: String,
default: 'Отмена',
},
});
defineEmits(['confirm', 'cancel']);
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 25px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
max-width: 400px;
width: 90%;
text-align: center;
}
.modal-content h4 {
margin-top: 0;
font-size: 1.2em;
}
.modal-actions {
margin-top: 20px;
display: flex;
justify-content: center;
gap: 15px;
}
.modal-actions button {
padding: 10px 20px;
border-radius: 5px;
border: none;
font-size: 1em;
cursor: pointer;
}
.btn-confirm {
background-color: #bf616a; /* Nord Red for destructive actions */
color: white;
}
.btn-cancel {
background-color: #e5e9f0; /* Nord light gray */
color: #4c566a;
}
</style>

View File

@ -1,41 +1,75 @@
<template>
<div class="habits-container">
<h3>My Habits</h3>
<h3>Мои Привычки</h3>
<!-- Create Habit Form -->
<form @submit.prevent="createHabit" class="create-habit-form">
<h4>Create a New Habit</h4>
<h4>Создать новую привычку</h4>
<div v-if="error" class="error-message">{{ error }}</div>
<input v-model="newHabitName" type="text" placeholder="e.g., Read for 15 minutes" required />
<input v-model="newHabitName" type="text" placeholder="Например, читать 15 минут" required />
<div class="days-selector">
<label v-for="day in dayOptions" :key="day" class="day-label">
<input type="checkbox" :value="day" v-model="newHabitDays" />
<span>{{ day }}</span>
<label v-for="day in dayOptions" :key="day.value" class="day-label">
<input type="checkbox" :value="day.name" v-model="newHabitDays" />
<span>{{ day.name }}</span>
</label>
</div>
<button type="submit" :disabled="loading.create">
{{ loading.create ? 'Adding...' : 'Add Habit' }}
{{ loading.create ? 'Добавляем...' : 'Добавить Привычку' }}
</button>
</form>
<!-- Habits List -->
<div class="habits-list">
<p v-if="loading.fetch">Loading habits...</p>
<p v-if="loading.fetch">Загрузка привычек...</p>
<div v-for="habit in habits" :key="habit.id" class="habit-card">
<!-- Viewing Mode -->
<div v-if="editingHabitId !== habit.id" class="habit-view">
<div class="habit-info">
<h4>{{ habit.name }}</h4>
<div class="habit-days">
<span v-for="day in habit.daysOfWeek" :key="day" class="day-chip">{{ dayMap[day] }}</span>
</div>
</div>
<div class="habit-actions">
<button @click="startEditing(habit)" class="edit-btn">Редактировать</button>
<button @click="promptForDelete(habit.id)" :disabled="loading.delete" class="delete-btn">Удалить</button>
</div>
<p v-if="!loading.fetch && habits.length === 0">No habits yet. Add one above!</p>
</div>
<!-- Editing Mode -->
<form v-else @submit.prevent="saveHabit(habit.id)" class="habit-edit-form">
<input v-model="editHabitName" type="text" required />
<div class="days-selector edit-days">
<label v-for="day in dayOptions" :key="day.value" class="day-label">
<input type="checkbox" :value="day.name" v-model="editHabitDays" />
<span>{{ day.name }}</span>
</label>
</div>
<div class="edit-actions">
<button type="submit" :disabled="loading.edit" class="save-btn">Сохранить</button>
<button type="button" @click="cancelEditing" class="cancel-btn">Отмена</button>
</div>
</form>
</div>
<p v-if="!loading.fetch && habits.length === 0">Пока нет привычек. Добавьте одну!</p>
</div>
<!-- Deletion Confirmation Dialog -->
<ConfirmDialog
:show="isConfirmDialogOpen"
title="Подтвердить удаление"
message="Вы уверены, что хотите удалить эту привычку? Это действие нельзя отменить."
confirm-text="Удалить"
cancel-text="Отмена"
@confirm="handleDeleteConfirm"
@cancel="handleDeleteCancel"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import ConfirmDialog from '~/components/ConfirmDialog.vue';
// --- Type Definitions ---
interface Habit {
@ -50,15 +84,36 @@ const api = useApi();
// --- State ---
const habits = ref<Habit[]>([]);
const newHabitName = ref('');
const dayOptions = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const dayMap: { [key: number]: string } = { 0: 'Mon', 1: 'Tue', 2: 'Wed', 3: 'Thu', 4: 'Fri', 5: 'Sat', 6: 'Sun' };
// Day mapping based on Mon=0, ..., Sun=6
const dayOptions = [
{ name: 'Пн', value: 0 },
{ name: 'Вт', value: 1 },
{ name: 'Ср', value: 2 },
{ name: 'Чт', value: 3 },
{ name: 'Пт', value: 4 },
{ name: 'Сб', value: 5 },
{ name: 'Вс', value: 6 },
];
const dayMap: { [key: number]: string } = Object.fromEntries(dayOptions.map(d => [d.value, d.name]));
const newHabitDays = ref<string[]>([]);
const error = ref<string | null>(null);
const loading = ref({
fetch: false,
create: false,
edit: false,
delete: false,
});
// Editing state
const editingHabitId = ref<number | null>(null);
const editHabitName = ref('');
const editHabitDays = ref<string[]>([]);
// Deletion state
const isConfirmDialogOpen = ref(false);
const habitToDeleteId = ref<number | null>(null);
// --- API Functions ---
const fetchHabits = async () => {
loading.value.fetch = true;
@ -67,7 +122,7 @@ const fetchHabits = async () => {
habits.value = await api<Habit[]>('/habits');
} catch (err: any) {
console.error('Failed to fetch habits:', err);
error.value = 'Could not load habits.';
error.value = 'Не удалось загрузить привычки.';
} finally {
loading.value.fetch = false;
}
@ -75,17 +130,16 @@ const fetchHabits = async () => {
const createHabit = async () => {
if (!newHabitName.value || newHabitDays.value.length === 0) {
error.value = 'Please provide a name and select at least one day.';
error.value = 'Пожалуйста, укажите название и выберите хотя бы один день.';
return;
}
loading.value.create = true;
error.value = null;
// Convert day names to numbers
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.indexOf(dayName));
const dayNumbers = newHabitDays.value.map(dayName => dayOptions.find(d => d.name === dayName)!.value);
try {
await api<Habit>('/habits', {
const newHabit = await api<Habit>('/habits', {
method: 'POST',
body: {
name: newHabitName.value,
@ -93,19 +147,97 @@ const createHabit = async () => {
},
});
// Clear form and re-fetch the list from the server
habits.value.push(newHabit); // Optimistic update
newHabitName.value = '';
newHabitDays.value = [];
await fetchHabits();
} catch (err: any) {
console.error('Failed to create habit:', err);
error.value = err.data?.message || 'Could not create habit.';
error.value = err.data?.message || 'Не удалось создать привычку.';
// Re-fetch on error to ensure consistency
await fetchHabits();
} finally {
loading.value.create = false;
}
};
const startEditing = (habit: Habit) => {
editingHabitId.value = habit.id;
editHabitName.value = habit.name;
editHabitDays.value = habit.daysOfWeek.map(dayValue => dayMap[dayValue]);
};
const cancelEditing = () => {
editingHabitId.value = null;
editHabitName.value = '';
editHabitDays.value = [];
};
const saveHabit = async (habitId: number) => {
loading.value.edit = true;
error.value = null;
const dayNumbers = editHabitDays.value.map(dayName => dayOptions.find(d => d.name === dayName)!.value);
try {
const updatedHabit = await api<Habit>(`/habits/${habitId}`, {
method: 'PUT',
body: {
name: editHabitName.value,
daysOfWeek: dayNumbers,
},
});
// Update local state
const index = habits.value.findIndex(h => h.id === habitId);
if (index !== -1) {
habits.value[index] = updatedHabit;
}
cancelEditing();
} catch (err: any) {
console.error('Failed to save habit:', err);
error.value = err.data?.message || 'Не удалось сохранить привычку.';
} finally {
loading.value.edit = false;
}
};
const promptForDelete = (habitId: number) => {
habitToDeleteId.value = habitId;
isConfirmDialogOpen.value = true;
};
const handleDeleteCancel = () => {
isConfirmDialogOpen.value = false;
habitToDeleteId.value = null;
};
const handleDeleteConfirm = () => {
if (habitToDeleteId.value !== null) {
deleteHabit(habitToDeleteId.value);
}
handleDeleteCancel();
};
const deleteHabit = async (habitId: number) => {
loading.value.delete = true;
error.value = null;
try {
await api(`/habits/${habitId}`, { method: 'DELETE' });
// Update local state
habits.value = habits.value.filter(h => h.id !== habitId);
} catch (err: any) {
console.error('Failed to delete habit:', err);
error.value = err.data?.message || 'Не удалось удалить привычку.';
} finally {
loading.value.delete = false;
}
};
// --- Lifecycle Hooks ---
onMounted(fetchHabits);
</script>
@ -202,6 +334,13 @@ h3 {
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.habit-view {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.habit-info h4 {
margin: 0 0 10px 0;
}
@ -227,4 +366,43 @@ h3 {
margin-bottom: 15px;
text-align: center;
}
/* Actions */
.habit-actions button {
padding: 5px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: 10px;
}
.edit-btn { background-color: #d8dee9; color: #4c566a; }
.delete-btn { background-color: #bf616a; color: #fff; }
/* Edit Form */
.habit-edit-form {
width: 100%;
}
.habit-edit-form input[type="text"] {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 10px;
}
.edit-days {
margin-bottom: 10px;
}
.edit-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.edit-actions button {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.save-btn { background-color: #a3be8c; color: #fff; }
.cancel-btn { background-color: #eceff4; color: #4c566a; }
</style>

View File

@ -3,7 +3,7 @@
<div v-if="isAuthenticated && user" class="dashboard-content">
<h1>Ваши цели на сегодня</h1>
<p>Цели обновляются раз в сутки. Бонусы за выполнение целей усиливаются, если посещать страницу ежедневно!</p>
<p>Цели обновляются раз в сутки. Получаемые бонусы усиливаются, если посещать сайт ежедневно.</p>
<div class="streak-section">
<div class="streak-card" :class="{ 'active-streak': user.dailyStreak === 1 }">

View File

@ -0,0 +1,36 @@
import { getUserIdFromSession } from '../../../utils/auth';
export default defineEventHandler(async (event) => {
const userId = await getUserIdFromSession(event);
const habitId = Number(event.context.params?.id);
if (isNaN(habitId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
}
// --- Authorization & Deletion ---
// First, verify the habit exists and belongs to the user.
const habit = await prisma.habit.findUnique({
where: {
id: habitId,
},
});
if (!habit || habit.userId !== userId) {
throw createError({ statusCode: 404, statusMessage: 'Habit not found or permission denied.' });
}
// Now, delete the habit
await prisma.habit.delete({
where: {
id: habitId,
},
});
// --- Response ---
// Send 204 No Content status
setResponseStatus(event, 204);
// Return null or an empty body
return null;
});

View File

@ -0,0 +1,56 @@
import { getUserIdFromSession } from '../../../utils/auth';
interface HabitDto {
id: number;
name: string;
daysOfWeek: number[];
}
export default defineEventHandler(async (event): Promise<HabitDto> => {
const userId = await getUserIdFromSession(event);
const habitId = Number(event.context.params?.id);
const { name, daysOfWeek } = await readBody(event);
if (isNaN(habitId)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid habit ID.' });
}
// --- Validation ---
if (!name || !Array.isArray(daysOfWeek)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid input: name and daysOfWeek are required.' });
}
// Sanitize daysOfWeek to ensure it's a unique set of valid numbers
const validDays = daysOfWeek.filter(day => typeof day === 'number' && day >= 0 && day <= 6);
const sanitizedDays = [...new Set(validDays)].sort();
// --- Authorization & Update ---
// First, verify the habit exists and belongs to the user.
const habit = await prisma.habit.findUnique({
where: {
id: habitId,
},
});
if (!habit || habit.userId !== userId) {
throw createError({ statusCode: 404, statusMessage: 'Habit not found or permission denied.' });
}
// Now, update the habit
const updatedHabit = await prisma.habit.update({
where: {
id: habitId,
},
data: {
name,
daysOfWeek: sanitizedDays,
},
});
// Return DTO
return {
id: updatedHabit.id,
name: updatedHabit.name,
daysOfWeek: updatedHabit.daysOfWeek as number[],
};
});