basic frontend (#3)
All checks were successful
continuous-integration/drone/push Build is passing

Co-authored-by: aovantsev <aovantsev@avito.ru>
Reviewed-on: #3
This commit is contained in:
2025-10-03 16:25:14 +00:00
parent 78122bc996
commit ebf4bdeede
52 changed files with 21272 additions and 26 deletions

43
frontend/src/App.css Normal file
View File

@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
.btn-success {
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.card {
@apply bg-white rounded-lg shadow-md border border-gray-200;
}
.counter-card {
@apply card p-6 hover:shadow-lg transition-shadow duration-200;
}
}

44
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './hooks/useAuth';
import { CountersProvider } from './contexts/CountersContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Layout } from './components/Layout';
import { Login } from './components/Login';
import { Register } from './components/Register';
import { Dashboard } from './components/Dashboard';
import { CounterDetail } from './components/CounterDetail';
import './App.css';
function App() {
return (
<AuthProvider>
<CountersProvider>
<Router>
<div className="App">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/counter/:id" element={<CounterDetail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</div>
</Router>
</CountersProvider>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { CounterWithStats } from '../types';
import { Plus, Minus, Edit, Trash2, MoreVertical } from 'lucide-react';
interface CounterCardProps {
counter: CounterWithStats;
onIncrement: (id: number | string, value: number) => Promise<void>;
onDelete: (id: number | string) => Promise<void>;
}
export const CounterCard: React.FC<CounterCardProps> = ({ counter, onIncrement, onDelete }) => {
const [isLoading, setIsLoading] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const handleIncrement = async (value: number) => {
setIsLoading(true);
try {
await onIncrement(counter.id, value);
console.log('Counter incremented:', counter.id, value);
} catch (error) {
console.error('Failed to increment counter:', error);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (window.confirm('Are you sure you want to delete this counter?')) {
try {
await onDelete(counter.id);
} catch (error) {
console.error('Failed to delete counter:', error);
}
}
};
return (
<div className="counter-card">
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-1">
{counter.name}
</h3>
{counter.description && (
<p className="text-sm text-gray-600 mb-2">{counter.description}</p>
)}
</div>
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 text-gray-400 hover:text-gray-600"
>
<MoreVertical className="h-5 w-5" />
</button>
{showMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-10 border border-gray-200">
<Link
to={`/counter/${counter.id}`}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowMenu(false)}
>
<Edit className="h-4 w-4 mr-2" />
View Details
</Link>
<button
onClick={() => {
handleDelete();
setShowMenu(false);
}}
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</button>
</div>
)}
</div>
</div>
{/* Counter Value */}
<div className="text-center mb-6">
<div className="text-4xl font-bold text-primary-600 mb-2">
{counter.total_value}
</div>
<div className="text-sm text-gray-500">Total</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-3 gap-4 mb-6 text-center">
<div>
<div className="text-lg font-semibold text-gray-900">{counter.today_value}</div>
<div className="text-xs text-gray-500">Today</div>
</div>
<div>
<div className="text-lg font-semibold text-gray-900">{counter.week_value}</div>
<div className="text-xs text-gray-500">Week</div>
</div>
<div>
<div className="text-lg font-semibold text-gray-900">{counter.month_value}</div>
<div className="text-xs text-gray-500">Month</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<button
onClick={() => handleIncrement(-1)}
disabled={isLoading}
className="btn btn-secondary flex-1 flex items-center justify-center disabled:opacity-50"
>
<Minus className="h-4 w-4" />
</button>
<button
onClick={() => handleIncrement(1)}
disabled={isLoading}
className="btn btn-primary flex-1 flex items-center justify-center disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* Entry Count */}
<div className="mt-4 text-center">
<div className="text-xs text-gray-500">
{counter.entry_count} entries
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useCountersContext } from '../contexts/CountersContext';
import { useAuth } from '../hooks/useAuth';
import { CounterEntry } from '../types';
import { ArrowLeft, Plus, Minus, Trash2, Calendar } from 'lucide-react';
import { format } from 'date-fns';
export const CounterDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const { counters, incrementCounter, deleteCounter, getCounterEntries } = useCountersContext();
// Get the current counter from the useCounters hook
const counter = counters.find(c => c.id.toString() === id) || null;
const [entries, setEntries] = useState<CounterEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isIncrementing, setIsIncrementing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load entries when component mounts or counter changes
useEffect(() => {
const loadEntries = async () => {
if (!id || !counter) return;
setIsLoading(true);
setError(null);
try {
const counterEntries = await getCounterEntries(id);
setEntries(counterEntries);
} catch (err: any) {
setError(err.message || 'Failed to load counter entries');
} finally {
setIsLoading(false);
}
};
loadEntries();
}, [id, counter, getCounterEntries]);
const handleIncrement = async (value: number) => {
if (!counter) return;
setIsIncrementing(true);
try {
await incrementCounter(counter.id, value);
console.log('Counter incremented:', counter.id, value);
// Entries will be reloaded automatically when counter changes
} catch (error) {
console.error('Failed to increment counter:', error);
} finally {
setIsIncrementing(false);
}
};
const handleDelete = async () => {
if (!counter) return;
if (window.confirm('Are you sure you want to delete this counter?')) {
try {
await deleteCounter(counter.id);
navigate('/');
} catch (error) {
console.error('Failed to delete counter:', error);
}
}
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
}
if (error || !counter) {
return (
<div className="text-center py-12">
<div className="text-red-600 mb-4">{error || 'Counter not found'}</div>
<button
onClick={() => navigate('/')}
className="btn btn-primary"
>
Back to Dashboard
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => navigate('/')}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{counter.name}</h1>
{counter.description && (
<p className="text-gray-600 mt-1">{counter.description}</p>
)}
</div>
</div>
<button
onClick={handleDelete}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card p-4">
<div className="text-center">
<p className="text-sm font-medium text-gray-600">Total Value</p>
<p className="text-2xl font-bold text-gray-900">{counter.total_value}</p>
</div>
</div>
<div className="card p-4">
<div className="text-center">
<p className="text-sm font-medium text-gray-600">Today</p>
<p className="text-2xl font-bold text-gray-900">{counter.today_value}</p>
</div>
</div>
<div className="card p-4">
<div className="text-center">
<p className="text-sm font-medium text-gray-600">This Week</p>
<p className="text-2xl font-bold text-gray-900">{counter.week_value}</p>
</div>
</div>
<div className="card p-4">
<div className="text-center">
<p className="text-sm font-medium text-gray-600">This Month</p>
<p className="text-2xl font-bold text-gray-900">{counter.month_value}</p>
</div>
</div>
</div>
{/* Increment Controls */}
<div className="card p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
<div className="flex space-x-4">
<button
onClick={() => handleIncrement(-1)}
disabled={isIncrementing}
className="btn btn-secondary flex items-center space-x-2 disabled:opacity-50"
>
<Minus className="h-5 w-5" />
<span>-1</span>
</button>
<button
onClick={() => handleIncrement(1)}
disabled={isIncrementing}
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
>
<Plus className="h-5 w-5" />
<span>+1</span>
</button>
<button
onClick={() => handleIncrement(5)}
disabled={isIncrementing}
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
>
<Plus className="h-5 w-5" />
<span>+5</span>
</button>
<button
onClick={() => handleIncrement(10)}
disabled={isIncrementing}
className="btn btn-primary flex items-center space-x-2 disabled:opacity-50"
>
<Plus className="h-5 w-5" />
<span>+10</span>
</button>
</div>
</div>
{/* Recent Entries */}
<div className="card p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<Calendar className="h-5 w-5 mr-2" />
Recent Entries
</h2>
{(entries?.length || 0) === 0 ? (
<div className="text-center py-8 text-gray-500">
No entries yet. Start by incrementing your counter!
</div>
) : (
<div className="space-y-3">
{entries?.slice(0, 10).map((entry) => (
<div
key={entry.id}
className="flex justify-between items-center py-2 px-4 bg-gray-50 rounded-lg"
>
<div className="flex items-center space-x-3">
<div className="text-sm text-gray-600">
{format(new Date(entry.date), 'MMM dd, yyyy')}
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`font-medium ${entry.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{entry.value > 0 ? '+' : ''}{entry.value}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,110 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useCountersContext } from '../contexts/CountersContext';
import { CreateCounterRequest } from '../types';
import { X } from 'lucide-react';
interface CreateCounterModalProps {
onClose: () => void;
}
export const CreateCounterModal: React.FC<CreateCounterModalProps> = ({ onClose }) => {
const { createCounter } = useCountersContext();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateCounterRequest>();
const onSubmit = async (data: CreateCounterRequest) => {
setIsLoading(true);
setError(null);
try {
await createCounter(data);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to create counter');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Create New Counter</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name *
</label>
<input
{...register('name', {
required: 'Name is required',
maxLength: { value: 100, message: 'Name must be less than 100 characters' }
})}
type="text"
className="input mt-1"
placeholder="e.g., Cigarettes Smoked"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
{...register('description', {
maxLength: { value: 500, message: 'Description must be less than 500 characters' }
})}
rows={3}
className="input mt-1"
placeholder="Optional description..."
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description.message}</p>
)}
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn btn-secondary"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating...' : 'Create Counter'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react';
import { useCountersContext } from '../contexts/CountersContext';
import { useAuth } from '../hooks/useAuth';
import { CreateCounterModal } from './CreateCounterModal';
import { CounterCard } from './CounterCard';
import { Plus, Search, TrendingUp, Calendar, Clock } from 'lucide-react';
export const Dashboard: React.FC = () => {
console.log('Dashboard: Component rendering...');
const { counters, isLoading, error, searchCounters, version, incrementCounter, deleteCounter } = useCountersContext();
const { isAuthenticated } = useAuth();
const [showCreateModal, setShowCreateModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleSearch = (query: string) => {
setSearchQuery(query);
searchCounters(query);
};
useEffect(() => {
console.log('Dashboard: Counters changed:', counters, 'version:', version);
}, [counters, version]);
console.log('Dashboard: Current counters:', counters, 'version:', version);
const totalCounters = counters?.length || 0;
const totalValue = counters?.reduce((sum, counter) => sum + counter.total_value, 0) || 0;
const todayValue = counters?.reduce((sum, counter) => sum + counter.today_value, 0) || 0;
if (error) {
return (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600 mt-1">
{isAuthenticated
? 'Maaaanage your counters and track your progress'
: 'Track your habits and activities (data stored locally)'
}
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary flex items-center space-x-2"
>
<Plus className="h-5 w-5" />
<span>New Counter</span>
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="card p-6">
<div className="flex items-center">
<div className="p-2 bg-primary-100 rounded-lg">
<TrendingUp className="h-6 w-6 text-primary-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Counters</p>
<p className="text-2xl font-bold text-gray-900">{totalCounters}</p>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-lg">
<Calendar className="h-6 w-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Value</p>
<p className="text-2xl font-bold text-gray-900">{totalValue}</p>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-lg">
<Clock className="h-6 w-6 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Today's Value</p>
<p className="text-2xl font-bold text-gray-900">{todayValue}</p>
</div>
</div>
</div>
</div>
{/* Search */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search counters..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="input pl-10"
/>
</div>
{/* Counters Grid */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
) : (counters?.length || 0) === 0 ? (
<div className="text-center py-12">
<TrendingUp className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No counters</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating your first counter.
</p>
<div className="mt-6">
<button
onClick={() => setShowCreateModal(true)}
className="btn btn-primary"
>
<Plus className="h-5 w-5 mr-2" />
New Counter
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{counters?.map((counter) => (
<CounterCard
key={counter.id}
counter={counter}
onIncrement={incrementCounter}
onDelete={deleteCounter}
/>
))}
</div>
)}
{/* Create Counter Modal */}
{showCreateModal && (
<CreateCounterModal
onClose={() => setShowCreateModal(false)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { LogOut, User, Plus, Search } from 'lucide-react';
interface LayoutProps {
children: React.ReactNode;
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
const [showUserMenu, setShowUserMenu] = useState(false);
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link to="/" className="flex items-center">
<div className="text-2xl font-bold text-primary-600">Counter</div>
</Link>
{/* Navigation */}
<nav className="flex items-center space-x-4">
<Link
to="/"
className="text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium"
>
Dashboard
</Link>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center space-x-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium"
>
<User className="h-5 w-5" />
<span>{isAuthenticated ? user?.username : 'Anonymous'}</span>
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
{isAuthenticated ? (
<>
<div className="px-4 py-2 text-sm text-gray-700 border-b border-gray-200">
{user?.email}
</div>
<button
onClick={handleLogout}
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</button>
</>
) : (
<>
<Link
to="/login"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Login
</Link>
<Link
to="/register"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Register
</Link>
</>
)}
</div>
)}
</div>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{children}
</main>
</div>
);
};

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useAuth } from '../hooks/useAuth';
import { LoginRequest } from '../types';
import { Eye, EyeOff } from 'lucide-react';
export const Login: React.FC = () => {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginRequest>();
// Redirect if already authenticated
React.useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
const onSubmit = async (data: LoginRequest) => {
setIsLoading(true);
setError(null);
try {
await login(data);
navigate('/');
} catch (err: any) {
setError(err.response?.data?.error || 'Login failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/register"
className="font-medium text-primary-600 hover:text-primary-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
{...register('username', { required: 'Username is required' })}
type="text"
className="input mt-1"
placeholder="Enter your username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">{errors.username.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
{...register('password', { required: 'Password is required' })}
type={showPassword ? 'text' : 'password'}
className="input pr-10"
placeholder="Enter your password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary w-full flex justify-center py-2 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</div>
<div className="text-center">
<Link
to="/"
className="text-sm text-primary-600 hover:text-primary-500"
>
Continue as anonymous user
</Link>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
</div>
);
}
// Allow access to authenticated users or anonymous users
// The app works for both authenticated and anonymous users
return <>{children}</>;
};

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useAuth } from '../hooks/useAuth';
import { RegisterRequest } from '../types';
import { Eye, EyeOff } from 'lucide-react';
export const Register: React.FC = () => {
const { register: registerUser, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<RegisterRequest & { confirmPassword: string }>();
const password = watch('password');
// Redirect if already authenticated
React.useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
const onSubmit = async (data: RegisterRequest & { confirmPassword: string }) => {
setIsLoading(true);
setError(null);
try {
await registerUser({
username: data.username,
email: data.email,
password: data.password,
});
navigate('/');
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/login"
className="font-medium text-primary-600 hover:text-primary-500"
>
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'Username must be at least 3 characters' },
maxLength: { value: 50, message: 'Username must be less than 50 characters' }
})}
type="text"
className="input mt-1"
placeholder="Choose a username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-600">{errors.username.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address'
}
})}
type="email"
className="input mt-1"
placeholder="Enter your email"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
{...register('password', {
required: 'Password is required',
minLength: { value: 6, message: 'Password must be at least 6 characters' }
})}
type={showPassword ? 'text' : 'password'}
className="input pr-10"
placeholder="Create a password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<input
{...register('confirmPassword', {
required: 'Please confirm your password',
validate: value => value === password || 'Passwords do not match'
})}
type="password"
className="input mt-1"
placeholder="Confirm your password"
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary w-full flex justify-center py-2 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
</div>
<div className="text-center">
<Link
to="/"
className="text-sm text-primary-600 hover:text-primary-500"
>
Continue as anonymous user
</Link>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,102 @@
// Environment configuration for React frontend
export type Environment = 'development' | 'staging' | 'production';
export interface AppConfig {
environment: Environment;
apiUrl: string;
debug: boolean;
logLevel: string;
}
// Get environment from process.env
export const getEnvironment = (): Environment => {
const env = process.env.REACT_APP_ENVIRONMENT as Environment;
// Fallback to NODE_ENV if REACT_APP_ENVIRONMENT is not set
if (!env) {
const nodeEnv = process.env.NODE_ENV;
switch (nodeEnv) {
case 'production':
return 'production';
case 'development':
return 'development';
default:
return 'development';
}
}
return env;
};
// Get API URL based on environment
export const getApiUrl = (): string => {
const apiUrl = process.env.REACT_APP_API_URL;
if (apiUrl) {
return apiUrl;
}
// Fallback based on environment
const env = getEnvironment();
switch (env) {
case 'production':
return '/api/v1'; // Relative URL for production
case 'staging':
return 'https://staging-api.yourdomain.com/api/v1';
case 'development':
default:
return 'http://localhost:8080/api/v1';
}
};
// Get debug flag
export const isDebugMode = (): boolean => {
const debug = process.env.REACT_APP_DEBUG;
if (debug !== undefined) {
return debug === 'true';
}
// Fallback based on environment
return getEnvironment() === 'development';
};
// Get log level
export const getLogLevel = (): string => {
return process.env.REACT_APP_LOG_LEVEL || 'info';
};
// Get complete app configuration
export const getAppConfig = (): AppConfig => {
return {
environment: getEnvironment(),
apiUrl: getApiUrl(),
debug: isDebugMode(),
logLevel: getLogLevel(),
};
};
// Environment checks
export const isDevelopment = (): boolean => {
return getEnvironment() === 'development';
};
export const isStaging = (): boolean => {
return getEnvironment() === 'staging';
};
export const isProduction = (): boolean => {
return getEnvironment() === 'production';
};
// Log configuration (only in development)
export const logConfig = (): void => {
if (isDevelopment()) {
const config = getAppConfig();
console.log('🚀 Frontend Configuration:', {
environment: config.environment,
apiUrl: config.apiUrl,
debug: config.debug,
logLevel: config.logLevel,
});
}
};

View File

@@ -0,0 +1,41 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { CounterWithStats, CreateCounterRequest, UpdateCounterRequest, CounterEntry } from '../types';
import { useCounters } from '../hooks/useCounters';
interface CountersContextType {
counters: CounterWithStats[];
isLoading: boolean;
error: string | null;
version: number;
createCounter: (data: CreateCounterRequest) => Promise<any>;
updateCounter: (id: number | string, data: UpdateCounterRequest) => Promise<any>;
deleteCounter: (id: number | string) => Promise<void>;
incrementCounter: (id: number | string, value: number) => Promise<any>;
getCounterEntries: (id: number | string) => Promise<CounterEntry[]>;
searchCounters: (search: string) => void;
refreshCounters: () => Promise<void>;
}
const CountersContext = createContext<CountersContextType | undefined>(undefined);
interface CountersProviderProps {
children: ReactNode;
}
export const CountersProvider: React.FC<CountersProviderProps> = ({ children }) => {
const countersData = useCounters();
return (
<CountersContext.Provider value={countersData}>
{children}
</CountersContext.Provider>
);
};
export const useCountersContext = (): CountersContextType => {
const context = useContext(CountersContext);
if (context === undefined) {
throw new Error('useCountersContext must be used within a CountersProvider');
}
return context;
};

View File

@@ -0,0 +1,111 @@
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
import { User, AuthResponse, RegisterRequest, LoginRequest } from '../types';
import { authAPI } from '../services/api';
import { localStorageService } from '../services/localStorage';
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (data: LoginRequest) => Promise<void>;
register: (data: RegisterRequest) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const isAuthenticated = !!user;
useEffect(() => {
const initAuth = async () => {
const token = localStorageService.getAuthToken();
if (token) {
try {
const response = await authAPI.getCurrentUser();
setUser(response.data);
} catch (error) {
console.error('Failed to get current user:', error);
localStorageService.clearAuth();
}
}
setIsLoading(false);
};
initAuth();
}, []);
const login = async (data: LoginRequest) => {
try {
const response = await authAPI.login(data);
const { token, user: userData } = response.data;
localStorageService.setAuthToken(token);
localStorageService.setUser(userData);
setUser(userData);
} catch (error) {
throw error;
}
};
const register = async (data: RegisterRequest) => {
try {
const response = await authAPI.register(data);
const { token, user: userData } = response.data;
localStorageService.setAuthToken(token);
localStorageService.setUser(userData);
setUser(userData);
} catch (error) {
throw error;
}
};
const logout = () => {
localStorageService.clearAuth();
setUser(null);
};
const refreshUser = async () => {
try {
const response = await authAPI.getCurrentUser();
setUser(response.data);
localStorageService.setUser(response.data);
} catch (error) {
console.error('Failed to refresh user:', error);
logout();
}
};
const value: AuthContextType = {
user,
isAuthenticated,
isLoading,
login,
register,
logout,
refreshUser,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,232 @@
import { useState, useEffect } from 'react';
import { CounterWithStats, AnonymousCounter, CreateCounterRequest, UpdateCounterRequest, CounterEntry } from '../types';
import { countersAPI } from '../services/api';
import { localStorageService } from '../services/localStorage';
import { useAuth } from './useAuth';
export const useCounters = () => {
const { isAuthenticated } = useAuth();
const [counters, setCounters] = useState<CounterWithStats[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0); // Force re-renders
// Load counters based on authentication status
useEffect(() => {
loadCounters();
}, [isAuthenticated]);
useEffect(() => {
console.log('Counters:', counters);
}, [counters]);
const loadCounters = async (search?: string) => {
setIsLoading(true);
setError(null);
try {
if (isAuthenticated) {
// Load from API
const response = await countersAPI.getCounters(search);
console.log('Setting authenticated counters:', response.data);
setCounters([...response.data]); // Ensure new array reference
setVersion(prev => prev + 1); // Force re-render
} else {
// Load from localStorage and convert to CounterWithStats format
const anonymousCounters = localStorageService.getAnonymousCounters();
const convertedCounters = convertAnonymousToDisplay(anonymousCounters);
console.log('Setting anonymous counters:', convertedCounters);
setCounters([...convertedCounters]); // Ensure new array reference
setVersion(prev => prev + 1); // Force re-render
}
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to load counters');
} finally {
setIsLoading(false);
}
};
const convertAnonymousToDisplay = (anonymousCounters: AnonymousCounter[]): CounterWithStats[] => {
console.log('Converting anonymous counters:', anonymousCounters);
const converted = anonymousCounters.map(counter => {
const today = localStorageService.getTodayString();
const todayValue = counter.entries[today] || 0;
const weekValue = Object.keys(counter.entries)
.filter((date) => {
const entryDate = new Date(date);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return entryDate >= weekAgo;
})
.reduce((sum, date) => sum + (counter.entries[date] as number), 0);
const monthValue = Object.keys(counter.entries)
.filter((date) => {
const entryDate = new Date(date);
const monthAgo = new Date();
monthAgo.setMonth(monthAgo.getMonth() - 1);
return entryDate >= monthAgo;
})
.reduce((sum, date) => sum + (counter.entries[date] as number), 0);
const entryCount = Object.keys(counter.entries).reduce((sum: number, date: string) => sum + Math.abs(counter.entries[date] as number), 0);
return {
id: counter.id,
name: counter.name,
description: counter.description,
created_at: counter.created_at,
updated_at: counter.updated_at,
total_value: counter.total_value,
today_value: todayValue,
week_value: weekValue,
month_value: monthValue,
entry_count: entryCount,
};
});
console.log('Converted counters:', converted);
return converted;
};
const createCounter = async (data: CreateCounterRequest) => {
if (isAuthenticated) {
try {
const response = await countersAPI.createCounter(data);
await loadCounters(); // Refresh the list
return response.data;
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to create counter');
}
} else {
// Create anonymous counter
const newCounter: AnonymousCounter = {
id: localStorageService.generateId(),
name: data.name,
description: data.description,
total_value: 0,
entries: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
localStorageService.addAnonymousCounter(newCounter);
await loadCounters(); // Refresh the list
return newCounter;
}
};
const updateCounter = async (id: number | string, data: UpdateCounterRequest) => {
if (isAuthenticated) {
try {
const response = await countersAPI.updateCounter(id as number, data);
await loadCounters(); // Refresh the list
return response.data;
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to update counter');
}
} else {
// Update anonymous counter
const updated = { ...data, updated_at: new Date().toISOString() };
localStorageService.updateAnonymousCounter(id as string, updated);
await loadCounters(); // Refresh the list
return { id, ...updated };
}
};
const deleteCounter = async (id: number | string) => {
if (isAuthenticated) {
try {
await countersAPI.deleteCounter(id as number);
await loadCounters(); // Refresh the list
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to delete counter');
}
} else {
// Delete anonymous counter
localStorageService.deleteAnonymousCounter(id as string);
await loadCounters(); // Refresh the list
}
};
const incrementCounter = async (id: number | string, value: number) => {
if (isAuthenticated) {
try {
const response = await countersAPI.incrementCounter(id as number, { value });
await loadCounters(); // Refresh the list
return response.data;
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to increment counter');
}
} else {
// Increment anonymous counter
const today = localStorageService.getTodayString();
const counters = localStorageService.getAnonymousCounters();
const counter = counters.find((c: AnonymousCounter) => c.id === id);
if (!counter) {
throw new Error('Counter not found');
}
const newTotal = counter.total_value + value;
const newEntries = { ...counter.entries };
newEntries[today] = (newEntries[today] || 0) + value;
const updated = {
total_value: newTotal,
entries: newEntries,
updated_at: new Date().toISOString(),
};
localStorageService.updateAnonymousCounter(id as string, updated);
console.log('Increment completed, reloading counters...');
await loadCounters(); // Refresh the list
return { id, ...updated };
}
};
const getCounterEntries = async (id: number | string): Promise<CounterEntry[]> => {
if (isAuthenticated) {
try {
const response = await countersAPI.getCounterEntries(id as number);
return response.data;
} catch (err: any) {
throw new Error(err.response?.data?.error || 'Failed to load counter entries');
}
} else {
// Get entries from localStorage
const counters = localStorageService.getAnonymousCounters();
const counter = counters.find((c: AnonymousCounter) => c.id === id);
if (!counter) {
throw new Error('Counter not found');
}
return Object.entries(counter.entries || {}).map(([date, value], index) => ({
id: index + 1,
counter_id: parseInt(counter.id as string),
value: value as number,
date: date,
created_at: counter.updated_at,
}));
}
};
const searchCounters = (search: string) => {
loadCounters(search);
};
return {
counters,
isLoading,
error,
version, // Add version to force re-renders
createCounter,
updateCounter,
deleteCounter,
incrementCounter,
getCounterEntries,
searchCounters,
refreshCounters: loadCounters,
};
};

20
frontend/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f8fafc;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}

13
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,96 @@
import axios, { AxiosResponse } from 'axios';
import {
AuthResponse,
RegisterRequest,
LoginRequest,
Counter,
CounterWithStats,
CounterEntry,
CreateCounterRequest,
UpdateCounterRequest,
IncrementRequest,
CounterStats,
User,
} from '../types';
import { getApiUrl, logConfig } from '../config/environment';
// Initialize configuration
logConfig();
const API_BASE_URL = getApiUrl();
// Create axios instance
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Auth API
export const authAPI = {
register: (data: RegisterRequest): Promise<AxiosResponse<AuthResponse>> =>
api.post('/auth/register', data),
login: (data: LoginRequest): Promise<AxiosResponse<AuthResponse>> =>
api.post('/auth/login', data),
getCurrentUser: (): Promise<AxiosResponse<User>> =>
api.get('/auth/me'),
};
// Counters API
export const countersAPI = {
getCounters: (search?: string): Promise<AxiosResponse<CounterWithStats[]>> => {
const params = search ? { search } : {};
return api.get('/counters', { params });
},
getCounter: (id: number): Promise<AxiosResponse<CounterWithStats>> =>
api.get(`/counters/${id}`),
createCounter: (data: CreateCounterRequest): Promise<AxiosResponse<Counter>> =>
api.post('/counters', data),
updateCounter: (id: number, data: UpdateCounterRequest): Promise<AxiosResponse<Counter>> =>
api.put(`/counters/${id}`, data),
deleteCounter: (id: number): Promise<AxiosResponse<{ message: string }>> =>
api.delete(`/counters/${id}`),
incrementCounter: (id: number, data: IncrementRequest): Promise<AxiosResponse<CounterEntry>> =>
api.post(`/counters/${id}/increment`, data),
getCounterEntries: (id: number, startDate?: string, endDate?: string): Promise<AxiosResponse<CounterEntry[]>> => {
const params: any = {};
if (startDate) params.start_date = startDate;
if (endDate) params.end_date = endDate;
return api.get(`/counters/${id}/entries`, { params });
},
getCounterStats: (id: number): Promise<AxiosResponse<CounterStats>> =>
api.get(`/counters/${id}/stats`),
};
export default api;

View File

@@ -0,0 +1,90 @@
import { AnonymousCounter } from '../types';
const ANONYMOUS_COUNTERS_KEY = 'anonymous_counters';
export const localStorageService = {
// Anonymous counters
getAnonymousCounters: (): AnonymousCounter[] => {
try {
const data = localStorage.getItem(ANONYMOUS_COUNTERS_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('Error loading anonymous counters:', error);
return [];
}
},
saveAnonymousCounters: (counters: AnonymousCounter[]): void => {
try {
localStorage.setItem(ANONYMOUS_COUNTERS_KEY, JSON.stringify(counters));
} catch (error) {
console.error('Error saving anonymous counters:', error);
}
},
addAnonymousCounter: (counter: AnonymousCounter): void => {
const counters = localStorageService.getAnonymousCounters();
counters.push(counter);
localStorageService.saveAnonymousCounters(counters);
},
updateAnonymousCounter: (id: string, updates: Partial<AnonymousCounter>): void => {
const counters = localStorageService.getAnonymousCounters();
const index = counters.findIndex(c => c.id === id);
if (index !== -1) {
counters[index] = { ...counters[index], ...updates };
localStorageService.saveAnonymousCounters(counters);
}
},
deleteAnonymousCounter: (id: string): void => {
const counters = localStorageService.getAnonymousCounters();
const filtered = counters.filter(c => c.id !== id);
localStorageService.saveAnonymousCounters(filtered);
},
// Auth
setAuthToken: (token: string): void => {
localStorage.setItem('token', token);
},
getAuthToken: (): string | null => {
return localStorage.getItem('token');
},
removeAuthToken: (): void => {
localStorage.removeItem('token');
},
setUser: (user: any): void => {
localStorage.setItem('user', JSON.stringify(user));
},
getUser: (): any => {
try {
const data = localStorage.getItem('user');
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('Error loading user:', error);
return null;
}
},
removeUser: (): void => {
localStorage.removeItem('user');
},
clearAuth: (): void => {
localStorageService.removeAuthToken();
localStorageService.removeUser();
},
// Utility
generateId: (): string => {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
},
getTodayString: (): string => {
return new Date().toISOString().split('T')[0];
},
};

View File

@@ -0,0 +1,86 @@
export interface User {
id: number;
username: string;
email: string;
created_at: string;
updated_at: string;
}
export interface Counter {
id: number;
user_id?: number;
name: string;
description: string;
created_at: string;
updated_at: string;
}
export interface CounterEntry {
id: number;
counter_id: number;
value: number;
date: string;
created_at: string;
}
export interface CounterWithStats {
id: number | string; // Allow both number (authenticated) and string (anonymous) IDs
name: string;
description: string;
created_at: string;
updated_at: string;
total_value: number;
today_value: number;
week_value: number;
month_value: number;
entry_count: number;
}
export interface AnonymousCounter {
id: string;
name: string;
description: string;
total_value: number;
entries: Record<string, number>; // date -> count
created_at: string;
updated_at: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface CreateCounterRequest {
name: string;
description: string;
}
export interface UpdateCounterRequest {
name: string;
description: string;
}
export interface IncrementRequest {
value: number;
}
export interface DailyStat {
date: string;
total: number;
}
export interface CounterStats {
daily_stats: DailyStat[];
}