From 79b37d1891a009d05579810db9576ea6772bc357 Mon Sep 17 00:00:00 2001 From: test2 Date: Sat, 6 Jun 2026 21:59:09 +0300 Subject: [PATCH] first commit Co-authored-by: Cursor --- .env.example | 10 + .gitignore | 10 + Dockerfile | 22 ++ README.md | 383 ++++++++++++++++++++++++++ app/__init__.py | 65 +++++ app/routes.py | 113 ++++++++ app/static/css/style.css | 564 +++++++++++++++++++++++++++++++++++++++ app/static/js/main.js | 86 ++++++ app/templates/base.html | 44 +++ app/templates/index.html | 133 +++++++++ docker-compose.yml | 37 +++ requirements.txt | 7 + uploads/.gitkeep | 1 + wsgi.py | 3 + 14 files changed, 1478 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/routes.py create mode 100644 app/static/css/style.css create mode 100644 app/static/js/main.js create mode 100644 app/templates/base.html create mode 100644 app/templates/index.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 uploads/.gitkeep create mode 100644 wsgi.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..451b958 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Скопируйте в .env и измените значения +POSTGRES_USER=photohost +POSTGRES_PASSWORD=change_me_strong_password +POSTGRES_DB=photohost +DATABASE_URL=postgresql://photohost:change_me_strong_password@db:5432/photohost + +SECRET_KEY=change_me_random_secret_key_min_32_chars +MAX_UPLOAD_MB=10 +UPLOAD_FOLDER=/app/uploads +APP_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bd1b7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +uploads/* +!uploads/.gitkeep +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9c2f035 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/uploads && adduser --disabled-password --gecos "" appuser \ + && chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "wsgi:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfa3e6a --- /dev/null +++ b/README.md @@ -0,0 +1,383 @@ +# PhotoHost — Фото-хостинг + +Современный фото-хостинг на **Python (Flask)**, **PostgreSQL** и **Docker Compose**. + +- Красивая главная страница с drag-and-drop загрузкой +- Галерея загруженных фото +- Копирование прямых ссылок на изображения +- Хранение метаданных в PostgreSQL, файлов — в Docker volume + +--- + +## Структура проекта + +``` +фотохостинг/ +├── app/ +│ ├── __init__.py # Flask-приложение и модель Photo +│ ├── routes.py # Маршруты (загрузка, галерея, API) +│ ├── templates/ # HTML-шаблоны +│ └── static/ # CSS и JavaScript +├── uploads/ # Локальная папка (в Docker — volume) +├── docker-compose.yml # Оркестрация web + PostgreSQL +├── Dockerfile +├── wsgi.py # Точка входа для Gunicorn +├── requirements.txt +├── .env.example +└── README.md +``` + +--- + +## Развёртывание на Ubuntu 24.04 + +Подробная пошаговая инструкция для чистого сервера Ubuntu 24.04 LTS. + +### 1. Подключение к серверу + +```bash +ssh user@YOUR_SERVER_IP +``` + +Замените `user` на имя пользователя и `YOUR_SERVER_IP` на IP-адрес сервера. + +### 2. Обновление системы + +```bash +sudo apt update && sudo apt upgrade -y +``` + +### 3. Установка необходимых пакетов + +```bash +sudo apt install -y ca-certificates curl gnupg git +``` + +### 4. Установка Docker + +Docker официально поддерживается на Ubuntu 24.04. + +```bash +# Добавить GPG-ключ Docker +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +# Добавить репозиторий Docker +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Установить Docker Engine и Compose plugin +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +Проверка установки: + +```bash +sudo docker run hello-world +docker compose version +``` + +### 5. Добавить пользователя в группу docker (опционально) + +Чтобы не использовать `sudo` перед каждой командой docker: + +```bash +sudo usermod -aG docker $USER +newgrp docker +``` + +### 6. Копирование проекта на сервер + +**Вариант A — через Git:** + +```bash +cd ~ +git clone https://github.com/YOUR_USER/photohost.git фотохостинг +cd фотохостинг +``` + +**Вариант B — через SCP с локального компьютера:** + +```bash +# Выполнить на локальной машине (Windows PowerShell / Linux) +scp -r ./фотохостинг user@YOUR_SERVER_IP:~/ +``` + +```bash +# На сервере +cd ~/фотохостинг +``` + +**Вариант C — создать файлы вручную** — скопируйте содержимое проекта в каталог `~/фотохостинг`. + +### 7. Настройка переменных окружения + +```bash +cp .env.example .env +nano .env +``` + +Измените значения в `.env`: + +```env +POSTGRES_USER=photohost +POSTGRES_PASSWORD=ВАШ_НАДЁЖНЫЙ_ПАРОЛЬ_БД +POSTGRES_DB=photohost +DATABASE_URL=postgresql://photohost:ВАШ_НАДЁЖНЫЙ_ПАРОЛЬ_БД@db:5432/photohost + +SECRET_KEY=случайная_строка_минимум_32_символа +MAX_UPLOAD_MB=10 +APP_PORT=8080 +``` + +Сгенерировать случайный `SECRET_KEY`: + +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +### 8. Запуск приложения + +```bash +docker compose up -d --build +``` + +Проверка статуса контейнеров: + +```bash +docker compose ps +``` + +Ожидаемый результат — оба сервиса `running`: + +| Сервис | Контейнер | Порт | +|--------|----------------|-------------| +| web | photohost-web | 8080 → 8000 | +| db | photohost-db | 5432 (внутр.) | + +Просмотр логов: + +```bash +docker compose logs -f web +``` + +### 9. Проверка работы + +Откройте в браузере: + +``` +http://YOUR_SERVER_IP:8080 +``` + +Загрузите тестовое изображение — оно должно появиться в галерее. + +### 10. Открытие порта в файрволе (UFW) + +Если включён UFW: + +```bash +sudo ufw allow 8080/tcp +sudo ufw allow OpenSSH +sudo ufw enable +sudo ufw status +``` + +### 11. Автозапуск при перезагрузке сервера + +Docker Compose с `restart: unless-stopped` уже перезапускает контейнеры. Убедитесь, что Docker включён: + +```bash +sudo systemctl enable docker +sudo systemctl start docker +``` + +--- + +## Полезные команды + +| Действие | Команда | +|-----------------------|----------------------------------| +| Остановить | `docker compose down` | +| Перезапустить | `docker compose restart` | +| Пересобрать | `docker compose up -d --build` | +| Логи web | `docker compose logs -f web` | +| Логи БД | `docker compose logs -f db` | +| Зайти в контейнер web | `docker compose exec web bash` | +| Зайти в PostgreSQL | `docker compose exec db psql -U photohost -d photohost` | + +--- + +## Резервное копирование + +### База данных + +```bash +docker compose exec db pg_dump -U photohost photohost > backup_$(date +%Y%m%d).sql +``` + +Восстановление: + +```bash +cat backup_20250606.sql | docker compose exec -T db psql -U photohost -d photohost +``` + +### Загруженные фото + +Фото хранятся в Docker volume `uploads_data`. Список volumes: + +```bash +docker volume ls +``` + +Бэкап volume: + +```bash +docker run --rm -v photohost_uploads_data:/data -v $(pwd):/backup alpine tar czf /backup/uploads_backup.tar.gz -C /data . +``` + +--- + +## Настройка домена и HTTPS (Nginx + Let's Encrypt) + +### Установка Nginx и Certbot + +```bash +sudo apt install -y nginx certbot python3-certbot-nginx +``` + +### Конфиг Nginx + +```bash +sudo nano /etc/nginx/sites-available/photohost +``` + +```nginx +server { + listen 80; + server_name photos.example.com; + + client_max_body_size 15M; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/photohost /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +### SSL-сертификат + +```bash +sudo certbot --nginx -d photos.example.com +``` + +Откройте порты 80 и 443: + +```bash +sudo ufw allow 'Nginx Full' +``` + +--- + +## Локальная разработка (без Docker) + +```bash +# Установить PostgreSQL локально или запустить только БД: +docker compose up -d db + +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt + +cp .env.example .env +# Измените DATABASE_URL на localhost: +# DATABASE_URL=postgresql://photohost:photohost_secret@localhost:5432/photohost + +export FLASK_APP=wsgi.py +python wsgi.py +``` + +Приложение будет доступно на `http://localhost:8000`. + +> Для локального запуска только БД добавьте в `docker-compose.yml` для сервиса `db` строку `ports: - "5432:5432"`. + +--- + +## API + +| Метод | URL | Описание | +|-------|---------------|-----------------------------| +| GET | `/` | Главная страница | +| POST | `/upload` | Загрузка фото (form-data) | +| GET | `/uploads/` | Прямая ссылка на файл | +| GET | `/api/photos` | JSON-список всех фото | +| POST | `/delete/` | Удаление фото | + +Пример ответа `/api/photos`: + +```json +[ + { + "id": 1, + "url": "/uploads/abc123.jpg", + "original_name": "photo.jpg", + "file_size": 245760, + "size_human": "240.0 КБ", + "created_at": "2025-06-06T12:00:00+00:00" + } +] +``` + +--- + +## Устранение неполадок + +**Контейнер web не запускается** + +```bash +docker compose logs web +``` + +Частая причина — БД ещё не готова. Healthcheck в `docker-compose.yml` решает это; подождите 30 секунд и перезапустите: + +```bash +docker compose restart web +``` + +**Ошибка подключения к PostgreSQL** + +Проверьте, что пароли в `.env` совпадают в `POSTGRES_PASSWORD` и `DATABASE_URL`. + +**Фото не загружаются (413 Request Entity Too Large)** + +Увеличьте `MAX_UPLOAD_MB` в `.env` и `client_max_body_size` в Nginx. + +**Порт 8080 занят** + +Измените `APP_PORT=9090` в `.env` и перезапустите: + +```bash +docker compose down && docker compose up -d +``` + +--- + +## Технологии + +- Python 3.12, Flask 3, Gunicorn +- PostgreSQL 16 +- SQLAlchemy, Pillow +- Docker & Docker Compose diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ca204f1 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,65 @@ +import os +from datetime import datetime, timezone + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from dotenv import load_dotenv + +load_dotenv() + +db = SQLAlchemy() + + +def create_app(): + app = Flask(__name__) + + app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me") + app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv( + "DATABASE_URL", + "postgresql://photohost:photohost_secret@localhost:5432/photohost", + ) + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["UPLOAD_FOLDER"] = os.getenv("UPLOAD_FOLDER", "uploads") + app.config["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_UPLOAD_MB", "10")) * 1024 * 1024 + app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp", "bmp"} + + os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) + + db.init_app(app) + + from .routes import bp + + app.register_blueprint(bp) + + with app.app_context(): + db.create_all() + + return app + + +class Photo(db.Model): + __tablename__ = "photos" + + id = db.Column(db.Integer, primary_key=True) + filename = db.Column(db.String(255), nullable=False) + original_name = db.Column(db.String(255), nullable=False) + file_size = db.Column(db.Integer, nullable=False, default=0) + mime_type = db.Column(db.String(100), nullable=False, default="image/jpeg") + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + @property + def url(self): + return f"/uploads/{self.filename}" + + @property + def size_human(self): + size = self.file_size + for unit in ("Б", "КБ", "МБ", "ГБ"): + if size < 1024: + return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} ТБ" diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..350a9b5 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,113 @@ +import os +import uuid +from datetime import datetime, timezone + +from flask import ( + Blueprint, + current_app, + flash, + jsonify, + redirect, + render_template, + request, + send_from_directory, + url_for, +) +from werkzeug.utils import secure_filename + +from app import Photo, db + +bp = Blueprint("main", __name__) + + +def allowed_file(filename): + return ( + "." in filename + and filename.rsplit(".", 1)[1].lower() in current_app.config["ALLOWED_EXTENSIONS"] + ) + + +@bp.route("/") +def index(): + photos = Photo.query.order_by(Photo.created_at.desc()).all() + total_size = sum(p.file_size for p in photos) + return render_template( + "index.html", + photos=photos, + total_photos=len(photos), + total_size=total_size, + max_upload_mb=current_app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024), + ) + + +@bp.route("/upload", methods=["POST"]) +def upload(): + if "photo" not in request.files: + flash("Файл не выбран", "error") + return redirect(url_for("main.index")) + + file = request.files["photo"] + if file.filename == "": + flash("Файл не выбран", "error") + return redirect(url_for("main.index")) + + if not allowed_file(file.filename): + flash("Недопустимый формат. Разрешены: PNG, JPG, GIF, WEBP, BMP", "error") + return redirect(url_for("main.index")) + + ext = file.filename.rsplit(".", 1)[1].lower() + stored_name = f"{uuid.uuid4().hex}.{ext}" + safe_original = secure_filename(file.filename) or f"photo.{ext}" + + upload_dir = current_app.config["UPLOAD_FOLDER"] + filepath = os.path.join(upload_dir, stored_name) + file.save(filepath) + file_size = os.path.getsize(filepath) + + photo = Photo( + filename=stored_name, + original_name=safe_original, + file_size=file_size, + mime_type=file.content_type or f"image/{ext}", + created_at=datetime.now(timezone.utc), + ) + db.session.add(photo) + db.session.commit() + + flash("Фото успешно загружено", "success") + return redirect(url_for("main.index")) + + +@bp.route("/api/photos") +def api_photos(): + photos = Photo.query.order_by(Photo.created_at.desc()).all() + return jsonify( + [ + { + "id": p.id, + "url": p.url, + "original_name": p.original_name, + "file_size": p.file_size, + "size_human": p.size_human, + "created_at": p.created_at.isoformat(), + } + for p in photos + ] + ) + + +@bp.route("/uploads/") +def uploaded_file(filename): + return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename) + + +@bp.route("/delete/", methods=["POST"]) +def delete_photo(photo_id): + photo = Photo.query.get_or_404(photo_id) + filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], photo.filename) + if os.path.exists(filepath): + os.remove(filepath) + db.session.delete(photo) + db.session.commit() + flash("Фото удалено", "success") + return redirect(url_for("main.index")) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..8f4b992 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,564 @@ +:root { + --bg: #0a0a0f; + --bg-card: rgba(255, 255, 255, 0.04); + --bg-card-hover: rgba(255, 255, 255, 0.07); + --border: rgba(255, 255, 255, 0.08); + --text: #f4f4f5; + --text-muted: #a1a1aa; + --accent: #6366f1; + --accent-light: #818cf8; + --accent-glow: rgba(99, 102, 241, 0.35); + --success: #22c55e; + --error: #ef4444; + --radius: 16px; + --radius-sm: 10px; + --shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + --font: "Inter", system-ui, -apple-system, sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + overflow-x: hidden; +} + +.bg-gradient { + position: fixed; + inset: 0; + background: + radial-gradient(ellipse 80% 50% at 50% -20%, rgba(99, 102, 241, 0.25), transparent), + radial-gradient(ellipse 60% 40% at 100% 50%, rgba(168, 85, 247, 0.12), transparent), + radial-gradient(ellipse 50% 30% at 0% 80%, rgba(59, 130, 246, 0.1), transparent); + pointer-events: none; + z-index: 0; +} + +.bg-grid { + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 60px 60px; + mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +/* Header */ +.header { + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(20px); + background: rgba(10, 10, 15, 0.7); + border-bottom: 1px solid var(--border); +} + +.header__inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 72px; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: var(--text); + font-weight: 700; + font-size: 1.25rem; +} + +.logo__icon { + font-size: 1.5rem; +} + +.nav { + display: flex; + gap: 8px; +} + +.nav__link { + color: var(--text-muted); + text-decoration: none; + padding: 8px 16px; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-weight: 500; + transition: color 0.2s, background 0.2s; +} + +.nav__link:hover { + color: var(--text); + background: var(--bg-card); +} + +/* Hero */ +.hero { + position: relative; + z-index: 1; + padding: 80px 0 60px; + text-align: center; +} + +.hero__badge { + display: inline-block; + padding: 6px 16px; + border-radius: 999px; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--accent-light); + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 24px; +} + +.hero__title { + font-size: clamp(2.5rem, 6vw, 4rem); + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.03em; + margin-bottom: 20px; +} + +.hero__accent { + background: linear-gradient(135deg, var(--accent-light), #a855f7); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero__subtitle { + max-width: 560px; + margin: 0 auto 48px; + color: var(--text-muted); + font-size: 1.125rem; +} + +.stats { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; +} + +.stat-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 32px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + backdrop-filter: blur(10px); + min-width: 140px; +} + +.stat-card__value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); +} + +.stat-card__label { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 4px; +} + +/* Alerts */ +.alerts { + position: relative; + z-index: 1; + margin-bottom: 24px; +} + +.alert { + padding: 14px 20px; + border-radius: var(--radius-sm); + font-weight: 500; + margin-bottom: 8px; +} + +.alert--success { + background: rgba(34, 197, 94, 0.15); + border: 1px solid rgba(34, 197, 94, 0.3); + color: #86efac; +} + +.alert--error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #fca5a5; +} + +/* Sections */ +.upload-section, +.gallery-section { + position: relative; + z-index: 1; + padding: 60px 0; +} + +.section-title { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 24px; + letter-spacing: -0.02em; +} + +/* Upload */ +.upload-form { + display: flex; + flex-direction: column; + gap: 20px; + max-width: 640px; + margin: 0 auto; +} + +.dropzone { + position: relative; + padding: 48px 32px; + border: 2px dashed var(--border); + border-radius: var(--radius); + background: var(--bg-card); + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s, box-shadow 0.2s; +} + +.dropzone:hover, +.dropzone--active { + border-color: var(--accent); + background: rgba(99, 102, 241, 0.06); + box-shadow: 0 0 40px var(--accent-glow); +} + +.dropzone__icon { + color: var(--accent-light); + margin-bottom: 16px; +} + +.dropzone__title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 6px; +} + +.dropzone__hint { + color: var(--text-muted); + font-size: 0.9rem; +} + +.dropzone__formats { + margin-top: 16px; + font-size: 0.75rem; + color: var(--text-muted); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.dropzone__preview { + margin-top: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.dropzone__preview img { + max-width: 200px; + max-height: 160px; + border-radius: var(--radius-sm); + object-fit: cover; + box-shadow: var(--shadow); +} + +.dropzone__preview span { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 28px; + border: none; + border-radius: var(--radius-sm); + font-family: var(--font); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: transform 0.15s, box-shadow 0.2s, opacity 0.2s; +} + +.btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn--primary { + background: linear-gradient(135deg, var(--accent), #7c3aed); + color: white; + box-shadow: 0 4px 20px var(--accent-glow); +} + +.btn--primary:not(:disabled):hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px var(--accent-glow); +} + +.btn--ghost { + background: rgba(255, 255, 255, 0.1); + color: var(--text); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.btn--ghost:hover { + background: rgba(255, 255, 255, 0.18); +} + +.btn--danger { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.btn--danger:hover { + background: rgba(239, 68, 68, 0.25); +} + +.btn--sm { + padding: 8px 14px; + font-size: 0.8rem; +} + +/* Gallery */ +.gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.gallery-count { + color: var(--text-muted); + font-size: 0.9rem; +} + +.gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 24px; +} + +.photo-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; +} + +.photo-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow); + border-color: rgba(99, 102, 241, 0.3); +} + +.photo-card__image-wrap { + position: relative; + aspect-ratio: 4 / 3; + overflow: hidden; + background: rgba(0, 0, 0, 0.3); +} + +.photo-card__image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s; +} + +.photo-card:hover .photo-card__image { + transform: scale(1.05); +} + +.photo-card__overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: rgba(0, 0, 0, 0.6); + opacity: 0; + transition: opacity 0.2s; +} + +.photo-card:hover .photo-card__overlay { + opacity: 1; +} + +.photo-card__info { + padding: 16px; +} + +.photo-card__name { + display: block; + font-weight: 500; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 8px; +} + +.photo-card__meta { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 12px; +} + +.photo-card__delete { + margin: 0; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 80px 24px; + background: var(--bg-card); + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +.empty-state__icon { + font-size: 4rem; + margin-bottom: 16px; +} + +.empty-state h3 { + font-size: 1.25rem; + margin-bottom: 8px; +} + +.empty-state p { + color: var(--text-muted); + margin-bottom: 24px; +} + +/* Footer */ +.footer { + position: relative; + z-index: 1; + border-top: 1px solid var(--border); + padding: 32px 0; + margin-top: 40px; +} + +.footer__inner { + text-align: center; +} + +.footer__muted { + color: var(--text-muted); + font-size: 0.85rem; + margin-top: 4px; +} + +/* Toast for copy */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 14px 24px; + background: var(--success); + color: white; + border-radius: var(--radius-sm); + font-weight: 500; + box-shadow: var(--shadow); + z-index: 1000; + animation: slideIn 0.3s ease, fadeOut 0.3s ease 2s forwards; +} + +@keyframes slideIn { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes fadeOut { + to { + opacity: 0; + transform: translateY(10px); + } +} + +/* Responsive */ +@media (max-width: 640px) { + .hero { + padding: 48px 0 40px; + } + + .stats { + flex-direction: column; + align-items: stretch; + } + + .stat-card { + flex-direction: row; + justify-content: space-between; + padding: 16px 20px; + } + + .gallery-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .photo-card__overlay { + opacity: 1; + background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent 60%); + align-items: flex-end; + padding-bottom: 12px; + } +} diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..8dce600 --- /dev/null +++ b/app/static/js/main.js @@ -0,0 +1,86 @@ +document.addEventListener("DOMContentLoaded", () => { + const dropzone = document.getElementById("dropzone"); + const photoInput = document.getElementById("photoInput"); + const preview = document.getElementById("preview"); + const previewImg = document.getElementById("previewImg"); + const previewName = document.getElementById("previewName"); + const submitBtn = document.getElementById("submitBtn"); + + if (!dropzone || !photoInput) return; + + dropzone.addEventListener("click", (e) => { + if (e.target.closest("button")) return; + photoInput.click(); + }); + + ["dragenter", "dragover"].forEach((event) => { + dropzone.addEventListener(event, (e) => { + e.preventDefault(); + dropzone.classList.add("dropzone--active"); + }); + }); + + ["dragleave", "drop"].forEach((event) => { + dropzone.addEventListener(event, (e) => { + e.preventDefault(); + dropzone.classList.remove("dropzone--active"); + }); + }); + + dropzone.addEventListener("drop", (e) => { + const files = e.dataTransfer.files; + if (files.length > 0) { + photoInput.files = files; + showPreview(files[0]); + } + }); + + photoInput.addEventListener("change", () => { + if (photoInput.files.length > 0) { + showPreview(photoInput.files[0]); + } + }); + + function showPreview(file) { + if (!file.type.startsWith("image/")) return; + + const reader = new FileReader(); + reader.onload = (e) => { + previewImg.src = e.target.result; + previewName.textContent = file.name; + preview.hidden = false; + submitBtn.disabled = false; + }; + reader.readAsDataURL(file); + } + + document.querySelectorAll(".copy-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { + e.stopPropagation(); + const url = btn.dataset.url; + try { + await navigator.clipboard.writeText(url); + showToast("Ссылка скопирована!"); + } catch { + const input = document.createElement("input"); + input.value = url; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + showToast("Ссылка скопирована!"); + } + }); + }); +}); + +function showToast(message) { + const existing = document.querySelector(".toast"); + if (existing) existing.remove(); + + const toast = document.createElement("div"); + toast.className = "toast"; + toast.textContent = message; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2500); +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..d4dab6b --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,44 @@ + + + + + + {% block title %}PhotoHost — Бесплатный фото-хостинг{% endblock %} + + + + + + + +
+
+ +
+ +
+ +
+ {% block content %}{% endblock %} +
+ +
+ +
+ + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..2b07130 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,133 @@ +{% extends "base.html" %} + +{% macro format_size(bytes) %} +{% set size = bytes|float %} +{% if size < 1024 %} +{{ size|int }} Б +{% elif size < 1048576 %} +{{ "%.1f"|format(size / 1024) }} КБ +{% elif size < 1073741824 %} +{{ "%.1f"|format(size / 1048576) }} МБ +{% else %} +{{ "%.1f"|format(size / 1073741824) }} ГБ +{% endif %} +{% endmacro %} + +{% block content %} +
+
+
Бесплатно · Без регистрации
+

+ Загружайте фото
+ мгновенно +

+

+ Современный фото-хостинг на Python и PostgreSQL. + Перетащите изображение — получите прямую ссылку за секунды. +

+
+
+ {{ total_photos }} + фото загружено +
+
+ {{ format_size(total_size) }} + общий объём +
+
+ до {{ max_upload_mb }} МБ + на файл +
+
+
+
+ +{% with messages = get_flashed_messages(with_categories=true) %} +{% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+{% endif %} +{% endwith %} + +
+
+

Загрузить фото

+
+
+ +
+ + + + +
+

Перетащите фото сюда

+

или нажмите для выбора файла

+

PNG · JPG · GIF · WEBP · BMP

+ +
+ +
+
+
+ + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe7637f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + db: + image: postgres:16-alpine + container_name: photohost-db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-photohost} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-photohost_secret} + POSTGRES_DB: ${POSTGRES_DB:-photohost} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-photohost} -d ${POSTGRES_DB:-photohost}"] + interval: 5s + timeout: 5s + retries: 10 + + web: + build: . + container_name: photohost-web + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:8000" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-photohost}:${POSTGRES_PASSWORD:-photohost_secret}@db:5432/${POSTGRES_DB:-photohost} + SECRET_KEY: ${SECRET_KEY:-dev-secret-change-in-production} + MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10} + UPLOAD_FOLDER: /app/uploads + volumes: + - uploads_data:/app/uploads + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: + uploads_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..38705b9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +psycopg2-binary==2.9.10 +gunicorn==23.0.0 +Pillow==11.1.0 +python-dotenv==1.0.1 +Werkzeug==3.1.3 diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/uploads/.gitkeep @@ -0,0 +1 @@ + diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..0a23b5a --- /dev/null +++ b/wsgi.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app()