diff --git a/README.md b/README.md
index 324c044..8c0eba4 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,193 @@
-# Drop-me-files-analog
-Analog dropmefiles.com
+# Drop me Files (analog)
+
+Аналог сервиса [dropmefiles.com](https://dropmefiles.com) — веб-приложение для временного обмена файлами с прямой загрузкой в S3-совместимое хранилище.
+
+Попробовать демо: [dropmefiles.viaproger.ru](https://dropmefiles.viaproger.ru/)
+
+## 📋 Описание
+
+Drop-me-files-analog позволяет пользователям:
+- Загружать файлы через веб-интерфейс
+- Получать короткие ссылки для скачивания (6-символьный UUID)
+
+Файлы загружаются напрямую в S3-совместимое хранилище (MinIO) с использованием presigned URLs, что снижает нагрузку на backend-сервер. Файлы в S3 хранилище автоматически удаляются с течением времени, и доступ к ним исчезает.
+
+## 🏗️ Архитектура
+
+Проект построен на микросервисной архитектуре и состоит из нескольких компонентов:
+
+**Frontend**: статическое веб-приложение (HTML, CSS, JS), предоставляющее интерфейс для загрузки и скачивания файлов. Взаимодействует с Backend через REST API.
+
+**Backend (FastAPI)**: API-шлюз, который генерирует presigned URLs для прямого взаимодействия клиента с S3-хранилищем, управляет метаданными файлов, валидирует запросы и логирует операции.
+
+**MinIO**: S3-совместимое хранилище для файлов. Доступ к файлам возможен только через presigned URLs с ограниченным временем жизни.
+
+**Redis**: хранилище метаданных файлов. Записи автоматически удаляются по истечении TTL.
+
+**Nginx**: служит веб-сервером для раздачи frontend и reverse proxy для backend.
+
+
+
+Процесс работы:
+
+1. **Загрузка**: **Frontend** запрашивает токен, передавая метаданные файла. **Backend** генерирует короткий UUID и presigned POST URL для **MinIO**, сохраняет метаданные в **Redis** с TTL и возвращает URL и UUID. Frontend загружает файл напрямую в **MinIO**.
+
+2. **Скачивание**: По запросу с UUID **backend** проверяет **Redis**, генерирует presigned GET URL и возвращает его клиенту с метаданными. Файл скачивается напрямую из **MinIO**.
+
+3. **Очистка**: По истечении TTL запись удаляется из **Redis**; Файл в **MinIO** удаляется по правилам конфигурации S3 хранилища (в данном проекте, TTL MinIO равен 1 суткам).
+
+## 🛠️ Технологический стек
+
+### Backend
+- **Python 3.14**
+- **FastAPI** — современный асинхронный веб-фреймворк
+- **Uvicorn** — ASGI сервер
+- **aiobotocore** — асинхронный клиент для S3-совместимых хранилищ
+- **redis** — клиент Redis
+- **Pydantic** — валидация данных
+- **Loguru** — логирование
+
+### Frontend
+- **HTML**, **CSS**, **JavaScript** — базовый стек
+
+### Инфраструктура
+- **Docker** и **Docker Compose** — для быстрой развертки сервисов
+- **MinIO** — S3-совместимое хранилище
+- **Redis** — кэш и хранилище метаданных
+- **Nginx** — веб-сервер
+
+## 📦 Установка и запуск
+
+### Предварительные требования
+
+- **Docker** и **Docker Compose** — для запуска MinIO и Redis
+- **Python 3.14** — для локальной разработки backend
+- **UV** пакетный менеджер (рекомендуется)
+
+### Установка
+
+1. **Клонируйте репозиторий:**
+```bash
+git clone https://github.com/IgorVolochay/Drop-me-files-analog.git
+cd Drop-me-files-analog
+```
+
+2. **Создайте файл `.env`** на основе примера:
+```bash
+cp app/.env-example app/.env
+cp docker/.env-example docker/.env
+```
+Затем отредактируйте `.env` и укажите необходимые значения (см. раздел "Конфигурация").
+
+3. **Запустите сервисы (MinIO и Redis) через Docker Compose:**
+```bash
+cd docker
+docker-compose up -d
+```
+
+Это запустит:
+- MinIO на указанных портах 9000 (API) и 9001 (Веб-интерфейс)
+- Автоматическую инициализацию бакета MinIO и TTL
+- Redis на порту 6380
+
+
+4. **Установите зависимости и запустите backend:**
+```bash
+cd ../app
+uv sync # или pip install ..
+uv run main.py
+```
+
+Backend будет доступен на `http://localhost:8000` (или порт из `BACKEND_PORT` в `.env`).
+
+5. **Запустите frontend:**
+
+В режиме разработки используйте любой статический сервер:
+
+```bash
+ cd frontend
+ python -m http.server 8080
+ # или
+ npx serve .
+```
+
+В production настройте Nginx для раздачи статических файлов из `frontend/` и проксирования API запросов на backend.
+
+
+## ⚙️ Конфигурация
+
+### Переменные окружения
+
+В проекте есть пример файла конфигурации `app/.env-example`. Скопируйте его и создайте файл `.env` в корне проекта или в папке `app/` со следующими переменными:
+
+```python
+DISABLE_DOCS=false # Отключение базового FastAPI endpoint /docs
+BACKEND_PORT=8000 # Сетевой порт FastAPI Backend
+
+LOGS_PATH=./logs # Директория для хранения логов
+
+FILES_TTL=86400 # Время хранения файла (указан 1 день)
+MAX_FILES_SIZE=1073741824 # Максимальный размер файла в байтах (указан 1 GB)
+
+# Переменные для подключения к S3 хранилищу
+S3_ACCESS_KEY_ID=Some_username
+S3_SECRET_ACCESS_KEY=Some_password
+S3_ENDPOINT_URL=Some_url
+BUCKET_NAME=drop-me-files
+
+# Переменные для подключения к Redis
+REDIS_ADDRESS=Some_url
+REDIS_PORT=6379
+REDIS_USERNAME=Some_username
+REDIS_PASSWORD=Some_password
+```
+
+
+## 🔧 Разработка
+
+### Структура проекта
+
+```
+Drop-me-files-analog/
+├── app/ # Backend приложение
+│ ├── main.py # Главный файл FastAPI приложения
+│ ├── s3_worker.py # Обработчик S3/MinIO
+│ ├── redis_worker.py # Обработчик Redis
+│ ├── schemas/ # Pydantic схемы
+│ │ └── api_schemas.py
+│ └── pyproject.toml # Зависимости проекта
+├── frontend/ # Frontend приложение
+│ ├── index.html
+│ ├── script.js
+│ └── style.css
+├── docker/
+│ └── docker-compose.yml # Docker конфигурация для запуска MinIO и Redis
+└── README.md # Этот файл
+```
+
+### Логирование
+
+Логи сохраняются в файл: `{LOGS_PATH}/dmf-logs.log` (ротация при 1 MB, сжатие zip)
+
+
+## 🤝 Вклад
+
+Вклад в проект приветствуется! Пожалуйста:
+
+1. Создайте fork проекта
+2. Создайте ветку для новой функции (`git checkout -b feature/AmazingFeature`)
+3. Зафиксируйте изменения (`git commit -m 'Add some AmazingFeature'`)
+4. Отправьте в ветку (`git push origin feature/AmazingFeature`)
+5. Откройте Pull Request
+
+## 📞 Контакты
+
+- **GitHub проекта:** [IgorVolochay/Drop-me-files-analog](https://github.com/IgorVolochay/Drop-me-files-analog)
+- **Автор:** Игорь Волочай
+
+Если нашли баг или уязвимость, пишите на почту (pseudo.developer.ru@gmail.com) или открывайте **Issues** на GitHub!
+
+## 🙏 Благодарности
+
+- Проект вдохновлён сервисом [dropmefiles.com](https://dropmefiles.com)
+- Использует открытые технологии: [FastAPI](https://github.com/fastapi/fastapi), [MinIO](https://github.com/minio/minio), [Redis](https://github.com/redis/redis)
diff --git a/app/.env-example b/app/.env-example
new file mode 100644
index 0000000..9216b31
--- /dev/null
+++ b/app/.env-example
@@ -0,0 +1,18 @@
+DISABLE_DOCS=false
+BACKEND_PORT=5000
+
+LOGS_PATH=./logs
+
+FILES_TTL=86400 # 1 Day
+MAX_FILES_SIZE=1073741824 # 1GB
+
+S3_ACCESS_KEY_ID=Some_username
+S3_SECRET_ACCESS_KEY=Some_password
+S3_ENDPOINT_URL=Some_url
+
+BUCKET_NAME=drop-me-files
+
+REDIS_ADDRESS=Some_url
+REDIS_PORT=6379
+REDIS_USERNAME=Some_username
+REDIS_PASSWORD=Some_password
\ No newline at end of file
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..385cf22
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,124 @@
+import dotenv
+import os
+import sys
+import random
+import string
+
+import asyncio
+import uvicorn
+
+from datetime import datetime
+from fastapi import FastAPI, Response, status, Request
+from starlette.middleware.base import BaseHTTPMiddleware
+from loguru import logger
+
+from schemas.api_schemas import *
+from s3_worker import S3Worker
+from redis_worker import RedisWorker
+
+dotenv.load_dotenv()
+
+logger.remove()
+logger.add(f"{os.getenv("LOGS_PATH", "./logs")}/dmf-logs.log",
+ format="{time:DD-MM-YYYY HH:mm:ss.SSS}; {level}; {message}", level="DEBUG",
+ rotation="1 MB", compression="zip")
+logger.add(sys.stdout,
+ level="DEBUG",
+ format="{time:HH:mm:ss} | {level} | {message}",)
+
+disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
+app: FastAPI = FastAPI(title="DropMeFiles analog",
+ summary="OpenAPI schema for \"DropMeFiles analog\" project!",
+ version="1.0",
+ contact={"GitHub": "https://github.com/IgorVolochay/Drop-me-files-analog"},
+ docs_url=None if disable_docs else "/docs",
+ redoc_url=None if disable_docs else "/redoc",
+ openapi_url=None if disable_docs else "/openapi.json")
+
+s3_worker = S3Worker()
+redis_worker = RedisWorker()
+
+max_file_size = int(os.getenv('MAX_FILES_SIZE', 1048576)) # 1 MB if None
+
+class RealIPMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ forwarded = request.headers.get("X-Forwarded-For", "")
+ if forwarded:
+ request.state.client_ip = forwarded.split(",")[0].strip()
+ else:
+ request.state.client_ip = request.headers.get("X-Real-IP", request.client.host)
+
+ response = await call_next(request)
+ return response
+app.add_middleware(RealIPMiddleware)
+
+@logger.catch
+@app.get("/upload_token", status_code=200)
+async def get_upload_token(file_name: str, file_type: str, file_size: int, response: Response, request: Request) -> BaseResponse:
+ user_ip = request.state.client_ip
+ logger.debug(f"User IP: {user_ip}; Endpoint: /upload_token; File Name: {file_name}; File Type: {file_type}; File Size: {file_size}")
+ if file_size > max_file_size:
+ response.status_code = status.HTTP_413_CONTENT_TOO_LARGE
+ logger.warning(f"User IP: {user_ip}; File Name: {file_name}; Exception: The file is too large")
+ return BaseResponse(result="The uploaded file is too large", error=True)
+ elif file_size <= 0:
+ response.status_code = status.HTTP_400_BAD_REQUEST
+ logger.warning(f"User IP: {user_ip}; File Name: {file_name}; Exception: The file is less than 1 byte")
+ return BaseResponse(result="The file you are uploading is less than 1 byte, WTF?", error=True)
+
+ file_uuid = ''.join(random.choices(string.ascii_letters + string.digits, k=6))
+ try:
+ post_data = await s3_worker.generate_upload_post(file_uuid, content_type=file_type)
+ except Exception as exception:
+ response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
+ logger.error(f"User IP: {user_ip}; File Name: {file_name}; Exception: {exception}")
+ return BaseResponse(result="Error generating S3 access token", error=True)
+
+ redis_worker.create_record(user_ip, file_name, file_uuid, file_type, datetime.now().isoformat(), file_size)
+
+ logger.success(f"User IP: {user_ip}; POST data: {post_data}")
+ return BaseResponse(result={"data": post_data, "file_uuid": file_uuid, "comment": "Ok"})
+
+@app.get("/max_file_size", status_code=200)
+def get_max_file_size() -> int:
+ logger.debug("Max file size")
+ return int(os.getenv('MAX_FILES_SIZE'))
+
+@logger.catch
+@app.get("/get_download_link/{file_uuid}", status_code=200)
+async def get_file_by_uuid(file_uuid:str, response: Response, request: Request) -> BaseResponse:
+ user_ip = request.state.client_ip
+ logger.debug(f"User IP: {user_ip}; Endpoint: /get_download_link/; File UUID: {file_uuid}")
+ if len(file_uuid) != 6:
+ response.status_code = status.HTTP_404_NOT_FOUND
+ logger.warning(f"User IP: {user_ip}; File UUID: {file_uuid}; Exception: Invalid UUID")
+ return BaseResponse(result="The file UUID must be 6 characters long", error=True)
+ redis_data = redis_worker.get_record(file_uuid)
+ if not redis_data:
+ response.status_code = status.HTTP_400_BAD_REQUEST
+ logger.warning(f"User IP: {user_ip}; File UUID: {file_uuid}; Exception: File with this UUID not found")
+ return BaseResponse(result={"data": None, "comment": "File with this UUID not found"}, error=True)
+
+ try:
+ download_url = await s3_worker.generate_download_url(file_uuid, redis_data["file_name"])
+ except Exception as exception:
+ response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
+ logger.error(f"User IP: {user_ip}; File UUID: {file_uuid}; Exception: {exception}")
+ return BaseResponse(result="Error generating S3 access token", error=True)
+
+ logger.success(f"User IP: {user_ip}; File UUID: {file_uuid}")
+ return BaseResponse(result={"data": {"url": download_url, "file_name": redis_data["file_name"], "file_size": redis_data["file_size"]}, "comment": "Ok"})
+
+@app.get("/health_check")
+def health_check():
+ logger.debug("Health check")
+ return True
+
+
+async def main():
+ config = uvicorn.Config("main:app", port=int(os.getenv('BACKEND_PORT', 8000)), host="0.0.0.0", log_level="critical")
+ server = uvicorn.Server(config)
+ await server.serve()
+
+if __name__ == "__main__":
+ asyncio.run(main())
\ No newline at end of file
diff --git a/app/pyproject.toml b/app/pyproject.toml
new file mode 100644
index 0000000..8013285
--- /dev/null
+++ b/app/pyproject.toml
@@ -0,0 +1,16 @@
+[project]
+name = "drop-me-files-analog"
+version = "0.1.0"
+description = ""
+readme = "README.md"
+requires-python = ">=3.14"
+dependencies = [
+ "aiobotocore>=2.26.0",
+ "dotenv>=0.9.9",
+ "fastapi>=0.121.1",
+ "loguru>=0.7.3",
+ "pydantic>=2.12.5",
+ "python-multipart>=0.0.20",
+ "redis>=7.1.0",
+ "uvicorn>=0.38.0",
+]
diff --git a/app/redis_worker.py b/app/redis_worker.py
new file mode 100644
index 0000000..d74a615
--- /dev/null
+++ b/app/redis_worker.py
@@ -0,0 +1,35 @@
+import os
+
+import redis
+from dotenv import load_dotenv
+
+
+class RedisWorker:
+ def __init__(self):
+ load_dotenv()
+ self.client = redis.Redis(
+ host=os.getenv('REDIS_ADDRESS'),
+ port=int(os.getenv('REDIS_PORT')),
+ username=os.getenv('REDIS_USERNAME'),
+ password=os.getenv('REDIS_PASSWORD'),
+ decode_responses=True,
+ socket_timeout=10,
+ socket_connect_timeout=10,
+ retry_on_timeout=True,
+ max_connections=50
+ )
+ self.files_ttl = int(os.getenv('FILES_TTL'))
+
+ def create_record(self, user_ip: str, file_name: str, file_uuid: str, file_type: str, add_date:str, file_size:int):
+ self.client.hset(file_uuid, mapping={
+ "file_name": file_name,
+ "file_size": file_size,
+ "user_ip": user_ip,
+ "file_type": file_type,
+ "add_date": add_date
+ })
+ self.client.expire(file_uuid, self.files_ttl)
+
+
+ def get_record(self, file_uuid:str):
+ return self.client.hgetall(file_uuid)
\ No newline at end of file
diff --git a/app/s3_worker.py b/app/s3_worker.py
new file mode 100644
index 0000000..79024b8
--- /dev/null
+++ b/app/s3_worker.py
@@ -0,0 +1,56 @@
+import os
+
+from contextlib import asynccontextmanager
+
+from dotenv import load_dotenv
+from aiobotocore.session import get_session
+
+
+class S3Worker:
+ def __init__(self):
+ load_dotenv()
+
+ self._s3_config = {
+ "aws_access_key_id": os.getenv('S3_ACCESS_KEY_ID'),
+ "aws_secret_access_key": os.getenv('S3_SECRET_ACCESS_KEY'),
+ "endpoint_url": os.getenv('S3_ENDPOINT_URL')
+ }
+
+ self._s3_session = get_session()
+
+ self.bucket = os.getenv('BUCKET_NAME')
+ self.max_file_size = os.getenv('MAX_FILES_SIZE')
+
+ @asynccontextmanager
+ async def get_client(self):
+ async with self._s3_session.create_client("s3", **self._s3_config) as client:
+ yield client
+
+ async def generate_upload_post(self, key: str, content_type: str, expires_in: int = 300) -> dict:
+ async with self.get_client() as client:
+ return await client.generate_presigned_post(
+ Bucket=self.bucket,
+ Key=key,
+ Fields={
+ "Content-Type": content_type,
+ "acl": "private",
+ },
+ Conditions=[
+ ["content-length-range", 0, self.max_file_size],
+ {"acl": "private"},
+ ],
+ ExpiresIn=expires_in,
+ )
+
+ async def generate_download_url(self, key: str, filename: str, expires_in: int = 300) -> str:
+ async with self.get_client() as client:
+ return await client.generate_presigned_url("get_object",
+ Params={
+ "Bucket": self.bucket,
+ "Key": key,
+ "ResponseContentDisposition": (
+ f'attachment; filename="{filename}"'
+ ),
+ },
+ ExpiresIn=expires_in,
+ )
\ No newline at end of file
diff --git a/app/schemas/api_schemas.py b/app/schemas/api_schemas.py
new file mode 100644
index 0000000..8d0dd1b
--- /dev/null
+++ b/app/schemas/api_schemas.py
@@ -0,0 +1,21 @@
+import typing
+
+from pydantic import BaseModel, Field, HttpUrl, PositiveInt
+
+
+class BaseResponse(BaseModel):
+ result: typing.Any
+ error: bool = False
+
+class UploadFields(BaseModel):
+ content_type: str = Field(alias="Content-Type")
+ acl: str
+ key: str
+ aws_access_key_id: str = Field(alias="AWSAccessKeyId")
+ policy: str
+ signature: str
+
+
+class UploadToken(BaseModel):
+ url: HttpUrl
+ fields: UploadFields
\ No newline at end of file
diff --git a/docker/.env-example b/docker/.env-example
new file mode 100644
index 0000000..64c2b4d
--- /dev/null
+++ b/docker/.env-example
@@ -0,0 +1,17 @@
+S3_ACCESS_KEY_ID=your_access_key
+S3_SECRET_ACCESS_KEY=your_secret_key
+
+BUCKET_NAME=your_bucket_name
+
+MINIO_DATA_PATH=/path/to/data
+
+MINIO_API_PORT=9000
+MINIO_CONSOLE_PORT=9001
+
+
+REDIS_PORT=6380
+REDIS_DATA_PATH=/path/to/data
+
+REDIS_PASSWORD=my_redis_password
+REDIS_USER=my_user
+REDIS_USER_PASSWORD=my_user_password
\ No newline at end of file
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 0000000..58cf880
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,69 @@
+version: "3.9"
+
+services:
+ minio:
+ image: minio/minio:RELEASE.2025-09-07T16-13-09Z
+ container_name: dmf-minio-server
+ restart: always
+ ports:
+ - "${MINIO_API_PORT}:9000"
+ - "${MINIO_CONSOLE_PORT}:9001"
+ environment:
+ MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID}
+ MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY}
+ volumes:
+ - "${MINIO_DATA_PATH}:/data"
+ command: server /data --console-address ":9001"
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/ready"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+
+ minio-init:
+ image: minio/mc:RELEASE.2025-09-07T16-13-09Z
+ depends_on:
+ minio:
+ condition: service_healthy
+ environment:
+ S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
+ S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
+ BUCKET_NAME: ${BUCKET_NAME}
+ entrypoint: >
+ /bin/sh -c "
+ mc alias set local http://minio:9000 ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY} &&
+ mc mb -p local/${BUCKET_NAME} || true &&
+ mc ilm rule add --expire-days 1 local/${BUCKET_NAME}
+ "
+
+
+ redis:
+ image: redis:latest
+ container_name: dmf-redis
+ environment:
+ - REDIS_PASSWORD=${REDIS_PASSWORD}
+ - REDIS_USER=${REDIS_USER}
+ - REDIS_USER_PASSWORD=${REDIS_USER_PASSWORD}
+ ports:
+ - "${REDIS_PORT}:6379"
+ volumes:
+ - "${REDIS_DATA_PATH}:/data"
+ command: >
+ sh -c '
+ mkdir -p /usr/local/etc/redis &&
+ echo "bind 0.0.0.0" > /usr/local/etc/redis/redis.conf &&
+ echo "requirepass $REDIS_PASSWORD" >> /usr/local/etc/redis/redis.conf &&
+ echo "appendonly yes" >> /usr/local/etc/redis/redis.conf &&
+ echo "appendfsync everysec" >> /usr/local/etc/redis/redis.conf &&
+ echo "user default on nopass ~* +@all" > /usr/local/etc/redis/users.acl &&
+ echo "user $REDIS_USER on >$REDIS_USER_PASSWORD ~* +@all" >> /usr/local/etc/redis/users.acl &&
+ redis-server /usr/local/etc/redis/redis.conf --aclfile /usr/local/etc/redis/users.acl
+ '
+ healthcheck:
+ test: [ "CMD", "redis-cli", "-a", "$REDIS_PASSWORD", "ping" ]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ restart: always
+ tty: true
+ stdin_open: true
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..aa97202
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+ Аналог DropMeFiles
+
+
+
+
+
+
+
Аналог DropMeFiles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GitHub проекта
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/script.js b/frontend/script.js
new file mode 100644
index 0000000..4408ffa
--- /dev/null
+++ b/frontend/script.js
@@ -0,0 +1,451 @@
+// Utility function to format bytes to human-readable format
+function formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
+}
+
+// Check if we're on the download page
+const path = window.location.pathname;
+// Match /get/ followed by any alphanumeric characters (not just exactly 6)
+const downloadMatch = path.match(/^\/get\/([a-zA-Z0-9]+)$/);
+
+if (downloadMatch) {
+ // Show download page
+ document.getElementById('uploadPage').style.display = 'none';
+ document.getElementById('downloadPage').style.display = 'block';
+
+ const fileUuid = downloadMatch[1];
+ handleDownloadPage(fileUuid);
+} else {
+ // Show upload page
+ document.getElementById('uploadPage').style.display = 'block';
+ document.getElementById('downloadPage').style.display = 'none';
+ initUploadPage();
+}
+
+// Download page handler
+async function handleDownloadPage(fileUuid) {
+ const downloadStatus = document.getElementById('downloadStatus');
+ const downloadContent = document.getElementById('downloadContent');
+
+ // Check UUID length first (must be exactly 6 characters)
+ if (fileUuid.length !== 6) {
+ downloadStatus.textContent = 'Мы не смогли ничего найти.\nПерепроверьте введенный адрес.';
+ downloadStatus.className = 'status error';
+ downloadStatus.style.display = 'block';
+ downloadContent.innerHTML = '';
+ return;
+ }
+
+ // Show loading state
+ downloadStatus.textContent = 'Загрузка...';
+ downloadStatus.className = 'status';
+ downloadStatus.style.display = 'block';
+ downloadContent.innerHTML = '';
+
+ try {
+ const response = await fetch(`/api/get_download_link/${fileUuid}`);
+
+ // Check for network/connection errors
+ if (!response.ok && (response.status === 0 || response.status >= 500)) {
+ downloadStatus.textContent = 'Сервис временно недоступен,\nприносим наши извинения.';
+ downloadStatus.className = 'status error';
+ downloadStatus.style.display = 'block';
+ downloadContent.innerHTML = '';
+ return;
+ }
+
+ const data = await response.json();
+
+ // Check for errors (400, 404, or error flag in response)
+ if (data.error || !data.result || !data.result.data || response.status === 400 || response.status === 404) {
+ downloadStatus.textContent = 'Мы не смогли ничего найти.\nПерепроверьте введенный адрес.';
+ downloadStatus.className = 'status error';
+ downloadStatus.style.display = 'block';
+ downloadContent.innerHTML = '';
+ return;
+ }
+
+ const fileData = data.result.data;
+ const fileSize = parseInt(fileData.file_size);
+
+ // Create download button handler with progress tracking
+ const handleDownload = async () => {
+ const downloadButton = document.getElementById('downloadButton');
+ const downloadProgress = document.getElementById('downloadProgress');
+ const progressBar = document.getElementById('progressBar');
+ const progressText = document.getElementById('progressText');
+
+ // For small files (< 10MB), use direct download (browser's native download)
+ // For larger files, show progress bar
+ if (fileSize < 10 * 1024 * 1024) {
+ // Small file - use direct download with browser's native progress
+ const link = document.createElement('a');
+ link.href = fileData.url;
+ link.download = fileData.file_name;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ return;
+ }
+
+ // Large file - download with progress tracking
+ try {
+ downloadButton.disabled = true;
+ downloadProgress.style.display = 'block';
+ progressBar.style.width = '0%';
+ progressText.textContent = '0%';
+
+ const response = await fetch(fileData.url);
+ if (!response.ok) {
+ throw new Error('Ошибка при загрузке файла');
+ }
+
+ const contentLength = response.headers.get('content-length');
+ const total = contentLength ? parseInt(contentLength, 10) : fileSize;
+ let loaded = 0;
+
+ const reader = response.body.getReader();
+ const chunks = [];
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ chunks.push(value);
+ loaded += value.length;
+
+ const percent = Math.round((loaded / total) * 100);
+ progressBar.style.width = percent + '%';
+ progressText.textContent = `${percent}% (${formatBytes(loaded)} / ${formatBytes(total)})`;
+ }
+
+ // Combine chunks into blob
+ const blob = new Blob(chunks);
+ const blobUrl = window.URL.createObjectURL(blob);
+
+ const link = document.createElement('a');
+ link.href = blobUrl;
+ link.download = fileData.file_name;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ // Clean up
+ window.URL.revokeObjectURL(blobUrl);
+ downloadProgress.style.display = 'none';
+ downloadButton.disabled = false;
+ progressText.textContent = 'Загрузка завершена!';
+
+ setTimeout(() => {
+ progressText.textContent = '';
+ }, 2000);
+ } catch (error) {
+ downloadProgress.style.display = 'none';
+ downloadButton.disabled = false;
+ // Fallback: try direct navigation
+ window.location.href = fileData.url;
+ }
+ };
+
+ downloadContent.innerHTML = `
+
+
${fileData.file_name}
+
Размер: ${formatBytes(parseInt(fileData.file_size))}
+
+
+ `;
+
+ // Attach click handler to the download button
+ const downloadButton = document.getElementById('downloadButton');
+ downloadButton.addEventListener('click', handleDownload);
+
+ downloadStatus.style.display = 'none';
+ } catch (error) {
+ // Network error or other connection issues
+ downloadStatus.textContent = 'Сервис временно недоступен,\nприносим наши извинения.';
+ downloadStatus.className = 'status error';
+ downloadStatus.style.display = 'block';
+ }
+}
+
+// Show error popup
+function showErrorPopup(message) {
+ const popup = document.getElementById('errorPopup');
+ popup.textContent = message;
+ popup.style.display = 'block';
+
+ // Auto-hide after 5 seconds
+ setTimeout(() => {
+ popup.style.display = 'none';
+ }, 5000);
+}
+
+// Upload page initialization
+function initUploadPage() {
+ const fileInput = document.getElementById('fileInput');
+ const dropzone = document.getElementById('dropzone');
+ const uploadButton = document.getElementById('uploadButton');
+ const uploadForm = document.getElementById('uploadForm');
+ const statusDiv = document.getElementById('status');
+ const maxFileSizeSpan = document.getElementById('maxFileSize');
+ const successContent = document.getElementById('successContent');
+ const uploadFormContainer = uploadForm.parentElement;
+
+ let maxFileSizeBytes = 0;
+
+ // Fetch max file size on page load
+ async function loadMaxFileSize() {
+ try {
+ const response = await fetch('/api/max_file_size');
+ if (!response.ok) {
+ throw new Error(`Ошибка сервера: ${response.status}`);
+ }
+ maxFileSizeBytes = await response.json();
+ maxFileSizeSpan.textContent = formatBytes(maxFileSizeBytes);
+
+ // Set max file size attribute
+ fileInput.setAttribute('data-max-size', maxFileSizeBytes);
+ } catch (error) {
+ maxFileSizeSpan.textContent = 'ошибка загрузки';
+ maxFileSizeSpan.style.color = '#dc2626';
+ console.error('Error loading max file size:', error);
+ }
+ }
+
+ loadMaxFileSize();
+
+ const updateButtonState = () => {
+ if (!fileInput.files || fileInput.files.length === 0) {
+ uploadButton.disabled = true;
+ return;
+ }
+
+ const file = fileInput.files[0];
+ if (file.size > maxFileSizeBytes && maxFileSizeBytes > 0) {
+ const errorMsg = `Файл слишком большой. Максимальный размер: ${formatBytes(maxFileSizeBytes)}`;
+ showErrorPopup(errorMsg);
+ uploadButton.disabled = true;
+ return;
+ }
+
+ uploadButton.disabled = false;
+ setStatus('', '');
+ };
+
+ const setStatus = (message, type = '') => {
+ statusDiv.textContent = message;
+ statusDiv.className = type ? `status ${type}` : 'status';
+ // Hide status div when empty to avoid unnecessary spacing
+ if (!message || message.trim() === '') {
+ statusDiv.style.display = 'none';
+ } else {
+ statusDiv.style.display = 'block';
+ }
+ };
+
+ const showSuccessState = (fileName, fileSize, downloadUrl) => {
+ // Hide the form
+ uploadForm.style.display = 'none';
+ statusDiv.style.display = 'none';
+
+ // Show success content
+ document.getElementById('successFileName').textContent = fileName;
+ document.getElementById('successFileSize').textContent = `Размер: ${formatBytes(fileSize)}`;
+ document.getElementById('downloadLinkInput').value = downloadUrl;
+ successContent.style.display = 'block';
+
+ // Setup copy button
+ const copyButton = document.getElementById('copyLinkButton');
+ copyButton.onclick = () => {
+ const input = document.getElementById('downloadLinkInput');
+ input.select();
+ input.setSelectionRange(0, 99999); // For mobile devices
+ document.execCommand('copy');
+
+ const originalText = copyButton.textContent;
+ copyButton.textContent = 'Скопировано!';
+ copyButton.classList.add('copied');
+
+ setTimeout(() => {
+ copyButton.textContent = originalText;
+ copyButton.classList.remove('copied');
+ }, 2000);
+ };
+
+ // Setup "upload another" button
+ const uploadAnotherButton = document.getElementById('uploadAnotherButton');
+ uploadAnotherButton.onclick = () => {
+ // Reset form
+ uploadForm.style.display = 'block';
+ successContent.style.display = 'none';
+ fileInput.value = '';
+ updateButtonState();
+ };
+ };
+
+ dropzone.addEventListener('dragover', (event) => {
+ event.preventDefault();
+ dropzone.classList.add('dragover');
+ });
+
+ dropzone.addEventListener('dragleave', () => {
+ dropzone.classList.remove('dragover');
+ });
+
+ dropzone.addEventListener('drop', (event) => {
+ event.preventDefault();
+ dropzone.classList.remove('dragover');
+
+ if (event.dataTransfer?.files?.length) {
+ // Only take the first file
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(event.dataTransfer.files[0]);
+ fileInput.files = dataTransfer.files;
+ updateButtonState();
+ }
+ });
+
+ fileInput.addEventListener('change', () => {
+ updateButtonState();
+ });
+
+ uploadForm.addEventListener('submit', async (event) => {
+ event.preventDefault();
+
+ if (!fileInput.files || fileInput.files.length === 0) {
+ showErrorPopup('Выберите файл для загрузки.');
+ return;
+ }
+
+ const file = fileInput.files[0];
+
+ if (maxFileSizeBytes > 0 && file.size > maxFileSizeBytes) {
+ showErrorPopup(`Файл слишком большой. Максимальный размер: ${formatBytes(maxFileSizeBytes)}`);
+ return;
+ }
+
+ uploadButton.disabled = true;
+ dropzone.classList.remove('dragover');
+ setStatus('Получение токена загрузки...', '');
+
+ try {
+ // Step 1: Get upload token
+ const tokenUrl = `/api/upload_token?file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type)}&file_size=${file.size}`;
+ const tokenResponse = await fetch(tokenUrl);
+
+ if (!tokenResponse.ok) {
+ const errorData = await tokenResponse.json().catch(() => ({}));
+ // Extract error message without status codes
+ let errorMessage = errorData.result || 'Ошибка при загрузке файла';
+ if (typeof errorMessage === 'object' && errorMessage.comment) {
+ errorMessage = errorMessage.comment;
+ }
+ if (typeof errorMessage === 'string' && errorMessage.includes('too large')) {
+ errorMessage = 'Файл слишком большой';
+ }
+ throw new Error(errorMessage);
+ }
+
+ const tokenData = await tokenResponse.json();
+
+ if (tokenData.error || !tokenData.result || !tokenData.result.data) {
+ let errorMessage = tokenData.result?.comment || 'Ошибка при получении токена загрузки';
+ throw new Error(errorMessage);
+ }
+
+ const uploadData = tokenData.result.data;
+ const fileUuid = tokenData.result.file_uuid;
+ uploadData.fields["key"] = fileUuid;
+ delete uploadData.fields["Content-Type"];
+
+ // Hide status, show progress bar
+ statusDiv.style.display = 'none';
+ const uploadProgress = document.getElementById('uploadProgress');
+ const uploadProgressBar = document.getElementById('uploadProgressBar');
+ const uploadProgressText = document.getElementById('uploadProgressText');
+ uploadProgress.style.display = 'block';
+ uploadProgressBar.style.width = '0%';
+ uploadProgressText.textContent = '0%';
+
+ // Step 2: Upload to S3 using XMLHttpRequest for progress tracking
+ await new Promise((resolve, reject) => {
+ const formData = new FormData();
+ Object.keys(uploadData.fields).forEach(key => {
+ formData.append(key, uploadData.fields[key]);
+ });
+ // File must be appended last
+ formData.append('file', file);
+
+ const xhr = new XMLHttpRequest();
+
+ // Track upload progress
+ xhr.upload.addEventListener('progress', (event) => {
+ if (event.lengthComputable) {
+ const percent = Math.round((event.loaded / event.total) * 100);
+ uploadProgressBar.style.width = percent + '%';
+ uploadProgressText.textContent = `${percent}% (${formatBytes(event.loaded)} / ${formatBytes(event.total)})`;
+ }
+ });
+
+ xhr.addEventListener('load', () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve();
+ } else {
+ reject(new Error(`Ошибка загрузки: ${xhr.status}`));
+ }
+ });
+
+ xhr.addEventListener('error', () => {
+ reject(new Error('Ошибка при загрузке файла на сервер'));
+ });
+
+ xhr.addEventListener('abort', () => {
+ reject(new Error('Загрузка прервана'));
+ });
+
+ xhr.open('POST', uploadData.url);
+ xhr.send(formData);
+ });
+
+ // Hide progress bar after successful upload
+ uploadProgress.style.display = 'none';
+
+ // Success!
+ const downloadUrl = `${window.location.origin}/get/${fileUuid}`;
+ showSuccessState(file.name, file.size, downloadUrl);
+ fileInput.value = '';
+
+ } catch (error) {
+ // Extract clean error message without status codes
+ let errorMessage = error.message;
+ if (errorMessage.includes('status') || errorMessage.match(/\d{3}/)) {
+ if (errorMessage.includes('too large') || errorMessage.includes('413')) {
+ errorMessage = 'Файл слишком большой';
+ } else if (errorMessage.includes('500')) {
+ errorMessage = 'Ошибка сервера. Попробуйте позже';
+ } else {
+ errorMessage = 'Не удалось загрузить файл';
+ }
+ }
+ showErrorPopup(errorMessage);
+ setStatus('', '');
+ // Hide progress bar on error
+ const uploadProgress = document.getElementById('uploadProgress');
+ if (uploadProgress) {
+ uploadProgress.style.display = 'none';
+ }
+ } finally {
+ uploadButton.disabled = false;
+ }
+ });
+
+ updateButtonState();
+}
diff --git a/frontend/style.css b/frontend/style.css
new file mode 100644
index 0000000..4ba8173
--- /dev/null
+++ b/frontend/style.css
@@ -0,0 +1,570 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-family: Arial, sans-serif;
+ background: radial-gradient(circle at top, #f7f7fb, #eff1f8);
+ color: #111827;
+ padding: 24px;
+}
+
+@media (max-width: 768px) {
+ body {
+ padding: 16px;
+ justify-content: center;
+ }
+}
+
+.page-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-width: 640px;
+}
+
+h1 {
+ font-size: clamp(32px, 4vw, 48px);
+ text-align: center;
+ margin: 0 0 32px 0;
+ width: 100%;
+}
+
+@media (max-width: 768px) {
+ h1 {
+ font-size: clamp(24px, 6vw, 32px);
+ margin: 0 0 24px 0;
+ }
+}
+
+.upload-container {
+ width: min(640px, 100%);
+ background: #ffffffdd;
+ border-radius: 20px;
+ box-shadow: 0 20px 45px -20px rgba(15, 23, 42, 0.65);
+ padding: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ backdrop-filter: blur(8px);
+}
+
+@media (max-width: 768px) {
+ .upload-container {
+ padding: 20px;
+ gap: 16px;
+ border-radius: 16px;
+ }
+}
+
+.dropzone {
+ border: 2px dashed #6366f1;
+ border-radius: 16px;
+ padding: 48px 24px;
+ text-align: center;
+ transition: all 0.2s ease;
+ background: rgba(99, 102, 241, 0.04);
+ cursor: pointer;
+ position: relative;
+ display: block;
+ overflow: hidden;
+}
+
+@media (max-width: 768px) {
+ .dropzone {
+ padding: 32px 16px;
+ border-radius: 12px;
+ }
+}
+
+.dropzone:focus,
+.dropzone:focus-within {
+ outline: none;
+ border: 2px dashed #6366f1;
+}
+
+.dropzone.dragover {
+ background: rgba(99, 102, 241, 0.12);
+ transform: translateY(-2px);
+ box-shadow: 0 14px 28px -24px rgba(99, 102, 241, 0.65);
+}
+
+.dropzone input[type="file"] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+ outline: none !important;
+ border: none !important;
+ box-shadow: none !important;
+ -webkit-appearance: none !important;
+ -moz-appearance: none !important;
+ appearance: none !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ background: transparent !important;
+ font-size: 0 !important;
+ line-height: 0 !important;
+ z-index: 1;
+}
+
+.dropzone input[type="file"]:focus,
+.dropzone input[type="file"]:active,
+.dropzone input[type="file"]:hover,
+.dropzone input[type="file"]:focus-visible,
+.dropzone input[type="file"]::-webkit-file-upload-button {
+ outline: none !important;
+ border: none !important;
+ box-shadow: none !important;
+ -webkit-appearance: none !important;
+ -moz-appearance: none !important;
+ appearance: none !important;
+}
+
+.dropzone input[type="file"]::-webkit-file-upload-button {
+ display: none !important;
+ visibility: hidden !important;
+ opacity: 0 !important;
+}
+
+.dropzone p {
+ margin: 8px 0;
+ color: #4b5563;
+ font-size: 16px;
+}
+
+.dropzone p.strong {
+ font-weight: 600;
+ color: #1f2937;
+ font-size: 18px;
+}
+
+button {
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
+ color: white;
+ border: none;
+ border-radius: 12px;
+ padding: 14px 20px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ touch-action: manipulation;
+ -webkit-tap-highlight-color: transparent;
+}
+
+@media (max-width: 768px) {
+ button {
+ padding: 16px 24px;
+ font-size: 16px;
+ min-height: 48px;
+ width: 100%;
+ }
+}
+
+button:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 16px 32px -24px rgba(99, 102, 241, 0.8);
+}
+
+button:disabled {
+ background: #cbd5f5;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+.button-container {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ margin-top: 24px;
+}
+
+.status {
+ font-size: 15px;
+ color: #374151;
+ line-height: 1.5;
+ word-break: break-all;
+ min-height: 0;
+}
+
+.status:empty {
+ display: none;
+}
+
+.status.error {
+ color: #dc2626;
+ font-size: 18px;
+ font-weight: 500;
+ white-space: pre-line;
+ line-height: 1.6;
+}
+
+.status.success {
+ color: #166534;
+}
+
+.file-info {
+ text-align: center;
+ padding: 24px;
+}
+
+.file-info h2 {
+ margin: 0 0 16px 0;
+ font-size: 24px;
+ color: #1f2937;
+}
+
+.file-size {
+ margin: 0 0 24px 0;
+ color: #6b7280;
+ font-size: 16px;
+}
+
+.download-button {
+ display: inline-block;
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
+ color: white;
+ border: none;
+ border-radius: 12px;
+ padding: 14px 32px;
+ font-size: 16px;
+ font-weight: 600;
+ text-decoration: none;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ font-family: inherit;
+ touch-action: manipulation;
+ -webkit-tap-highlight-color: transparent;
+}
+
+@media (max-width: 768px) {
+ .download-button {
+ padding: 16px 24px;
+ width: 100%;
+ min-height: 48px;
+ }
+}
+
+.download-button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 16px 32px -24px rgba(99, 102, 241, 0.8);
+}
+
+.download-button:active {
+ transform: translateY(0);
+}
+
+/* Error popup */
+.error-popup {
+ position: fixed;
+ top: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #fee2e2;
+ border: 2px solid #dc2626;
+ border-radius: 12px;
+ padding: 16px 24px;
+ color: #991b1b;
+ font-weight: 600;
+ font-size: 15px;
+ z-index: 1000;
+ box-shadow: 0 10px 25px -5px rgba(220, 38, 38, 0.3);
+ animation: slideDown 0.3s ease-out;
+ max-width: 90%;
+ text-align: center;
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+}
+
+@media (max-width: 768px) {
+ .error-popup {
+ top: 10px;
+ left: 10px;
+ right: 10px;
+ transform: none;
+ max-width: none;
+ padding: 12px 16px;
+ font-size: 14px;
+ }
+
+ @keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ .status {
+ font-size: 14px;
+ }
+
+ .status.error {
+ font-size: 16px;
+ }
+
+ .file-info h2 {
+ font-size: 20px;
+ }
+
+ .file-size {
+ font-size: 14px;
+ }
+
+ .dropzone p {
+ font-size: 14px;
+ }
+
+ .dropzone p.strong {
+ font-size: 16px;
+ }
+}
+
+/* Success content */
+.success-content {
+ animation: fadeIn 0.3s ease-in;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.download-link-container {
+ display: flex;
+ gap: 12px;
+ margin-top: 24px;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.download-link-input {
+ flex: 1;
+ min-width: 200px;
+ padding: 12px 16px;
+ border: 2px solid #e5e7eb;
+ border-radius: 8px;
+ font-size: 14px;
+ font-family: monospace;
+ background: #f9fafb;
+ color: #111827;
+ cursor: text;
+}
+
+@media (max-width: 768px) {
+ .download-link-input {
+ width: 100%;
+ min-width: 0;
+ font-size: 12px;
+ padding: 14px;
+ }
+}
+
+.download-link-input:focus {
+ outline: none;
+ border-color: #6366f1;
+ background: #ffffff;
+}
+
+.copy-link-button {
+ padding: 12px 24px;
+ background: linear-gradient(135deg, #10b981, #059669);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ white-space: nowrap;
+ touch-action: manipulation;
+ -webkit-tap-highlight-color: transparent;
+}
+
+@media (max-width: 768px) {
+ .copy-link-button {
+ width: 100%;
+ padding: 14px 24px;
+ min-height: 48px;
+ }
+}
+
+.copy-link-button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 8px 16px -4px rgba(16, 185, 129, 0.4);
+}
+
+.copy-link-button:active {
+ transform: translateY(0);
+}
+
+.copy-link-button.copied {
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
+}
+
+.upload-another-button {
+ margin-top: 24px;
+ padding: 12px 24px;
+ background: transparent;
+ color: #6366f1;
+ border: 2px solid #6366f1;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ touch-action: manipulation;
+ -webkit-tap-highlight-color: transparent;
+}
+
+@media (max-width: 768px) {
+ .upload-another-button {
+ width: 100%;
+ padding: 14px 24px;
+ min-height: 48px;
+ }
+}
+
+.upload-another-button:hover {
+ background: #6366f1;
+ color: white;
+ transform: translateY(-1px);
+ box-shadow: 0 8px 16px -4px rgba(99, 102, 241, 0.4);
+}
+
+/* Download progress */
+.download-progress {
+ margin-top: 24px;
+ text-align: center;
+}
+
+.progress-bar-container {
+ width: 100%;
+ height: 8px;
+ background: #e5e7eb;
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 12px;
+}
+
+.progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, #6366f1, #8b5cf6);
+ border-radius: 4px;
+ transition: width 0.3s ease;
+ width: 0%;
+}
+
+.progress-text {
+ font-size: 14px;
+ color: #6b7280;
+ margin: 0;
+ font-weight: 500;
+}
+
+@media (max-width: 768px) {
+ .status {
+ font-size: 14px;
+ }
+
+ .status.error {
+ font-size: 16px;
+ }
+
+ .file-info h2 {
+ font-size: 20px;
+ }
+
+ .file-size {
+ font-size: 14px;
+ }
+
+ .dropzone p {
+ font-size: 14px;
+ }
+
+ .dropzone p.strong {
+ font-size: 16px;
+ }
+
+ .error-popup {
+ top: 10px;
+ left: 10px;
+ right: 10px;
+ transform: none;
+ max-width: none;
+ padding: 12px 16px;
+ font-size: 14px;
+ }
+
+ @keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ .progress-text {
+ font-size: 12px;
+ }
+
+ .button-container {
+ margin-top: 16px;
+ }
+}
+
+@media (max-width: 480px) {
+ body {
+ padding: 12px;
+ }
+
+ h1 {
+ font-size: 22px;
+ margin: 0 0 20px 0;
+ }
+
+ .upload-container {
+ padding: 16px;
+ gap: 12px;
+ }
+
+ .dropzone {
+ padding: 24px 12px;
+ }
+
+ .file-info {
+ padding: 16px;
+ }
+}