diff --git a/DOCKER.md b/DOCKER.md
new file mode 100644
index 0000000..c23f6af
--- /dev/null
+++ b/DOCKER.md
@@ -0,0 +1,147 @@
+# Docker Setup for Mainty
+
+## Quick Start
+
+### Build and Run
+
+```bash
+# Build the Docker image
+docker-compose build
+
+# Start the container
+docker-compose up -d
+
+# View logs
+docker-compose logs -f
+```
+
+The application will be available at: **http://localhost:8080**
+
+### Stop the Application
+
+```bash
+docker-compose down
+```
+
+## What's Included
+
+- **PHP 8.4** with Apache
+- **SQLite** with PDO extension
+- **mod_rewrite** enabled for pretty URLs
+- **Persistent data** volume for the database
+
+## Configuration
+
+### Change Port
+
+Edit `docker-compose.yml` and modify the ports section:
+
+```yaml
+ports:
+ - "3000:80" # Access on port 3000 instead
+```
+
+### Development Mode
+
+To sync code changes without rebuilding, uncomment this line in `docker-compose.yml`:
+
+```yaml
+volumes:
+ - ./data:/var/www/html/data
+ - .:/var/www/html # Uncomment this line
+```
+
+Then restart:
+```bash
+docker-compose restart
+```
+
+## Data Persistence
+
+The SQLite database is stored in the `./data` directory on your host machine, which is mounted to the container. This means:
+
+- ✅ Your data persists even if you stop/remove the container
+- ✅ You can backup by copying the `data` folder
+- ✅ Database survives container rebuilds
+
+## Useful Commands
+
+```bash
+# Rebuild after code changes
+docker-compose up -d --build
+
+# View container logs
+docker-compose logs -f mainty
+
+# Access container shell
+docker-compose exec mainty bash
+
+# Stop and remove everything
+docker-compose down -v
+
+# Check container status
+docker-compose ps
+```
+
+## Troubleshooting
+
+### Permission Issues
+
+If you get permission errors with the database:
+
+```bash
+chmod -R 755 data
+```
+
+### Port Already in Use
+
+If port 8080 is already in use, change it in `docker-compose.yml`:
+
+```yaml
+ports:
+ - "8081:80" # Use a different port
+```
+
+### Reset Everything
+
+To start fresh:
+
+```bash
+docker-compose down -v
+rm -rf data/mainty.db
+docker-compose up -d
+```
+
+Then access http://localhost:8080 to run setup again.
+
+## Production Deployment
+
+For production:
+
+1. Set `DEBUG` to `false` in `config.php`
+2. Use environment variables for sensitive data
+3. Consider using a reverse proxy (nginx) in front
+4. Enable HTTPS
+
+Example production `docker-compose.yml`:
+
+```yaml
+version: '3.8'
+
+services:
+ mainty:
+ build: .
+ container_name: mainty-app
+ ports:
+ - "8080:80"
+ volumes:
+ - ./data:/var/www/html/data
+ environment:
+ - APACHE_DOCUMENT_ROOT=/var/www/html
+ restart: always
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+```
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bf7b409
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,31 @@
+FROM php:8.4-apache
+
+# Enable Apache mod_rewrite
+RUN a2enmod rewrite
+
+# Install SQLite extensions
+RUN apt-get update && apt-get install -y \
+ sqlite3 \
+ libsqlite3-dev \
+ && docker-php-ext-install pdo_sqlite \
+ && rm -rf /var/lib/apt/lists/*
+
+# Set the working directory
+WORKDIR /var/www/html
+
+# Copy application files
+COPY . /var/www/html/
+
+# Create data directory and set permissions
+RUN mkdir -p /var/www/html/data && \
+ chown -R www-data:www-data /var/www/html/data && \
+ chmod -R 755 /var/www/html/data
+
+# Configure Apache to allow .htaccess
+RUN sed -i '//,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
+
+# Expose port 80
+EXPOSE 80
+
+# Start Apache
+CMD ["apache2-foreground"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6e9ee76
--- /dev/null
+++ b/README.md
@@ -0,0 +1,39 @@
+# Mainty
+
+A simple PHP web app for tracking vehicle maintenance records. Free, simple, open source, and self-hosted. Runs on any Apache/PHP web server, or use Docker. Uses SQLite for easy backup, with built-in Export via JSON or HTML so you can import that data into something else or print records for your mechanic or the next owner of your vehicle.
+
+## Requirements
+
+- Apache web server
+- PHP 8 or higher
+- SQLite extension
+
+## Installation
+
+### Option 1: Traditional Web Server
+
+1. Upload the entire folder to your web server
+2. Rename `example.htaccess` to `.htaccess`
+3. If the app is not in the root directory, edit `.htaccess` and set the `RewriteBase`:
+ ```apache
+ RewriteBase /subfolder/
+ ```
+4. Navigate to the app URL in your browser
+5. If everything is configured correctly, you'll see the setup page
+6. Set your password to initialize the database
+
+### Option 2: Docker
+
+```bash
+docker-compose up -d
+```
+
+Then open http://localhost:8080
+
+## First Time Setup
+
+When you first access the app, you'll be prompted to:
+1. Create a password
+2. Initialize the database
+
+That's it! You're ready to start tracking your vehicle maintenance.
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..48e610a
--- /dev/null
+++ b/config.php
@@ -0,0 +1,35 @@
+requireSetup();
+
+ if (isset($_SESSION['user_id'])) {
+ $this->redirect('/home');
+ return;
+ }
+
+ $this->view('login');
+ }
+
+ public function authenticate(): void {
+ $this->requireSetup();
+
+ $password = $_POST['password'] ?? '';
+
+ if (empty($password)) {
+ $_SESSION['error'] = 'Password is required';
+ $this->redirect('/login');
+ return;
+ }
+
+ $user = new User();
+
+ if ($user->verifyPassword($password)) {
+ $_SESSION['user_id'] = $user->getId();
+ $this->redirect('/home');
+ } else {
+ $_SESSION['error'] = 'Invalid password';
+ $this->redirect('/login');
+ }
+ }
+
+ public function logout(): void {
+ session_destroy();
+ $this->redirect('/login');
+ }
+}
diff --git a/controllers/MaintenanceController.php b/controllers/MaintenanceController.php
new file mode 100644
index 0000000..4b7f01c
--- /dev/null
+++ b/controllers/MaintenanceController.php
@@ -0,0 +1,119 @@
+maintenanceModel = new Maintenance();
+ }
+
+ public function add(): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $vehicleId = (int)($_POST['vehicle_id'] ?? 0);
+ $name = trim($_POST['name'] ?? '');
+ $date = trim($_POST['date'] ?? '');
+ $mileage = str_replace(',', '', trim($_POST['mileage'] ?? ''));
+
+ if (empty($name) || empty($date) || empty($mileage)) {
+ $_SESSION['error'] = 'Name, date, and mileage are required';
+ $this->redirect('/vehicles/' . $vehicleId);
+ return;
+ }
+
+ $data = [
+ 'vehicle_id' => $vehicleId,
+ 'name' => $name,
+ 'date' => $date,
+ 'mileage' => (int)$mileage,
+ 'description' => trim($_POST['description'] ?? ''),
+ 'cost' => !empty($_POST['cost']) ? (float)$_POST['cost'] : null,
+ 'parts_list' => trim($_POST['parts_list'] ?? ''),
+ 'performed_by' => trim($_POST['performed_by'] ?? '')
+ ];
+
+ $this->maintenanceModel->create($data);
+ $_SESSION['success'] = 'Maintenance item added successfully';
+ $this->redirect('/vehicles/' . $vehicleId);
+ }
+
+ public function edit(string $id): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $item = $this->maintenanceModel->getById((int)$id);
+
+ if (!$item) {
+ $_SESSION['error'] = 'Maintenance item not found';
+ $this->redirect('/home');
+ return;
+ }
+
+ $name = trim($_POST['name'] ?? '');
+ $date = trim($_POST['date'] ?? '');
+ $mileage = str_replace(',', '', trim($_POST['mileage'] ?? ''));
+
+ if (empty($name) || empty($date) || empty($mileage)) {
+ $_SESSION['error'] = 'Name, date, and mileage are required';
+ $this->redirect('/vehicles/' . $item['vehicle_id']);
+ return;
+ }
+
+ $data = [
+ 'name' => $name,
+ 'date' => $date,
+ 'mileage' => (int)$mileage,
+ 'description' => trim($_POST['description'] ?? ''),
+ 'cost' => !empty($_POST['cost']) ? (float)$_POST['cost'] : null,
+ 'parts_list' => trim($_POST['parts_list'] ?? ''),
+ 'performed_by' => trim($_POST['performed_by'] ?? '')
+ ];
+
+ if ($this->maintenanceModel->update((int)$id, $data)) {
+ $_SESSION['success'] = 'Maintenance item updated successfully';
+ } else {
+ $_SESSION['error'] = 'Failed to update maintenance item';
+ }
+
+ $this->redirect('/vehicles/' . $item['vehicle_id']);
+ }
+
+ public function delete(string $id): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $item = $this->maintenanceModel->getById((int)$id);
+
+ if (!$item) {
+ $_SESSION['error'] = 'Maintenance item not found';
+ $this->redirect('/home');
+ return;
+ }
+
+ $vehicleId = $item['vehicle_id'];
+
+ if ($this->maintenanceModel->delete((int)$id)) {
+ $_SESSION['success'] = 'Maintenance item deleted successfully';
+ } else {
+ $_SESSION['error'] = 'Failed to delete maintenance item';
+ }
+
+ $this->redirect('/vehicles/' . $vehicleId);
+ }
+
+ public function search(): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $query = $_GET['q'] ?? '';
+
+ if (strlen($query) < 2) {
+ $this->json([]);
+ return;
+ }
+
+ $results = $this->maintenanceModel->searchByName($query);
+ $this->json($results);
+ }
+}
diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php
new file mode 100644
index 0000000..9ea888e
--- /dev/null
+++ b/controllers/SettingsController.php
@@ -0,0 +1,99 @@
+quickTaskModel = new QuickTask();
+ $this->userModel = new User();
+ }
+
+ public function index(): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $quickTasks = $this->quickTaskModel->getAll();
+
+ $this->view('settings', [
+ 'quickTasks' => $quickTasks
+ ]);
+ }
+
+ public function changePassword(): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $currentPassword = $_POST['current_password'] ?? '';
+ $newPassword = $_POST['new_password'] ?? '';
+ $confirmPassword = $_POST['confirm_password'] ?? '';
+
+ if (empty($currentPassword) || empty($newPassword) || empty($confirmPassword)) {
+ $_SESSION['error'] = 'All password fields are required';
+ $this->redirect('/settings');
+ return;
+ }
+
+ if (!$this->userModel->verifyPassword($currentPassword)) {
+ $_SESSION['error'] = 'Current password is incorrect';
+ $this->redirect('/settings');
+ return;
+ }
+
+ if ($newPassword !== $confirmPassword) {
+ $_SESSION['error'] = 'New passwords do not match';
+ $this->redirect('/settings');
+ return;
+ }
+
+ if (strlen($newPassword) < 6) {
+ $_SESSION['error'] = 'New password must be at least 6 characters';
+ $this->redirect('/settings');
+ return;
+ }
+
+ if ($this->userModel->updatePassword($newPassword)) {
+ $_SESSION['success'] = 'Password changed successfully';
+ } else {
+ $_SESSION['error'] = 'Failed to change password';
+ }
+
+ $this->redirect('/settings');
+ }
+
+ public function addQuickTask(): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $name = trim($_POST['name'] ?? '');
+
+ if (empty($name)) {
+ $_SESSION['error'] = 'Task name is required';
+ $this->redirect('/settings');
+ return;
+ }
+
+ $id = $this->quickTaskModel->create($name);
+
+ if ($id > 0) {
+ $_SESSION['success'] = 'Quick task added successfully';
+ } else {
+ $_SESSION['error'] = 'Task already exists or could not be added';
+ }
+
+ $this->redirect('/settings');
+ }
+
+ public function deleteQuickTask(string $id): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ if ($this->quickTaskModel->delete((int)$id)) {
+ $_SESSION['success'] = 'Quick task deleted successfully';
+ } else {
+ $_SESSION['error'] = 'Failed to delete quick task';
+ }
+
+ $this->redirect('/settings');
+ }
+}
diff --git a/controllers/SetupController.php b/controllers/SetupController.php
new file mode 100644
index 0000000..92ed3f1
--- /dev/null
+++ b/controllers/SetupController.php
@@ -0,0 +1,49 @@
+redirect('/home');
+ return;
+ }
+
+ $this->view('setup');
+ }
+
+ public function setup(): void {
+ if (Database::isInitialized()) {
+ $this->redirect('/home');
+ return;
+ }
+
+ $password = $_POST['password'] ?? '';
+ $confirmPassword = $_POST['confirm_password'] ?? '';
+
+ if (empty($password)) {
+ $_SESSION['error'] = 'Password is required';
+ $this->redirect('/setup');
+ return;
+ }
+
+ if ($password !== $confirmPassword) {
+ $_SESSION['error'] = 'Passwords do not match';
+ $this->redirect('/setup');
+ return;
+ }
+
+ if (strlen($password) < 6) {
+ $_SESSION['error'] = 'Password must be at least 6 characters';
+ $this->redirect('/setup');
+ return;
+ }
+
+ if (Database::initialize($password)) {
+ $_SESSION['success'] = 'Setup completed successfully! Please login.';
+ $this->redirect('/login');
+ } else {
+ $_SESSION['error'] = 'Setup failed. Please try again.';
+ $this->redirect('/setup');
+ }
+ }
+}
diff --git a/controllers/VehicleController.php b/controllers/VehicleController.php
new file mode 100644
index 0000000..de1c74f
--- /dev/null
+++ b/controllers/VehicleController.php
@@ -0,0 +1,150 @@
+vehicleModel = new Vehicle();
+ $this->maintenanceModel = new Maintenance();
+ }
+
+ public function index(): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $vehicles = $this->vehicleModel->getAll();
+ $viewMode = $_GET['view'] ?? 'grid'; // grid or list
+
+ $this->view('index', [
+ 'vehicles' => $vehicles,
+ 'viewMode' => $viewMode
+ ]);
+ }
+
+ public function show(string $id): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $vehicle = $this->vehicleModel->getById((int)$id);
+
+ if (!$vehicle) {
+ $_SESSION['error'] = 'Vehicle not found';
+ $this->redirect('/home');
+ return;
+ }
+
+ $maintenanceItems = $this->maintenanceModel->getByVehicleId((int)$id);
+
+ $this->view('vehicle', [
+ 'vehicle' => $vehicle,
+ 'maintenanceItems' => $maintenanceItems
+ ]);
+ }
+
+ public function add(): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $name = trim($_POST['name'] ?? '');
+
+ if (empty($name)) {
+ $_SESSION['error'] = 'Vehicle name is required';
+ $this->redirect('/home');
+ return;
+ }
+
+ $data = [
+ 'name' => $name,
+ 'year' => trim($_POST['year'] ?? ''),
+ 'make' => trim($_POST['make'] ?? ''),
+ 'model' => trim($_POST['model'] ?? ''),
+ 'color' => trim($_POST['color'] ?? ''),
+ 'license_plate' => trim($_POST['license_plate'] ?? '')
+ ];
+
+ $id = $this->vehicleModel->create($data);
+ $_SESSION['success'] = 'Vehicle added successfully';
+ $this->redirect('/vehicles/' . $id);
+ }
+
+ public function edit(string $id): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $name = trim($_POST['name'] ?? '');
+
+ if (empty($name)) {
+ $_SESSION['error'] = 'Vehicle name is required';
+ $this->redirect('/vehicles/' . $id);
+ return;
+ }
+
+ $data = [
+ 'name' => $name,
+ 'year' => trim($_POST['year'] ?? ''),
+ 'make' => trim($_POST['make'] ?? ''),
+ 'model' => trim($_POST['model'] ?? ''),
+ 'color' => trim($_POST['color'] ?? ''),
+ 'license_plate' => trim($_POST['license_plate'] ?? '')
+ ];
+
+ if ($this->vehicleModel->update((int)$id, $data)) {
+ $_SESSION['success'] = 'Vehicle updated successfully';
+ } else {
+ $_SESSION['error'] = 'Failed to update vehicle';
+ }
+
+ $this->redirect('/vehicles/' . $id);
+ }
+
+ public function delete(string $id): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ if ($this->vehicleModel->delete((int)$id)) {
+ $_SESSION['success'] = 'Vehicle deleted successfully';
+ } else {
+ $_SESSION['error'] = 'Failed to delete vehicle';
+ }
+
+ $this->redirect('/home');
+ }
+
+ public function export(string $id, string $format): void {
+ $this->requireSetup();
+ $this->requireAuth();
+
+ $vehicle = $this->vehicleModel->getById((int)$id);
+
+ if (!$vehicle) {
+ $_SESSION['error'] = 'Vehicle not found';
+ $this->redirect('/home');
+ return;
+ }
+
+ $maintenanceItems = $this->maintenanceModel->getByVehicleId((int)$id);
+
+ if ($format === 'json') {
+ header('Content-Type: application/json');
+ header('Content-Disposition: attachment; filename="vehicle-' . $id . '-export.json"');
+ echo json_encode([
+ 'vehicle' => $vehicle,
+ 'maintenance_items' => $maintenanceItems
+ ], JSON_PRETTY_PRINT);
+ exit;
+ } elseif ($format === 'html') {
+ header('Content-Type: text/html');
+ header('Content-Disposition: attachment; filename="vehicle-' . $id . '-export.html"');
+
+ $this->view('export', [
+ 'vehicle' => $vehicle,
+ 'maintenanceItems' => $maintenanceItems
+ ]);
+ exit;
+ } else {
+ $_SESSION['error'] = 'Invalid export format';
+ $this->redirect('/vehicles/' . $id);
+ }
+ }
+}
diff --git a/core/Controller.php b/core/Controller.php
new file mode 100644
index 0000000..140362e
--- /dev/null
+++ b/core/Controller.php
@@ -0,0 +1,41 @@
+redirect('/login');
+ }
+ }
+
+ protected function requireSetup(): void {
+ if (!Database::isInitialized()) {
+ $this->redirect('/setup');
+ }
+ }
+}
diff --git a/core/Database.php b/core/Database.php
new file mode 100644
index 0000000..a3a678d
--- /dev/null
+++ b/core/Database.php
@@ -0,0 +1,130 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ self::$instance->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+ } catch (PDOException $e) {
+ die('Database connection failed: ' . $e->getMessage());
+ }
+ }
+
+ return self::$instance;
+ }
+
+ public static function isInitialized(): bool {
+ if (!file_exists(DB_PATH)) {
+ return false;
+ }
+
+ try {
+ $db = self::getInstance();
+ $result = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='users'");
+ return $result->fetch() !== false;
+ } catch (PDOException $e) {
+ return false;
+ }
+ }
+
+ public static function initialize(string $password): bool {
+ try {
+ $db = self::getInstance();
+
+ // Create users table
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ password_hash TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ ");
+
+ // Create vehicles table
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS vehicles (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ year TEXT,
+ make TEXT,
+ model TEXT,
+ color TEXT,
+ license_plate TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ ");
+
+ // Create maintenance_items table
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS maintenance_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ vehicle_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ date DATE NOT NULL,
+ mileage INTEGER NOT NULL,
+ description TEXT,
+ cost REAL,
+ parts_list TEXT,
+ performed_by TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
+ )
+ ");
+
+ // Create quick_tasks table (predefined maintenance items)
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS quick_tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ ");
+
+ // Insert default password
+ $stmt = $db->prepare("INSERT INTO users (password_hash) VALUES (?)");
+ $stmt->execute([password_hash($password, PASSWORD_DEFAULT)]);
+
+ // Insert default quick tasks
+ $defaultTasks = [
+ 'Oil Change - Oil and Filter',
+ 'Oil Change - Oil Only',
+ 'Tire Rotation',
+ 'Air Filter Replacement',
+ 'Battery Replacement',
+ 'Spark Plug Replacement',
+ 'Wiper Blade Replacement - Front',
+ 'Wiper Blade Replacement - Rear',
+ 'Wiper Blade Replacement - Front and Rear',
+ 'Cabin Air Filter Replacement',
+ 'Wheel Alignment',
+ 'Tire Replacement',
+ 'Brake Fluid Change',
+ 'Power Steering Fluid Change',
+ 'Fuel Filter Replacement',
+ 'Serpentine Belt Replacement',
+ 'Timing Belt Replacement',
+ 'Inspection/Emissions Test',
+ 'Registration Renewed',
+ ];
+
+ $stmt = $db->prepare("INSERT INTO quick_tasks (name) VALUES (?)");
+ foreach ($defaultTasks as $task) {
+ $stmt->execute([$task]);
+ }
+
+ return true;
+ } catch (PDOException $e) {
+ error_log('Database initialization failed: ' . $e->getMessage());
+ return false;
+ }
+ }
+}
diff --git a/core/Router.php b/core/Router.php
new file mode 100644
index 0000000..c930bbf
--- /dev/null
+++ b/core/Router.php
@@ -0,0 +1,74 @@
+addRoute('GET', $path, $handler);
+ }
+
+ public function post(string $path, string $handler): void {
+ $this->addRoute('POST', $path, $handler);
+ }
+
+ private function addRoute(string $method, string $path, string $handler): void {
+ $this->routes[] = [
+ 'method' => $method,
+ 'path' => $path,
+ 'handler' => $handler
+ ];
+ }
+
+ public function dispatch(string $uri, string $method): void {
+ // Remove query string
+ $uri = parse_url($uri, PHP_URL_PATH);
+
+ // Remove base directory from URI if running in subdirectory
+ $scriptName = dirname($_SERVER['SCRIPT_NAME']);
+ if ($scriptName !== '/' && strpos($uri, $scriptName) === 0) {
+ $uri = substr($uri, strlen($scriptName));
+ }
+
+ // Ensure URI starts with /
+ if (empty($uri) || $uri[0] !== '/') {
+ $uri = '/' . $uri;
+ }
+
+ // Remove trailing slash except for root
+ if ($uri !== '/' && str_ends_with($uri, '/')) {
+ $uri = rtrim($uri, '/');
+ }
+
+ foreach ($this->routes as $route) {
+ if ($route['method'] !== $method) {
+ continue;
+ }
+
+ $pattern = $this->convertToRegex($route['path']);
+
+ if (preg_match($pattern, $uri, $matches)) {
+ array_shift($matches); // Remove full match
+ $this->callHandler($route['handler'], $matches);
+ return;
+ }
+ }
+
+ // 404 Not Found
+ http_response_code(404);
+ echo '404 - Page Not Found
';
+ echo 'Requested URI: ' . htmlspecialchars($uri) . '
';
+ echo 'Request Method: ' . htmlspecialchars($method);
+ }
+
+ private function convertToRegex(string $path): string {
+ $path = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([a-zA-Z0-9_-]+)', $path);
+ return '#^' . $path . '$#';
+ }
+
+ private function callHandler(string $handler, array $params): void {
+ [$controller, $method] = explode('@', $handler);
+
+ $controllerInstance = new $controller();
+ call_user_func_array([$controllerInstance, $method], $params);
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..4c24227
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,15 @@
+version: '3.8'
+
+services:
+ mainty:
+ build: .
+ container_name: mainty-app
+ ports:
+ - "8080:80"
+ volumes:
+ - ./data:/var/www/html/data
+ # Uncomment below to sync code changes in development
+ # - .:/var/www/html
+ environment:
+ - APACHE_DOCUMENT_ROOT=/var/www/html
+ restart: unless-stopped
diff --git a/example.htaccess b/example.htaccess
new file mode 100644
index 0000000..c8fd1a9
--- /dev/null
+++ b/example.htaccess
@@ -0,0 +1,14 @@
+RewriteEngine On
+#if this is in a folder, edit this. example.com/mainty/ would be /mainty/
+RewriteBase /
+
+# Redirect to HTTPS (optional, uncomment if needed)
+# RewriteCond %{HTTPS} off
+# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+
+# Don't rewrite files or directories
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+
+# Rewrite everything else to index.php
+RewriteRule ^(.*)$ index.php [L,QSA]
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..8b5f878
--- /dev/null
+++ b/index.php
@@ -0,0 +1,71 @@
+get('/', 'SetupController@index');
+$router->get('/setup', 'SetupController@index');
+$router->post('/setup', 'SetupController@setup');
+
+$router->get('/login', 'AuthController@login');
+$router->post('/login', 'AuthController@authenticate');
+$router->get('/logout', 'AuthController@logout');
+
+$router->get('/home', 'VehicleController@index');
+$router->get('/vehicles', 'VehicleController@index');
+$router->post('/vehicles/add', 'VehicleController@add');
+$router->get('/vehicles/{id}', 'VehicleController@show');
+$router->post('/vehicles/{id}/edit', 'VehicleController@edit');
+$router->post('/vehicles/{id}/delete', 'VehicleController@delete');
+$router->get('/vehicles/{id}/export/{format}', 'VehicleController@export');
+
+$router->post('/maintenance/add', 'MaintenanceController@add');
+$router->post('/maintenance/{id}/edit', 'MaintenanceController@edit');
+$router->post('/maintenance/{id}/delete', 'MaintenanceController@delete');
+$router->get('/maintenance/search', 'MaintenanceController@search');
+
+$router->get('/settings', 'SettingsController@index');
+$router->post('/settings/password', 'SettingsController@changePassword');
+$router->post('/settings/quick-tasks/add', 'SettingsController@addQuickTask');
+$router->post('/settings/quick-tasks/{id}/delete', 'SettingsController@deleteQuickTask');
+
+// Dispatch the request
+try {
+ $router->dispatch($_SERVER['REQUEST_URI'], $_SERVER['REQUEST_METHOD']);
+} catch (Exception $e) {
+ http_response_code(500);
+ echo "Error: " . $e->getMessage();
+}
diff --git a/models/Maintenance.php b/models/Maintenance.php
new file mode 100644
index 0000000..0896ee4
--- /dev/null
+++ b/models/Maintenance.php
@@ -0,0 +1,88 @@
+db = Database::getInstance();
+ }
+
+ public function getByVehicleId(int $vehicleId): array {
+ $stmt = $this->db->prepare("
+ SELECT * FROM maintenance_items
+ WHERE vehicle_id = ?
+ ORDER BY date DESC, id DESC
+ ");
+ $stmt->execute([$vehicleId]);
+ return $stmt->fetchAll();
+ }
+
+ public function getById(int $id): ?array {
+ $stmt = $this->db->prepare("SELECT * FROM maintenance_items WHERE id = ?");
+ $stmt->execute([$id]);
+ $item = $stmt->fetch();
+ return $item ?: null;
+ }
+
+ public function create(array $data): int {
+ $stmt = $this->db->prepare("
+ INSERT INTO maintenance_items
+ (vehicle_id, name, date, mileage, description, cost, parts_list, performed_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ");
+
+ $stmt->execute([
+ $data['vehicle_id'],
+ $data['name'],
+ $data['date'],
+ $data['mileage'],
+ $data['description'] ?? null,
+ $data['cost'] ?? null,
+ $data['parts_list'] ?? null,
+ $data['performed_by'] ?? null
+ ]);
+
+ return (int)$this->db->lastInsertId();
+ }
+
+ public function update(int $id, array $data): bool {
+ $stmt = $this->db->prepare("
+ UPDATE maintenance_items
+ SET name = ?, date = ?, mileage = ?, description = ?, cost = ?, parts_list = ?, performed_by = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ");
+
+ return $stmt->execute([
+ $data['name'],
+ $data['date'],
+ $data['mileage'],
+ $data['description'] ?? null,
+ $data['cost'] ?? null,
+ $data['parts_list'] ?? null,
+ $data['performed_by'] ?? null,
+ $id
+ ]);
+ }
+
+ public function delete(int $id): bool {
+ $stmt = $this->db->prepare("DELETE FROM maintenance_items WHERE id = ?");
+ return $stmt->execute([$id]);
+ }
+
+ public function searchByName(string $query): array {
+ // Search in both quick_tasks and existing maintenance items
+ $stmt = $this->db->prepare("
+ SELECT DISTINCT name FROM (
+ SELECT name FROM quick_tasks WHERE name LIKE ?
+ UNION
+ SELECT DISTINCT name FROM maintenance_items WHERE name LIKE ?
+ )
+ ORDER BY name ASC
+ LIMIT 10
+ ");
+
+ $searchTerm = '%' . $query . '%';
+ $stmt->execute([$searchTerm, $searchTerm]);
+ return $stmt->fetchAll(PDO::FETCH_COLUMN);
+ }
+}
diff --git a/models/QuickTask.php b/models/QuickTask.php
new file mode 100644
index 0000000..449f558
--- /dev/null
+++ b/models/QuickTask.php
@@ -0,0 +1,33 @@
+db = Database::getInstance();
+ }
+
+ public function getAll(): array {
+ $stmt = $this->db->query("SELECT * FROM quick_tasks ORDER BY name ASC");
+ return $stmt->fetchAll();
+ }
+
+ public function create(string $name): int {
+ try {
+ $stmt = $this->db->prepare("INSERT INTO quick_tasks (name) VALUES (?)");
+ $stmt->execute([$name]);
+ return (int)$this->db->lastInsertId();
+ } catch (PDOException $e) {
+ // Handle duplicate entry
+ if ($e->getCode() == 23000) {
+ return 0;
+ }
+ throw $e;
+ }
+ }
+
+ public function delete(int $id): bool {
+ $stmt = $this->db->prepare("DELETE FROM quick_tasks WHERE id = ?");
+ return $stmt->execute([$id]);
+ }
+}
diff --git a/models/User.php b/models/User.php
new file mode 100644
index 0000000..b93add6
--- /dev/null
+++ b/models/User.php
@@ -0,0 +1,36 @@
+db = Database::getInstance();
+ }
+
+ public function verifyPassword(string $password): bool {
+ $stmt = $this->db->query("SELECT password_hash FROM users LIMIT 1");
+ $user = $stmt->fetch();
+
+ if (!$user) {
+ return false;
+ }
+
+ return password_verify($password, $user['password_hash']);
+ }
+
+ public function updatePassword(string $newPassword): bool {
+ try {
+ $stmt = $this->db->prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP");
+ return $stmt->execute([password_hash($newPassword, PASSWORD_DEFAULT)]);
+ } catch (PDOException $e) {
+ error_log('Password update failed: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ public function getId(): ?int {
+ $stmt = $this->db->query("SELECT id FROM users LIMIT 1");
+ $user = $stmt->fetch();
+ return $user ? (int)$user['id'] : null;
+ }
+}
diff --git a/models/Vehicle.php b/models/Vehicle.php
new file mode 100644
index 0000000..5524ca8
--- /dev/null
+++ b/models/Vehicle.php
@@ -0,0 +1,70 @@
+db = Database::getInstance();
+ }
+
+ public function getAll(): array {
+ $stmt = $this->db->query("
+ SELECT v.*,
+ COUNT(m.id) as maintenance_count,
+ MAX(m.date) as last_maintenance_date
+ FROM vehicles v
+ LEFT JOIN maintenance_items m ON v.id = m.vehicle_id
+ GROUP BY v.id
+ ORDER BY v.name ASC
+ ");
+ return $stmt->fetchAll();
+ }
+
+ public function getById(int $id): ?array {
+ $stmt = $this->db->prepare("SELECT * FROM vehicles WHERE id = ?");
+ $stmt->execute([$id]);
+ $vehicle = $stmt->fetch();
+ return $vehicle ?: null;
+ }
+
+ public function create(array $data): int {
+ $stmt = $this->db->prepare("
+ INSERT INTO vehicles (name, year, make, model, color, license_plate)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ");
+
+ $stmt->execute([
+ $data['name'],
+ $data['year'] ?? null,
+ $data['make'] ?? null,
+ $data['model'] ?? null,
+ $data['color'] ?? null,
+ $data['license_plate'] ?? null
+ ]);
+
+ return (int)$this->db->lastInsertId();
+ }
+
+ public function update(int $id, array $data): bool {
+ $stmt = $this->db->prepare("
+ UPDATE vehicles
+ SET name = ?, year = ?, make = ?, model = ?, color = ?, license_plate = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ");
+
+ return $stmt->execute([
+ $data['name'],
+ $data['year'] ?? null,
+ $data['make'] ?? null,
+ $data['model'] ?? null,
+ $data['color'] ?? null,
+ $data['license_plate'] ?? null,
+ $id
+ ]);
+ }
+
+ public function delete(int $id): bool {
+ $stmt = $this->db->prepare("DELETE FROM vehicles WHERE id = ?");
+ return $stmt->execute([$id]);
+ }
+}
diff --git a/robots.txt b/robots.txt
new file mode 100644
index 0000000..77470cb
--- /dev/null
+++ b/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
\ No newline at end of file
diff --git a/views/export.php b/views/export.php
new file mode 100644
index 0000000..be958ee
--- /dev/null
+++ b/views/export.php
@@ -0,0 +1,114 @@
+
+
+
+
+
+ - Maintenance Export
+
+
+
+
+
+
+
+ Vehicle Maintenance Export
+
+
+
+
+
Vehicle Information
+
+
+ Name:
+
+
+
+
+ Year:
+
+
+
+
+
+ Make:
+
+
+
+
+
+ Model:
+
+
+
+
+
+ Color:
+
+
+
+
+
+ License Plate:
+
+
+
+
+
+
+
+
+
Maintenance History
+
+
+
No maintenance records available.
+
+
+
+
+
+
+
+
+ $
+
+
+
+
+
+
+ Date:
+
+
+ Mileage: miles
+
+
+
+ Performed By:
+
+
+
+
+ Parts:
+
+
+
+
+
+
+ Description:
+
+
+
+
+
+
+
+
+
+
+
Exported on
+
Generated by
+
+
+
+
diff --git a/views/index.php b/views/index.php
new file mode 100644
index 0000000..3efe5b3
--- /dev/null
+++ b/views/index.php
@@ -0,0 +1,210 @@
+
+
+
+
+
+ - Vehicles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No vehicles yet
+
Get started by adding your first vehicle
+
+
+
+
+
+
+
+
+
+
+ | Vehicle |
+ Details |
+ License Plate |
+ Records |
+ Last Service |
+
+
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add New Vehicle
+
+
+
+
+
+
+
+
+
diff --git a/views/login.php b/views/login.php
new file mode 100644
index 0000000..ac19a3f
--- /dev/null
+++ b/views/login.php
@@ -0,0 +1,41 @@
+
+
+
+
+
+ - Login
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/settings.php b/views/settings.php
new file mode 100644
index 0000000..aba0189
--- /dev/null
+++ b/views/settings.php
@@ -0,0 +1,129 @@
+
+
+
+
+
+ - Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+
+ Quick Tasks
+
+
Predefined maintenance items for quick selection when adding records.
+
+
+
+
+
+
+
+
+
+
+
+
+ Change Password
+
+
Update your login password.
+
+
+
+
+
+
+
+
+
+
diff --git a/views/setup.php b/views/setup.php
new file mode 100644
index 0000000..86b5ccc
--- /dev/null
+++ b/views/setup.php
@@ -0,0 +1,78 @@
+
+
+
+
+
+ - Setup
+
+
+
+
+
+
Setup
+
+
+
+
+
+
+
+
+
System Requirements
+
+ -
+
+ PHP Version:
+ =')): ?>
+ (✓ Meets requirement)
+
+ (✗ Requires 8.0+)
+
+
+
+ -
+
+
+ SQLite: Available (✓)
+
+
+ SQLite: Not Available (✗)
+
+
+
+
+
+ =') && extension_loaded('pdo_sqlite');
+ ?>
+
+
+
+
System requirements not met!
+
Please ensure PHP 8.0+ and SQLite extension are available.
+
+
+
+
+
+
+
diff --git a/views/vehicle.php b/views/vehicle.php
new file mode 100644
index 0000000..7f621fa
--- /dev/null
+++ b/views/vehicle.php
@@ -0,0 +1,425 @@
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add Maintenance Record
+
+
+
+
+
+
Maintenance History ()
+
+
+
+
+
No maintenance records yet
+
+
+
+
+
+
+
+
+
+
+
+ $
+
+
+
+
+
+
+
+
+
+
+ miles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Vehicle
+
+
+
+
+
+
+
+
+
+
+
Edit Maintenance Record
+
+
+
+
+
+
+
+
+
+
+
+