feat: implement timer screens and navigation
This commit is contained in:
269
client/src/views/TimerUpdate/TimerUpdate.css
Normal file
269
client/src/views/TimerUpdate/TimerUpdate.css
Normal file
@@ -0,0 +1,269 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.timer-update-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #ffffff;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: auto;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #f44336;
|
||||
background-color: transparent;
|
||||
color: #f44336;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: rgba(244, 67, 54, 0.10);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: #212121;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 13px;
|
||||
color: #757575;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-shell {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
color: #212121;
|
||||
}
|
||||
|
||||
.state-card.error {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.timer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.field-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #212121;
|
||||
}
|
||||
|
||||
.field-caption {
|
||||
font-size: 12px;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.field-card input[type="text"] {
|
||||
width: 100%;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
color: #212121;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin-top: 6px;
|
||||
color: #d32f2f;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.title-input--error {
|
||||
border-color: #d32f2f !important;
|
||||
box-shadow: 0 0 0 2px rgba(211, 47, 47, 0.12) !important;
|
||||
}
|
||||
|
||||
.field-card input[type="text"]:focus,
|
||||
.title-input:focus,
|
||||
.title-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: #f44336;
|
||||
box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.12);
|
||||
}
|
||||
|
||||
.title-input:hover {
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.tomato-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tomato-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tomato-display {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
background: radial-gradient(circle at 30% 30%, #ff6b6b 0%, #f44336 65%, #d32f2f 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 18px rgba(244, 67, 54, 0.22);
|
||||
}
|
||||
|
||||
.tomato-display strong {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tomato-emoji {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.slider-card .slider-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.value-pill {
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
background: linear-gradient(145deg, #f44336, #ff7043);
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 6px 14px rgba(244, 67, 54, 0.25);
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.value-pill small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.slider-card input[type="range"] {
|
||||
flex: 1;
|
||||
accent-color: #f44336;
|
||||
}
|
||||
|
||||
.slider-card input[type="range"]::-webkit-slider-thumb {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.slider-card input[type="range"]::-moz-range-thumb {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.range-note {
|
||||
margin-top: 8px;
|
||||
color: #757575;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 2px solid #4caf50;
|
||||
border-radius: 999px;
|
||||
background-color: transparent;
|
||||
color: #4caf50;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: rgba(76, 175, 80, 0.10);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-shell {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.slider-card .slider-wrap {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.value-pill {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
91
client/src/views/TimerUpdate/TimerUpdate.html
Normal file
91
client/src/views/TimerUpdate/TimerUpdate.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<ion-page>
|
||||
<ion-content>
|
||||
<div class="timer-update-container">
|
||||
<div class="page-header">
|
||||
<button class="back-button" type="button" @click="goBack">
|
||||
<ion-icon :icon="arrowBackOutline"></ion-icon>
|
||||
Назад
|
||||
</button>
|
||||
<h1>{{ modeLabel }}</h1>
|
||||
<p>Подстрой параметры под нужный режим работы.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-shell">
|
||||
<div v-if="loading" class="state-card">Загрузка таймера...</div>
|
||||
<div v-else-if="error" class="state-card error">{{ error }}</div>
|
||||
|
||||
<form v-else class="timer-form" @submit.prevent="saveTimer">
|
||||
<label class="field-card">
|
||||
<span class="field-label">Название</span>
|
||||
<input
|
||||
class="title-input"
|
||||
:class="{ 'title-input--error': titleError }"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
placeholder="Например, Утренний фокус"
|
||||
/>
|
||||
<p v-if="titleError" class="field-error">{{ titleError }}</p>
|
||||
</label>
|
||||
|
||||
<section class="field-card tomato-card">
|
||||
<div class="field-head">
|
||||
<span class="field-label">Количество помидоров</span>
|
||||
</div>
|
||||
<div class="tomato-stage">
|
||||
<div class="tomato-display">
|
||||
<span class="tomato-emoji">🍅</span>
|
||||
<strong>{{ form.count_pomodoro }}</strong>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="form.count_pomodoro"
|
||||
type="range"
|
||||
:min="LIMITS.countPomodoro.min"
|
||||
:max="LIMITS.countPomodoro.max"
|
||||
@input="onSliderInput('count_pomodoro', LIMITS.countPomodoro.min, LIMITS.countPomodoro.max, $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="field-card slider-card">
|
||||
<div class="field-head">
|
||||
<span class="field-label">Длительность помидора</span>
|
||||
</div>
|
||||
<div class="slider-wrap">
|
||||
<div class="value-pill">{{ form.pomodoro_time }}<small>мин</small></div>
|
||||
<input v-model.number="form.pomodoro_time" type="range" :min="LIMITS.pomodoroTime.min" :max="LIMITS.pomodoroTime.max" @input="onSliderInput('pomodoro_time', LIMITS.pomodoroTime.min, LIMITS.pomodoroTime.max, $event)" />
|
||||
</div>
|
||||
<p class="range-note">Пределы: {{ LIMITS.pomodoroTime.min }} — {{ LIMITS.pomodoroTime.max }} минут</p>
|
||||
</section>
|
||||
|
||||
<section class="field-card slider-card">
|
||||
<div class="field-head">
|
||||
<span class="field-label">Короткий перерыв</span>
|
||||
</div>
|
||||
<div class="slider-wrap">
|
||||
<div class="value-pill">{{ form.break_time }}<small>мин</small></div>
|
||||
<input v-model.number="form.break_time" type="range" :min="LIMITS.breakTime.min" :max="LIMITS.breakTime.max" @input="onSliderInput('break_time', LIMITS.breakTime.min, LIMITS.breakTime.max, $event)" />
|
||||
</div>
|
||||
<p class="range-note">Пределы: {{ LIMITS.breakTime.min }} — {{ LIMITS.breakTime.max }} минут</p>
|
||||
</section>
|
||||
|
||||
<section class="field-card slider-card">
|
||||
<div class="field-head">
|
||||
<span class="field-label">Длинный перерыв</span>
|
||||
</div>
|
||||
<div class="slider-wrap">
|
||||
<div class="value-pill">{{ form.break_long_time }}<small>мин</small></div>
|
||||
<input v-model.number="form.break_long_time" type="range" :min="LIMITS.breakLongTime.min" :max="LIMITS.breakLongTime.max" @input="onSliderInput('break_long_time', LIMITS.breakLongTime.min, LIMITS.breakLongTime.max, $event)" />
|
||||
</div>
|
||||
<p class="range-note">Пределы: {{ LIMITS.breakLongTime.min }} — {{ LIMITS.breakLongTime.max }} минут</p>
|
||||
</section>
|
||||
|
||||
<button class="save-button" type="submit">
|
||||
<ion-icon :icon="checkmarkOutline"></ion-icon>
|
||||
Сохранить и открыть
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
226
client/src/views/TimerUpdate/TimerUpdate.ts
Normal file
226
client/src/views/TimerUpdate/TimerUpdate.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { computed, defineComponent, onMounted, ref, watch } from 'vue';
|
||||
import { IonIcon, IonPage, IonContent } from '@ionic/vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { arrowBackOutline, checkmarkOutline } from 'ionicons/icons';
|
||||
|
||||
import { createTimer, getTimer, updateTimer } from '@/db/crud/timerCrud';
|
||||
import type { TimerRow } from '@/types/timerType';
|
||||
|
||||
const LIMITS = {
|
||||
pomodoroTime: { min: 10, max: 90 },
|
||||
breakTime: { min: 3, max: 10 },
|
||||
breakLongTime: { min: 15, max: 40 },
|
||||
countPomodoro: { min: 2, max: 8 },
|
||||
} as const;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TimerUpdate',
|
||||
components: { IonIcon, IonPage, IonContent },
|
||||
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isCreateMode = computed(() => route.params.id === 'new');
|
||||
const currentId = computed(() => (isCreateMode.value ? 0 : Number(route.params.id)));
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const titleError = ref('');
|
||||
|
||||
const modeLabel = computed(() => (isCreateMode.value ? 'Создать таймер' : 'Редактировать таймер'));
|
||||
|
||||
const form = ref<TimerRow>({
|
||||
id: currentId.value,
|
||||
title: '',
|
||||
pomodoro_time: LIMITS.pomodoroTime.min,
|
||||
break_time: LIMITS.breakTime.min,
|
||||
break_long_time: LIMITS.breakLongTime.min,
|
||||
count_pomodoro: LIMITS.countPomodoro.min,
|
||||
});
|
||||
|
||||
/**
|
||||
* Безопасно преобразует значение в число.
|
||||
* Если преобразование невозможно, возвращает значение по умолчанию.
|
||||
*/
|
||||
const safeNumber = (value: unknown, fallback: number): number => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ограничивает число диапазоном min-max.
|
||||
*/
|
||||
const clamp = (value: number, min: number, max: number): number => {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Нормализует объект таймера и приводит числовые поля
|
||||
* к допустимым диапазонам значений.
|
||||
*/
|
||||
const normalizeTimer = (timer?: Partial<TimerRow>): TimerRow => {
|
||||
return {
|
||||
id: currentId.value,
|
||||
title: timer?.title ?? '',
|
||||
pomodoro_time: clamp(safeNumber(timer?.pomodoro_time, LIMITS.pomodoroTime.min), LIMITS.pomodoroTime.min, LIMITS.pomodoroTime.max),
|
||||
break_time: clamp(safeNumber(timer?.break_time, LIMITS.breakTime.min), LIMITS.breakTime.min, LIMITS.breakTime.max),
|
||||
break_long_time: clamp(safeNumber(timer?.break_long_time, LIMITS.breakLongTime.min), LIMITS.breakLongTime.min, LIMITS.breakLongTime.max),
|
||||
count_pomodoro: clamp(safeNumber(timer?.count_pomodoro, LIMITS.countPomodoro.min), LIMITS.countPomodoro.min, LIMITS.countPomodoro.max),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ограничивает значение поля формы указанным диапазоном.
|
||||
*/
|
||||
const clampField = (field: keyof TimerRow, min: number, max: number): void => {
|
||||
if (typeof form.value[field] === 'number') {
|
||||
form.value[field] = clamp(safeNumber(form.value[field], min), min, max) as never;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Обрабатывает изменение значения слайдера.
|
||||
*/
|
||||
const onSliderInput = (field: keyof TimerRow, min: number, max: number, value: Event): void => {
|
||||
const nextValue = safeNumber((value.target as HTMLInputElement | null)?.value, min);
|
||||
|
||||
if (typeof form.value[field] === 'number') {
|
||||
form.value[field] = clamp(nextValue, min, max) as never;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Загружает таймер из базы данных либо
|
||||
* создаёт форму со значениями по умолчанию.
|
||||
*/
|
||||
const loadTimer = async (): Promise<void> => {
|
||||
if (isCreateMode.value) {
|
||||
form.value = normalizeTimer({
|
||||
id: 0,
|
||||
title: '',
|
||||
pomodoro_time: LIMITS.pomodoroTime.min,
|
||||
break_time: LIMITS.breakTime.min,
|
||||
break_long_time: LIMITS.breakLongTime.min,
|
||||
count_pomodoro: LIMITS.countPomodoro.min,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const timer = await getTimer(currentId.value);
|
||||
form.value = normalizeTimer(timer);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Ошибка загрузки таймера';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Проверяет корректность названия таймера.
|
||||
*/
|
||||
const validateTitle = (): boolean => {
|
||||
const trimmedTitle = form.value.title.trim();
|
||||
|
||||
if (!trimmedTitle) {
|
||||
titleError.value = 'Введите название таймера';
|
||||
return false;
|
||||
}
|
||||
|
||||
titleError.value = '';
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Создаёт новый таймер или обновляет существующий.
|
||||
*/
|
||||
const saveTimer = async (): Promise<void> => {
|
||||
if (!validateTitle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmedTitle = form.value.title.trim();
|
||||
|
||||
form.value = normalizeTimer({
|
||||
id: isCreateMode.value ? 0 : currentId.value,
|
||||
title: trimmedTitle,
|
||||
pomodoro_time: form.value.pomodoro_time,
|
||||
break_time: form.value.break_time,
|
||||
break_long_time: form.value.break_long_time,
|
||||
count_pomodoro: form.value.count_pomodoro,
|
||||
});
|
||||
|
||||
if (isCreateMode.value) {
|
||||
const result = await createTimer(
|
||||
form.value.title,
|
||||
form.value.pomodoro_time,
|
||||
form.value.break_time,
|
||||
form.value.break_long_time,
|
||||
form.value.count_pomodoro,
|
||||
);
|
||||
|
||||
const newId = Number((result as { lastInsertId?: number }).lastInsertId ?? 0);
|
||||
if (!newId) {
|
||||
throw new Error('Не удалось получить id созданного таймера');
|
||||
}
|
||||
|
||||
router.push(`/timer/${newId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateTimer(
|
||||
currentId.value,
|
||||
form.value.title,
|
||||
form.value.pomodoro_time,
|
||||
form.value.break_time,
|
||||
form.value.break_long_time,
|
||||
form.value.count_pomodoro,
|
||||
);
|
||||
|
||||
router.push(`/timer/${currentId.value}`);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Ошибка сохранения таймера';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Возвращает пользователя на предыдущую страницу.
|
||||
*/
|
||||
const goBack = (): void => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
onMounted(loadTimer);
|
||||
|
||||
watch(() => route.params.id, () => {
|
||||
loadTimer();
|
||||
});
|
||||
|
||||
watch(() => form.value.title, () => {
|
||||
if (titleError.value && form.value.title.trim()) {
|
||||
titleError.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
form,
|
||||
loading,
|
||||
error,
|
||||
titleError,
|
||||
isCreateMode,
|
||||
modeLabel,
|
||||
LIMITS,
|
||||
arrowBackOutline,
|
||||
checkmarkOutline,
|
||||
clampField,
|
||||
onSliderInput,
|
||||
saveTimer,
|
||||
validateTitle,
|
||||
goBack,
|
||||
};
|
||||
},
|
||||
});
|
||||
4
client/src/views/TimerUpdate/TimerUpdate.vue
Normal file
4
client/src/views/TimerUpdate/TimerUpdate.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- TimerUpdate.vue -->
|
||||
<template src="@/views/TimerUpdate/TimerUpdate.html"></template>
|
||||
<script lang="ts" src="@/views/TimerUpdate/TimerUpdate.ts"></script>
|
||||
<style src="@/views/TimerUpdate/TimerUpdate.css" scoped></style>
|
||||
Reference in New Issue
Block a user