8 Commits

11 changed files with 81 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/backend", "name": "@family-budget/backend",
"version": "0.1.0", "version": "0.5.12",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "tsx watch src/app.ts",

View File

@@ -1,3 +1,5 @@
import fs from 'fs';
import path from 'path';
import express from 'express'; import express from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
@@ -5,6 +7,10 @@ import { config } from './config';
import { runMigrations } from './db/migrate'; import { runMigrations } from './db/migrate';
import { requireAuth } from './middleware/auth'; import { requireAuth } from './middleware/auth';
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
) as { version: string };
import authRouter from './routes/auth'; import authRouter from './routes/auth';
import importRouter from './routes/import'; import importRouter from './routes/import';
import importsRouter from './routes/imports'; import importsRouter from './routes/imports';
@@ -24,6 +30,10 @@ app.use(cors({ origin: true, credentials: true }));
// Auth routes (login is public; me/logout apply auth internally) // Auth routes (login is public; me/logout apply auth internally)
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
app.get('/api/version', (_req, res) => {
res.json({ version: pkg.version });
});
// All remaining /api routes require authentication // All remaining /api routes require authentication
app.use('/api', requireAuth); app.use('/api', requireAuth);
app.use('/api/import', importRouter); app.use('/api/import', importRouter);

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#0f172a" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Семейный бюджет</title> <title>Семейный бюджет</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-budget/frontend", "name": "@family-budget/frontend",
"version": "0.1.0", "version": "0.8.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f172a"/>
<text x="16" y="23" font-family="Georgia, serif" font-size="20" font-weight="bold" fill="#3b82f6" text-anchor="middle">&#8381;</text>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@@ -0,0 +1,5 @@
import { api } from './client';
export async function getBackendVersion(): Promise<{ version: string }> {
return api.get<{ version: string }>('/api/version');
}

View File

@@ -1,8 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { clearAllTransactions } from '../api/transactions'; import { clearAllTransactions } from '../api/transactions';
const CONFIRM_WORD = 'УДАЛИТЬ';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onDone: () => void; onDone: () => void;
@@ -10,15 +8,11 @@ interface Props {
export function ClearHistoryModal({ onClose, onDone }: Props) { export function ClearHistoryModal({ onClose, onDone }: Props) {
const [check1, setCheck1] = useState(false); const [check1, setCheck1] = useState(false);
const [confirmInput, setConfirmInput] = useState('');
const [check2, setCheck2] = useState(false); const [check2, setCheck2] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const canConfirm = const canConfirm = check1 && check2;
check1 &&
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
check2;
const handleConfirm = async () => { const handleConfirm = async () => {
if (!canConfirm || loading) return; if (!canConfirm || loading) return;
@@ -65,20 +59,6 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
</label> </label>
</div> </div>
<div className="form-group">
<label>
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
</label>
<input
type="text"
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
placeholder={CONFIRM_WORD}
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
autoComplete="off"
/>
</div>
<div className="form-group form-group-checkbox clear-history-check"> <div className="form-group form-group-checkbox clear-history-check">
<label> <label>
<input <input

View File

@@ -1,13 +1,21 @@
import { useState, type ReactNode } from 'react'; import { useState, useEffect, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { getBackendVersion } from '../api/version';
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [beVersion, setBeVersion] = useState<string | null>(null);
const closeDrawer = () => setDrawerOpen(false); const closeDrawer = () => setDrawerOpen(false);
useEffect(() => {
getBackendVersion()
.then((r) => setBeVersion(r.version))
.catch(() => setBeVersion(null));
}, []);
return ( return (
<div className="layout"> <div className="layout">
<button <button
@@ -86,10 +94,18 @@ export function Layout({ children }: { children: ReactNode }) {
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
<span className="sidebar-user">{user?.login}</span> <div className="sidebar-footer-top">
<button className="btn-logout" onClick={() => logout()}> <span className="sidebar-user">{user?.login}</span>
Выход <button className="btn-logout" onClick={() => logout()}>
</button> Выход
</button>
</div>
<div className="sidebar-footer-bottom">
<span className="sidebar-version">
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
</span>
<span className="sidebar-copyright">© 2025 Семейный бюджет</span>
</div>
</div> </div>
</aside> </aside>

View File

@@ -138,11 +138,34 @@ body {
.sidebar-footer { .sidebar-footer {
padding: 16px 20px; padding: 16px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar-footer-top {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.sidebar-footer-bottom {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
color: var(--color-sidebar-text);
opacity: 0.85;
}
.sidebar-version {
font-family: ui-monospace, monospace;
}
.sidebar-copyright {
font-size: 10px;
}
.sidebar-user { .sidebar-user {
font-size: 13px; font-size: 13px;
color: var(--color-sidebar-text); color: var(--color-sidebar-text);
@@ -249,6 +272,7 @@ body {
.page { .page {
max-width: 1200px; max-width: 1200px;
margin: 0 auto;
} }
.page-header { .page-header {

View File

@@ -1 +1,3 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __FE_VERSION__: string;

View File

@@ -1,8 +1,19 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
readFileSync(join(__dirname, 'package.json'), 'utf-8'),
) as { version: string };
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
__FE_VERSION__: JSON.stringify(pkg.version),
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {