Compare commits
22 Commits
0.1.0-alph
...
0.3.0-alph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b91776a21 | ||
|
|
80d94e2fa2 | ||
|
|
b72f154016 | ||
|
|
f73be8c219 | ||
|
|
632bf8202f | ||
|
|
939be73abf | ||
|
|
067f8e066c | ||
|
|
51ebdc8213 | ||
|
|
15fd9ae275 | ||
|
|
79e87dbbe8 | ||
|
|
5f004ddcbc | ||
|
|
23239e303a | ||
|
|
196f3c6cb4 | ||
|
|
f45e23b440 | ||
|
|
35061a44f8 | ||
|
|
d56c983901 | ||
|
|
5b52549404 | ||
|
|
46b8b181da | ||
|
|
a431f70c25 | ||
|
|
e7b768c926 | ||
|
|
1716306e86 | ||
|
|
4d2bb6597a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ gen/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db*
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
|
||||
42
README.en.md
Normal file
42
README.en.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Kawai-Focus
|
||||
A focus app based on the Pomodoro technique.
|
||||
|
||||
The project is currently at the MVP1 development stage:
|
||||
|
||||
- **Pomodoro** — timers for the Pomodoro technique to manage your work sessions and rest breaks;
|
||||
- **Guide** — information on how to use the timers and the app, as well as details about the Pomodoro technique itself;
|
||||
- **App Limiter** — block or limit distracting applications during your work blocks.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Vue.js** — handles the user interface and SQLite operations (frontend);
|
||||
- **Ionic** — provides native UI components and the mobile version;
|
||||
- **Tauri** — the engine powering the desktop version of the application.
|
||||
|
||||
## Author
|
||||
|
||||
**Project Developer:**
|
||||
- **Name and Surname** — Eugene Kaddo
|
||||
- **Nickname** — Arduinum628
|
||||
|
||||
I share detailed insights about the app's development process in my articles on [Kawai-Focus](https://pressanybutton.ru/category/kawai-focus/) at the website [Kod on napkin](https://pressanybutton.ru).
|
||||
|
||||
<details>
|
||||
<summary><strong>Branch and Commit Naming Conventions</strong></summary>
|
||||
|
||||
Branch example: `nickname/name_task`
|
||||
- **nickname** — your GitHub username;
|
||||
- **name_task** — task name (matches the card title on YouGile).
|
||||
|
||||
Commit example: `refactor: renaming a variable`
|
||||
- **feat:** — a new feature for the application code (excluding build configurations);
|
||||
- **devops:** — build configuration changes (adding, removing, or fixing configurations);
|
||||
- **fix:** — a bug fix in the application code;
|
||||
- **docs:** — documentation changes only;
|
||||
- **style:** — changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.);
|
||||
- **refactor:** — a code change that neither fixes a bug nor adds a feature (e.g., renaming a variable);
|
||||
- **test:** — adding missing tests or correcting existing tests;
|
||||
- **chore:** — changes to the build process or auxiliary tools and libraries such as documentation generation.
|
||||
|
||||
Conventions are based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
</details>
|
||||
@@ -1,7 +1,7 @@
|
||||
# kawai-focus-v2
|
||||
Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro.
|
||||
# Кавай-Фокус
|
||||
Приложение для фокусировки внимания на основе таймера Pomodoro.
|
||||
|
||||
роект находится на стадии разработки MVP1:
|
||||
Проект находится на стадии разработки MVP1:
|
||||
|
||||
- **Pomodoro** - таймеры для техники Pomodoro, которая управляет вашим временем работы и перерывами на отдых;
|
||||
- **Гайд** - информация о том как использовать таймеры и приложение, а так же о самой технике Pomodoro;
|
||||
@@ -20,7 +20,7 @@ Kawai-Focus - приложение для фокусировки внимани
|
||||
- **Имя и Фамилия** - Евгений Каддо
|
||||
- **Никнейм** - Arduinum628
|
||||
|
||||
Подробно рассказываю о процессе написания кода приложения в своих статьях <a href="https://pressanybutton.ru/category/kawai-focus/">kawai-focus</a> на сайте <a href="https://pressanybutton.ru">Код на салфетке</a>.
|
||||
Подробно рассказываю о процессе написания кода приложения в своих статьях <a href="https://pressanybutton.ru/category/kawai-focus/">Kawai-Focus</a> на сайте <a href="https://pressanybutton.ru">Код на салфетке</a>.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
|
||||
679
client/package-lock.json
generated
679
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,10 @@
|
||||
"@ionic/vue": "^8.0.0",
|
||||
"@ionic/vue-router": "^8.0.0",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-sql": "^2.3.1",
|
||||
"ionicons": "^7.0.0",
|
||||
"toml": "^4.1.1",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
|
||||
4
client/public/config.toml
Normal file
4
client/public/config.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Конфиг таймера
|
||||
|
||||
[sound]
|
||||
sound_id = "alarm_beep"
|
||||
BIN
client/public/sounds/alarm-beep.mp3
Normal file
BIN
client/public/sounds/alarm-beep.mp3
Normal file
Binary file not shown.
35
client/src/composables/chainTimer.ts
Normal file
35
client/src/composables/chainTimer.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TypeTimer } from '@/types/timerType';
|
||||
import { getTimer } from '@/db/crud/timerCrud';
|
||||
|
||||
|
||||
/** Фомирует цепочку таймеров */
|
||||
export async function queueTimer(TimerId: number): Promise<TypeTimer[]> {
|
||||
let timers: TypeTimer[] = [];
|
||||
|
||||
const result = await getTimer(TimerId);
|
||||
const timerData = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
for (let i = 0; i < timerData.count_pomodoro; i++) {
|
||||
timers.push({
|
||||
title: timerData.title,
|
||||
time: timerData.pomodoro_time,
|
||||
typeTimer: "Помидор"
|
||||
});
|
||||
|
||||
if (i === timerData.count_pomodoro - 1) {
|
||||
timers.push({
|
||||
title: timerData.title,
|
||||
time: timerData.break_long_time,
|
||||
typeTimer: "Перерывище"
|
||||
});
|
||||
} else {
|
||||
timers.push({
|
||||
title: timerData.title,
|
||||
time: timerData.break_time,
|
||||
typeTimer: "Перерыв"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return timers;
|
||||
}
|
||||
16
client/src/composables/libAudio.ts
Normal file
16
client/src/composables/libAudio.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/** Получает путь к аудио по id */
|
||||
export function getSoundPathById(id: string): string {
|
||||
const sound = SOUND_LIBRARY.find(s => s.id === id);
|
||||
|
||||
if (!sound) {
|
||||
throw new Error("Sound not found");
|
||||
}
|
||||
|
||||
return sound.file;
|
||||
}
|
||||
|
||||
const base_path = "/sounds"
|
||||
|
||||
const SOUND_LIBRARY = [
|
||||
{ id: "alarm_beep", file: `${base_path}/alarm-beep.mp3`, name: "Alarm Beep" }
|
||||
];
|
||||
49
client/src/composables/useAudio.ts
Normal file
49
client/src/composables/useAudio.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
let audioContext: AudioContext | null = null;
|
||||
let currentSource: AudioBufferSourceNode | null = null;
|
||||
|
||||
/** Получить AudioContext */
|
||||
function getAudioContext(): AudioContext {
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
/** Воспроизводит звук */
|
||||
export async function playSound(url: string): Promise<void> {
|
||||
const context = getAudioContext();
|
||||
|
||||
if (context.state === 'suspended') {
|
||||
await context.resume();
|
||||
}
|
||||
|
||||
// Если уже играет звук — остановит
|
||||
stopSound();
|
||||
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await context.decodeAudioData(arrayBuffer);
|
||||
const source = context.createBufferSource();
|
||||
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(context.destination);
|
||||
currentSource = source;
|
||||
source.start(0);
|
||||
source.onended = () => {
|
||||
source.disconnect();
|
||||
|
||||
if (currentSource === source) {
|
||||
currentSource = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Останавливает звук */
|
||||
export function stopSound(): void {
|
||||
if (currentSource) {
|
||||
currentSource.stop();
|
||||
currentSource.disconnect();
|
||||
currentSource = null;
|
||||
}
|
||||
}
|
||||
36
client/src/composables/useConfig.ts
Normal file
36
client/src/composables/useConfig.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
|
||||
import TOML from 'toml';
|
||||
import { Config } from '@/types/configType';
|
||||
|
||||
|
||||
/**
|
||||
* Загрузит или создаст TOML-конфиг
|
||||
*/
|
||||
export async function loadConfig(): Promise<Config> {
|
||||
let text: string | null = null;
|
||||
const configName = 'config.toml';
|
||||
|
||||
// 1. пробуем прочитать пользовательский конфиг
|
||||
try {
|
||||
text = await readTextFile(configName, {
|
||||
baseDir: BaseDirectory.AppConfig
|
||||
});
|
||||
} catch {
|
||||
text = null;
|
||||
}
|
||||
|
||||
// 2. если нет — создаст из дефолта
|
||||
if (!text) {
|
||||
const defaultConfig = await fetch(`/${configName}`)
|
||||
.then(r => r.text());
|
||||
|
||||
await writeTextFile(configName, defaultConfig, {
|
||||
baseDir: BaseDirectory.AppConfig
|
||||
});
|
||||
|
||||
text = defaultConfig;
|
||||
}
|
||||
|
||||
// 3. парсим TOML
|
||||
return TOML.parse(text) as Config;
|
||||
}
|
||||
120
client/src/composables/useTimer.ts
Normal file
120
client/src/composables/useTimer.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { CountdownOptions, CountdownReturn } from '@/types/timerType';
|
||||
import { stopSound } from '@/composables/useAudio';
|
||||
import { TypeTimer } from '@/types/timerType';
|
||||
|
||||
|
||||
/** Composable для управления обратным отсчётом времени */
|
||||
export function useCountdown({ timers, onFinish }: CountdownOptions): CountdownReturn {
|
||||
const totalSeconds = ref<number>(0);
|
||||
const timerNow = ref<TypeTimer | undefined>(timers.shift());
|
||||
|
||||
if (timerNow.value) {
|
||||
totalSeconds.value = timerNow.value.time * 60;
|
||||
}
|
||||
|
||||
/** Реактивное оставшееся время в секундах */
|
||||
const timeLeft = ref<number>(totalSeconds.value);
|
||||
|
||||
/** Флаг активного состояния таймера */
|
||||
const isRunning = ref<boolean>(false);
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Форматирует оставшееся время в строку ЧЧ:ММ:СС или сообщение о прохождении таймера */
|
||||
const formatted = computed<string>(() => {
|
||||
const hours = Math.floor(timeLeft.value / 3600);
|
||||
const mins = Math.floor((timeLeft.value % 3600) / 60);
|
||||
const secs = timeLeft.value % 60;
|
||||
|
||||
if (timerNow.value) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return "Завершён!";
|
||||
});
|
||||
|
||||
/** Вычисляет процент прошедшего времени от общего */
|
||||
const progress = computed<number>(() => {
|
||||
if (totalSeconds.value === 0) return 0;
|
||||
|
||||
return ((totalSeconds.value - timeLeft.value) / totalSeconds.value) * 100;
|
||||
});
|
||||
|
||||
/** Отслеживает завершённость таймера */
|
||||
const isFinished = computed<boolean>(() => {
|
||||
return !timerNow.value || timeLeft.value <= 0;
|
||||
});
|
||||
|
||||
/** Задаёт следующий таймер */
|
||||
const setNextTimer = (): void => {
|
||||
timerNow.value = timers.shift();
|
||||
|
||||
if (timerNow.value) {
|
||||
totalSeconds.value = timerNow.value.time * 60;
|
||||
timeLeft.value = totalSeconds.value;
|
||||
}
|
||||
};
|
||||
|
||||
//** Заканчивает таймер */
|
||||
const finish = (): void => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
isRunning.value = false;
|
||||
timeLeft.value = 0;
|
||||
|
||||
onFinish?.();
|
||||
};
|
||||
|
||||
/** Запускает таймер */
|
||||
const start = (): void => {
|
||||
if (isRunning.value) return
|
||||
isRunning.value = true;
|
||||
|
||||
interval = setInterval(() => {
|
||||
if (timeLeft.value > 0) {
|
||||
timeLeft.value--;
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/** Останавливает таймер и сбрасывает время до следующего таймера */
|
||||
const stop = (): void => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
isRunning.value = false;
|
||||
|
||||
stopSound();
|
||||
setNextTimer()
|
||||
}
|
||||
|
||||
/** Приостанавливает таймер без сброса оставшегося времени */
|
||||
const pause = (): void => {
|
||||
if (!isRunning.value) return
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
isRunning.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
timeLeft,
|
||||
isRunning,
|
||||
isFinished,
|
||||
formatted,
|
||||
progress,
|
||||
timerNow,
|
||||
start,
|
||||
stop,
|
||||
pause
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { QueryResult } from "@tauri-apps/plugin-sql";
|
||||
import { getDb } from "@/db/initDb";
|
||||
import { SELECT_TIMERS } from "@/db/dml/timerDML";
|
||||
import { TimersRow } from "@/types/timerType"
|
||||
import { SELECT_TIMERS, SELECT_TIMER, INSERT_TIMER, UPDATE_TIMER, DELETE_TIMER } from "@/db/dml/timerDML";
|
||||
import { TimersRow, TimerRow } from "@/types/timerType";
|
||||
|
||||
|
||||
/** Получает список таймеров */
|
||||
@@ -8,3 +9,59 @@ export async function getTimers(): Promise<TimersRow[]> {
|
||||
const db = await getDb();
|
||||
return await db.select<TimersRow[]>(SELECT_TIMERS);
|
||||
}
|
||||
|
||||
/** Получает таймер по его id */
|
||||
export async function getTimer(TimerId: number): Promise<TimerRow> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select<TimerRow[]>(SELECT_TIMER, [TimerId]);
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
throw new Error('Таймер не найден');
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/** Создаёт новый таймер */
|
||||
export async function createTimer(
|
||||
title: string,
|
||||
pomodoroTime: number,
|
||||
breakTime: number,
|
||||
breakLongTime: number,
|
||||
countPomodoro: number
|
||||
): Promise<QueryResult> {
|
||||
const db = await getDb();
|
||||
return await db.execute(INSERT_TIMER, [
|
||||
title,
|
||||
pomodoroTime,
|
||||
breakTime,
|
||||
breakLongTime,
|
||||
countPomodoro
|
||||
]);
|
||||
}
|
||||
|
||||
/** Обновляет таймер */
|
||||
export async function updateTimer(
|
||||
TimerId: number,
|
||||
title: string,
|
||||
pomodoroTime: number,
|
||||
breakTime: number,
|
||||
breakLongTime: number,
|
||||
countPomodoro: number
|
||||
): Promise<QueryResult> {
|
||||
const db = await getDb();
|
||||
return await db.execute(UPDATE_TIMER, [
|
||||
title,
|
||||
pomodoroTime,
|
||||
breakTime,
|
||||
breakLongTime,
|
||||
countPomodoro,
|
||||
TimerId
|
||||
]);
|
||||
}
|
||||
|
||||
/** Удаляет таймер */
|
||||
export async function deleteTimer(TimerId: number): Promise<QueryResult> {
|
||||
const db = await getDb();
|
||||
return await db.execute(DELETE_TIMER, [TimerId]);
|
||||
}
|
||||
|
||||
@@ -4,3 +4,7 @@ export const INSERT_SEED_DB = `
|
||||
INSERT INTO timer (title, pomodoro_time, break_time, break_long_time, count_pomodoro) VALUES
|
||||
('Timer mini example', 10, 3, 15, 2), ('Timer max example', 90, 10, 40, 8)
|
||||
`
|
||||
export const SELECT_TIMER = 'SELECT * FROM timer WHERE id = ?'
|
||||
export const INSERT_TIMER = 'INSERT INTO timer (title, pomodoro_time, break_time, break_long_time, count_pomodoro) VALUES (?, ?, ?, ?, ?)'
|
||||
export const UPDATE_TIMER = 'UPDATE timer SET title = ?, pomodoro_time = ?, break_time = ?, break_long_time = ?, count_pomodoro = ? WHERE id = ?'
|
||||
export const DELETE_TIMER = 'DELETE FROM timer WHERE id = ?'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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[] = [
|
||||
{
|
||||
@@ -11,6 +13,16 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/timers',
|
||||
name: 'Timers',
|
||||
component: TimersList
|
||||
},
|
||||
{
|
||||
path: '/timer/:id',
|
||||
name: 'Timer',
|
||||
component: Timer
|
||||
},
|
||||
{
|
||||
path: '/timer-update/:id',
|
||||
name: 'TimerUpdate',
|
||||
component: TimerUpdate
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
6
client/src/types/configType.ts
Normal file
6
client/src/types/configType.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
//** Тип для конфига */
|
||||
export type Config = {
|
||||
sound: {
|
||||
sound_id: string;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Ref, ComputedRef } from 'vue';
|
||||
|
||||
/** Строка списка таймеров с основными полями */
|
||||
export type TimersRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -5,4 +8,41 @@ export type TimersRow = {
|
||||
count_pomodoro: number;
|
||||
};
|
||||
|
||||
/** Тип для хранения результата COUNT-запроса */
|
||||
export type CountRow = { cnt: number };
|
||||
|
||||
/** Полная строка таймера со всеми временными параметрами */
|
||||
export type TimerRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
pomodoro_time: number;
|
||||
break_time: number;
|
||||
break_long_time: number;
|
||||
count_pomodoro: number;
|
||||
}
|
||||
|
||||
/** Параметры инициализации обратного отсчёта */
|
||||
export type CountdownOptions = {
|
||||
timers: TypeTimer[];
|
||||
onFinish?: () => void;
|
||||
}
|
||||
|
||||
/** Возвращаемые значения и методы composable обратного отсчёта */
|
||||
export type CountdownReturn = {
|
||||
timeLeft: Ref<number>;
|
||||
isRunning: Ref<boolean>;
|
||||
formatted: ComputedRef<string>; // ЧЧ:ММ:СС
|
||||
progress: Ref<number>;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
timerNow: Ref<TypeTimer | undefined>;
|
||||
isFinished: ComputedRef<boolean>;
|
||||
}
|
||||
|
||||
/** Тип таймеров */
|
||||
export type TypeTimer = {
|
||||
title: string;
|
||||
time: number;
|
||||
typeTimer: string;
|
||||
}
|
||||
|
||||
260
client/src/views/Timer/Timer.css
Normal file
260
client/src/views/Timer/Timer.css
Normal file
@@ -0,0 +1,260 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.timer-page-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
display: flex;
|
||||
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 {
|
||||
padding: 24px 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
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 {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 28px 24px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timer-card-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label-left,
|
||||
.label-right {
|
||||
font-size: 13px;
|
||||
color: #9e9e9e;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.timer-time {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 56px;
|
||||
font-weight: 400;
|
||||
color: #212121;
|
||||
letter-spacing: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timer-progress-bar {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timer-progress-fill {
|
||||
height: 100%;
|
||||
background-color: #1976d2;
|
||||
border-radius: 2px;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
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;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 120px;
|
||||
height: 44px;
|
||||
flex-shrink: 0; /* запрет на сжатие */
|
||||
background-color: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.5px;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.btn-timer ion-icon {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.btn-timer-start {
|
||||
border-color: #4caf50;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.btn-timer-start:hover {
|
||||
background-color: rgba(76, 175, 80, 0.10);
|
||||
}
|
||||
|
||||
.btn-timer-start:active {
|
||||
background-color: rgba(76, 175, 80, 0.22);
|
||||
}
|
||||
|
||||
.btn-timer-stop {
|
||||
border-color: #f44336;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-timer-stop:hover {
|
||||
background-color: rgba(244, 67, 54, 0.10);
|
||||
}
|
||||
|
||||
.btn-timer-stop:active {
|
||||
background-color: rgba(244, 67, 54, 0.22);
|
||||
}
|
||||
|
||||
.btn-timer-pause {
|
||||
border-color: #ffc107;
|
||||
color: #f9a825;
|
||||
}
|
||||
|
||||
.btn-timer-pause:hover {
|
||||
background-color: rgba(255, 193, 7, 0.10);
|
||||
}
|
||||
|
||||
.btn-timer-pause:active {
|
||||
background-color: rgba(255, 193, 7, 0.22);
|
||||
}
|
||||
|
||||
.btn-timer {
|
||||
transition: all 0.2s ease, opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.btn-timer.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.timer-display-card {
|
||||
padding: 20px 16px;
|
||||
border-radius: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
.btn-timer {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
93
client/src/views/Timer/Timer.html
Normal file
93
client/src/views/Timer/Timer.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<ion-page>
|
||||
<ion-content>
|
||||
<div class="timer-page-container">
|
||||
<div class="page-header">
|
||||
<button
|
||||
v-if="showBackButton"
|
||||
class="btn-back"
|
||||
type="button"
|
||||
@click="askLeaveTimer"
|
||||
>
|
||||
<ion-icon :icon="arrowBackOutline"></ion-icon>
|
||||
Назад
|
||||
</button>
|
||||
<h1>Таймер</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="confirmLeave" class="confirm-overlay" @click.self="closeLeaveModal">
|
||||
<div class="confirm-card">
|
||||
<h3>Выйти из таймера?</h3>
|
||||
<p>Текущий прогресс будет сброшен, если вы уйдёте из этого экрана.</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn-timer btn-timer-pause" type="button" @click="closeLeaveModal">Нет</button>
|
||||
<button class="btn-timer btn-timer-stop" type="button" @click="confirmLeaveTimer">Да</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timer-screen">
|
||||
|
||||
<!-- Timer Card -->
|
||||
<div class="timer-display-card">
|
||||
<!-- Card top labels -->
|
||||
<div class="timer-card-labels">
|
||||
<span class="label-left">{{ countdown?.timerNow.value?.title }}</span>
|
||||
<span class="label-right">{{ countdown?.timerNow.value?.typeTimer }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Time display -->
|
||||
<div class="timer-time">
|
||||
<span class="time-text">{{ countdown?.formatted }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-show="!countdown?.isFinished.value" class="timer-progress-bar">
|
||||
<div class="timer-progress-fill" :style="{ width: `${countdown?.progress.value}%` }"></div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="timer-controls">
|
||||
|
||||
<!-- Видна только при running и paused -->
|
||||
<button
|
||||
class="btn-timer
|
||||
btn-timer-stop"
|
||||
id="btn-stop"
|
||||
v-show="state === 'running' || state === 'paused'"
|
||||
@click="() => { setState('idle'); countdown?.stop(); }"
|
||||
>
|
||||
<ion-icon :icon="stopOutline"></ion-icon>
|
||||
Стоп
|
||||
</button>
|
||||
|
||||
<!-- Видна при idle и paused -->
|
||||
<button
|
||||
class="btn-timer
|
||||
btn-timer-start"
|
||||
id="btn-start"
|
||||
v-show="state === 'idle' && !countdown?.isFinished.value || state === 'paused'"
|
||||
@click="() => { setState('running'); countdown?.start(); }"
|
||||
>
|
||||
<ion-icon :icon="playOutline"></ion-icon>
|
||||
Старт
|
||||
</button>
|
||||
|
||||
<!-- Видна только при running -->
|
||||
<button
|
||||
class="btn-timer
|
||||
btn-timer-pause"
|
||||
id="btn-pause"
|
||||
v-show="state === 'running' && !countdown?.isFinished.value"
|
||||
@click="() => { setState('paused'); countdown?.pause(); }"
|
||||
>
|
||||
<ion-icon :icon="pauseOutline"></ion-icon>
|
||||
Пауза
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
129
client/src/views/Timer/Timer.ts
Normal file
129
client/src/views/Timer/Timer.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { computed, defineComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
||||
import { IonIcon, IonPage, IonContent } from '@ionic/vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
stopOutline,
|
||||
playOutline,
|
||||
pauseOutline,
|
||||
arrowBackOutline
|
||||
} from 'ionicons/icons';
|
||||
|
||||
import type { TimerRow } from '@/types/timerType';
|
||||
import { useCountdown } from '@/composables/useTimer';
|
||||
import { playSound } from '@/composables/useAudio';
|
||||
import { getSoundPathById } from '@/composables/libAudio';
|
||||
import { loadConfig } from '@/composables/useConfig';
|
||||
import { queueTimer } from '@/composables/chainTimer';
|
||||
import { CountdownReturn } from '@/types/timerType';
|
||||
|
||||
/** Возможные состояния таймера */
|
||||
type TimerState = 'idle' | 'running' | 'paused';
|
||||
|
||||
/** Компонент страницы таймера с управлением обратным отсчётом */
|
||||
export default defineComponent({
|
||||
name: 'Timer',
|
||||
components: { IonIcon, IonPage, IonContent },
|
||||
|
||||
/** Инициализирует данные и логику компонента таймера */
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = Number(route.params.id);
|
||||
|
||||
/** Реактивные данные загруженного таймера */
|
||||
const timer = ref<TimerRow>();
|
||||
|
||||
/** Сообщение об ошибке при загрузке */
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
/** Текущее состояние кнопок управления */
|
||||
const state = ref<TimerState>('idle');
|
||||
const confirmLeave = ref(false);
|
||||
|
||||
/** Обновляет текущее состояние таймера */
|
||||
const setState = (newState: TimerState) => {
|
||||
state.value = newState;
|
||||
};
|
||||
|
||||
/** Shallow-ссылка на экземпляр composable обратного отсчёта */
|
||||
const countdown = shallowRef<CountdownReturn | null>(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()
|
||||
|
||||
/** Останавливает таймер при размонтировании компонента */
|
||||
onUnmounted(() => {
|
||||
countdown.value?.stop();
|
||||
});
|
||||
|
||||
/** Загружает данные таймера из БД и инициализирует отсчёт */
|
||||
const loadTimer = async (): Promise<void> => {
|
||||
try {
|
||||
const timers = await queueTimer(id);
|
||||
|
||||
countdown.value = useCountdown({
|
||||
timers: timers,
|
||||
onFinish: async () => {
|
||||
await playSound(getSoundPathById((await config).sound.sound_id));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = 'Ошибка загрузки таймера';
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadTimer);
|
||||
|
||||
return {
|
||||
timer,
|
||||
error,
|
||||
state,
|
||||
setState,
|
||||
showBackButton,
|
||||
confirmLeave,
|
||||
askLeaveTimer,
|
||||
closeLeaveModal,
|
||||
confirmLeaveTimer,
|
||||
stopOutline,
|
||||
playOutline,
|
||||
pauseOutline,
|
||||
arrowBackOutline,
|
||||
countdown
|
||||
};
|
||||
}
|
||||
});
|
||||
4
client/src/views/Timer/Timer.vue
Normal file
4
client/src/views/Timer/Timer.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Timer.vue -->
|
||||
<template src="@/views/Timer/Timer.html"></template>
|
||||
<script lang="ts" src="@/views/Timer/Timer.ts"></script>
|
||||
<style src="@/views/Timer/Timer.css" scoped></style>
|
||||
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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,81 +1,100 @@
|
||||
<div class="timers-container">
|
||||
<div class="page-header">
|
||||
<h1>Таймеры</h1>
|
||||
</div>
|
||||
<ion-page>
|
||||
<ion-content>
|
||||
<div class="timers-container">
|
||||
<div class="page-header">
|
||||
<h1>Таймеры</h1>
|
||||
</div>
|
||||
|
||||
<div class="timers-list">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading">
|
||||
<p>Загрузка таймеров...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-message">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="timers.length === 0" class="empty-state">
|
||||
<p>Нет таймеров</p>
|
||||
</div>
|
||||
|
||||
<!-- Timers list -->
|
||||
<div v-else class="timer-cards">
|
||||
<div
|
||||
v-for="timer in timers"
|
||||
:key="timer.id"
|
||||
class="timer-card"
|
||||
:class="{ expanded: expandedId === timer.id }"
|
||||
>
|
||||
<!-- Card header -->
|
||||
<div class="card-header" @click="toggleExpand(timer.id)">
|
||||
<div class="timer-icon">
|
||||
<ion-icon :icon="timerOutline"></ion-icon>
|
||||
</div>
|
||||
<div class="timer-info">
|
||||
<h3 class="timer-title">{{ timer.title }}</h3>
|
||||
<p class="timer-subtitle">{{ timer.count_pomodoro }} помидоров по {{ timer.pomodoro_time }} минут</p>
|
||||
</div>
|
||||
<div class="expand-icon">
|
||||
<ion-icon
|
||||
:icon="expandedId === timer.id ? chevronUp : chevronDown"
|
||||
></ion-icon>
|
||||
<div class="timers-list">
|
||||
<div v-if="confirmDeleteId !== null" class="confirm-overlay" @click.self="cancelDeleteTimer">
|
||||
<div class="confirm-card">
|
||||
<h3>Удалить таймер?</h3>
|
||||
<p>Это действие нельзя будет отменить.</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn btn-secondary" type="button" @click="cancelDeleteTimer">Отмена</button>
|
||||
<button class="btn btn-danger" type="button" @click="removeTimer">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div v-show="expandedId === timer.id" class="card-content">
|
||||
<div class="timer-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Название:</span>
|
||||
<span class="detail-value">{{ timer.title }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Длительность помидора:</span>
|
||||
<span class="detail-value">{{ timer.pomodoro_time }} минут</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Количество помидоров:</span>
|
||||
<span class="detail-value">{{ timer.count_pomodoro }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="goToCreateTimer" class="add-timer-button" type="button">
|
||||
<ion-icon :icon="addCircleOutline"></ion-icon>
|
||||
Создать таймер
|
||||
</button>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading">
|
||||
<p>Загрузка таймеров...</p>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary">
|
||||
<ion-icon :icon="playCircleOutline"></ion-icon>
|
||||
Открыть
|
||||
</button>
|
||||
<button class="btn btn-secondary">
|
||||
<ion-icon :icon="pencilOutline"></ion-icon>
|
||||
Редактировать
|
||||
</button>
|
||||
<button class="btn btn-danger">
|
||||
<ion-icon :icon="trashOutline"></ion-icon>
|
||||
Удалить
|
||||
</button>
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-message">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="timers.length === 0" class="empty-state">
|
||||
<p>Нет таймеров</p>
|
||||
</div>
|
||||
|
||||
<!-- Timers list -->
|
||||
<div v-else class="timer-cards">
|
||||
<div
|
||||
v-for="timer in timers"
|
||||
:key="timer.id"
|
||||
class="timer-card"
|
||||
:class="{ expanded: expandedId === timer.id }"
|
||||
>
|
||||
<!-- Card header -->
|
||||
<div class="card-header" @click="toggleExpand(timer.id)">
|
||||
<div class="timer-icon">
|
||||
<ion-icon :icon="timerOutline"></ion-icon>
|
||||
</div>
|
||||
<div class="timer-info">
|
||||
<h3 class="timer-title">{{ timer.title }}</h3>
|
||||
<p class="timer-subtitle">{{ timer.count_pomodoro }} помидоров по {{ timer.pomodoro_time }} минут</p>
|
||||
</div>
|
||||
<div class="expand-icon">
|
||||
<ion-icon
|
||||
:icon="expandedId === timer.id ? chevronUp : chevronDown"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div v-show="expandedId === timer.id" class="card-content">
|
||||
<div class="timer-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Название:</span>
|
||||
<span class="detail-value">{{ timer.title }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Длительность помидора:</span>
|
||||
<span class="detail-value">{{ timer.pomodoro_time }} минут</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Количество помидоров:</span>
|
||||
<span class="detail-value">{{ timer.count_pomodoro }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button @click="goToTimer(timer.id)" class="btn btn-primary">
|
||||
<ion-icon :icon="playCircleOutline"></ion-icon>
|
||||
Открыть
|
||||
</button>
|
||||
<button @click="goToEditTimer(timer.id)" class="btn btn-secondary">
|
||||
<ion-icon :icon="pencilOutline"></ion-icon>
|
||||
Редактировать
|
||||
</button>
|
||||
<button @click="askDeleteTimer(timer.id)" class="btn btn-danger">
|
||||
<ion-icon :icon="trashOutline"></ion-icon>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { IonIcon } from '@ionic/vue'
|
||||
import { defineComponent, onMounted, ref, watch } from 'vue'
|
||||
import { IonIcon, IonPage, IonContent } from '@ionic/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
timerOutline,
|
||||
chevronUp,
|
||||
@@ -7,20 +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 },
|
||||
components: { IonIcon, IonPage, IonContent },
|
||||
|
||||
setup() {
|
||||
/** Список всех таймеров */
|
||||
const timers = ref<TimersRow[]>([])
|
||||
|
||||
/** Флаг загрузки списка таймеров */
|
||||
const loading = ref<boolean>(true)
|
||||
|
||||
/** Ошибка загрузки или операций */
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/** ID раскрытого таймера в списке */
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
/** ID таймера, ожидающего удаления */
|
||||
const confirmDeleteId = ref<number | null>(null)
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
/** Переход на страницу конкретного таймера */
|
||||
const goToTimer = (id: number) => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -35,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,
|
||||
|
||||
@@ -55,6 +126,13 @@ export default defineComponent({
|
||||
playCircleOutline,
|
||||
pencilOutline,
|
||||
trashOutline,
|
||||
addCircleOutline,
|
||||
goToTimer,
|
||||
goToCreateTimer,
|
||||
goToEditTimer,
|
||||
askDeleteTimer,
|
||||
cancelDeleteTimer,
|
||||
removeTimer,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
1860
desktop/src-tauri/Cargo.lock
generated
1860
desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro."
|
||||
name = "kawai-focus"
|
||||
version = "0.3.0"
|
||||
description = "Кавай-Фокус (Kawai-Focus) - приложение для фокусировки внимания на основе таймера Pomodoro."
|
||||
authors = ["Arduinum"]
|
||||
license = "MIT"
|
||||
repository = ""
|
||||
@@ -11,7 +11,7 @@ rust-version = "1.77.2"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
name = "kawai_focus"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
@@ -24,3 +24,4 @@ log = "0.4"
|
||||
tauri = { version = "2.9.5", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-sql = { version = "2.3.1", features = ["sqlite"] }
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"sql:default",
|
||||
"sql:allow-execute"
|
||||
]
|
||||
"sql:allow-execute",
|
||||
"fs:allow-appconfig-read",
|
||||
"fs:allow-appconfig-write"
|
||||
],
|
||||
"fs": {
|
||||
"read": {
|
||||
"scope": [
|
||||
"$APPCONFIG/**"
|
||||
]
|
||||
},
|
||||
"write": {
|
||||
"scope": [
|
||||
"$APPCONFIG/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
desktop/src-tauri/src/flags.rs
Normal file
44
desktop/src-tauri/src/flags.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn apply_linux_cli_flags() {
|
||||
let disable_webkit_compositing_mode = std::env::args().any(|a| a == "--webkit-disable-compositing-mode");
|
||||
if disable_webkit_compositing_mode {
|
||||
unsafe { std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); }
|
||||
}
|
||||
|
||||
let webkit_disable_dmabuf_renderer = std::env::args().any(|a| a == "--webkit-disable-dmabuf-renderer");
|
||||
if webkit_disable_dmabuf_renderer {
|
||||
unsafe { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); }
|
||||
}
|
||||
|
||||
let nv_disable_explicit_sync = std::env::args().any(|a| a == "--nv-disable-explicit-sync");
|
||||
if nv_disable_explicit_sync {
|
||||
unsafe { std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1"); }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn print_help() {
|
||||
println!(
|
||||
r#"Kawai Focus — Pomodoro таймер
|
||||
|
||||
Использование:
|
||||
kawai-focus [FLAGS]
|
||||
|
||||
Флаги (Linux):
|
||||
|
||||
--webkit-disable-compositing-mode
|
||||
Отключает WebKit compositing mode.
|
||||
Помогает при графических артефактах на некоторых GPU.
|
||||
|
||||
--webkit-disable-dmabuf-renderer
|
||||
Отключает DMA-BUF renderer WebKit.
|
||||
Полезно при проблемах с Wayland и с отсутствием GBM.
|
||||
|
||||
--nv-disable-explicit-sync
|
||||
Отключает explicit sync драйвера NVIDIA.
|
||||
Иногда исправляет: зависания интерфейса, проблемы с отсутствием GBM.
|
||||
|
||||
--help
|
||||
Показать эту справку и выйти.
|
||||
"#);
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_linux_webkit_cli_flags() {
|
||||
let disable = std::env::args().any(|a| a == "--disable-webkit-compositing");
|
||||
if disable {
|
||||
unsafe { std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); }
|
||||
}
|
||||
}
|
||||
mod flags;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
apply_linux_webkit_cli_flags();
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::args().any(|a| a == "--help" || a == "-h") {
|
||||
flags::print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
flags::apply_linux_cli_flags();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Ошибка при запуске приложения Tauri.");
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
|
||||
fn main() {
|
||||
println!("Запуск приложения Tauri...");
|
||||
app_lib::run();
|
||||
kawai_focus::run();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Kawai-Focus",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"identifier": "io.github.arduinum.KawaiFocus",
|
||||
"build": {
|
||||
"frontendDist": "../../client/dist",
|
||||
@@ -14,7 +14,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Kawai-Focus",
|
||||
"title": "Кавай-Фокус",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"resizable": true,
|
||||
@@ -37,7 +37,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortDescription": "Kawai-Focus - приложение для фокусировки внимания на основе таймера Pomodoro.",
|
||||
"shortDescription": "Кавай-Фокус (Kawai-Focus) - приложение для фокусировки внимания на основе таймера Pomodoro.",
|
||||
"copyright": "© 2026 Eugene Kaddo",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
Reference in New Issue
Block a user