basic frontend (#3)
All checks were successful
continuous-integration/drone/push Build is passing
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:
43
frontend/src/App.css
Normal file
43
frontend/src/App.css
Normal 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
44
frontend/src/App.tsx
Normal 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;
|
||||
131
frontend/src/components/CounterCard.tsx
Normal file
131
frontend/src/components/CounterCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
228
frontend/src/components/CounterDetail.tsx
Normal file
228
frontend/src/components/CounterDetail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
110
frontend/src/components/CreateCounterModal.tsx
Normal file
110
frontend/src/components/CreateCounterModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
156
frontend/src/components/Dashboard.tsx
Normal file
156
frontend/src/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
frontend/src/components/Layout.tsx
Normal file
97
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
132
frontend/src/components/Login.tsx
Normal file
132
frontend/src/components/Login.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
frontend/src/components/ProtectedRoute.tsx
Normal file
24
frontend/src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
};
|
||||
185
frontend/src/components/Register.tsx
Normal file
185
frontend/src/components/Register.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
frontend/src/config/environment.ts
Normal file
102
frontend/src/config/environment.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
41
frontend/src/contexts/CountersContext.tsx
Normal file
41
frontend/src/contexts/CountersContext.tsx
Normal 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;
|
||||
};
|
||||
111
frontend/src/hooks/useAuth.tsx
Normal file
111
frontend/src/hooks/useAuth.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
232
frontend/src/hooks/useCounters.tsx
Normal file
232
frontend/src/hooks/useCounters.tsx
Normal 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
20
frontend/src/index.css
Normal 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
13
frontend/src/index.tsx
Normal 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>
|
||||
);
|
||||
96
frontend/src/services/api.ts
Normal file
96
frontend/src/services/api.ts
Normal 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;
|
||||
90
frontend/src/services/localStorage.ts
Normal file
90
frontend/src/services/localStorage.ts
Normal 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];
|
||||
},
|
||||
};
|
||||
86
frontend/src/types/index.ts
Normal file
86
frontend/src/types/index.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user