aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.env.example9
-rw-r--r--.gitignore5
-rw-r--r--.php-cs-fixer.dist.php30
-rw-r--r--README.md116
-rw-r--r--composer.json8
-rw-r--r--composer.lock82
-rw-r--r--docker-compose.yml31
-rw-r--r--docker/mariadb/init.sql39
-rw-r--r--docker/nginx/Dockerfile2
-rw-r--r--docker/nginx/default.conf21
-rw-r--r--docker/php/Dockerfile15
-rw-r--r--docs/camagru.subject.pdfbin0 -> 2314748 bytes
-rw-r--r--src/app/Controllers/HomeController.php21
-rw-r--r--src/app/Database.php44
-rw-r--r--src/app/Router.php56
-rw-r--r--src/app/Views/home/index.php3
-rw-r--r--src/app/Views/layouts/main.php35
-rw-r--r--src/app/bootstrap.php42
-rw-r--r--src/config/routes.php7
-rw-r--r--src/public/assets/overlays/.gitkeep0
-rw-r--r--src/public/css/style.css64
-rw-r--r--src/public/index.php11
-rw-r--r--src/public/js/app.js3
-rw-r--r--src/uploads/.gitkeep0
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
new file mode 100644
index 0000000..e31b5ea
--- /dev/null
+++ b/docs/camagru.subject.pdf
Binary files differ
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 &copy; <?= 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