редактирование и удаление привычек
This commit is contained in:
parent
b2b1ba078e
commit
8e1d026fd4
10
GEMINI.md
10
GEMINI.md
|
|
@ -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.
|
||||
|
|
|
|||
94
app/components/ConfirmDialog.vue
Normal file
94
app/components/ConfirmDialog.vue
Normal 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>
|
||||
|
|
@ -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">
|
||||
<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>
|
||||
<!-- 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>
|
||||
</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">No habits yet. Add one above!</p>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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 }">
|
||||
|
|
|
|||
36
server/api/habits/[id]/index.delete.ts
Normal file
36
server/api/habits/[id]/index.delete.ts
Normal 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;
|
||||
});
|
||||
56
server/api/habits/[id]/index.put.ts
Normal file
56
server/api/habits/[id]/index.put.ts
Normal 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[],
|
||||
};
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user