mirror of
https://github.com/EdiFarcas/Car-Fuel-Tracking-App.git
synced 2026-06-22 05:00:53 +03:00
Logic + UI upgrade
This commit is contained in:
@@ -1,36 +1,82 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Car Fuel Tracking App
|
||||
|
||||
A modern web application to track your car's fuel fill-ups, mileage, and fuel statistics. Built with Next.js, React, Prisma, and PostgreSQL, it provides a beautiful dashboard for managing your vehicles and analyzing fuel efficiency over time.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚗 **Multi-car support**: Track multiple vehicles, each with its own stats and history.
|
||||
- ⛽ **Fill-up logging**: Record every fuel fill-up with mileage, liters, cost, and currency.
|
||||
- 🛣️ **Mileage tracking**: Log odometer readings to monitor your driving habits.
|
||||
- 📊 **Statistics dashboard**: View total distance, fuel used, cost, average consumption, and more.
|
||||
- 🔒 **Authentication**: Secure login and registration with hashed passwords.
|
||||
- 🌗 **Responsive & modern UI**: Clean, mobile-friendly design with dark mode support.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- [Next.js](https://nextjs.org/) (App Router)
|
||||
- [React](https://react.dev/)
|
||||
- [Prisma ORM](https://www.prisma.io/)
|
||||
- [PostgreSQL](https://www.postgresql.org/)
|
||||
- [NextAuth.js](https://next-auth.js.org/) (Credentials provider)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- TypeScript
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- PostgreSQL database
|
||||
|
||||
### 1. Clone the repository
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
git clone <your-repo-url>
|
||||
cd Car-Fuel-Tracking-App
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### 2. Install dependencies
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### 3. Configure environment variables
|
||||
Create a `.env` file in the root with the following:
|
||||
```
|
||||
DATABASE_URL=postgresql://<user>:<password>@<host>:<port>/<db>
|
||||
NEXTAUTH_SECRET=your_secret_key
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
### 4. Set up the database
|
||||
Run Prisma migrations to set up the schema:
|
||||
```bash
|
||||
npx prisma migrate deploy
|
||||
# or for development
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
## Learn More
|
||||
### 5. Start the development server
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Project Structure
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
- `src/app/` — Next.js app directory (routes, pages, API)
|
||||
- `src/components/` — Reusable UI components
|
||||
- `prisma/schema.prisma` — Database schema
|
||||
- `src/lib/` — Auth and Prisma helpers
|
||||
- `public/` — Static assets
|
||||
|
||||
## Deploy on Vercel
|
||||
## Scripts
|
||||
- `npm run dev` — Start development server
|
||||
- `npm run build` — Build for production
|
||||
- `npm start` — Start production server
|
||||
- `npm run lint` — Lint code
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
## Database Schema
|
||||
See [`prisma/schema.prisma`](prisma/schema.prisma) for models: `User`, `Car`, `FillUp`, `MileageEntry`, enums for `FuelType` and `Currency`.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
---
|
||||
|
||||
> Built with passion using Next.js, Prisma, and Tailwind CSS.
|
||||
|
||||
@@ -27,16 +27,16 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Login</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<main className="max-w-md mx-auto p-8 bg-[var(--muted)] rounded-xl shadow space-y-6">
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Login</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
@@ -44,12 +44,12 @@ export default function LoginPage() {
|
||||
value={password}
|
||||
required
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded">
|
||||
<button type="submit" className="w-full bg-[var(--primary)] text-white py-3 rounded-lg font-semibold shadow hover:bg-blue-700 transition">
|
||||
Sign In
|
||||
</button>
|
||||
{error && <p className="text-red-500">{error}</p>}
|
||||
{error && <p className="text-red-500 text-center">{error}</p>}
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -11,13 +11,11 @@ export default function RegisterPage() {
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
router.push('/auth/login');
|
||||
} else {
|
||||
@@ -27,16 +25,16 @@ export default function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Register</h1>
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<main className="max-w-md mx-auto p-8 bg-[var(--muted)] rounded-xl shadow space-y-6">
|
||||
<h1 className="text-2xl font-bold text-[var(--secondary)]">Register</h1>
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--secondary)]"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
@@ -44,12 +42,12 @@ export default function RegisterPage() {
|
||||
value={password}
|
||||
required
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--secondary)]"
|
||||
/>
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded">
|
||||
<button type="submit" className="w-full bg-[var(--secondary)] text-white py-3 rounded-lg font-semibold shadow hover:bg-green-700 transition">
|
||||
Register
|
||||
</button>
|
||||
{error && <p className="text-red-500">{error}</p>}
|
||||
{error && <p className="text-red-500 text-center">{error}</p>}
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -57,56 +57,75 @@ export default function FillUpsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-xl mx-auto p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">Fuel Fill-Ups</h1>
|
||||
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow">
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Fuel Fill-Ups</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
name="mileage"
|
||||
type="number"
|
||||
placeholder="Odometer"
|
||||
value={form.mileage}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
/>
|
||||
<input
|
||||
name="liters"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Liters"
|
||||
value={form.liters}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
/>
|
||||
<input
|
||||
name="cost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Total Cost"
|
||||
value={form.cost}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
/>
|
||||
<select
|
||||
name="currency"
|
||||
value={form.currency}
|
||||
onChange={handleChange}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
>
|
||||
{currencies.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="w-full bg-green-600 text-white py-2 rounded">
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-white p-6 rounded-lg border border-[var(--border)]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="mileage" className="font-medium text-[var(--primary)]">Odometer</label>
|
||||
<input
|
||||
id="mileage"
|
||||
name="mileage"
|
||||
type="number"
|
||||
placeholder="e.g. 102300"
|
||||
value={form.mileage}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="border border-[var(--border)] px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--primary)] bg-[var(--muted)] text-[var(--foreground)]"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Enter the odometer reading at fill-up</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="liters" className="font-medium text-[var(--primary)]">Liters</label>
|
||||
<input
|
||||
id="liters"
|
||||
name="liters"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="e.g. 45.5"
|
||||
value={form.liters}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="border border-[var(--border)] px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--primary)] bg-[var(--muted)] text-[var(--foreground)]"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">How many liters did you fill?</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="cost" className="font-medium text-[var(--primary)]">Total Cost</label>
|
||||
<input
|
||||
id="cost"
|
||||
name="cost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="e.g. 300.00"
|
||||
value={form.cost}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="border border-[var(--border)] px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--primary)] bg-[var(--muted)] text-[var(--foreground)]"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Total price paid for this fill-up</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="currency" className="font-medium text-[var(--primary)]">Currency</label>
|
||||
<select
|
||||
id="currency"
|
||||
name="currency"
|
||||
value={form.currency}
|
||||
onChange={handleChange}
|
||||
className="border border-[var(--border)] px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--primary)] bg-[var(--muted)] text-[var(--foreground)]"
|
||||
>
|
||||
{currencies.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="col-span-full bg-[var(--secondary)] text-white py-2 rounded-lg font-semibold shadow hover:bg-green-700 transition mt-2">
|
||||
➕ Add Fill-Up
|
||||
</button>
|
||||
{error && <p className="text-red-500">{error}</p>}
|
||||
{error && <p className="col-span-full text-red-500 text-center mt-2">{error}</p>}
|
||||
</form>
|
||||
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-3">
|
||||
{fillups.map((fill) => (
|
||||
<FillUpCard key={fill.id} fill={fill} /> // ✅
|
||||
))}
|
||||
|
||||
@@ -44,30 +44,40 @@ export default function MileagePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-xl mx-auto p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">Mileage Log</h1>
|
||||
<main className="max-w-xl mx-auto p-6 space-y-6 bg-[var(--muted)] rounded-xl shadow">
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)] flex items-center gap-2">
|
||||
<span role="img" aria-label="mileage">🛣️</span> Mileage Log
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-4">
|
||||
<input
|
||||
type="number"
|
||||
value={mileage}
|
||||
onChange={(e) => setMileage(e.target.value)}
|
||||
placeholder="Odometer (e.g. 102300)"
|
||||
className="flex-1 border px-3 py-2 rounded"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row items-center gap-4 bg-white p-6 rounded-lg border border-[var(--border)] shadow">
|
||||
<div className="flex-1 flex flex-col gap-1 w-full">
|
||||
<label htmlFor="mileage" className="font-medium text-[var(--primary)]">Odometer</label>
|
||||
<input
|
||||
id="mileage"
|
||||
type="number"
|
||||
value={mileage}
|
||||
onChange={(e) => setMileage(e.target.value)}
|
||||
placeholder="e.g. 102300"
|
||||
className="w-full border border-[var(--border)] px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--primary)] bg-[var(--muted)] text-[var(--foreground)] placeholder:text-gray-500 dark:placeholder:text-gray-400"
|
||||
required
|
||||
/>
|
||||
<span className="text-xs text-[var(--primary)] font-semibold">Enter the current odometer reading</span>
|
||||
</div>
|
||||
<button type="submit" className="w-full sm:w-auto bg-[var(--secondary)] text-white py-2 px-6 rounded-lg font-semibold shadow hover:bg-green-700 transition mt-2 sm:mt-6">
|
||||
➕ Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="text-red-500">{error}</p>}
|
||||
{error && <p className="text-red-500 text-center">{error}</p>}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id} className="border rounded p-3">
|
||||
<p className="font-medium">{entry.mileage} km</p>
|
||||
<p className="text-sm text-gray-500">{new Date(entry.date).toLocaleString()}</p>
|
||||
<li key={entry.id} className="border rounded p-3 bg-white shadow-sm flex items-center gap-4">
|
||||
<span className="text-[var(--primary)] text-xl">⛽</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{entry.mileage} km</p>
|
||||
<p className="text-sm text-gray-600">{new Date(entry.date).toLocaleString()}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -22,33 +22,30 @@ export default async function CarDetailPage({ params }: CarDetailPageProps) {
|
||||
});
|
||||
|
||||
if (!car) {
|
||||
return <div className="p-6">Car not found or unauthorized access.</div>;
|
||||
return <div className="p-8 text-center text-red-500 text-lg font-semibold">Car not found or unauthorized access.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto p-6 space-y-4">
|
||||
<h1 className="text-3xl font-bold">{car.name}</h1>
|
||||
<p className="text-gray-700">{car.make} {car.model} ({car.year})</p>
|
||||
<p className="text-gray-600">Fuel Type: {car.fuelType}</p>
|
||||
|
||||
<div className="flex gap-4 mt-6">
|
||||
<main className="max-w-3xl mx-auto p-8 space-y-6 bg-[var(--muted)] rounded-xl shadow">
|
||||
<h1 className="text-3xl font-bold text-[var(--primary)]">{car.name}</h1>
|
||||
<p className="text-lg text-gray-700">{car.make} {car.model} ({car.year})</p>
|
||||
<p className="text-gray-600">Fuel Type: <span className="font-semibold text-[var(--secondary)]">{car.fuelType}</span></p>
|
||||
<div className="flex flex-wrap gap-4 mt-8">
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/mileage`}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
className="bg-[var(--primary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-blue-700 transition"
|
||||
>
|
||||
➕ Add Mileage
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/fillups`}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded"
|
||||
className="bg-[var(--secondary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-green-700 transition"
|
||||
>
|
||||
➕ Add Fill-Up
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/dashboard/cars/${car.id}/stats`}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded"
|
||||
className="bg-[var(--accent)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-purple-800 transition"
|
||||
>
|
||||
📊 View Stats
|
||||
</Link>
|
||||
|
||||
@@ -16,7 +16,7 @@ export default async function StatsPage({ params }: StatsProps) {
|
||||
const car = await prisma.car.findFirst({
|
||||
where: {
|
||||
id: params.carId,
|
||||
user: { email: session.user?.email! },
|
||||
user: { email: session.user?.email || '' },
|
||||
},
|
||||
include: {
|
||||
fillUps: {
|
||||
@@ -30,9 +30,13 @@ export default async function StatsPage({ params }: StatsProps) {
|
||||
|
||||
if (fillUps.length < 2) {
|
||||
return (
|
||||
<main className="max-w-xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-2">Not Enough Data</h1>
|
||||
<p className="text-gray-600">Add at least 2 fill-ups to see statistics.</p>
|
||||
<main className="max-w-xl mx-auto p-8 flex flex-col items-center justify-center bg-[var(--muted)] rounded-xl shadow space-y-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-5xl text-[var(--primary)]">⛽</span>
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Not Enough Data</h1>
|
||||
<p className="text-gray-700 text-center">Add at least 2 fill-ups to see your fuel statistics and insights.</p>
|
||||
<a href={`/dashboard/cars/${params.carId}/fillups`} className="mt-4 inline-block bg-[var(--secondary)] text-white px-6 py-2 rounded-lg font-semibold shadow hover:bg-green-700 transition">➕ Add Fill-Up</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -49,15 +53,16 @@ export default async function StatsPage({ params }: StatsProps) {
|
||||
const costPerKm = totalCost / totalDistance;
|
||||
|
||||
return (
|
||||
<main className="max-w-xl mx-auto p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Fuel Stats</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<StatCard label="Total Distance" value={`${totalDistance.toFixed(0)} km`} />
|
||||
<StatCard label="Total Fuel Used" value={`${totalLiters.toFixed(2)} L`} />
|
||||
<StatCard label="Total Fuel Cost" value={`${totalCost.toFixed(2)} ${fillUps[0].currency}`} />
|
||||
<StatCard label="Average Consumption" value={`${avgConsumption.toFixed(2)} L / 100km`} />
|
||||
<StatCard label="Average Cost / km" value={`${costPerKm.toFixed(2)} ${fillUps[0].currency}`} />
|
||||
<main className="max-w-2xl mx-auto p-8 space-y-8 bg-[var(--muted)] rounded-xl shadow">
|
||||
<h1 className="text-3xl font-bold text-[var(--primary)] flex items-center gap-2">
|
||||
<span role="img" aria-label="stats">📊</span> Fuel Stats
|
||||
</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<StatCard label="Total Distance" value={`${totalDistance.toFixed(0)} km`} icon="🛣️" color="primary" />
|
||||
<StatCard label="Total Fuel Used" value={`${totalLiters.toFixed(2)} L`} icon="⛽" color="secondary" />
|
||||
<StatCard label="Total Fuel Cost" value={`${totalCost.toFixed(2)} ${fillUps[0].currency}`} icon="💸" color="accent" />
|
||||
<StatCard label="Average Consumption" value={`${avgConsumption.toFixed(2)} L / 100km`} icon="📏" color="primary" />
|
||||
<StatCard label="Average Cost / km" value={`${costPerKm.toFixed(2)} ${fillUps[0].currency}`} icon="💰" color="secondary" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -22,13 +22,11 @@ export default function AddCarPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch('/api/cars', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
@@ -38,16 +36,16 @@ export default function AddCarPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="max-w-md mx-auto p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Add New Car</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<main className="max-w-lg mx-auto p-8 bg-[var(--muted)] rounded-xl shadow space-y-6">
|
||||
<h1 className="text-2xl font-bold text-[var(--primary)]">Add New Car</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Car Name"
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
name="make"
|
||||
@@ -55,7 +53,7 @@ export default function AddCarPage() {
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Make (e.g. BMW)"
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
name="model"
|
||||
@@ -63,7 +61,7 @@ export default function AddCarPage() {
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Model (e.g. 320i)"
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
name="year"
|
||||
@@ -72,22 +70,22 @@ export default function AddCarPage() {
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Year"
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<select
|
||||
name="fuelType"
|
||||
value={form.fuelType}
|
||||
onChange={handleChange}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
className="w-full border border-[var(--border)] px-4 py-3 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
|
||||
>
|
||||
{fuelTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded">
|
||||
<button type="submit" className="w-full bg-[var(--primary)] text-white py-3 rounded-lg font-semibold shadow hover:bg-blue-700 transition">
|
||||
Save Car
|
||||
</button>
|
||||
{error && <p className="text-red-500">{error}</p>}
|
||||
{error && <p className="text-red-500 text-center">{error}</p>}
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import CarCard from '@/components/CarCard';
|
||||
|
||||
@@ -15,22 +15,24 @@ export default async function DashboardPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Your Cars</h1>
|
||||
<Link href="/dashboard/cars/new" className="bg-blue-600 text-white px-4 py-2 rounded">
|
||||
<main className="flex flex-col gap-8 max-w-5xl mx-auto p-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<h1 className="text-3xl font-bold text-[var(--primary)]">Your Cars</h1>
|
||||
<Link href="/dashboard/cars/new" className="bg-[var(--primary)] text-white px-5 py-2 rounded-lg font-semibold shadow hover:bg-blue-700 transition">
|
||||
+ Add Car
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{user?.cars.length ? (
|
||||
<ul className="grid gap-4">
|
||||
<ul className="grid gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||
{user.cars.map((car) => (
|
||||
<CarCard key={car.id} car={car} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No cars added yet.</p>
|
||||
<div className="text-center text-gray-500 py-12">
|
||||
<p className="mb-4">No cars added yet.</p>
|
||||
<Link href="/dashboard/cars/new" className="text-[var(--primary)] underline hover:text-blue-700">Add your first car</Link>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--primary: #2563eb; /* blue-600 */
|
||||
--secondary: #22c55e; /* green-500 */
|
||||
--accent: #a21caf; /* purple-700 */
|
||||
--muted: #f3f4f6; /* gray-100 */
|
||||
--border: #e5e7eb; /* gray-200 */
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -16,6 +21,8 @@
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--muted: #171717;
|
||||
--border: #232323;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +30,14 @@ body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: var(--muted);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
+17
-4
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Link from "next/link";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Car Fuel Tracker",
|
||||
description: "Track your car's fuel and mileage easily.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,9 +26,21 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[var(--background)] text-[var(--foreground)]`}
|
||||
>
|
||||
{children}
|
||||
<header className="w-full px-6 py-4 flex items-center justify-between border-b border-[var(--border)] bg-[var(--muted)] shadow-sm">
|
||||
<Link href="/dashboard" className="text-2xl font-bold tracking-tight text-[var(--primary)]">
|
||||
🚗 Car Fuel Tracker
|
||||
</Link>
|
||||
<nav className="flex gap-4">
|
||||
<Link href="/dashboard" className="hover:text-[var(--primary)] transition">Dashboard</Link>
|
||||
<Link href="/dashboard/cars/new" className="hover:text-[var(--secondary)] transition">Add Car</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="h-4" /> {/* Spacer between navbar and content */}
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+35
-89
@@ -1,102 +1,48 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<div className="flex flex-col min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<section className="flex-1 flex flex-col items-center justify-center gap-10 p-8 sm:p-20">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={120}
|
||||
height={30}
|
||||
priority
|
||||
/>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-[var(--primary)] mb-2">
|
||||
Welcome to Car Fuel Tracker
|
||||
</h1>
|
||||
<p className="text-lg text-gray-500 max-w-xl text-center">
|
||||
Track your car's fuel fill-ups, mileage, and stats with a beautiful,
|
||||
simple dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-wrap justify-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-lg bg-[var(--primary)] text-white px-6 py-3 font-semibold shadow hover:bg-blue-700 transition"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--muted)] text-[var(--primary)] px-6 py-3 font-semibold hover:bg-[var(--primary)] hover:text-white transition"
|
||||
href="https://nextjs.org/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
Next.js Docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</section>
|
||||
<footer className="w-full py-4 flex items-center justify-center border-t border-[var(--border)] bg-[var(--muted)] text-sm text-gray-500">
|
||||
<span>
|
||||
© {new Date().getFullYear()} Car Fuel Tracker. Built with Next.js.
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Link from 'next/link';
|
||||
import { Car } from '@prisma/client';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function CarCard({ car }: { car: Car }) {
|
||||
return (
|
||||
<Link href={`/dashboard/cars/${car.id}`}>
|
||||
<div className="border rounded-xl p-4 shadow-sm hover:shadow-md transition bg-gray-50">
|
||||
<h2 className="text-xl font-bold text-black">{car.name}</h2>
|
||||
<p className="text-gray-600">{car.make} {car.model} ({car.year})</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Fuel Type: {car.fuelType}</p>
|
||||
<Link href={`/dashboard/cars/${car.id}`} className="block group">
|
||||
<div className="border border-[var(--border)] rounded-xl p-6 shadow-sm bg-white group-hover:shadow-lg transition flex flex-col gap-2 h-full">
|
||||
<h2 className="text-xl font-bold text-[var(--primary)] group-hover:underline">{car.name}</h2>
|
||||
<p className="text-gray-700">{car.make} {car.model} <span className="text-gray-400">({car.year})</span></p>
|
||||
<p className="text-sm text-[var(--secondary)] mt-1 font-semibold">Fuel Type: {car.fuelType}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
export default function StatCard({ label, value }: { label: string; value: string }) {
|
||||
export default function StatCard({ label, value, icon, color }: { label: string; value: string; icon?: string; color?: 'primary' | 'secondary' | 'accent' }) {
|
||||
const valueClass =
|
||||
label === 'Total Fuel Cost'
|
||||
? 'text-gray-900'
|
||||
: color
|
||||
? `text-[var(--${color})]`
|
||||
: 'text-[var(--foreground)]';
|
||||
return (
|
||||
<div className="border rounded-xl p-4 shadow-sm bg-white">
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
<div className="border rounded-xl p-4 shadow-sm bg-white flex items-center gap-4">
|
||||
{icon && <span className={`text-2xl ${color ? `text-[var(--${color})]` : ''}`}>{icon}</span>}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
<p className={`text-2xl font-semibold ${valueClass}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user