basic frontend
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing

This commit is contained in:
aovantsev
2025-10-03 11:32:59 +03:00
parent 78122bc996
commit 324e861218
35 changed files with 20764 additions and 22 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;
}
}

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

@@ -0,0 +1,41 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './hooks/useAuth';
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>
<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>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useCounters } from '../hooks/useCounters';
import { CounterWithStats } from '../types';
import { Plus, Minus, Edit, Trash2, MoreVertical } from 'lucide-react';
interface CounterCardProps {
counter: CounterWithStats;
}
export const CounterCard: React.FC<CounterCardProps> = ({ counter }) => {
const { incrementCounter, deleteCounter } = useCounters();
const [isLoading, setIsLoading] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const handleIncrement = async (value: number) => {
setIsLoading(true);
try {
await incrementCounter(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 deleteCounter(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,256 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useCounters } from '../hooks/useCounters';
import { useAuth } from '../hooks/useAuth';
import { CounterWithStats, CounterEntry } from '../types';
import { countersAPI } from '../services/api';
import { ArrowLeft, Plus, Minus, Edit, Trash2, Calendar, TrendingUp } 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 { incrementCounter, deleteCounter } = useCounters();
const [counter, setCounter] = useState<CounterWithStats | null>(null);
const [entries, setEntries] = useState<CounterEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isIncrementing, setIsIncrementing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (id) {
loadCounterData();
}
}, [id]);
const loadCounterData = async () => {
if (!id) return;
setIsLoading(true);
setError(null);
try {
if (isAuthenticated) {
// Load from API
const [counterResponse, entriesResponse] = await Promise.all([
countersAPI.getCounter(parseInt(id)),
countersAPI.getCounterEntries(parseInt(id))
]);
setCounter(counterResponse.data);
setEntries(entriesResponse.data);
} else {
// Load from localStorage
const counters = JSON.parse(localStorage.getItem('anonymous_counters') || '[]');
const foundCounter = counters.find((c: any) => c.id === id);
if (foundCounter) {
// Convert to CounterWithStats format
const today = new Date().toISOString().split('T')[0];
const counterWithStats: CounterWithStats = {
id: parseInt(foundCounter.id),
name: foundCounter.name,
description: foundCounter.description,
created_at: foundCounter.created_at,
updated_at: foundCounter.updated_at,
total_value: foundCounter.total_value,
today_value: foundCounter.entries[today] || 0,
week_value: Object.entries(foundCounter.entries)
.filter(([date]) => {
const entryDate = new Date(date);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return entryDate >= weekAgo;
})
.reduce((sum, [, value]) => sum + (value as number), 0),
month_value: Object.entries(foundCounter.entries)
.filter(([date]) => {
const entryDate = new Date(date);
const monthAgo = new Date();
monthAgo.setMonth(monthAgo.getMonth() - 1);
return entryDate >= monthAgo;
})
.reduce((sum, [, value]) => sum + (value as number), 0),
entry_count: Object.values(foundCounter.entries).reduce((sum: number, value: unknown) => sum + Math.abs(value as number), 0),
};
setCounter(counterWithStats);
// Convert entries to CounterEntry format
const counterEntries: CounterEntry[] = Object.entries(foundCounter.entries)
.map(([date, value]) => ({
id: Math.random(), // Generate random ID for display
counter_id: parseInt(foundCounter.id),
value: value as number,
date: date,
created_at: new Date().toISOString(),
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setEntries(counterEntries);
} else {
setError('Counter not found');
}
}
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to load counter');
} finally {
setIsLoading(false);
}
};
const handleIncrement = async (value: number) => {
if (!counter) return;
setIsIncrementing(true);
try {
await incrementCounter(counter.id, value);
// Reload data to get updated values
await loadCounterData();
} 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 space-x-4">
<button
onClick={() => navigate('/')}
className="p-2 text-gray-400 hover:text-gray-600"
>
<ArrowLeft className="h-6 w-6" />
</button>
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900">{counter.name}</h1>
{counter.description && (
<p className="text-gray-600 mt-1">{counter.description}</p>
)}
</div>
<div className="flex space-x-2">
<button
onClick={handleDelete}
className="btn btn-danger flex items-center space-x-2"
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</button>
</div>
</div>
{/* Counter Value and Controls */}
<div className="card p-8 text-center">
<div className="text-6xl font-bold text-primary-600 mb-4">
{counter.total_value}
</div>
<div className="text-lg text-gray-600 mb-8">Total Count</div>
<div className="flex justify-center space-x-4">
<button
onClick={() => handleIncrement(-1)}
disabled={isIncrementing}
className="btn btn-secondary text-2xl px-8 py-4 disabled:opacity-50"
>
<Minus className="h-8 w-8" />
</button>
<button
onClick={() => handleIncrement(1)}
disabled={isIncrementing}
className="btn btn-primary text-2xl px-8 py-4 disabled:opacity-50"
>
<Plus className="h-8 w-8" />
</button>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="card p-6 text-center">
<div className="text-2xl font-bold text-green-600 mb-2">{counter.today_value}</div>
<div className="text-sm text-gray-600">Today</div>
</div>
<div className="card p-6 text-center">
<div className="text-2xl font-bold text-blue-600 mb-2">{counter.week_value}</div>
<div className="text-sm text-gray-600">This Week</div>
</div>
<div className="card p-6 text-center">
<div className="text-2xl font-bold text-purple-600 mb-2">{counter.month_value}</div>
<div className="text-sm text-gray-600">This Month</div>
</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 ? (
<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={`w-3 h-3 rounded-full ${
entry.value > 0 ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className="text-sm text-gray-600">
{format(new Date(entry.date), 'MMM dd, yyyy')}
</span>
</div>
<div className={`text-sm font-medium ${
entry.value > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{entry.value > 0 ? '+' : ''}{entry.value}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,110 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useCounters } from '../hooks/useCounters';
import { CreateCounterRequest } from '../types';
import { X } from 'lucide-react';
interface CreateCounterModalProps {
onClose: () => void;
}
export const CreateCounterModal: React.FC<CreateCounterModalProps> = ({ onClose }) => {
const { createCounter } = useCounters();
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,145 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useCounters } from '../hooks/useCounters';
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 = () => {
const { counters, isLoading, error, searchCounters } = useCounters();
const { isAuthenticated } = useAuth();
const [showCreateModal, setShowCreateModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleSearch = (query: string) => {
setSearchQuery(query);
searchCounters(query);
};
const totalCounters = counters.length;
const totalValue = counters.reduce((sum, counter) => sum + counter.total_value, 0);
const todayValue = counters.reduce((sum, counter) => sum + counter.today_value, 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
? 'Manage 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 ? (
<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} />
))}
</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,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,191 @@
import { useState, useEffect } from 'react';
import { CounterWithStats, AnonymousCounter, CreateCounterRequest, UpdateCounterRequest, IncrementRequest } 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 [anonymousCounters, setAnonymousCounters] = useState<AnonymousCounter[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load counters based on authentication status
useEffect(() => {
if (isAuthenticated) {
loadCounters();
} else {
loadAnonymousCounters();
}
}, [isAuthenticated]);
const loadCounters = async (search?: string) => {
setIsLoading(true);
setError(null);
try {
const response = await countersAPI.getCounters(search);
setCounters(response.data);
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to load counters');
} finally {
setIsLoading(false);
}
};
const loadAnonymousCounters = () => {
const stored = localStorageService.getAnonymousCounters();
setAnonymousCounters(stored);
};
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);
loadAnonymousCounters();
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);
loadAnonymousCounters();
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);
loadAnonymousCounters();
}
};
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 counter = anonymousCounters.find(c => c.id === id);
if (counter) {
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);
loadAnonymousCounters();
return { id, ...updated };
}
throw new Error('Counter not found');
}
};
const searchCounters = (search: string) => {
if (isAuthenticated) {
loadCounters(search);
} else {
// Filter anonymous counters locally
const filtered = localStorageService.getAnonymousCounters().filter(counter =>
counter.name.toLowerCase().includes(search.toLowerCase()) ||
counter.description.toLowerCase().includes(search.toLowerCase())
);
setAnonymousCounters(filtered);
}
};
// Convert anonymous counters to CounterWithStats format for consistent UI
const getDisplayCounters = (): CounterWithStats[] => {
if (isAuthenticated) {
return counters;
} else {
return anonymousCounters.map(counter => ({
id: parseInt(counter.id), // Convert string ID to number for display
name: counter.name,
description: counter.description,
created_at: counter.created_at,
updated_at: counter.updated_at,
total_value: counter.total_value,
today_value: counter.entries[localStorageService.getTodayString()] || 0,
week_value: Object.entries(counter.entries)
.filter(([date]) => {
const entryDate = new Date(date);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return entryDate >= weekAgo;
})
.reduce((sum, [, value]) => sum + (value as number), 0),
month_value: Object.entries(counter.entries)
.filter(([date]) => {
const entryDate = new Date(date);
const monthAgo = new Date();
monthAgo.setMonth(monthAgo.getMonth() - 1);
return entryDate >= monthAgo;
})
.reduce((sum, [, value]) => sum + (value as number), 0),
entry_count: Object.values(counter.entries).reduce((sum: number, value: unknown) => sum + Math.abs(value as number), 0),
}));
}
};
return {
counters: getDisplayCounters(),
isLoading,
error,
createCounter,
updateCounter,
deleteCounter,
incrementCounter,
searchCounters,
refreshCounters: isAuthenticated ? loadCounters : loadAnonymousCounters,
};
};

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,93 @@
import axios, { AxiosResponse } from 'axios';
import {
AuthResponse,
RegisterRequest,
LoginRequest,
Counter,
CounterWithStats,
CounterEntry,
CreateCounterRequest,
UpdateCounterRequest,
IncrementRequest,
CounterStats,
User,
} from '../types';
const API_BASE_URL = process.env.REACT_APP_API_URL || '/api/v1';
// 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,81 @@
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 extends Counter {
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[];
}