diff options
| author | Thomas Vanbesien <tvanbesi@proton.me> | 2026-03-21 20:50:43 +0100 |
|---|---|---|
| committer | Thomas Vanbesien <tvanbesi@proton.me> | 2026-03-21 20:50:43 +0100 |
| commit | d1ef15fa39935bfa0420c5ac2b8c269e294c9a6d (patch) | |
| tree | 618158449863123f6b9527b9db6183f8c3ce5c91 | |
| download | camagru-d1ef15fa39935bfa0420c5ac2b8c269e294c9a6d.tar.gz camagru-d1ef15fa39935bfa0420c5ac2b8c269e294c9a6d.zip | |
Initial project scaffold
Set up MVC architecture with front controller, router, autoloader,
database singleton, and Docker Compose stack (Nginx + PHP-FPM + MariaDB).
Includes DB schema, responsive layout, dev tooling (php-cs-fixer,
parallel-lint), and documentation.
| -rw-r--r-- | .env.example | 9 | ||||
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | .php-cs-fixer.dist.php | 30 | ||||
| -rw-r--r-- | README.md | 116 | ||||
| -rw-r--r-- | composer.json | 8 | ||||
| -rw-r--r-- | composer.lock | 82 | ||||
| -rw-r--r-- | docker-compose.yml | 31 | ||||
| -rw-r--r-- | docker/mariadb/init.sql | 39 | ||||
| -rw-r--r-- | docker/nginx/Dockerfile | 2 | ||||
| -rw-r--r-- | docker/nginx/default.conf | 21 | ||||
| -rw-r--r-- | docker/php/Dockerfile | 15 | ||||
| -rw-r--r-- | docs/camagru.subject.pdf | bin | 0 -> 2314748 bytes | |||
| -rw-r--r-- | src/app/Controllers/HomeController.php | 21 | ||||
| -rw-r--r-- | src/app/Database.php | 44 | ||||
| -rw-r--r-- | src/app/Router.php | 56 | ||||
| -rw-r--r-- | src/app/Views/home/index.php | 3 | ||||
| -rw-r--r-- | src/app/Views/layouts/main.php | 35 | ||||
| -rw-r--r-- | src/app/bootstrap.php | 42 | ||||
| -rw-r--r-- | src/config/routes.php | 7 | ||||
| -rw-r--r-- | src/public/assets/overlays/.gitkeep | 0 | ||||
| -rw-r--r-- | src/public/css/style.css | 64 | ||||
| -rw-r--r-- | src/public/index.php | 11 | ||||
| -rw-r--r-- | src/public/js/app.js | 3 | ||||
| -rw-r--r-- | src/uploads/.gitkeep | 0 |
24 files changed, 644 insertions, 0 deletions
diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff57479 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +MYSQL_ROOT_PASSWORD=changeme +MYSQL_DATABASE=camagru +MYSQL_USER=camagru +MYSQL_PASSWORD=changeme + +APP_URL=http://localhost:8080 +APP_SECRET=changeme_random_string + +MAIL_FROM=noreply@camagru.local diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12cc3bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +src/uploads/* +!src/uploads/.gitkeep +vendor/ +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..0723cc9 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +use PhpCsFixer\Config; +use PhpCsFixer\Finder; + +return (new Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@auto' => true, + '@auto:risky' => true, + '@PhpCsFixer:risky' => true + ]) + // 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config + ->setFinder( + (new Finder()) + // 💡 root folder to check + ->in(__DIR__) + // 💡 additional files, eg bin entry file + // ->append([__DIR__.'/bin-entry-file']) + // 💡 folders to exclude, if any + // ->exclude([/* ... */]) + // 💡 path patterns to exclude, if any + // ->notPath([/* ... */]) + // 💡 extra configs + // ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode + // ->ignoreVCS(true) // true by default + ) +; diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bea6c5 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Camagru + +A web application for creating and sharing photos with superposable overlays. Users can capture images via webcam (or upload), apply overlay effects, and share them in a public gallery with likes and comments. + +## Features + +- **User accounts** — Register, email verification, login, password reset, profile editing +- **Photo editor** — Webcam capture or image upload with selectable overlay images (PNG with alpha), server-side compositing +- **Gallery** — Public paginated feed of all creations, likes and comments for logged-in users +- **Notifications** — Email notification when someone comments on your image (configurable) +- **Responsive** — Mobile-friendly layout + +## Stack + +| Component | Technology | +|-----------|------------| +| Backend | PHP 8.5 (standard library only) | +| Frontend | HTML / CSS / JavaScript | +| Database | MariaDB 10.11 | +| Web server | Nginx 1.27 | +| Containers | Docker Compose | + +## Getting started + +```sh +# 1. Clone the repository +git clone <repo-url> && cd camagru + +# 2. Create your environment file +cp .env.example .env +# Edit .env with your own values + +# 3. Install dev tools +composer install + +# 4. Build and run +docker-compose up --build + +# 5. Open in browser +# http://localhost:8080 +``` + +## Host setup (Manjaro) + +`phpactor` (LSP) requires the `iconv` extension. Install and enable it: + +```sh +pamac install php85-iconv +sudo sed -i 's/;extension=iconv/extension=iconv/' /etc/php/php.ini +``` + +## Note on composer.json + +This project does not use Composer for runtime dependencies — only the PHP standard library is used. The `composer.json` exists to satisfy dev tooling: + +- `php-cs-fixer` — code style formatting (requires `composer.json` to determine target PHP version) +- `php-parallel-lint` — syntax checking (dev dependency) + +These are development tools only and are not part of the application code. + +## Linting + +```sh +vendor/bin/parallel-lint src/ +``` + +## PHP architecture + +This project follows standard PHP patterns commonly used in framework-less applications: + +### Front controller + +All HTTP requests are routed by Nginx to a single file: `src/public/index.php`. This file bootstraps the app and delegates to the router. No other PHP file is directly accessible from the web. + +### Namespaces and autoloading + +PHP namespaces organize classes into a hierarchy (like directories). `namespace App\Controllers;` means the class lives under `App\Controllers`. The `use` keyword imports a class so you can refer to it by its short name instead of the full path (e.g. `Database` instead of `\App\Database`). + +The autoloader in `bootstrap.php` uses `spl_autoload_register` to automatically `require` class files when they're first used. It maps the `App\` namespace prefix to the `src/app/` directory, so `App\Controllers\HomeController` loads from `src/app/Controllers/HomeController.php`. + +### MVC pattern + +- **Models** (`src/app/Models/`) — Data access layer, each model wraps a database table and uses prepared statements via PDO +- **Views** (`src/app/Views/`) — PHP templates that output HTML. A layout file (`layouts/main.php`) wraps page-specific content via an `include`. View files have variables that appear undefined — this is because they are `include`d by controllers and inherit all variables from the calling scope (e.g. `$dbStatus` defined in `HomeController::index()` is available in the view it includes) +- **Controllers** (`src/app/Controllers/`) — Handle requests, call models, and render views + +### Request lifecycle + +1. Nginx receives request, forwards to `public/index.php` +2. `bootstrap.php` starts the session, loads `.env`, registers the autoloader +3. Routes are defined in `config/routes.php` (mapping URL patterns to controller methods) +4. `Router::dispatch()` matches the URL against registered patterns and calls the matching controller method +5. The controller fetches data via models and renders a view inside the layout + +## Image format choices + +- **Overlays** are PNG (alpha channel required for transparency) +- **User input** (webcam captures, uploads) may be JPEG or PNG — GD is compiled with JPEG support to handle both +- **Final composited images** are saved as PNG to preserve overlay transparency + +## Project structure + +``` +camagru/ +├── docker-compose.yml +├── .env.example +├── docker/ +│ ├── nginx/ # Nginx Dockerfile and config +│ ├── php/ # PHP-FPM Dockerfile (GD + PDO) +│ └── mariadb/ # Database init script +└── src/ + ├── public/ # Document root (entry point, CSS, JS, assets) + ├── app/ # Application code (Controllers, Models, Views) + ├── config/ # Route definitions + └── uploads/ # User-generated images +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..31eae67 --- /dev/null +++ b/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "php": ">=8.5" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..dc72dac --- /dev/null +++ b/composer.lock @@ -0,0 +1,82 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "046262f846349d25321d6192e76120f8", + "packages": [], + "packages-dev": [ + { + "name": "php-parallel-lint/php-parallel-lint", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-parallel-lint/PHP-Parallel-Lint.git", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-parallel-lint/PHP-Parallel-Lint/zipball/6db563514f27e19595a19f45a4bf757b6401194e", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.3.0" + }, + "replace": { + "grogy/php-parallel-lint": "*", + "jakub-onderka/php-parallel-lint": "*" + }, + "require-dev": { + "nette/tester": "^1.3 || ^2.0", + "php-parallel-lint/php-console-highlighter": "0.* || ^1.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "suggest": { + "php-parallel-lint/php-console-highlighter": "Highlight syntax in code snippet" + }, + "bin": [ + "parallel-lint" + ], + "type": "library", + "autoload": { + "classmap": [ + "./src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Jakub Onderka", + "email": "ahoj@jakubonderka.cz" + } + ], + "description": "This tool checks the syntax of PHP files about 20x faster than serial check.", + "homepage": "https://github.com/php-parallel-lint/PHP-Parallel-Lint", + "keywords": [ + "lint", + "static analysis" + ], + "support": { + "issues": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/issues", + "source": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/tree/v1.4.0" + }, + "time": "2024-03-27T12:14:49+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.5" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a173959 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + nginx: + build: ./docker/nginx + ports: + - "8080:80" + volumes: + - ./src:/var/www/html + depends_on: + - php + + php: + build: ./docker/php + volumes: + - ./src:/var/www/html + env_file: + - .env + depends_on: + - mariadb + + mariadb: + image: mariadb:10.11.11 + env_file: + - .env + volumes: + - db_data:/var/lib/mysql + - ./docker/mariadb/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "3306:3306" + +volumes: + db_data: diff --git a/docker/mariadb/init.sql b/docker/mariadb/init.sql new file mode 100644 index 0000000..2cc0d6f --- /dev/null +++ b/docker/mariadb/init.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_verified BOOLEAN DEFAULT FALSE, + verification_token VARCHAR(64), + reset_token VARCHAR(64), + reset_token_expires DATETIME, + notify_comments BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS posts ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + image_path VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS likes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + post_id INT NOT NULL, + UNIQUE KEY unique_like (user_id, post_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + post_id INT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE +); diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 0000000..7ab8af1 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.27-alpine +COPY default.conf /etc/nginx/conf.d/default.conf diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..8a1bcc6 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,21 @@ +server { + listen 80; + server_name localhost; + root /var/www/html/public; + index index.php; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\. { + deny all; + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..8626073 --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,15 @@ +FROM php:8.5.4-fpm + +# Install system libs for GD (PNG + JPEG) and a mail agent, then compile PHP extensions +RUN apt-get update && apt-get install -y \ + libpng-dev \ + libjpeg62-turbo-dev \ + msmtp \ + && docker-php-ext-configure gd --with-jpeg \ + && docker-php-ext-install -j$(nproc) gd pdo_mysql \ + && rm -rf /var/lib/apt/lists/* + +RUN echo "upload_max_filesize = 10M" > /usr/local/etc/php/conf.d/uploads.ini \ + && echo "post_max_size = 10M" >> /usr/local/etc/php/conf.d/uploads.ini + +WORKDIR /var/www/html diff --git a/docs/camagru.subject.pdf b/docs/camagru.subject.pdf Binary files differnew file mode 100644 index 0000000..e31b5ea --- /dev/null +++ b/docs/camagru.subject.pdf diff --git a/src/app/Controllers/HomeController.php b/src/app/Controllers/HomeController.php new file mode 100644 index 0000000..8c462a0 --- /dev/null +++ b/src/app/Controllers/HomeController.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); +// Handles the home page and verifies database connectivity. + +namespace App\Controllers; + +use App\Database; + +class HomeController +{ + public function index(): void + { + $db = Database::getInstance()->getPdo(); + $stmt = $db->query('SELECT 1'); + $dbStatus = $stmt ? 'Connected' : 'Failed'; + + $content = __DIR__ . '/../Views/home/index.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } +} diff --git a/src/app/Database.php b/src/app/Database.php new file mode 100644 index 0000000..f4dff79 --- /dev/null +++ b/src/app/Database.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); +// PDO singleton for MariaDB connections. + +namespace App; + +class Database +{ + private static ?Database $instance = null; + private \PDO $pdo; + + private function __construct() + { + $host = getenv('MYSQL_HOST') ?: 'mariadb'; + $db = getenv('MYSQL_DATABASE'); + $user = getenv('MYSQL_USER'); + $pass = getenv('MYSQL_PASSWORD'); + + $this->pdo = new \PDO( + "mysql:host=$host;dbname=$db;charset=utf8mb4", + $user, + $pass, + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + public function getPdo(): \PDO + { + return $this->pdo; + } +} diff --git a/src/app/Router.php b/src/app/Router.php new file mode 100644 index 0000000..9bfe09d --- /dev/null +++ b/src/app/Router.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); +// Regex-based HTTP router that dispatches requests to controller methods. + +namespace App; + +class Router +{ + private array $routes = []; + + public function get(string $path, string $controller, string $method): void + { + $this->routes[] = ['GET', $path, $controller, $method]; + } + + public function post(string $path, string $controller, string $method): void + { + $this->routes[] = ['POST', $path, $controller, $method]; + } + + public function dispatch(): void + { + $requestMethod = $_SERVER['REQUEST_METHOD']; + $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + $uri = rtrim($uri, '/') ?: '/'; + + foreach ($this->routes as [$method, $path, $controller, $action]) { + if ($method !== $requestMethod) { + continue; + } + + // Convert route placeholders to named regex capture groups. + // Example with route '/post/{id}' and request '/post/42': + // 1. preg_replace turns '{id}' into '(?P<id>[^/]+)' + // result: '#^/post/(?P<id>[^/]+)$#' + // 2. preg_match against '/post/42' produces: + // [0 => '/post/42', 'id' => '42', 1 => '42'] + // 3. array_filter keeps only string keys: ['id' => '42'] + // 4. PostController->show('42') is called + $pattern = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $path); + $pattern = '#^' . $pattern . '$#'; + + if (preg_match($pattern, $uri, $matches)) { + $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + $controllerClass = "App\\Controllers\\$controller"; + $instance = new $controllerClass(); + $instance->$action(...array_values($params)); + return; + } + } + + http_response_code(404); + echo '404 Not Found'; + } +} diff --git a/src/app/Views/home/index.php b/src/app/Views/home/index.php new file mode 100644 index 0000000..f5cde93 --- /dev/null +++ b/src/app/Views/home/index.php @@ -0,0 +1,3 @@ +<?php // Home page: displays a welcome message and database connection status.?> +<h1>Welcome to Camagru</h1> +<p>Database status: <?= htmlspecialchars($dbStatus) ?></p> diff --git a/src/app/Views/layouts/main.php b/src/app/Views/layouts/main.php new file mode 100644 index 0000000..b4c7dad --- /dev/null +++ b/src/app/Views/layouts/main.php @@ -0,0 +1,35 @@ +<?php // Base HTML layout: header with navigation, content slot, and footer.?> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Camagru</title> + <link rel="stylesheet" href="/css/style.css"> +</head> +<body> + <header> + <nav> + <a href="/" class="logo">Camagru</a> + <div class="nav-links"> + <a href="/gallery">Gallery</a> + <?php if (isset($_SESSION['user_id'])): ?> + <a href="/editor">Editor</a> + <a href="/profile">Profile</a> + <a href="/logout">Logout</a> + <?php else: ?> + <a href="/login">Login</a> + <a href="/register">Register</a> + <?php endif; ?> + </div> + </nav> + </header> + <main> + <?php include $content; ?> + </main> + <footer> + <p>Camagru © <?= date('Y') ?></p> + </footer> + <script src="/js/app.js"></script> +</body> +</html> diff --git a/src/app/bootstrap.php b/src/app/bootstrap.php new file mode 100644 index 0000000..835615b --- /dev/null +++ b/src/app/bootstrap.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); +// Application bootstrap: loads .env, registers the autoloader, and configures error reporting. + +session_start(); + +// Load .env +$envFile = dirname(__DIR__, 2) . '/.env'; +if (file_exists($envFile)) { + $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + if (str_starts_with(trim($line), '#')) { + continue; + } + $parts = explode('=', $line, 2); + if (count($parts) === 2) { + $key = trim($parts[0]); + $value = trim($parts[1]); + $_ENV[$key] = $value; + putenv("$key=$value"); + } + } +} + +// Autoloader +spl_autoload_register(function (string $class): void { + $prefix = 'App\\'; + if (!str_starts_with($class, $prefix)) { + return; + } + $relative = substr($class, strlen($prefix)); + $file = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php'; + if (file_exists($file)) { + require $file; + } +}); + +// Error reporting +error_reporting(E_ALL); +ini_set('display_errors', '0'); +ini_set('log_errors', '1'); diff --git a/src/config/routes.php b/src/config/routes.php new file mode 100644 index 0000000..d25cfb5 --- /dev/null +++ b/src/config/routes.php @@ -0,0 +1,7 @@ +<?php + +declare(strict_types=1); +// Route definitions: maps URL patterns to controller methods. + +/** @var \App\Router $router */ +$router->get('/', 'HomeController', 'index'); diff --git a/src/public/assets/overlays/.gitkeep b/src/public/assets/overlays/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/public/assets/overlays/.gitkeep diff --git a/src/public/css/style.css b/src/public/css/style.css new file mode 100644 index 0000000..5677e91 --- /dev/null +++ b/src/public/css/style.css @@ -0,0 +1,64 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + min-height: 100vh; + display: flex; + flex-direction: column; + color: #333; + background: #fafafa; +} + +header { + background: #fff; + border-bottom: 1px solid #dbdbdb; + padding: 0.75rem 1rem; +} + +header nav { + max-width: 960px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + font-size: 1.5rem; + font-weight: bold; + text-decoration: none; + color: #333; +} + +.nav-links a { + margin-left: 1rem; + text-decoration: none; + color: #0095f6; +} + +main { + flex: 1; + max-width: 960px; + margin: 2rem auto; + padding: 0 1rem; + width: 100%; +} + +footer { + text-align: center; + padding: 1rem; + color: #999; + font-size: 0.85rem; + border-top: 1px solid #dbdbdb; +} + +@media (max-width: 600px) { + header nav { + flex-direction: column; + gap: 0.5rem; + } +} diff --git a/src/public/index.php b/src/public/index.php new file mode 100644 index 0000000..2709bc0 --- /dev/null +++ b/src/public/index.php @@ -0,0 +1,11 @@ +<?php + +// Front controller: single entry point for all HTTP requests. + +declare(strict_types=1); + +require_once __DIR__ . '/../app/bootstrap.php'; + +$router = new \App\Router(); +require_once __DIR__ . '/../config/routes.php'; +$router->dispatch(); diff --git a/src/public/js/app.js b/src/public/js/app.js new file mode 100644 index 0000000..86b3c33 --- /dev/null +++ b/src/public/js/app.js @@ -0,0 +1,3 @@ +document.addEventListener('DOMContentLoaded', function () { + // App initialization +}); diff --git a/src/uploads/.gitkeep b/src/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/uploads/.gitkeep |
