From 51ebdc821359c0d0ab655d72af794ee17042ba7e Mon Sep 17 00:00:00 2001 From: Arduinum Date: Thu, 4 Jun 2026 22:12:26 +0300 Subject: [PATCH] feat: implement timer screens and navigation --- client/src/router/index.ts | 6 + client/src/views/Timer/Timer.css | 91 +++++- client/src/views/Timer/Timer.html | 25 +- client/src/views/Timer/Timer.ts | 48 +++- client/src/views/TimerUpdate/TimerUpdate.css | 269 ++++++++++++++++++ client/src/views/TimerUpdate/TimerUpdate.html | 91 ++++++ client/src/views/TimerUpdate/TimerUpdate.ts | 226 +++++++++++++++ client/src/views/TimerUpdate/TimerUpdate.vue | 4 + client/src/views/TimersList/TimersList.css | 92 +++++- client/src/views/TimersList/TimersList.html | 21 +- client/src/views/TimersList/TimersList.ts | 84 +++++- 11 files changed, 926 insertions(+), 31 deletions(-) create mode 100644 client/src/views/TimerUpdate/TimerUpdate.css create mode 100644 client/src/views/TimerUpdate/TimerUpdate.html create mode 100644 client/src/views/TimerUpdate/TimerUpdate.ts create mode 100644 client/src/views/TimerUpdate/TimerUpdate.vue diff --git a/client/src/router/index.ts b/client/src/router/index.ts index fbce953..44419ef 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from '@ionic/vue-router'; import type { RouteRecordRaw } from 'vue-router'; import TimersList from '@/views/TimersList/TimersList.vue'; import Timer from '@/views/Timer/Timer.vue'; +import TimerUpdate from '@/views/TimerUpdate/TimerUpdate.vue'; const routes: RouteRecordRaw[] = [ { @@ -17,6 +18,11 @@ const routes: RouteRecordRaw[] = [ path: '/timer/:id', name: 'Timer', component: Timer + }, + { + path: '/timer-update/:id', + name: 'TimerUpdate', + component: TimerUpdate } ]; diff --git a/client/src/views/Timer/Timer.css b/client/src/views/Timer/Timer.css index b859379..c63dbdf 100644 --- a/client/src/views/Timer/Timer.css +++ b/client/src/views/Timer/Timer.css @@ -10,8 +10,29 @@ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; display: flex; - align-items: center; - justify-content: center; + flex-direction: column; +} + +.page-header { + background-color: #ffffff; + padding: 16px; + border-bottom: 1px solid #e0e0e0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + width: 100%; + min-height: 72px; + position: relative; +} + +.page-header h1 { + font-size: 32px; + font-weight: 400; + color: #212121; + letter-spacing: -0.5px; + margin: 0; + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); } .timer-screen { @@ -21,6 +42,47 @@ display: flex; flex-direction: column; align-items: center; + margin: 0 auto; + flex: 1; + justify-content: center; +} + +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.35); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; +} + +.confirm-card { + width: 100%; + max-width: 380px; + background: #ffffff; + border-radius: 16px; + padding: 18px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18); +} + +.confirm-card h3 { + font-size: 18px; + color: #212121; + margin-bottom: 8px; +} + +.confirm-card p { + font-size: 14px; + color: #666; + margin-bottom: 16px; +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; } .timer-display-card { @@ -87,6 +149,31 @@ height: 48px; } +.btn-back { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 40px; + min-width: 96px; + padding: 0 14px; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + border-radius: 999px; + border: 2px solid #f44336; + background-color: transparent; + color: #f44336; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.btn-back:hover { + background-color: rgba(244, 67, 54, 0.10); +} + .btn-timer { display: flex; align-items: center; diff --git a/client/src/views/Timer/Timer.html b/client/src/views/Timer/Timer.html index a7b3404..f5c17f4 100644 --- a/client/src/views/Timer/Timer.html +++ b/client/src/views/Timer/Timer.html @@ -1,11 +1,34 @@
+ + +
+
+

Выйти из таймера?

+

Текущий прогресс будет сброшен, если вы уйдёте из этого экрана.

+
+ + +
+
+
+
-
{{ countdown?.timerNow.value?.title }} diff --git a/client/src/views/Timer/Timer.ts b/client/src/views/Timer/Timer.ts index 6b95695..4c3f422 100644 --- a/client/src/views/Timer/Timer.ts +++ b/client/src/views/Timer/Timer.ts @@ -1,11 +1,12 @@ -import { defineComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { computed, defineComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { IonIcon, IonPage, IonContent } from '@ionic/vue'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import { stopOutline, playOutline, - pauseOutline + pauseOutline, + arrowBackOutline } from 'ionicons/icons'; import type { TimerRow } from '@/types/timerType'; @@ -27,6 +28,7 @@ export default defineComponent({ /** Инициализирует данные и логику компонента таймера */ setup() { const route = useRoute(); + const router = useRouter(); const id = Number(route.params.id); /** Реактивные данные загруженного таймера */ @@ -37,6 +39,7 @@ export default defineComponent({ /** Текущее состояние кнопок управления */ const state = ref('idle'); + const confirmLeave = ref(false); /** Обновляет текущее состояние таймера */ const setState = (newState: TimerState) => { @@ -46,6 +49,39 @@ export default defineComponent({ /** Shallow-ссылка на экземпляр composable обратного отсчёта */ const countdown = shallowRef(null); +/** Определяет, нужно ли показывать кнопку "Назад". */ +const showBackButton = computed(() => { + return ( + state.value === 'paused' || + (state.value === 'idle' && + countdown.value?.isRunning.value === false && + countdown.value?.timerNow.value !== undefined) + ); +}); + +/** Обрабатывает попытку выхода из таймера. */ +const askLeaveTimer = (): void => { + if (state.value === 'paused') { + confirmLeave.value = true; + return; + } + + router.back(); +}; + +/** Закрывает модальное окно подтверждения выхода. */ +const closeLeaveModal = (): void => { + confirmLeave.value = false; +}; + +/** Подтверждает выход из таймера. */ +const confirmLeaveTimer = (): void => { + confirmLeave.value = false; + countdown.value?.stop(); + state.value = 'idle'; + router.back(); +}; + /** Чтение конфига */ const config = loadConfig() @@ -78,9 +114,15 @@ export default defineComponent({ error, state, setState, + showBackButton, + confirmLeave, + askLeaveTimer, + closeLeaveModal, + confirmLeaveTimer, stopOutline, playOutline, pauseOutline, + arrowBackOutline, countdown }; } diff --git a/client/src/views/TimerUpdate/TimerUpdate.css b/client/src/views/TimerUpdate/TimerUpdate.css new file mode 100644 index 0000000..a2c1b68 --- /dev/null +++ b/client/src/views/TimerUpdate/TimerUpdate.css @@ -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; + } +} diff --git a/client/src/views/TimerUpdate/TimerUpdate.html b/client/src/views/TimerUpdate/TimerUpdate.html new file mode 100644 index 0000000..91dc064 --- /dev/null +++ b/client/src/views/TimerUpdate/TimerUpdate.html @@ -0,0 +1,91 @@ + + +
+ + +
+
Загрузка таймера...
+
{{ error }}
+ +
+ + +
+
+ Количество помидоров +
+
+
+ 🍅 + {{ form.count_pomodoro }} +
+ +
+
+ +
+
+ Длительность помидора +
+
+
{{ form.pomodoro_time }}мин
+ +
+

Пределы: {{ LIMITS.pomodoroTime.min }} — {{ LIMITS.pomodoroTime.max }} минут

+
+ +
+
+ Короткий перерыв +
+
+
{{ form.break_time }}мин
+ +
+

Пределы: {{ LIMITS.breakTime.min }} — {{ LIMITS.breakTime.max }} минут

+
+ +
+
+ Длинный перерыв +
+
+
{{ form.break_long_time }}мин
+ +
+

Пределы: {{ LIMITS.breakLongTime.min }} — {{ LIMITS.breakLongTime.max }} минут

+
+ + +
+
+
+
+
diff --git a/client/src/views/TimerUpdate/TimerUpdate.ts b/client/src/views/TimerUpdate/TimerUpdate.ts new file mode 100644 index 0000000..12cf576 --- /dev/null +++ b/client/src/views/TimerUpdate/TimerUpdate.ts @@ -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(null); + const titleError = ref(''); + + const modeLabel = computed(() => (isCreateMode.value ? 'Создать таймер' : 'Редактировать таймер')); + + const form = ref({ + 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 => { + 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 => { + 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 => { + 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, + }; + }, +}); diff --git a/client/src/views/TimerUpdate/TimerUpdate.vue b/client/src/views/TimerUpdate/TimerUpdate.vue new file mode 100644 index 0000000..8bcaa87 --- /dev/null +++ b/client/src/views/TimerUpdate/TimerUpdate.vue @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/views/TimersList/TimersList.css b/client/src/views/TimersList/TimersList.css index b4020d3..891aeb9 100644 --- a/client/src/views/TimersList/TimersList.css +++ b/client/src/views/TimersList/TimersList.css @@ -31,6 +31,44 @@ margin: 0 auto; } +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.35); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; +} + +.confirm-card { + width: 100%; + max-width: 380px; + background: #ffffff; + border-radius: 16px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18); + padding: 18px; +} + +.confirm-card h3 { + font-size: 18px; + color: #212121; + margin-bottom: 8px; +} + +.confirm-card p { + font-size: 14px; + color: #666; + margin-bottom: 16px; +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + .loading, .error-message, .empty-state { @@ -179,6 +217,27 @@ font-weight: 400; } +.add-timer-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + min-height: 48px; + margin-bottom: 12px; + border: 2px solid #1976d2; + border-radius: 999px; + background-color: transparent; + color: #1976d2; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.add-timer-button:hover { + background-color: rgba(25, 118, 210, 0.10); +} + .card-actions { display: grid; grid-template-columns: 1fr 1fr 1fr; @@ -191,8 +250,8 @@ justify-content: center; gap: 6px; padding: 10px 12px; - border: none; - border-radius: 4px; + border: 2px solid transparent; + border-radius: 999px; font-size: 13px; font-weight: 500; cursor: pointer; @@ -207,43 +266,46 @@ } .btn-primary { - background-color: #1976d2; - color: #ffffff; + background-color: transparent; + color: #4caf50; + border: 2px solid #4caf50; } .btn-primary:hover { - background-color: #1565c0; - box-shadow: 0 4px 8px rgba(25, 118, 210, 0.3); + background-color: rgba(76, 175, 80, 0.10); + box-shadow: 0 4px 8px rgba(76, 175, 80, 0.18); } .btn-primary:active { - background-color: #0d47a1; + background-color: rgba(76, 175, 80, 0.20); } .btn-secondary { - background-color: #e0e0e0; - color: #212121; + background-color: transparent; + color: #f9a825; + border: 2px solid #f9a825; } .btn-secondary:hover { - background-color: #d0d0d0; + background-color: rgba(249, 168, 37, 0.10); } .btn-secondary:active { - background-color: #c0c0c0; + background-color: rgba(249, 168, 37, 0.20); } .btn-danger { - background-color: #fff3e0; - color: #e65100; + background-color: transparent; + color: #f44336; + border: 2px solid #f44336; } .btn-danger:hover { - background-color: #ffe0b2; + background-color: rgba(244, 67, 54, 0.10); } .btn-danger:active { - background-color: #ffcc80; + background-color: rgba(244, 67, 54, 0.20); } @media (max-width: 600px) { diff --git a/client/src/views/TimersList/TimersList.html b/client/src/views/TimersList/TimersList.html index c656447..f3ab735 100644 --- a/client/src/views/TimersList/TimersList.html +++ b/client/src/views/TimersList/TimersList.html @@ -6,6 +6,21 @@
+
+
+

Удалить таймер?

+

Это действие нельзя будет отменить.

+
+ + +
+
+
+ +

Загрузка таймеров...

@@ -63,15 +78,15 @@
- - - diff --git a/client/src/views/TimersList/TimersList.ts b/client/src/views/TimersList/TimersList.ts index 61ea5dd..88915e2 100644 --- a/client/src/views/TimersList/TimersList.ts +++ b/client/src/views/TimersList/TimersList.ts @@ -1,6 +1,6 @@ -import { defineComponent, onMounted, ref } from 'vue' +import { defineComponent, onMounted, ref, watch } from 'vue' import { IonIcon, IonPage, IonContent } from '@ionic/vue' -import { useRouter } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router' import { timerOutline, chevronUp, @@ -8,26 +8,76 @@ import { playCircleOutline, pencilOutline, trashOutline, + addCircleOutline, } from 'ionicons/icons' -import { getTimers } from '@/db/crud/timerCrud' +import { deleteTimer, getTimers } from '@/db/crud/timerCrud' import type { TimersRow } from '@/types/timerType' export default defineComponent({ name: 'TimersList', components: { IonIcon, IonPage, IonContent }, + setup() { + /** Список всех таймеров */ const timers = ref([]) + + /** Флаг загрузки списка таймеров */ const loading = ref(true) + + /** Ошибка загрузки или операций */ const error = ref(null) + + /** ID раскрытого таймера в списке */ const expandedId = ref(null) - const router = useRouter(); + /** ID таймера, ожидающего удаления */ + const confirmDeleteId = ref(null) + const router = useRouter() + const route = useRoute() + + /** Переход на страницу конкретного таймера */ const goToTimer = (id: number) => { - router.push(`/timer/${id}`); - }; + router.push(`/timer/${id}`) + } + /** Переход на создание нового таймера */ + const goToCreateTimer = () => { + router.push('/timer-update/new') + } + + /** Переход на редактирование таймера */ + const goToEditTimer = (id: number) => { + router.push(`/timer-update/${id}`) + } + + /** Открывает подтверждение удаления таймера */ + const askDeleteTimer = (id: number): void => { + confirmDeleteId.value = id + } + + /** Отмена удаления таймера */ + const cancelDeleteTimer = (): void => { + confirmDeleteId.value = null + } + + /** Удаляет таймер и обновляет список */ + const removeTimer = async (): Promise => { + if (confirmDeleteId.value === null) { + return + } + + try { + await deleteTimer(confirmDeleteId.value) + confirmDeleteId.value = null + await loadTimers() + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Ошибка удаления таймера' + } + } + + /** Загружает список таймеров из базы данных */ const loadTimers = async (): Promise => { loading.value = true error.value = null @@ -42,17 +92,31 @@ export default defineComponent({ } } + /** Переключает раскрытие карточки таймера */ const toggleExpand = (id: number): void => { expandedId.value = expandedId.value === id ? null : id } + /** Загружает таймеры при первом рендере */ onMounted(loadTimers) + /** Перезагружает список при возврате на страницу /timers */ + watch( + () => route.fullPath, + (path) => { + if (path === '/timers') { + loadTimers() + } + }, + { immediate: false } + ) + return { timers, loading, error, expandedId, + confirmDeleteId, toggleExpand, @@ -62,7 +126,13 @@ export default defineComponent({ playCircleOutline, pencilOutline, trashOutline, - goToTimer + addCircleOutline, + goToTimer, + goToCreateTimer, + goToEditTimer, + askDeleteTimer, + cancelDeleteTimer, + removeTimer, } }, })