init
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #2a2f3a;
|
||||
background: #151922;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header nav {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-header nav a {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
color: #a8b0bd;
|
||||
}
|
||||
|
||||
.app-header nav a.active {
|
||||
background: #2b3445;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import Chat from "./pages/Chat";
|
||||
import Pomodoro from "./pages/Pomodoro";
|
||||
import "./App.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Home AI Assistant</h1>
|
||||
<nav>
|
||||
<NavLink to="/" end>
|
||||
Чат
|
||||
</NavLink>
|
||||
<NavLink to="/pomodoro">Помидоро</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Chat />} />
|
||||
<Route path="/pomodoro" element={<Pomodoro />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||
|
||||
export interface ChatSession {
|
||||
id: number;
|
||||
title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessionDetail extends ChatSession {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export interface PomodoroStatus {
|
||||
status: string;
|
||||
duration_min: number;
|
||||
task_note: string;
|
||||
elapsed_seconds: number;
|
||||
remaining_seconds: number;
|
||||
session_id: number | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
export interface PomodoroHistoryItem {
|
||||
id: number;
|
||||
status: string;
|
||||
duration_min: number;
|
||||
task_note: string;
|
||||
result: string | null;
|
||||
completed: boolean;
|
||||
elapsed_seconds: number;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, options);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || response.statusText);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request<{ status: string }>("/api/v1/health"),
|
||||
|
||||
listSessions: () => request<ChatSession[]>("/api/v1/chat/sessions"),
|
||||
|
||||
createSession: (title = "Новый чат") =>
|
||||
request<ChatSession>("/api/v1/chat/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
}),
|
||||
|
||||
getSession: (id: number) => request<SessionDetail>(`/api/v1/chat/sessions/${id}`),
|
||||
|
||||
deleteSession: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/v1/chat/sessions/${id}`, { method: "DELETE" }),
|
||||
|
||||
sendMessage: async function* (sessionId: number, content: string) {
|
||||
const response = await fetch(`${API_BASE}/api/v1/chat/sessions/${sessionId}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error("Failed to send message");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split("\n\n");
|
||||
buffer = parts.pop() ?? "";
|
||||
|
||||
for (const part of parts) {
|
||||
const lines = part.split("\n");
|
||||
let event = "message";
|
||||
let data = "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) event = line.slice(7);
|
||||
if (line.startsWith("data: ")) data = line.slice(6);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
yield { event, data: JSON.parse(data) };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
pomodoroStatus: () => request<PomodoroStatus>("/api/v1/pomodoro/status"),
|
||||
|
||||
pomodoroStart: (duration_min: number, task_note: string) =>
|
||||
request<PomodoroStatus>("/api/v1/pomodoro/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ duration_min, task_note }),
|
||||
}),
|
||||
|
||||
pomodoroPause: () =>
|
||||
request<PomodoroStatus>("/api/v1/pomodoro/pause", { method: "POST" }),
|
||||
|
||||
pomodoroResume: () =>
|
||||
request<PomodoroStatus>("/api/v1/pomodoro/resume", { method: "POST" }),
|
||||
|
||||
pomodoroStop: (result: string, completed: boolean) =>
|
||||
request<PomodoroStatus>("/api/v1/pomodoro/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result, completed }),
|
||||
}),
|
||||
|
||||
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"),
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: #e8eaed;
|
||||
background-color: #0f1115;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,156 @@
|
||||
.chat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: calc(100vh - 65px);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
border-right: 1px solid #2a2f3a;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: #12151c;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: #4f7cff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.session-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.session-list li.active {
|
||||
background: #1f2633;
|
||||
}
|
||||
|
||||
.session-list li button:first-child {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0.55rem 0.7rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #7d8796;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
margin: auto;
|
||||
color: #7d8796;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 80%;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
align-self: flex-end;
|
||||
background: #2b4acb;
|
||||
}
|
||||
|
||||
.message-assistant,
|
||||
.message-tool {
|
||||
align-self: flex-start;
|
||||
background: #1b2130;
|
||||
border: 1px solid #2a3142;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-size: 0.75rem;
|
||||
color: #8b95a5;
|
||||
margin-bottom: 0.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.message-content p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.message-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #2a2f3a;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2f3748;
|
||||
background: #12151c;
|
||||
color: inherit;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
align-self: flex-end;
|
||||
background: #4f7cff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 1.2rem;
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { api, ChatMessage, ChatSession } from "../api/client";
|
||||
import "./Chat.css";
|
||||
|
||||
export default function Chat() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadSessions = async () => {
|
||||
const data = await api.listSessions();
|
||||
setSessions(data);
|
||||
if (!activeId && data.length > 0) {
|
||||
setActiveId(data[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMessages = async (sessionId: number) => {
|
||||
const data = await api.getSession(sessionId);
|
||||
setMessages(data.messages);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions().catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId) {
|
||||
loadMessages(activeId).catch(console.error);
|
||||
}
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streaming]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const session = await api.createSession();
|
||||
await loadSessions();
|
||||
setActiveId(session.id);
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await api.deleteSession(id);
|
||||
const data = await api.listSessions();
|
||||
setSessions(data);
|
||||
if (activeId === id) {
|
||||
setActiveId(data[0]?.id ?? null);
|
||||
setMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || !activeId || loading) return;
|
||||
|
||||
const text = input.trim();
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setStreaming("");
|
||||
|
||||
const tempUser: ChatMessage = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, tempUser]);
|
||||
|
||||
try {
|
||||
for await (const chunk of api.sendMessage(activeId, text)) {
|
||||
if (chunk.event === "token") {
|
||||
setStreaming((prev) => prev + chunk.data.content);
|
||||
}
|
||||
if (chunk.event === "done") {
|
||||
await loadMessages(activeId);
|
||||
await loadSessions();
|
||||
setStreaming("");
|
||||
}
|
||||
if (chunk.event === "error") {
|
||||
throw new Error(chunk.data.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStreaming("");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
<aside className="chat-sidebar">
|
||||
<button className="primary-btn" onClick={handleNewChat}>
|
||||
+ Новый чат
|
||||
</button>
|
||||
<ul className="session-list">
|
||||
{sessions.map((session) => (
|
||||
<li key={session.id} className={activeId === session.id ? "active" : ""}>
|
||||
<button onClick={() => setActiveId(session.id)}>{session.title}</button>
|
||||
<button className="delete-btn" onClick={() => handleDelete(session.id)}>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<section className="chat-main">
|
||||
{!activeId ? (
|
||||
<div className="chat-empty">Создайте новый чат, чтобы начать</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="messages">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`message message-${msg.role}`}>
|
||||
<div className="message-role">{msg.role}</div>
|
||||
<div className="message-content">
|
||||
{msg.role === "assistant" ? (
|
||||
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{streaming && (
|
||||
<div className="message message-assistant">
|
||||
<div className="message-role">assistant</div>
|
||||
<div className="message-content">
|
||||
<ReactMarkdown>{streaming}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<form className="chat-input" onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Напишите сообщение..."
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={loading || !input.trim()}>
|
||||
{loading ? "..." : "Отправить"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
.pomodoro-page {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.timer-card,
|
||||
.history-card {
|
||||
background: #151922;
|
||||
border: 1px solid #2a2f3a;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.timer-ring {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.timer-inner {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
background: #0f1115;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timer-value {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timer-status {
|
||||
color: #8b95a5;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.task-note {
|
||||
text-align: center;
|
||||
color: #c5ccd6;
|
||||
}
|
||||
|
||||
.timer-form,
|
||||
.timer-controls,
|
||||
.stop-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.timer-form label,
|
||||
.stop-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
color: #a8b0bd;
|
||||
}
|
||||
|
||||
.timer-form input,
|
||||
.stop-form input {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2f3748;
|
||||
background: #0f1115;
|
||||
color: inherit;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
.timer-controls button,
|
||||
.primary-btn {
|
||||
background: #4f7cff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff7b7b;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.history-card h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.history-card ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.history-card li {
|
||||
padding-bottom: 0.9rem;
|
||||
border-bottom: 1px solid #232936;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
color: #8b95a5;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.history-result {
|
||||
margin-top: 0.35rem;
|
||||
color: #c5ccd6;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pomodoro-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client";
|
||||
import "./Pomodoro.css";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function Pomodoro() {
|
||||
const [status, setStatus] = useState<PomodoroStatus | null>(null);
|
||||
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
|
||||
const [duration, setDuration] = useState(25);
|
||||
const [taskNote, setTaskNote] = useState("");
|
||||
const [result, setResult] = useState("");
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = async () => {
|
||||
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]);
|
||||
setStatus(current);
|
||||
setHistory(past);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(console.error);
|
||||
const timer = setInterval(() => {
|
||||
api.pomodoroStatus().then(setStatus).catch(console.error);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const handleStart = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.pomodoroStart(duration, taskNote);
|
||||
setStatus(data);
|
||||
setResult("");
|
||||
setCompleted(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка запуска");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
setError("");
|
||||
try {
|
||||
setStatus(await api.pomodoroPause());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
setError("");
|
||||
try {
|
||||
setStatus(await api.pomodoroResume());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await api.pomodoroStop(result, completed);
|
||||
await refresh();
|
||||
setTaskNote("");
|
||||
setResult("");
|
||||
setCompleted(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = status?.status === "running" || status?.status === "paused";
|
||||
const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60;
|
||||
const progress = status
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="pomodoro-page">
|
||||
<section className="timer-card">
|
||||
<div className="timer-ring" style={{ background: `conic-gradient(#4f7cff ${progress}%, #1f2633 0)` }}>
|
||||
<div className="timer-inner">
|
||||
<div className="timer-value">{formatTime(displaySeconds)}</div>
|
||||
<div className="timer-status">{status?.status ?? "idle"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.task_note && <p className="task-note">Задача: {status.task_note}</p>}
|
||||
|
||||
{!isActive ? (
|
||||
<form className="timer-form" onSubmit={handleStart}>
|
||||
<label>
|
||||
Минут
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={180}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Над чем работаем
|
||||
<input
|
||||
value={taskNote}
|
||||
onChange={(e) => setTaskNote(e.target.value)}
|
||||
placeholder="Опишите задачу"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="primary-btn">
|
||||
Старт
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="timer-controls">
|
||||
{status?.status === "running" ? (
|
||||
<button onClick={handlePause}>Пауза</button>
|
||||
) : (
|
||||
<button onClick={handleResume}>Продолжить</button>
|
||||
)}
|
||||
<div className="stop-form">
|
||||
<input
|
||||
value={result}
|
||||
onChange={(e) => setResult(e.target.value)}
|
||||
placeholder="Что успели сделать?"
|
||||
/>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={completed}
|
||||
onChange={(e) => setCompleted(e.target.checked)}
|
||||
/>
|
||||
Задача завершена
|
||||
</label>
|
||||
<button onClick={handleStop}>Стоп</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
</section>
|
||||
|
||||
<section className="history-card">
|
||||
<h2>История</h2>
|
||||
<ul>
|
||||
{history.map((item) => (
|
||||
<li key={item.id}>
|
||||
<div className="history-title">
|
||||
{item.task_note || "Без описания"} — {item.status}
|
||||
</div>
|
||||
<div className="history-meta">
|
||||
{item.duration_min} мин · {item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""}
|
||||
</div>
|
||||
{item.result && <div className="history-result">{item.result}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user