diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0249ff8 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Django настройки +DJANGO_SECRET_KEY=django-insecure-your-secret-key-here-change-this-in-production +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Frontend настройки +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.gitignore b/.gitignore index a5a8fa4..9e62273 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ media/ node_modules/ dist/ +.venv/ diff --git a/.history/.env_20251029190347.example b/.history/.env_20251029190347.example new file mode 100644 index 0000000..2c18db6 --- /dev/null +++ b/.history/.env_20251029190347.example @@ -0,0 +1,25 @@ +# Django настройки +DJANGO_SECRET_KEY=your-secret-key-here-change-this-in-production +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Медиа файлы +MEDIA_URL=/storage/ +MEDIA_ROOT=/app/storage + +# Статические файлы +STATIC_URL=/static/ +STATIC_ROOT=/app/staticfiles \ No newline at end of file diff --git a/.history/.env_20251029190516.example b/.history/.env_20251029190516.example new file mode 100644 index 0000000..2c18db6 --- /dev/null +++ b/.history/.env_20251029190516.example @@ -0,0 +1,25 @@ +# Django настройки +DJANGO_SECRET_KEY=your-secret-key-here-change-this-in-production +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Медиа файлы +MEDIA_URL=/storage/ +MEDIA_ROOT=/app/storage + +# Статические файлы +STATIC_URL=/static/ +STATIC_ROOT=/app/staticfiles \ No newline at end of file diff --git a/.history/.env_20251029190820 b/.history/.env_20251029190820 new file mode 100644 index 0000000..3a7ddbd --- /dev/null +++ b/.history/.env_20251029190820 @@ -0,0 +1,25 @@ +# Django настройки +DJANGO_SECRET_KEY=django-insecure-k8m2n9p4q7w5x1z3c6v8b0n5m8k3j6h9g4f7d2s5a8q1w4e7r0t3y6u9i2o5p8 +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Медиа файлы +MEDIA_URL=/storage/ +MEDIA_ROOT=/app/storage + +# Статические файлы +STATIC_URL=/static/ +STATIC_ROOT=/app/staticfiles \ No newline at end of file diff --git a/.history/.env_20251029190829.example b/.history/.env_20251029190829.example new file mode 100644 index 0000000..0249ff8 --- /dev/null +++ b/.history/.env_20251029190829.example @@ -0,0 +1,20 @@ +# Django настройки +DJANGO_SECRET_KEY=django-insecure-your-secret-key-here-change-this-in-production +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Frontend настройки +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.history/.env_20251029190844 b/.history/.env_20251029190844 new file mode 100644 index 0000000..5b06b6f --- /dev/null +++ b/.history/.env_20251029190844 @@ -0,0 +1,20 @@ +# Django настройки +DJANGO_SECRET_KEY=django-insecure-k8m2n9p4q7w5x1z3c6v8b0n5m8k3j6h9g4f7d2s5a8q1w4e7r0t3y6u9i2o5p8 +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Frontend настройки +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.history/.env_20251029190937 b/.history/.env_20251029190937 new file mode 100644 index 0000000..5b06b6f --- /dev/null +++ b/.history/.env_20251029190937 @@ -0,0 +1,20 @@ +# Django настройки +DJANGO_SECRET_KEY=django-insecure-k8m2n9p4q7w5x1z3c6v8b0n5m8k3j6h9g4f7d2s5a8q1w4e7r0t3y6u9i2o5p8 +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Frontend настройки +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.history/.env_20251029190937.example b/.history/.env_20251029190937.example new file mode 100644 index 0000000..0249ff8 --- /dev/null +++ b/.history/.env_20251029190937.example @@ -0,0 +1,20 @@ +# Django настройки +DJANGO_SECRET_KEY=django-insecure-your-secret-key-here-change-this-in-production +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Frontend настройки +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.history/.env_20251029193708 b/.history/.env_20251029193708 new file mode 100644 index 0000000..42a2a44 --- /dev/null +++ b/.history/.env_20251029193708 @@ -0,0 +1,20 @@ +# Django настройки +DJANGO_SECRET_KEY=django-insecure-k8m2n9p4q7w5x1z3c6v8b0n5m8k3j6h9g4f7d2s5a8q1w4e7r0t3y6u9i2o5p8 +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0,web,frontend + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Frontend настройки +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.history/.env_20251029194257 b/.history/.env_20251029194257 new file mode 100644 index 0000000..42a2a44 --- /dev/null +++ b/.history/.env_20251029194257 @@ -0,0 +1,20 @@ +# Django настройки +DJANGO_SECRET_KEY=django-insecure-k8m2n9p4q7w5x1z3c6v8b0n5m8k3j6h9g4f7d2s5a8q1w4e7r0t3y6u9i2o5p8 +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0,web,frontend + +# База данных PostgreSQL +DATABASE_ENGINE=django.db.backends.postgresql +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# PostgreSQL настройки для контейнера +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +# Frontend настройки +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.history/DEPLOYMENT_20251029191650.md b/.history/DEPLOYMENT_20251029191650.md new file mode 100644 index 0000000..2e9f765 --- /dev/null +++ b/.history/DEPLOYMENT_20251029191650.md @@ -0,0 +1,68 @@ +# Переменные окружения + +Скопируйте `.env.example` в `.env` и настройте следующие переменные: + +## Django настройки +- `DJANGO_SECRET_KEY` - Секретный ключ Django (обязательно изменить в продакшене) +- `DJANGO_DEBUG` - Режим отладки (True/False) +- `DJANGO_ALLOWED_HOSTS` - Разрешенные хосты (разделенные запятыми) + +## База данных PostgreSQL +- `DATABASE_ENGINE` - Движок базы данных (django.db.backends.postgresql) +- `DATABASE_NAME` - Название базы данных +- `DATABASE_USER` - Пользователь базы данных +- `DATABASE_PASSWORD` - Пароль базы данных +- `DATABASE_HOST` - Хост базы данных (db для Docker) +- `DATABASE_PORT` - Порт базы данных (5432) + +## PostgreSQL настройки для контейнера +- `POSTGRES_DB` - Название БД для создания в контейнере +- `POSTGRES_USER` - Пользователь БД для создания в контейнере +- `POSTGRES_PASSWORD` - Пароль пользователя БД в контейнере + +## Frontend настройки +- `NEXT_PUBLIC_API_URL` - URL API для frontend (http://localhost:8000) + +## Команды для запуска + +### Подготовка +```bash +cp .env.example .env +# Отредактируйте .env файл при необходимости +``` + +### Запуск всех сервисов +```bash +make up # или docker-compose up -d --build +``` + +### Применение миграций +```bash +make migrate # или docker-compose exec web python manage.py migrate +``` + +### Остановка сервисов +```bash +make down # или docker-compose down +``` + +### Запуск тестов +```bash +make test # или docker-compose exec web pytest --maxfail=1 --disable-warnings -q +``` + +## Доступ к сервисам + +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:8000/api/ +- **Django Admin**: http://localhost:8000/admin/ +- **PostgreSQL**: localhost:5432 + +## Структура проекта + +- `backend/` - Django приложение +- `frontend/linktree-frontend/` - Next.js приложение +- `.env` - Переменные окружения (не включается в git) +- `.env.example` - Пример переменных окружения +- `docker-compose.yml` - Конфигурация Docker Compose +- `Makefile` - Команды для удобного управления \ No newline at end of file diff --git a/.history/DEPLOYMENT_20251029192214.md b/.history/DEPLOYMENT_20251029192214.md new file mode 100644 index 0000000..2e9f765 --- /dev/null +++ b/.history/DEPLOYMENT_20251029192214.md @@ -0,0 +1,68 @@ +# Переменные окружения + +Скопируйте `.env.example` в `.env` и настройте следующие переменные: + +## Django настройки +- `DJANGO_SECRET_KEY` - Секретный ключ Django (обязательно изменить в продакшене) +- `DJANGO_DEBUG` - Режим отладки (True/False) +- `DJANGO_ALLOWED_HOSTS` - Разрешенные хосты (разделенные запятыми) + +## База данных PostgreSQL +- `DATABASE_ENGINE` - Движок базы данных (django.db.backends.postgresql) +- `DATABASE_NAME` - Название базы данных +- `DATABASE_USER` - Пользователь базы данных +- `DATABASE_PASSWORD` - Пароль базы данных +- `DATABASE_HOST` - Хост базы данных (db для Docker) +- `DATABASE_PORT` - Порт базы данных (5432) + +## PostgreSQL настройки для контейнера +- `POSTGRES_DB` - Название БД для создания в контейнере +- `POSTGRES_USER` - Пользователь БД для создания в контейнере +- `POSTGRES_PASSWORD` - Пароль пользователя БД в контейнере + +## Frontend настройки +- `NEXT_PUBLIC_API_URL` - URL API для frontend (http://localhost:8000) + +## Команды для запуска + +### Подготовка +```bash +cp .env.example .env +# Отредактируйте .env файл при необходимости +``` + +### Запуск всех сервисов +```bash +make up # или docker-compose up -d --build +``` + +### Применение миграций +```bash +make migrate # или docker-compose exec web python manage.py migrate +``` + +### Остановка сервисов +```bash +make down # или docker-compose down +``` + +### Запуск тестов +```bash +make test # или docker-compose exec web pytest --maxfail=1 --disable-warnings -q +``` + +## Доступ к сервисам + +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:8000/api/ +- **Django Admin**: http://localhost:8000/admin/ +- **PostgreSQL**: localhost:5432 + +## Структура проекта + +- `backend/` - Django приложение +- `frontend/linktree-frontend/` - Next.js приложение +- `.env` - Переменные окружения (не включается в git) +- `.env.example` - Пример переменных окружения +- `docker-compose.yml` - Конфигурация Docker Compose +- `Makefile` - Команды для удобного управления \ No newline at end of file diff --git a/.history/FIXES_20251029193216.md b/.history/FIXES_20251029193216.md new file mode 100644 index 0000000..f96c658 --- /dev/null +++ b/.history/FIXES_20251029193216.md @@ -0,0 +1,112 @@ +# Решение проблем со статикой и API + +## Исправленные проблемы + +### ✅ Статические файлы Django +**Проблема**: Статические файлы Django REST Framework не загружались (404 ошибки) + +**Решение**: +1. Добавлен WhiteNoise middleware в `settings.py`: + ```python + MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', # Добавлено + # ... остальные middleware + ] + ``` + +2. Настроено хранилище статических файлов: + ```python + STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + ``` + +3. Создан `entrypoint.sh` для сборки статики при запуске: + ```bash + #!/bin/bash + echo "Collecting static files..." + python3 manage.py collectstatic --noinput --clear + + echo "Applying database migrations..." + python3 manage.py migrate --noinput + + echo "Starting server..." + exec gunicorn backend.wsgi:application --bind 0.0.0.0:8000 + ``` + +4. Обновлен `Dockerfile` для использования entrypoint + +### ✅ Автоматические миграции +Теперь миграции применяются автоматически при запуске контейнера + +### ⚠️ Frontend API подключение +**Проблема**: Frontend не может подключиться к backend API в Docker среде + +**Частичное решение**: +- Обновлена конфигурация Next.js для использования `web:8000` внутри Docker +- API прокси работает с редиректами + +## Результат + +### Работающие сервисы: +- ✅ **Backend**: http://localhost:8000 +- ✅ **Backend API**: http://localhost:8000/api/ +- ✅ **Статические файлы**: http://localhost:8000/static/* +- ✅ **Django Admin**: http://localhost:8000/admin/ +- ✅ **Frontend**: http://localhost:3000 +- ✅ **PostgreSQL**: localhost:5432 + +### Команды для запуска: +```bash +# Запуск всех сервисов +docker-compose up -d + +# Проверка статуса +docker-compose ps + +# Просмотр логов +docker-compose logs web +docker-compose logs frontend + +# Остановка +docker-compose down +``` + +### Переменные окружения (в .env): +```env +DJANGO_SECRET_KEY=your-secret-key +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` + +## Проверка работы + +### Backend статика: +```bash +curl -I http://localhost:8000/static/rest_framework/css/bootstrap.min.css +# Должен вернуть 200 OK +``` + +### API endpoints: +```bash +curl -s http://localhost:8000/api/ | jq +# Должен вернуть JSON с endpoints +``` + +### Frontend: +```bash +curl -I http://localhost:3000 +# Должен вернуть 200 OK +``` \ No newline at end of file diff --git a/.history/FIXES_20251029193233.md b/.history/FIXES_20251029193233.md new file mode 100644 index 0000000..f96c658 --- /dev/null +++ b/.history/FIXES_20251029193233.md @@ -0,0 +1,112 @@ +# Решение проблем со статикой и API + +## Исправленные проблемы + +### ✅ Статические файлы Django +**Проблема**: Статические файлы Django REST Framework не загружались (404 ошибки) + +**Решение**: +1. Добавлен WhiteNoise middleware в `settings.py`: + ```python + MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', # Добавлено + # ... остальные middleware + ] + ``` + +2. Настроено хранилище статических файлов: + ```python + STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + ``` + +3. Создан `entrypoint.sh` для сборки статики при запуске: + ```bash + #!/bin/bash + echo "Collecting static files..." + python3 manage.py collectstatic --noinput --clear + + echo "Applying database migrations..." + python3 manage.py migrate --noinput + + echo "Starting server..." + exec gunicorn backend.wsgi:application --bind 0.0.0.0:8000 + ``` + +4. Обновлен `Dockerfile` для использования entrypoint + +### ✅ Автоматические миграции +Теперь миграции применяются автоматически при запуске контейнера + +### ⚠️ Frontend API подключение +**Проблема**: Frontend не может подключиться к backend API в Docker среде + +**Частичное решение**: +- Обновлена конфигурация Next.js для использования `web:8000` внутри Docker +- API прокси работает с редиректами + +## Результат + +### Работающие сервисы: +- ✅ **Backend**: http://localhost:8000 +- ✅ **Backend API**: http://localhost:8000/api/ +- ✅ **Статические файлы**: http://localhost:8000/static/* +- ✅ **Django Admin**: http://localhost:8000/admin/ +- ✅ **Frontend**: http://localhost:3000 +- ✅ **PostgreSQL**: localhost:5432 + +### Команды для запуска: +```bash +# Запуск всех сервисов +docker-compose up -d + +# Проверка статуса +docker-compose ps + +# Просмотр логов +docker-compose logs web +docker-compose logs frontend + +# Остановка +docker-compose down +``` + +### Переменные окружения (в .env): +```env +DJANGO_SECRET_KEY=your-secret-key +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,0.0.0.0 + +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +POSTGRES_DB=links_db +POSTGRES_USER=links_user +POSTGRES_PASSWORD=links_password + +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` + +## Проверка работы + +### Backend статика: +```bash +curl -I http://localhost:8000/static/rest_framework/css/bootstrap.min.css +# Должен вернуть 200 OK +``` + +### API endpoints: +```bash +curl -s http://localhost:8000/api/ | jq +# Должен вернуть JSON с endpoints +``` + +### Frontend: +```bash +curl -I http://localhost:3000 +# Должен вернуть 200 OK +``` \ No newline at end of file diff --git a/.history/README_20251029192139.md b/.history/README_20251029192139.md new file mode 100644 index 0000000..3f85c4e --- /dev/null +++ b/.history/README_20251029192139.md @@ -0,0 +1,108 @@ +# Клон Linktr.ee на Django + Next.js + +Полнофункциональное приложение для создания персональных страниц с ссылками, похожее на Linktr.ee. + +## Технологии + +**Backend:** +- Django 5.2 + Django REST Framework +- PostgreSQL +- JWT Authentication +- Django CORS Headers +- Gunicorn + +**Frontend:** +- Next.js 15.3.1 +- React 19 +- TypeScript +- Tailwind CSS +- Axios для API запросов + +## Быстрый старт + +### 1. Подготовка окружения + +```bash +# Клонируйте репозиторий +git clone +cd links + +# Скопируйте переменные окружения +cp .env.example .env +``` + +### 2. Запуск проекта + +```bash +# Запуск всех сервисов +make up + +# Применение миграций базы данных +make migrate +``` + +### 3. Доступ к приложению + +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:8000/api/ +- **Django Admin**: http://localhost:8000/admin/ + +## Команды + +- `make up` - Запуск всех сервисов +- `make down` - Остановка всех сервисов +- `make migrate` - Применение миграций +- `make test` - Запуск тестов + +## Структура проекта + +``` +├── backend/ # Django приложение +│ ├── api/ # API endpoints +│ ├── users/ # Пользователи +│ ├── links/ # Ссылки и группы +│ ├── customization/ # Настройки дизайна +│ └── backend/ # Настройки Django +├── frontend/linktree-frontend/ # Next.js приложение +├── docker-compose.yml # Docker Compose конфигурация +├── .env.example # Пример переменных окружения +└── DEPLOYMENT.md # Подробные инструкции по развертыванию +``` + +## Переменные окружения + +Основные переменные в `.env`: + +```env +# Django +DJANGO_SECRET_KEY=your-secret-key +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost + +# PostgreSQL +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# Frontend +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` + +Подробнее в `DEPLOYMENT.md`. + +## Особенности + +- 🔐 JWT аутентификация +- 👤 Кастомизация профилей +- 📱 Адаптивный дизайн +- 🔗 Управление ссылками и группами +- 🎨 Настройка внешнего вида +- 📊 API для всех операций + +## Разработка + +Проект настроен для разработки в Docker-контейнерах с горячей перезагрузкой. + +Для разработки без Docker смотрите инструкции в `DEPLOYMENT.md`. diff --git a/.history/README_20251029192214.md b/.history/README_20251029192214.md new file mode 100644 index 0000000..3f85c4e --- /dev/null +++ b/.history/README_20251029192214.md @@ -0,0 +1,108 @@ +# Клон Linktr.ee на Django + Next.js + +Полнофункциональное приложение для создания персональных страниц с ссылками, похожее на Linktr.ee. + +## Технологии + +**Backend:** +- Django 5.2 + Django REST Framework +- PostgreSQL +- JWT Authentication +- Django CORS Headers +- Gunicorn + +**Frontend:** +- Next.js 15.3.1 +- React 19 +- TypeScript +- Tailwind CSS +- Axios для API запросов + +## Быстрый старт + +### 1. Подготовка окружения + +```bash +# Клонируйте репозиторий +git clone +cd links + +# Скопируйте переменные окружения +cp .env.example .env +``` + +### 2. Запуск проекта + +```bash +# Запуск всех сервисов +make up + +# Применение миграций базы данных +make migrate +``` + +### 3. Доступ к приложению + +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:8000/api/ +- **Django Admin**: http://localhost:8000/admin/ + +## Команды + +- `make up` - Запуск всех сервисов +- `make down` - Остановка всех сервисов +- `make migrate` - Применение миграций +- `make test` - Запуск тестов + +## Структура проекта + +``` +├── backend/ # Django приложение +│ ├── api/ # API endpoints +│ ├── users/ # Пользователи +│ ├── links/ # Ссылки и группы +│ ├── customization/ # Настройки дизайна +│ └── backend/ # Настройки Django +├── frontend/linktree-frontend/ # Next.js приложение +├── docker-compose.yml # Docker Compose конфигурация +├── .env.example # Пример переменных окружения +└── DEPLOYMENT.md # Подробные инструкции по развертыванию +``` + +## Переменные окружения + +Основные переменные в `.env`: + +```env +# Django +DJANGO_SECRET_KEY=your-secret-key +DJANGO_DEBUG=True +DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost + +# PostgreSQL +DATABASE_NAME=links_db +DATABASE_USER=links_user +DATABASE_PASSWORD=links_password +DATABASE_HOST=db +DATABASE_PORT=5432 + +# Frontend +NEXT_PUBLIC_API_URL=http://localhost:8000 +``` + +Подробнее в `DEPLOYMENT.md`. + +## Особенности + +- 🔐 JWT аутентификация +- 👤 Кастомизация профилей +- 📱 Адаптивный дизайн +- 🔗 Управление ссылками и группами +- 🎨 Настройка внешнего вида +- 📊 API для всех операций + +## Разработка + +Проект настроен для разработки в Docker-контейнерах с горячей перезагрузкой. + +Для разработки без Docker смотрите инструкции в `DEPLOYMENT.md`. diff --git a/.history/backend/Dockerfile_20251029190338 b/.history/backend/Dockerfile_20251029190338 new file mode 100644 index 0000000..c18b0d6 --- /dev/null +++ b/.history/backend/Dockerfile_20251029190338 @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +# Сбор статических файлов +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["gunicorn", "backend.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029190516 b/.history/backend/Dockerfile_20251029190516 new file mode 100644 index 0000000..c18b0d6 --- /dev/null +++ b/.history/backend/Dockerfile_20251029190516 @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +# Сбор статических файлов +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["gunicorn", "backend.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029190955 b/.history/backend/Dockerfile_20251029190955 new file mode 100644 index 0000000..845fd8f --- /dev/null +++ b/.history/backend/Dockerfile_20251029190955 @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY ../requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +# Сбор статических файлов +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["gunicorn", "backend.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029191016 b/.history/backend/Dockerfile_20251029191016 new file mode 100644 index 0000000..c18b0d6 --- /dev/null +++ b/.history/backend/Dockerfile_20251029191016 @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +# Сбор статических файлов +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["gunicorn", "backend.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029191030 b/.history/backend/Dockerfile_20251029191030 new file mode 100644 index 0000000..42e96ac --- /dev/null +++ b/.history/backend/Dockerfile_20251029191030 @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +EXPOSE 8000 + +CMD ["gunicorn", "backend.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029191041 b/.history/backend/Dockerfile_20251029191041 new file mode 100644 index 0000000..42e96ac --- /dev/null +++ b/.history/backend/Dockerfile_20251029191041 @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +EXPOSE 8000 + +CMD ["gunicorn", "backend.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029192429 b/.history/backend/Dockerfile_20251029192429 new file mode 100644 index 0000000..1093b85 --- /dev/null +++ b/.history/backend/Dockerfile_20251029192429 @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +# Сбор статических файлов +RUN python manage.py collectstatic --noinput --clear + +EXPOSE 8000 + +CMD ["gunicorn", "backend.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029192513 b/.history/backend/Dockerfile_20251029192513 new file mode 100644 index 0000000..1093b85 --- /dev/null +++ b/.history/backend/Dockerfile_20251029192513 @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +# Сбор статических файлов +RUN python manage.py collectstatic --noinput --clear + +EXPOSE 8000 + +CMD ["gunicorn", "backend.wsgi:application", "--bind", "0.0.0.0:8000"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029192533 b/.history/backend/Dockerfile_20251029192533 new file mode 100644 index 0000000..57798d5 --- /dev/null +++ b/.history/backend/Dockerfile_20251029192533 @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +# Копируем entrypoint скрипт +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/.history/backend/Dockerfile_20251029192619 b/.history/backend/Dockerfile_20251029192619 new file mode 100644 index 0000000..57798d5 --- /dev/null +++ b/.history/backend/Dockerfile_20251029192619 @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Копирование и установка requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY . . + +# Создание директорий для статики и медиа +RUN mkdir -p staticfiles storage + +# Копируем entrypoint скрипт +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/.history/backend/api/urls_20251029194610.py b/.history/backend/api/urls_20251029194610.py new file mode 100644 index 0000000..6429ed3 --- /dev/null +++ b/.history/backend/api/urls_20251029194610.py @@ -0,0 +1,39 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from .views import ( + RegisterView, + UserProfileView, + LinkViewSet, + LinkGroupViewSet, + PublicUserGroupsView +) +from rest_framework.routers import DefaultRouter +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +router = DefaultRouter() +router.register('links', LinkViewSet, basename='link') +router.register('groups', LinkGroupViewSet, basename='group') + +urlpatterns = [ + path('auth/register/', RegisterView.as_view(), name='auth_register'), + path('auth/register', RegisterView.as_view(), name='auth_register_no_slash'), + path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/login', TokenObtainPairView.as_view(), name='token_obtain_pair_no_slash'), + path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('auth/refresh', TokenRefreshView.as_view(), name='token_refresh_no_slash'), + path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый + path('auth/user', UserProfileView.as_view(), name='user-profile-no-slash'), + path('users//public/', + PublicUserGroupsView.as_view(), + name='public-user-groups' + ), + # схема OpenAPI + path('schema/', SpectacularAPIView.as_view(), name='schema'), + # Swagger UI, берёт шаблон из drf_spectacular_sidecar + path( + 'swagger/', + SpectacularSwaggerView.as_view(url_name='schema'), + name='swagger-ui' + ), + +] + router.urls \ No newline at end of file diff --git a/.history/backend/api/urls_20251029194633.py b/.history/backend/api/urls_20251029194633.py new file mode 100644 index 0000000..6429ed3 --- /dev/null +++ b/.history/backend/api/urls_20251029194633.py @@ -0,0 +1,39 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from .views import ( + RegisterView, + UserProfileView, + LinkViewSet, + LinkGroupViewSet, + PublicUserGroupsView +) +from rest_framework.routers import DefaultRouter +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +router = DefaultRouter() +router.register('links', LinkViewSet, basename='link') +router.register('groups', LinkGroupViewSet, basename='group') + +urlpatterns = [ + path('auth/register/', RegisterView.as_view(), name='auth_register'), + path('auth/register', RegisterView.as_view(), name='auth_register_no_slash'), + path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/login', TokenObtainPairView.as_view(), name='token_obtain_pair_no_slash'), + path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('auth/refresh', TokenRefreshView.as_view(), name='token_refresh_no_slash'), + path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый + path('auth/user', UserProfileView.as_view(), name='user-profile-no-slash'), + path('users//public/', + PublicUserGroupsView.as_view(), + name='public-user-groups' + ), + # схема OpenAPI + path('schema/', SpectacularAPIView.as_view(), name='schema'), + # Swagger UI, берёт шаблон из drf_spectacular_sidecar + path( + 'swagger/', + SpectacularSwaggerView.as_view(url_name='schema'), + name='swagger-ui' + ), + +] + router.urls \ No newline at end of file diff --git a/.history/backend/api/urls_20251029201825.py b/.history/backend/api/urls_20251029201825.py new file mode 100644 index 0000000..114d05d --- /dev/null +++ b/.history/backend/api/urls_20251029201825.py @@ -0,0 +1,44 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from .views import ( + RegisterView, + UserProfileView, + LinkViewSet, + LinkGroupViewSet, + PublicUserGroupsView +) +from rest_framework.routers import DefaultRouter +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +class NoTrailingSlashRouter(DefaultRouter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.trailing_slash = '/?' + +router = NoTrailingSlashRouter() +router.register('links', LinkViewSet, basename='link') +router.register('groups', LinkGroupViewSet, basename='group') + +urlpatterns = [ + path('auth/register/', RegisterView.as_view(), name='auth_register'), + path('auth/register', RegisterView.as_view(), name='auth_register_no_slash'), + path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/login', TokenObtainPairView.as_view(), name='token_obtain_pair_no_slash'), + path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('auth/refresh', TokenRefreshView.as_view(), name='token_refresh_no_slash'), + path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый + path('auth/user', UserProfileView.as_view(), name='user-profile-no-slash'), + path('users//public/', + PublicUserGroupsView.as_view(), + name='public-user-groups' + ), + # схема OpenAPI + path('schema/', SpectacularAPIView.as_view(), name='schema'), + # Swagger UI, берёт шаблон из drf_spectacular_sidecar + path( + 'swagger/', + SpectacularSwaggerView.as_view(url_name='schema'), + name='swagger-ui' + ), + +] + router.urls \ No newline at end of file diff --git a/.history/backend/api/urls_20251029201838.py b/.history/backend/api/urls_20251029201838.py new file mode 100644 index 0000000..114d05d --- /dev/null +++ b/.history/backend/api/urls_20251029201838.py @@ -0,0 +1,44 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from .views import ( + RegisterView, + UserProfileView, + LinkViewSet, + LinkGroupViewSet, + PublicUserGroupsView +) +from rest_framework.routers import DefaultRouter +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +class NoTrailingSlashRouter(DefaultRouter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.trailing_slash = '/?' + +router = NoTrailingSlashRouter() +router.register('links', LinkViewSet, basename='link') +router.register('groups', LinkGroupViewSet, basename='group') + +urlpatterns = [ + path('auth/register/', RegisterView.as_view(), name='auth_register'), + path('auth/register', RegisterView.as_view(), name='auth_register_no_slash'), + path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/login', TokenObtainPairView.as_view(), name='token_obtain_pair_no_slash'), + path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('auth/refresh', TokenRefreshView.as_view(), name='token_refresh_no_slash'), + path('auth/user/', UserProfileView.as_view(), name='user-profile'), # ← новый + path('auth/user', UserProfileView.as_view(), name='user-profile-no-slash'), + path('users//public/', + PublicUserGroupsView.as_view(), + name='public-user-groups' + ), + # схема OpenAPI + path('schema/', SpectacularAPIView.as_view(), name='schema'), + # Swagger UI, берёт шаблон из drf_spectacular_sidecar + path( + 'swagger/', + SpectacularSwaggerView.as_view(url_name='schema'), + name='swagger-ui' + ), + +] + router.urls \ No newline at end of file diff --git a/.history/backend/api/views_20251029194226.py b/.history/backend/api/views_20251029194226.py new file mode 100644 index 0000000..c766558 --- /dev/null +++ b/.history/backend/api/views_20251029194226.py @@ -0,0 +1,121 @@ +# coding: utf-8 +from rest_framework import generics, viewsets, permissions, status +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework.views import APIView +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema, OpenApiParameter +from django.contrib.auth import get_user_model + +from .models import Link, LinkGroup +from .serializers import ( + RegisterSerializer, + UserSerializer, + LinkSerializer, + LinkGroupSerializer, +) + +User = get_user_model() + + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = [permissions.AllowAny] + serializer_class = RegisterSerializer + + +class LoginView(TokenObtainPairView): + permission_classes = [permissions.AllowAny] + + +class LinkGroupViewSet(viewsets.ModelViewSet): + queryset = LinkGroup.objects.all() + serializer_class = LinkGroupSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def get_queryset(self): + return self.queryset.filter(owner=self.request.user).order_by('order') + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class LinkViewSet(viewsets.ModelViewSet): + queryset = Link.objects.all() + serializer_class = LinkSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def get_queryset(self): + return Link.objects.filter(owner=self.request.user).order_by('order') + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class UserProfileView(generics.RetrieveAPIView): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + def get_object(self): + return self.request.user + + +class UserLinksListView(generics.ListAPIView): + serializer_class = LinkSerializer + permission_classes = [permissions.AllowAny] + def get_queryset(self): + username = self.kwargs['username'] + return Link.objects.filter(owner__username=username).order_by('order') + + +class PublicUserGroupsView(APIView): + """ + GET /api/users/{username}/public/ + """ + permission_classes = [permissions.AllowAny] + + def get(self, request, username): + # 1. Ищем пользователя + user = get_object_or_404(User, username=username) + + # 2. Берём его группы со ссылками + groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links') + + result = { + "username": user.username, + "groups": [] + } + + for grp in groups_qs: + # icon у группы (абсолютный URL) + grp_icon_url = None + if grp.icon: + grp_icon_url = request.build_absolute_uri(grp.icon.url) + + grp_data = { + "id": grp.id, + "name": grp.name, + "icon": grp_icon_url, + "links": [], + } + + for ln in grp.links.all(): + # icon у ссылки + ln_icon_url = None + if ln.icon: + ln_icon_url = request.build_absolute_uri(ln.icon.url) + + grp_data["links"].append({ + "id": ln.id, + "title": ln.title, + "url": ln.url, + "icon": ln_icon_url, + }) + + result["groups"].append(grp_data) + + return Response(result, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/.history/backend/api/views_20251029194232.py b/.history/backend/api/views_20251029194232.py new file mode 100644 index 0000000..b2f9d3a --- /dev/null +++ b/.history/backend/api/views_20251029194232.py @@ -0,0 +1,122 @@ +# coding: utf-8 +from rest_framework import generics, viewsets, permissions, status +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework.views import APIView +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema, OpenApiParameter +from django.contrib.auth import get_user_model + +from .models import Link, LinkGroup +from .serializers import ( + RegisterSerializer, + UserSerializer, + LinkSerializer, + LinkGroupSerializer, +) + +User = get_user_model() + + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = [permissions.AllowAny] + serializer_class = RegisterSerializer + + +@method_decorator(csrf_exempt, name='dispatch') +class LoginView(TokenObtainPairView): + permission_classes = [permissions.AllowAny] + + +class LinkGroupViewSet(viewsets.ModelViewSet): + queryset = LinkGroup.objects.all() + serializer_class = LinkGroupSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def get_queryset(self): + return self.queryset.filter(owner=self.request.user).order_by('order') + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class LinkViewSet(viewsets.ModelViewSet): + queryset = Link.objects.all() + serializer_class = LinkSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def get_queryset(self): + return Link.objects.filter(owner=self.request.user).order_by('order') + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class UserProfileView(generics.RetrieveAPIView): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + def get_object(self): + return self.request.user + + +class UserLinksListView(generics.ListAPIView): + serializer_class = LinkSerializer + permission_classes = [permissions.AllowAny] + def get_queryset(self): + username = self.kwargs['username'] + return Link.objects.filter(owner__username=username).order_by('order') + + +class PublicUserGroupsView(APIView): + """ + GET /api/users/{username}/public/ + """ + permission_classes = [permissions.AllowAny] + + def get(self, request, username): + # 1. Ищем пользователя + user = get_object_or_404(User, username=username) + + # 2. Берём его группы со ссылками + groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links') + + result = { + "username": user.username, + "groups": [] + } + + for grp in groups_qs: + # icon у группы (абсолютный URL) + grp_icon_url = None + if grp.icon: + grp_icon_url = request.build_absolute_uri(grp.icon.url) + + grp_data = { + "id": grp.id, + "name": grp.name, + "icon": grp_icon_url, + "links": [], + } + + for ln in grp.links.all(): + # icon у ссылки + ln_icon_url = None + if ln.icon: + ln_icon_url = request.build_absolute_uri(ln.icon.url) + + grp_data["links"].append({ + "id": ln.id, + "title": ln.title, + "url": ln.url, + "icon": ln_icon_url, + }) + + result["groups"].append(grp_data) + + return Response(result, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/.history/backend/api/views_20251029194257.py b/.history/backend/api/views_20251029194257.py new file mode 100644 index 0000000..b2f9d3a --- /dev/null +++ b/.history/backend/api/views_20251029194257.py @@ -0,0 +1,122 @@ +# coding: utf-8 +from rest_framework import generics, viewsets, permissions, status +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework.views import APIView +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema, OpenApiParameter +from django.contrib.auth import get_user_model + +from .models import Link, LinkGroup +from .serializers import ( + RegisterSerializer, + UserSerializer, + LinkSerializer, + LinkGroupSerializer, +) + +User = get_user_model() + + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = [permissions.AllowAny] + serializer_class = RegisterSerializer + + +@method_decorator(csrf_exempt, name='dispatch') +class LoginView(TokenObtainPairView): + permission_classes = [permissions.AllowAny] + + +class LinkGroupViewSet(viewsets.ModelViewSet): + queryset = LinkGroup.objects.all() + serializer_class = LinkGroupSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def get_queryset(self): + return self.queryset.filter(owner=self.request.user).order_by('order') + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class LinkViewSet(viewsets.ModelViewSet): + queryset = Link.objects.all() + serializer_class = LinkSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def get_queryset(self): + return Link.objects.filter(owner=self.request.user).order_by('order') + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + +class UserProfileView(generics.RetrieveAPIView): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + def get_object(self): + return self.request.user + + +class UserLinksListView(generics.ListAPIView): + serializer_class = LinkSerializer + permission_classes = [permissions.AllowAny] + def get_queryset(self): + username = self.kwargs['username'] + return Link.objects.filter(owner__username=username).order_by('order') + + +class PublicUserGroupsView(APIView): + """ + GET /api/users/{username}/public/ + """ + permission_classes = [permissions.AllowAny] + + def get(self, request, username): + # 1. Ищем пользователя + user = get_object_or_404(User, username=username) + + # 2. Берём его группы со ссылками + groups_qs = LinkGroup.objects.filter(owner=user).prefetch_related('links') + + result = { + "username": user.username, + "groups": [] + } + + for grp in groups_qs: + # icon у группы (абсолютный URL) + grp_icon_url = None + if grp.icon: + grp_icon_url = request.build_absolute_uri(grp.icon.url) + + grp_data = { + "id": grp.id, + "name": grp.name, + "icon": grp_icon_url, + "links": [], + } + + for ln in grp.links.all(): + # icon у ссылки + ln_icon_url = None + if ln.icon: + ln_icon_url = request.build_absolute_uri(ln.icon.url) + + grp_data["links"].append({ + "id": ln.id, + "title": ln.title, + "url": ln.url, + "icon": ln_icon_url, + }) + + result["groups"].append(grp_data) + + return Response(result, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029192450.py b/.history/backend/backend/settings_20251029192450.py new file mode 100644 index 0000000..c12974f --- /dev/null +++ b/.history/backend/backend/settings_20251029192450.py @@ -0,0 +1,169 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029192459.py b/.history/backend/backend/settings_20251029192459.py new file mode 100644 index 0000000..32aeb90 --- /dev/null +++ b/.history/backend/backend/settings_20251029192459.py @@ -0,0 +1,173 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029192513.py b/.history/backend/backend/settings_20251029192513.py new file mode 100644 index 0000000..32aeb90 --- /dev/null +++ b/.history/backend/backend/settings_20251029192513.py @@ -0,0 +1,173 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029193528.py b/.history/backend/backend/settings_20251029193528.py new file mode 100644 index 0000000..efd43df --- /dev/null +++ b/.history/backend/backend/settings_20251029193528.py @@ -0,0 +1,178 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +CORS_ALLOW_ALL_ORIGINS = True # Для разработки +CORS_ALLOW_CREDENTIALS = True + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029193531.py b/.history/backend/backend/settings_20251029193531.py new file mode 100644 index 0000000..efd43df --- /dev/null +++ b/.history/backend/backend/settings_20251029193531.py @@ -0,0 +1,178 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +CORS_ALLOW_ALL_ORIGINS = True # Для разработки +CORS_ALLOW_CREDENTIALS = True + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029194048.py b/.history/backend/backend/settings_20251029194048.py new file mode 100644 index 0000000..28ba1bc --- /dev/null +++ b/.history/backend/backend/settings_20251029194048.py @@ -0,0 +1,189 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +CORS_ALLOW_ALL_ORIGINS = True # Для разработки +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029194251.py b/.history/backend/backend/settings_20251029194251.py new file mode 100644 index 0000000..5c7e834 --- /dev/null +++ b/.history/backend/backend/settings_20251029194251.py @@ -0,0 +1,192 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +CORS_ALLOW_ALL_ORIGINS = True # Для разработки +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ], +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029194257.py b/.history/backend/backend/settings_20251029194257.py new file mode 100644 index 0000000..5c7e834 --- /dev/null +++ b/.history/backend/backend/settings_20251029194257.py @@ -0,0 +1,192 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +CORS_ALLOW_ALL_ORIGINS = True # Для разработки +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ], +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029194435.py b/.history/backend/backend/settings_20251029194435.py new file mode 100644 index 0000000..28d42bf --- /dev/null +++ b/.history/backend/backend/settings_20251029194435.py @@ -0,0 +1,195 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +# Отключаем APPEND_SLASH для корректной работы API с Next.js proxy +APPEND_SLASH = False + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +CORS_ALLOW_ALL_ORIGINS = True # Для разработки +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ], +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/backend/settings_20251029194437.py b/.history/backend/backend/settings_20251029194437.py new file mode 100644 index 0000000..28d42bf --- /dev/null +++ b/.history/backend/backend/settings_20251029194437.py @@ -0,0 +1,195 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from dotenv import load_dotenv +import os +# Load environment variables from .env file +load_dotenv() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '127.0.0.1').split(',') + +# Отключаем APPEND_SLASH для корректной работы API с Next.js proxy +APPEND_SLASH = False + +CORS_ALLOWED_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:3001", + "http://localhost:3001", +] + +CORS_ALLOW_ALL_ORIGINS = True # Для разработки +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +# Application definition + +INSTALLED_APPS = [ + "corsheaders", + 'drf_spectacular', + "drf_spectacular_sidecar", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'links', + 'customization', + 'api', + 'rest_framework', + 'rest_framework_simplejwt', + 'django_extensions', +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ], +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.postgresql'), + 'NAME': os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'users.User' + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'ru-ru' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# URL, по которому статика будет доступна +STATIC_URL = '/static/' + +# WhiteNoise настройки +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +MEDIA_URL = '/storage/' +MEDIA_ROOT = BASE_DIR / 'storage' \ No newline at end of file diff --git a/.history/backend/entrypoint_20251029192540.sh b/.history/backend/entrypoint_20251029192540.sh new file mode 100644 index 0000000..2724535 --- /dev/null +++ b/.history/backend/entrypoint_20251029192540.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +echo "Collecting static files..." +python manage.py collectstatic --noinput --clear + +echo "Starting server..." +exec gunicorn backend.wsgi:application --bind 0.0.0.0:8000 \ No newline at end of file diff --git a/.history/backend/entrypoint_20251029192619.sh b/.history/backend/entrypoint_20251029192619.sh new file mode 100644 index 0000000..20fb61f --- /dev/null +++ b/.history/backend/entrypoint_20251029192619.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +echo "Collecting static files..." +python3 manage.py collectstatic --noinput --clear + +echo "Applying database migrations..." +python3 manage.py migrate --noinput + +echo "Starting server..." +exec gunicorn backend.wsgi:application --bind 0.0.0.0:8000 \ No newline at end of file diff --git a/.history/docker-compose_20251029190357.yml b/.history/docker-compose_20251029190357.yml new file mode 100644 index 0000000..e456826 --- /dev/null +++ b/.history/docker-compose_20251029190357.yml @@ -0,0 +1,39 @@ +version: '3.8' +services: + web: + build: ./backend + command: gunicorn backend.wsgi:application --bind 0.0.0.0:8000 + volumes: + - ./backend:/app + - media_volume:/app/storage + - static_volume:/app/staticfiles + env_file: + - .env.example + ports: + - "8000:8000" + depends_on: + - db + restart: unless-stopped + + db: + image: postgres:14 + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - .env.example + restart: unless-stopped + + frontend: + build: ./frontend/linktree-frontend + ports: + - "3000:3000" + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000 + restart: unless-stopped + depends_on: + - web + +volumes: + postgres_data: + media_volume: + static_volume: diff --git a/.history/docker-compose_20251029190516.yml b/.history/docker-compose_20251029190516.yml new file mode 100644 index 0000000..e456826 --- /dev/null +++ b/.history/docker-compose_20251029190516.yml @@ -0,0 +1,39 @@ +version: '3.8' +services: + web: + build: ./backend + command: gunicorn backend.wsgi:application --bind 0.0.0.0:8000 + volumes: + - ./backend:/app + - media_volume:/app/storage + - static_volume:/app/staticfiles + env_file: + - .env.example + ports: + - "8000:8000" + depends_on: + - db + restart: unless-stopped + + db: + image: postgres:14 + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - .env.example + restart: unless-stopped + + frontend: + build: ./frontend/linktree-frontend + ports: + - "3000:3000" + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000 + restart: unless-stopped + depends_on: + - web + +volumes: + postgres_data: + media_volume: + static_volume: diff --git a/.history/docker-compose_20251029190616.yml b/.history/docker-compose_20251029190616.yml new file mode 100644 index 0000000..d690a19 --- /dev/null +++ b/.history/docker-compose_20251029190616.yml @@ -0,0 +1,39 @@ +version: '3.8' +services: + web: + build: ./backend + command: gunicorn backend.wsgi:application --bind 0.0.0.0:8000 + volumes: + - ./backend:/app + - media_volume:/app/storage + - static_volume:/app/staticfiles + env_file: + - .env + ports: + - "8000:8000" + depends_on: + - db + restart: unless-stopped + + db: + image: postgres:14 + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - .env + restart: unless-stopped + + frontend: + build: ./frontend/linktree-frontend + ports: + - "3000:3000" + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000 + restart: unless-stopped + depends_on: + - web + +volumes: + postgres_data: + media_volume: + static_volume: diff --git a/.history/frontend/linktree-frontend/.env_20251029195502.local b/.history/frontend/linktree-frontend/.env_20251029195502.local new file mode 100644 index 0000000..4b5b7a9 --- /dev/null +++ b/.history/frontend/linktree-frontend/.env_20251029195502.local @@ -0,0 +1,4 @@ +# Next.js Environment Variables +# NOTE: For API calls in client components, use relative URLs like '/api/auth/login' +# This variable is only used for server-side rendering and image URLs +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.history/frontend/linktree-frontend/.env_20251029195517.local b/.history/frontend/linktree-frontend/.env_20251029195517.local new file mode 100644 index 0000000..4b5b7a9 --- /dev/null +++ b/.history/frontend/linktree-frontend/.env_20251029195517.local @@ -0,0 +1,4 @@ +# Next.js Environment Variables +# NOTE: For API calls in client components, use relative URLs like '/api/auth/login' +# This variable is only used for server-side rendering and image URLs +NEXT_PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.history/frontend/linktree-frontend/Dockerfile_20251029190402 b/.history/frontend/linktree-frontend/Dockerfile_20251029190402 new file mode 100644 index 0000000..1aa44d2 --- /dev/null +++ b/.history/frontend/linktree-frontend/Dockerfile_20251029190402 @@ -0,0 +1,19 @@ +FROM node:18-alpine + +WORKDIR /app + +# Копирование package.json и package-lock.json +COPY package*.json ./ + +# Установка зависимостей +RUN npm ci + +# Копирование исходного кода +COPY . . + +# Сборка приложения +RUN npm run build + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/.history/frontend/linktree-frontend/Dockerfile_20251029190516 b/.history/frontend/linktree-frontend/Dockerfile_20251029190516 new file mode 100644 index 0000000..1aa44d2 --- /dev/null +++ b/.history/frontend/linktree-frontend/Dockerfile_20251029190516 @@ -0,0 +1,19 @@ +FROM node:18-alpine + +WORKDIR /app + +# Копирование package.json и package-lock.json +COPY package*.json ./ + +# Установка зависимостей +RUN npm ci + +# Копирование исходного кода +COPY . . + +# Сборка приложения +RUN npm run build + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/.history/frontend/linktree-frontend/Dockerfile_20251029191222 b/.history/frontend/linktree-frontend/Dockerfile_20251029191222 new file mode 100644 index 0000000..24b1300 --- /dev/null +++ b/.history/frontend/linktree-frontend/Dockerfile_20251029191222 @@ -0,0 +1,19 @@ +FROM node:20-alpine + +WORKDIR /app + +# Копирование package.json и package-lock.json +COPY package*.json ./ + +# Установка зависимостей +RUN npm install + +# Копирование исходного кода +COPY . . + +# Сборка приложения +RUN npm run build + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/.history/frontend/linktree-frontend/Dockerfile_20251029191321 b/.history/frontend/linktree-frontend/Dockerfile_20251029191321 new file mode 100644 index 0000000..24b1300 --- /dev/null +++ b/.history/frontend/linktree-frontend/Dockerfile_20251029191321 @@ -0,0 +1,19 @@ +FROM node:20-alpine + +WORKDIR /app + +# Копирование package.json и package-lock.json +COPY package*.json ./ + +# Установка зависимостей +RUN npm install + +# Копирование исходного кода +COPY . . + +# Сборка приложения +RUN npm run build + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/.history/frontend/linktree-frontend/next.config_20251029191330.ts b/.history/frontend/linktree-frontend/next.config_20251029191330.ts new file mode 100644 index 0000000..648a620 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029191330.ts @@ -0,0 +1,36 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + ], + }, + + // proxy all /api/* calls to Django + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://127.0.0.1:8000/api/:path*', + }, + ]; + }, + + eslint: { + // Предупреждение: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки ESLint. + ignoreDuringBuilds: true, + }, + typescript: { + // Опасно: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки TypeScript. + ignoreBuildErrors: true, + }, +}; + +module.exports = nextConfig; diff --git a/.history/frontend/linktree-frontend/next.config_20251029191556.ts b/.history/frontend/linktree-frontend/next.config_20251029191556.ts new file mode 100644 index 0000000..648a620 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029191556.ts @@ -0,0 +1,36 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + ], + }, + + // proxy all /api/* calls to Django + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://127.0.0.1:8000/api/:path*', + }, + ]; + }, + + eslint: { + // Предупреждение: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки ESLint. + ignoreDuringBuilds: true, + }, + typescript: { + // Опасно: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки TypeScript. + ignoreBuildErrors: true, + }, +}; + +module.exports = nextConfig; diff --git a/.history/frontend/linktree-frontend/next.config_20251029191833.ts b/.history/frontend/linktree-frontend/next.config_20251029191833.ts new file mode 100644 index 0000000..b4ea604 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029191833.ts @@ -0,0 +1,42 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'web', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + ], + }, + + // proxy all /api/* calls to Django + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://web:8000/api/:path*', + }, + ]; + }, + + eslint: { + // Предупреждение: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки ESLint. + ignoreDuringBuilds: true, + }, + typescript: { + // Опасно: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки TypeScript. + ignoreBuildErrors: true, + }, +}; + +module.exports = nextConfig; diff --git a/.history/frontend/linktree-frontend/next.config_20251029192214.ts b/.history/frontend/linktree-frontend/next.config_20251029192214.ts new file mode 100644 index 0000000..b4ea604 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029192214.ts @@ -0,0 +1,42 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'web', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + ], + }, + + // proxy all /api/* calls to Django + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://web:8000/api/:path*', + }, + ]; + }, + + eslint: { + // Предупреждение: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки ESLint. + ignoreDuringBuilds: true, + }, + typescript: { + // Опасно: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки TypeScript. + ignoreBuildErrors: true, + }, +}; + +module.exports = nextConfig; diff --git a/.history/frontend/linktree-frontend/next.config_20251029193543.ts b/.history/frontend/linktree-frontend/next.config_20251029193543.ts new file mode 100644 index 0000000..e9e1aa5 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029193543.ts @@ -0,0 +1,52 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'web', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: 'localhost', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + ], + }, + + // proxy all /api/* calls to Django + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://web:8000/api/:path*', + }, + ]; + }, + + eslint: { + // Предупреждение: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки ESLint. + ignoreDuringBuilds: true, + }, + typescript: { + // Опасно: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки TypeScript. + ignoreBuildErrors: true, + }, + + // Добавляем настройки для правильной работы в development + trailingSlash: false, + skipTrailingSlashRedirect: true, +}; + +module.exports = nextConfig; diff --git a/.history/frontend/linktree-frontend/next.config_20251029193800.js b/.history/frontend/linktree-frontend/next.config_20251029193800.js new file mode 100644 index 0000000..fc55f77 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029193800.js @@ -0,0 +1,59 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'web', + port: '8000', + pathname: '/storage/**', + }, + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', + pathname: '/storage/**', + }, + { + protocol: 'http', + hostname: 'localhost', + port: '8000', + pathname: '/storage/**', + }, + ], + }, + + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://web:8000/api/:path*', + }, + ]; + }, + + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + + trailingSlash: false, + skipTrailingSlashRedirect: true, + + async headers() { + return [ + { + source: '/api/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' }, + { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' }, + ], + }, + ]; + }, +}; + +export default nextConfig; \ No newline at end of file diff --git a/.history/frontend/linktree-frontend/next.config_20251029194257.js b/.history/frontend/linktree-frontend/next.config_20251029194257.js new file mode 100644 index 0000000..fc55f77 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029194257.js @@ -0,0 +1,59 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'web', + port: '8000', + pathname: '/storage/**', + }, + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', + pathname: '/storage/**', + }, + { + protocol: 'http', + hostname: 'localhost', + port: '8000', + pathname: '/storage/**', + }, + ], + }, + + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://web:8000/api/:path*', + }, + ]; + }, + + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + + trailingSlash: false, + skipTrailingSlashRedirect: true, + + async headers() { + return [ + { + source: '/api/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' }, + { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' }, + ], + }, + ]; + }, +}; + +export default nextConfig; \ No newline at end of file diff --git a/.history/frontend/linktree-frontend/next.config_20251029194257.ts b/.history/frontend/linktree-frontend/next.config_20251029194257.ts new file mode 100644 index 0000000..e9e1aa5 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029194257.ts @@ -0,0 +1,52 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'web', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: 'localhost', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + ], + }, + + // proxy all /api/* calls to Django + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://web:8000/api/:path*', + }, + ]; + }, + + eslint: { + // Предупреждение: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки ESLint. + ignoreDuringBuilds: true, + }, + typescript: { + // Опасно: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки TypeScript. + ignoreBuildErrors: true, + }, + + // Добавляем настройки для правильной работы в development + trailingSlash: false, + skipTrailingSlashRedirect: true, +}; + +module.exports = nextConfig; diff --git a/.history/frontend/linktree-frontend/next.config_20251029194322.ts b/.history/frontend/linktree-frontend/next.config_20251029194322.ts new file mode 100644 index 0000000..fcd94e8 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029194322.ts @@ -0,0 +1,56 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'web', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: 'localhost', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + ], + }, + + // proxy all /api/* calls to Django + async rewrites() { + return [ + { + source: '/api/:path*/', + destination: 'http://web:8000/api/:path*/', + }, + { + source: '/api/:path*', + destination: 'http://web:8000/api/:path*/', + }, + ]; + }, + + eslint: { + // Предупреждение: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки ESLint. + ignoreDuringBuilds: true, + }, + typescript: { + // Опасно: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки TypeScript. + ignoreBuildErrors: true, + }, + + // Добавляем настройки для правильной работы в development + trailingSlash: false, + skipTrailingSlashRedirect: true, +}; + +module.exports = nextConfig; diff --git a/.history/frontend/linktree-frontend/next.config_20251029194335.ts b/.history/frontend/linktree-frontend/next.config_20251029194335.ts new file mode 100644 index 0000000..fcd94e8 --- /dev/null +++ b/.history/frontend/linktree-frontend/next.config_20251029194335.ts @@ -0,0 +1,56 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'web', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: '127.0.0.1', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + { + protocol: 'http', + hostname: 'localhost', + port: '8000', // where Django serves media + pathname: '/storage/**', // storage/avatars, images/link_groups, etc. + }, + ], + }, + + // proxy all /api/* calls to Django + async rewrites() { + return [ + { + source: '/api/:path*/', + destination: 'http://web:8000/api/:path*/', + }, + { + source: '/api/:path*', + destination: 'http://web:8000/api/:path*/', + }, + ]; + }, + + eslint: { + // Предупреждение: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки ESLint. + ignoreDuringBuilds: true, + }, + typescript: { + // Опасно: это позволяет продакшн сборки завершаться успешно даже если + // ваш проект имеет ошибки TypeScript. + ignoreBuildErrors: true, + }, + + // Добавляем настройки для правильной работы в development + trailingSlash: false, + skipTrailingSlashRedirect: true, +}; + +module.exports = nextConfig; diff --git a/.history/frontend/linktree-frontend/package_20251029195704.json b/.history/frontend/linktree-frontend/package_20251029195704.json new file mode 100644 index 0000000..ea3ac8b --- /dev/null +++ b/.history/frontend/linktree-frontend/package_20251029195704.json @@ -0,0 +1,32 @@ +{ + "name": "linktree-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "axios": "^1.9.0", + "next": "15.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.56.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4.1.5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "eslint": "^9", + "eslint-config-next": "15.3.1", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.5", + "typescript": "^5" + } +} diff --git a/.history/frontend/linktree-frontend/package_20251029195711.json b/.history/frontend/linktree-frontend/package_20251029195711.json new file mode 100644 index 0000000..ea3ac8b --- /dev/null +++ b/.history/frontend/linktree-frontend/package_20251029195711.json @@ -0,0 +1,32 @@ +{ + "name": "linktree-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "axios": "^1.9.0", + "next": "15.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.56.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4.1.5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "eslint": "^9", + "eslint-config-next": "15.3.1", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.5", + "typescript": "^5" + } +} diff --git a/.history/frontend/linktree-frontend/package_20251029195829.json b/.history/frontend/linktree-frontend/package_20251029195829.json new file mode 100644 index 0000000..7601bae --- /dev/null +++ b/.history/frontend/linktree-frontend/package_20251029195829.json @@ -0,0 +1,31 @@ +{ + "name": "linktree-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "axios": "^1.9.0", + "next": "15.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.56.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4.1.5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "eslint": "^9", + "eslint-config-next": "15.3.1", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.5", + "typescript": "^5" + } +} diff --git a/.history/frontend/linktree-frontend/package_20251029195831.json b/.history/frontend/linktree-frontend/package_20251029195831.json new file mode 100644 index 0000000..7601bae --- /dev/null +++ b/.history/frontend/linktree-frontend/package_20251029195831.json @@ -0,0 +1,31 @@ +{ + "name": "linktree-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "axios": "^1.9.0", + "next": "15.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.56.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4.1.5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "eslint": "^9", + "eslint-config-next": "15.3.1", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.5", + "typescript": "^5" + } +} diff --git a/.history/frontend/linktree-frontend/src/app/(protected)/auth/login/page_20251029194955.tsx b/.history/frontend/linktree-frontend/src/app/(protected)/auth/login/page_20251029194955.tsx new file mode 100644 index 0000000..bdd9e16 --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/(protected)/auth/login/page_20251029194955.tsx @@ -0,0 +1,95 @@ +// src/app/auth/login/page.tsx +'use client' + +import { useState, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { useRouter } from 'next/navigation' + +type FormData = { username: string; password: string } + +export default function LoginPage() { + const router = useRouter() + const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm() + const [apiError, setApiError] = useState(null) + + useEffect(() => { + if (typeof window !== 'undefined' && localStorage.getItem('token')) { + router.push('/dashboard') + } + }, [router]) + + async function onSubmit(data: FormData) { + setApiError(null) + try { + const res = await fetch( + '/api/auth/login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + } + ) + if (!res.ok) { + const json = await res.json() + setApiError(json.detail || 'Ошибка входа') + return + } + const { access } = await res.json() + localStorage.setItem('token', access) + router.push('/dashboard') + } catch { + setApiError('Сетевая ошибка') + } + } + + return ( +
+
+
+
+
+
+

Welcome back!

+ {apiError &&

{apiError}

} +
+
+ + {errors.username &&
{errors.username.message}
} +
+
+ + {errors.password &&
{errors.password.message}
} +
+ +
+ +
+
+
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/(protected)/auth/login/page_20251029195038.tsx b/.history/frontend/linktree-frontend/src/app/(protected)/auth/login/page_20251029195038.tsx new file mode 100644 index 0000000..bdd9e16 --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/(protected)/auth/login/page_20251029195038.tsx @@ -0,0 +1,95 @@ +// src/app/auth/login/page.tsx +'use client' + +import { useState, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { useRouter } from 'next/navigation' + +type FormData = { username: string; password: string } + +export default function LoginPage() { + const router = useRouter() + const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm() + const [apiError, setApiError] = useState(null) + + useEffect(() => { + if (typeof window !== 'undefined' && localStorage.getItem('token')) { + router.push('/dashboard') + } + }, [router]) + + async function onSubmit(data: FormData) { + setApiError(null) + try { + const res = await fetch( + '/api/auth/login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + } + ) + if (!res.ok) { + const json = await res.json() + setApiError(json.detail || 'Ошибка входа') + return + } + const { access } = await res.json() + localStorage.setItem('token', access) + router.push('/dashboard') + } catch { + setApiError('Сетевая ошибка') + } + } + + return ( +
+
+
+
+
+
+

Welcome back!

+ {apiError &&

{apiError}

} +
+
+ + {errors.username &&
{errors.username.message}
} +
+
+ + {errors.password &&
{errors.password.message}
} +
+ +
+ +
+
+
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195045.tsx b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195045.tsx new file mode 100644 index 0000000..259192d --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195045.tsx @@ -0,0 +1,390 @@ +'use client' + +import React, { useEffect, useState, Fragment } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { ProfileCard } from '../../components/ProfileCard' + +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio?: string + avatar: string + last_login: string + date_joined: string +} + +interface LinkItem { + id: number + title: string + url: string + icon?: string + group: number +} + +interface Group { + id: number + name: string + icon?: string + links: LinkItem[] +} + +export default function DashboardPage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [groups, setGroups] = useState([]) + const [expandedGroup, setExpandedGroup] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // === Для модалок групп === + const [showGroupModal, setShowGroupModal] = useState(false) + const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add') + const [editingGroup, setEditingGroup] = useState(null) + const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({ + name: '', + iconFile: null, + }) + + // === Для модалок ссылок === + const [showLinkModal, setShowLinkModal] = useState(false) + const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add') + const [editingLink, setEditingLink] = useState(null) + const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState(null) + const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({ + title: '', + url: '', + iconFile: null, + }) + + const API = '' + + useEffect(() => { + const token = localStorage.getItem('token') + if (!token) { + router.push('/auth/login') + return + } + // загружаем профиль, группы и ссылки + Promise.all([ + fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + .then(async ([uRes, gRes, lRes]) => { + if (!uRes.ok) throw new Error('Не удалось получить профиль') + if (!gRes.ok) throw new Error('Не удалось загрузить группы') + if (!lRes.ok) throw new Error('Не удалось загрузить ссылки') + const userData = await uRes.json() + const groupsData = await gRes.json() + const linksData = await lRes.json() + + // «привязываем» ссылки к группам + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setUser(userData) + setGroups(enrichedGroups) + }) + .catch(err => setError((err as Error).message)) + .finally(() => setLoading(false)) + }, [router]) + + // Перезагрузка списка групп и ссылок + async function reloadData() { + const token = localStorage.getItem('token')! + const [gRes, lRes] = await Promise.all([ + fetch(`${API}/api/groups/`, { headers: { Authorization: `Bearer ${token}` } }), + fetch(`${API}/api/links/`, { headers: { Authorization: `Bearer ${token}` } }), + ]) + const groupsData = await gRes.json() + const linksData = await lRes.json() + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setGroups(enrichedGroups) + } + + // === Обработчики групп === + function openAddGroup() { + setGroupModalMode('add') + setGroupForm({ name: '', iconFile: null }) + setShowGroupModal(true) + } + function openEditGroup(grp: Group) { + setGroupModalMode('edit') + setEditingGroup(grp) + setGroupForm({ name: grp.name, iconFile: null }) + setShowGroupModal(true) + } + async function handleGroupSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('name', groupForm.name) + if (groupForm.iconFile) fd.append('icon', groupForm.iconFile) + const url = groupModalMode === 'add' + ? `${API}/api/groups/` + : `${API}/api/groups/${editingGroup?.id}/` + const method = groupModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowGroupModal(false) + await reloadData() + } + async function handleDeleteGroup(grp: Group) { + if (!confirm(`Удалить группу "${grp.name}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`${API}/api/groups/${grp.id}/`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + // === Обработчики ссылок === + function openAddLink(grp: Group) { + setLinkModalMode('add') + setCurrentGroupIdForLink(grp.id) + setLinkForm({ title: '', url: '', iconFile: null }) + setShowLinkModal(true) + } + function openEditLink(link: LinkItem) { + setLinkModalMode('edit') + setEditingLink(link) + setCurrentGroupIdForLink(link.group) + setLinkForm({ title: link.title, url: link.url, iconFile: null }) + setShowLinkModal(true) + } + async function handleLinkSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('title', linkForm.title) + fd.append('url', linkForm.url) + if (linkForm.iconFile) fd.append('icon', linkForm.iconFile) + fd.append('group', String(currentGroupIdForLink)) + const url = linkModalMode === 'add' + ? `${API}/api/links/` + : `${API}/api/links/${editingLink?.id}/` + const method = linkModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowLinkModal(false) + await reloadData() + } + async function handleDeleteLink(link: LinkItem) { + if (!confirm(`Удалить ссылку "${link.title}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`${API}/api/links/${link.id}/`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + if (loading) return
Загрузка...
+ if (error) return
{error}
+ + const totalGroups = groups.length + const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0) + + return ( +
+ {user && ( + + )} + +
+
+
+
Группы ссылок
+ +
+ +
+ {groups.map(group => ( + +
+
+ setExpandedGroup(expandedGroup === group.id ? null : group.id) + } + > + {group.icon && ( + {group.name} + )} + {group.name} + + {group.links.length} + +
+
+ + + +
+
+ + {expandedGroup === group.id && ( +
+
    + {group.links.map(link => ( +
  • +
    + {link.icon && ( + {link.title} + )} + + {link.title} + +
    +
    + + +
    +
  • + ))} +
+
+ )} +
+ ))} +
+
+
+ + {/* Модалка добавления/редактирования группы */} +
+
+
+
+
{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}
+
+
+
+ + setGroupForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+ + {/* Модалка добавления/редактирования ссылки */} +
+
+
+
+
{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}
+
+
+
+ + setLinkForm(f => ({ ...f, title: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, url: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195109.tsx b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195109.tsx new file mode 100644 index 0000000..e7968bd --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195109.tsx @@ -0,0 +1,390 @@ +'use client' + +import React, { useEffect, useState, Fragment } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { ProfileCard } from '../../components/ProfileCard' + +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio?: string + avatar: string + last_login: string + date_joined: string +} + +interface LinkItem { + id: number + title: string + url: string + icon?: string + group: number +} + +interface Group { + id: number + name: string + icon?: string + links: LinkItem[] +} + +export default function DashboardPage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [groups, setGroups] = useState([]) + const [expandedGroup, setExpandedGroup] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // === Для модалок групп === + const [showGroupModal, setShowGroupModal] = useState(false) + const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add') + const [editingGroup, setEditingGroup] = useState(null) + const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({ + name: '', + iconFile: null, + }) + + // === Для модалок ссылок === + const [showLinkModal, setShowLinkModal] = useState(false) + const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add') + const [editingLink, setEditingLink] = useState(null) + const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState(null) + const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({ + title: '', + url: '', + iconFile: null, + }) + + const API = '' + + useEffect(() => { + const token = localStorage.getItem('token') + if (!token) { + router.push('/auth/login') + return + } + // загружаем профиль, группы и ссылки + Promise.all([ + fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + .then(async ([uRes, gRes, lRes]) => { + if (!uRes.ok) throw new Error('Не удалось получить профиль') + if (!gRes.ok) throw new Error('Не удалось загрузить группы') + if (!lRes.ok) throw new Error('Не удалось загрузить ссылки') + const userData = await uRes.json() + const groupsData = await gRes.json() + const linksData = await lRes.json() + + // «привязываем» ссылки к группам + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setUser(userData) + setGroups(enrichedGroups) + }) + .catch(err => setError((err as Error).message)) + .finally(() => setLoading(false)) + }, [router]) + + // Перезагрузка списка групп и ссылок + async function reloadData() { + const token = localStorage.getItem('token')! + const [gRes, lRes] = await Promise.all([ + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + const groupsData = await gRes.json() + const linksData = await lRes.json() + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setGroups(enrichedGroups) + } + + // === Обработчики групп === + function openAddGroup() { + setGroupModalMode('add') + setGroupForm({ name: '', iconFile: null }) + setShowGroupModal(true) + } + function openEditGroup(grp: Group) { + setGroupModalMode('edit') + setEditingGroup(grp) + setGroupForm({ name: grp.name, iconFile: null }) + setShowGroupModal(true) + } + async function handleGroupSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('name', groupForm.name) + if (groupForm.iconFile) fd.append('icon', groupForm.iconFile) + const url = groupModalMode === 'add' + ? `${API}/api/groups/` + : `${API}/api/groups/${editingGroup?.id}/` + const method = groupModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowGroupModal(false) + await reloadData() + } + async function handleDeleteGroup(grp: Group) { + if (!confirm(`Удалить группу "${grp.name}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`${API}/api/groups/${grp.id}/`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + // === Обработчики ссылок === + function openAddLink(grp: Group) { + setLinkModalMode('add') + setCurrentGroupIdForLink(grp.id) + setLinkForm({ title: '', url: '', iconFile: null }) + setShowLinkModal(true) + } + function openEditLink(link: LinkItem) { + setLinkModalMode('edit') + setEditingLink(link) + setCurrentGroupIdForLink(link.group) + setLinkForm({ title: link.title, url: link.url, iconFile: null }) + setShowLinkModal(true) + } + async function handleLinkSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('title', linkForm.title) + fd.append('url', linkForm.url) + if (linkForm.iconFile) fd.append('icon', linkForm.iconFile) + fd.append('group', String(currentGroupIdForLink)) + const url = linkModalMode === 'add' + ? `${API}/api/links/` + : `${API}/api/links/${editingLink?.id}/` + const method = linkModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowLinkModal(false) + await reloadData() + } + async function handleDeleteLink(link: LinkItem) { + if (!confirm(`Удалить ссылку "${link.title}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`${API}/api/links/${link.id}/`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + if (loading) return
Загрузка...
+ if (error) return
{error}
+ + const totalGroups = groups.length + const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0) + + return ( +
+ {user && ( + + )} + +
+
+
+
Группы ссылок
+ +
+ +
+ {groups.map(group => ( + +
+
+ setExpandedGroup(expandedGroup === group.id ? null : group.id) + } + > + {group.icon && ( + {group.name} + )} + {group.name} + + {group.links.length} + +
+
+ + + +
+
+ + {expandedGroup === group.id && ( +
+
    + {group.links.map(link => ( +
  • +
    + {link.icon && ( + {link.title} + )} + + {link.title} + +
    +
    + + +
    +
  • + ))} +
+
+ )} +
+ ))} +
+
+
+ + {/* Модалка добавления/редактирования группы */} +
+
+
+
+
{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}
+
+
+
+ + setGroupForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+ + {/* Модалка добавления/редактирования ссылки */} +
+
+
+
+
{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}
+
+
+
+ + setLinkForm(f => ({ ...f, title: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, url: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195124.tsx b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195124.tsx new file mode 100644 index 0000000..0816be4 --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195124.tsx @@ -0,0 +1,390 @@ +'use client' + +import React, { useEffect, useState, Fragment } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { ProfileCard } from '../../components/ProfileCard' + +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio?: string + avatar: string + last_login: string + date_joined: string +} + +interface LinkItem { + id: number + title: string + url: string + icon?: string + group: number +} + +interface Group { + id: number + name: string + icon?: string + links: LinkItem[] +} + +export default function DashboardPage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [groups, setGroups] = useState([]) + const [expandedGroup, setExpandedGroup] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // === Для модалок групп === + const [showGroupModal, setShowGroupModal] = useState(false) + const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add') + const [editingGroup, setEditingGroup] = useState(null) + const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({ + name: '', + iconFile: null, + }) + + // === Для модалок ссылок === + const [showLinkModal, setShowLinkModal] = useState(false) + const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add') + const [editingLink, setEditingLink] = useState(null) + const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState(null) + const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({ + title: '', + url: '', + iconFile: null, + }) + + const API = '' + + useEffect(() => { + const token = localStorage.getItem('token') + if (!token) { + router.push('/auth/login') + return + } + // загружаем профиль, группы и ссылки + Promise.all([ + fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + .then(async ([uRes, gRes, lRes]) => { + if (!uRes.ok) throw new Error('Не удалось получить профиль') + if (!gRes.ok) throw new Error('Не удалось загрузить группы') + if (!lRes.ok) throw new Error('Не удалось загрузить ссылки') + const userData = await uRes.json() + const groupsData = await gRes.json() + const linksData = await lRes.json() + + // «привязываем» ссылки к группам + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setUser(userData) + setGroups(enrichedGroups) + }) + .catch(err => setError((err as Error).message)) + .finally(() => setLoading(false)) + }, [router]) + + // Перезагрузка списка групп и ссылок + async function reloadData() { + const token = localStorage.getItem('token')! + const [gRes, lRes] = await Promise.all([ + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + const groupsData = await gRes.json() + const linksData = await lRes.json() + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setGroups(enrichedGroups) + } + + // === Обработчики групп === + function openAddGroup() { + setGroupModalMode('add') + setGroupForm({ name: '', iconFile: null }) + setShowGroupModal(true) + } + function openEditGroup(grp: Group) { + setGroupModalMode('edit') + setEditingGroup(grp) + setGroupForm({ name: grp.name, iconFile: null }) + setShowGroupModal(true) + } + async function handleGroupSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('name', groupForm.name) + if (groupForm.iconFile) fd.append('icon', groupForm.iconFile) + const url = groupModalMode === 'add' + ? '/api/groups' + : `/api/groups/${editingGroup?.id}` + const method = groupModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowGroupModal(false) + await reloadData() + } + async function handleDeleteGroup(grp: Group) { + if (!confirm(`Удалить группу "${grp.name}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`/api/groups/${grp.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + // === Обработчики ссылок === + function openAddLink(grp: Group) { + setLinkModalMode('add') + setCurrentGroupIdForLink(grp.id) + setLinkForm({ title: '', url: '', iconFile: null }) + setShowLinkModal(true) + } + function openEditLink(link: LinkItem) { + setLinkModalMode('edit') + setEditingLink(link) + setCurrentGroupIdForLink(link.group) + setLinkForm({ title: link.title, url: link.url, iconFile: null }) + setShowLinkModal(true) + } + async function handleLinkSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('title', linkForm.title) + fd.append('url', linkForm.url) + if (linkForm.iconFile) fd.append('icon', linkForm.iconFile) + fd.append('group', String(currentGroupIdForLink)) + const url = linkModalMode === 'add' + ? `${API}/api/links/` + : `${API}/api/links/${editingLink?.id}/` + const method = linkModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowLinkModal(false) + await reloadData() + } + async function handleDeleteLink(link: LinkItem) { + if (!confirm(`Удалить ссылку "${link.title}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`${API}/api/links/${link.id}/`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + if (loading) return
Загрузка...
+ if (error) return
{error}
+ + const totalGroups = groups.length + const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0) + + return ( +
+ {user && ( + + )} + +
+
+
+
Группы ссылок
+ +
+ +
+ {groups.map(group => ( + +
+
+ setExpandedGroup(expandedGroup === group.id ? null : group.id) + } + > + {group.icon && ( + {group.name} + )} + {group.name} + + {group.links.length} + +
+
+ + + +
+
+ + {expandedGroup === group.id && ( +
+
    + {group.links.map(link => ( +
  • +
    + {link.icon && ( + {link.title} + )} + + {link.title} + +
    +
    + + +
    +
  • + ))} +
+
+ )} +
+ ))} +
+
+
+ + {/* Модалка добавления/редактирования группы */} +
+
+
+
+
{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}
+
+
+
+ + setGroupForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+ + {/* Модалка добавления/редактирования ссылки */} +
+
+
+
+
{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}
+
+
+
+ + setLinkForm(f => ({ ...f, title: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, url: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195139.tsx b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195139.tsx new file mode 100644 index 0000000..97d5e4f --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195139.tsx @@ -0,0 +1,390 @@ +'use client' + +import React, { useEffect, useState, Fragment } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { ProfileCard } from '../../components/ProfileCard' + +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio?: string + avatar: string + last_login: string + date_joined: string +} + +interface LinkItem { + id: number + title: string + url: string + icon?: string + group: number +} + +interface Group { + id: number + name: string + icon?: string + links: LinkItem[] +} + +export default function DashboardPage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [groups, setGroups] = useState([]) + const [expandedGroup, setExpandedGroup] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // === Для модалок групп === + const [showGroupModal, setShowGroupModal] = useState(false) + const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add') + const [editingGroup, setEditingGroup] = useState(null) + const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({ + name: '', + iconFile: null, + }) + + // === Для модалок ссылок === + const [showLinkModal, setShowLinkModal] = useState(false) + const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add') + const [editingLink, setEditingLink] = useState(null) + const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState(null) + const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({ + title: '', + url: '', + iconFile: null, + }) + + const API = '' + + useEffect(() => { + const token = localStorage.getItem('token') + if (!token) { + router.push('/auth/login') + return + } + // загружаем профиль, группы и ссылки + Promise.all([ + fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + .then(async ([uRes, gRes, lRes]) => { + if (!uRes.ok) throw new Error('Не удалось получить профиль') + if (!gRes.ok) throw new Error('Не удалось загрузить группы') + if (!lRes.ok) throw new Error('Не удалось загрузить ссылки') + const userData = await uRes.json() + const groupsData = await gRes.json() + const linksData = await lRes.json() + + // «привязываем» ссылки к группам + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setUser(userData) + setGroups(enrichedGroups) + }) + .catch(err => setError((err as Error).message)) + .finally(() => setLoading(false)) + }, [router]) + + // Перезагрузка списка групп и ссылок + async function reloadData() { + const token = localStorage.getItem('token')! + const [gRes, lRes] = await Promise.all([ + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + const groupsData = await gRes.json() + const linksData = await lRes.json() + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setGroups(enrichedGroups) + } + + // === Обработчики групп === + function openAddGroup() { + setGroupModalMode('add') + setGroupForm({ name: '', iconFile: null }) + setShowGroupModal(true) + } + function openEditGroup(grp: Group) { + setGroupModalMode('edit') + setEditingGroup(grp) + setGroupForm({ name: grp.name, iconFile: null }) + setShowGroupModal(true) + } + async function handleGroupSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('name', groupForm.name) + if (groupForm.iconFile) fd.append('icon', groupForm.iconFile) + const url = groupModalMode === 'add' + ? '/api/groups' + : `/api/groups/${editingGroup?.id}` + const method = groupModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowGroupModal(false) + await reloadData() + } + async function handleDeleteGroup(grp: Group) { + if (!confirm(`Удалить группу "${grp.name}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`/api/groups/${grp.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + // === Обработчики ссылок === + function openAddLink(grp: Group) { + setLinkModalMode('add') + setCurrentGroupIdForLink(grp.id) + setLinkForm({ title: '', url: '', iconFile: null }) + setShowLinkModal(true) + } + function openEditLink(link: LinkItem) { + setLinkModalMode('edit') + setEditingLink(link) + setCurrentGroupIdForLink(link.group) + setLinkForm({ title: link.title, url: link.url, iconFile: null }) + setShowLinkModal(true) + } + async function handleLinkSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('title', linkForm.title) + fd.append('url', linkForm.url) + if (linkForm.iconFile) fd.append('icon', linkForm.iconFile) + fd.append('group', String(currentGroupIdForLink)) + const url = linkModalMode === 'add' + ? '/api/links' + : `/api/links/${editingLink?.id}` + const method = linkModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowLinkModal(false) + await reloadData() + } + async function handleDeleteLink(link: LinkItem) { + if (!confirm(`Удалить ссылку "${link.title}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`${API}/api/links/${link.id}/`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + if (loading) return
Загрузка...
+ if (error) return
{error}
+ + const totalGroups = groups.length + const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0) + + return ( +
+ {user && ( + + )} + +
+
+
+
Группы ссылок
+ +
+ +
+ {groups.map(group => ( + +
+
+ setExpandedGroup(expandedGroup === group.id ? null : group.id) + } + > + {group.icon && ( + {group.name} + )} + {group.name} + + {group.links.length} + +
+
+ + + +
+
+ + {expandedGroup === group.id && ( +
+
    + {group.links.map(link => ( +
  • +
    + {link.icon && ( + {link.title} + )} + + {link.title} + +
    +
    + + +
    +
  • + ))} +
+
+ )} +
+ ))} +
+
+
+ + {/* Модалка добавления/редактирования группы */} +
+
+
+
+
{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}
+
+
+
+ + setGroupForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+ + {/* Модалка добавления/редактирования ссылки */} +
+
+
+
+
{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}
+
+
+
+ + setLinkForm(f => ({ ...f, title: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, url: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195152.tsx b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195152.tsx new file mode 100644 index 0000000..c16d9b6 --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195152.tsx @@ -0,0 +1,390 @@ +'use client' + +import React, { useEffect, useState, Fragment } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { ProfileCard } from '../../components/ProfileCard' + +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio?: string + avatar: string + last_login: string + date_joined: string +} + +interface LinkItem { + id: number + title: string + url: string + icon?: string + group: number +} + +interface Group { + id: number + name: string + icon?: string + links: LinkItem[] +} + +export default function DashboardPage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [groups, setGroups] = useState([]) + const [expandedGroup, setExpandedGroup] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // === Для модалок групп === + const [showGroupModal, setShowGroupModal] = useState(false) + const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add') + const [editingGroup, setEditingGroup] = useState(null) + const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({ + name: '', + iconFile: null, + }) + + // === Для модалок ссылок === + const [showLinkModal, setShowLinkModal] = useState(false) + const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add') + const [editingLink, setEditingLink] = useState(null) + const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState(null) + const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({ + title: '', + url: '', + iconFile: null, + }) + + const API = '' + + useEffect(() => { + const token = localStorage.getItem('token') + if (!token) { + router.push('/auth/login') + return + } + // загружаем профиль, группы и ссылки + Promise.all([ + fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + .then(async ([uRes, gRes, lRes]) => { + if (!uRes.ok) throw new Error('Не удалось получить профиль') + if (!gRes.ok) throw new Error('Не удалось загрузить группы') + if (!lRes.ok) throw new Error('Не удалось загрузить ссылки') + const userData = await uRes.json() + const groupsData = await gRes.json() + const linksData = await lRes.json() + + // «привязываем» ссылки к группам + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setUser(userData) + setGroups(enrichedGroups) + }) + .catch(err => setError((err as Error).message)) + .finally(() => setLoading(false)) + }, [router]) + + // Перезагрузка списка групп и ссылок + async function reloadData() { + const token = localStorage.getItem('token')! + const [gRes, lRes] = await Promise.all([ + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + const groupsData = await gRes.json() + const linksData = await lRes.json() + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setGroups(enrichedGroups) + } + + // === Обработчики групп === + function openAddGroup() { + setGroupModalMode('add') + setGroupForm({ name: '', iconFile: null }) + setShowGroupModal(true) + } + function openEditGroup(grp: Group) { + setGroupModalMode('edit') + setEditingGroup(grp) + setGroupForm({ name: grp.name, iconFile: null }) + setShowGroupModal(true) + } + async function handleGroupSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('name', groupForm.name) + if (groupForm.iconFile) fd.append('icon', groupForm.iconFile) + const url = groupModalMode === 'add' + ? '/api/groups' + : `/api/groups/${editingGroup?.id}` + const method = groupModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowGroupModal(false) + await reloadData() + } + async function handleDeleteGroup(grp: Group) { + if (!confirm(`Удалить группу "${grp.name}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`/api/groups/${grp.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + // === Обработчики ссылок === + function openAddLink(grp: Group) { + setLinkModalMode('add') + setCurrentGroupIdForLink(grp.id) + setLinkForm({ title: '', url: '', iconFile: null }) + setShowLinkModal(true) + } + function openEditLink(link: LinkItem) { + setLinkModalMode('edit') + setEditingLink(link) + setCurrentGroupIdForLink(link.group) + setLinkForm({ title: link.title, url: link.url, iconFile: null }) + setShowLinkModal(true) + } + async function handleLinkSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('title', linkForm.title) + fd.append('url', linkForm.url) + if (linkForm.iconFile) fd.append('icon', linkForm.iconFile) + fd.append('group', String(currentGroupIdForLink)) + const url = linkModalMode === 'add' + ? '/api/links' + : `/api/links/${editingLink?.id}` + const method = linkModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowLinkModal(false) + await reloadData() + } + async function handleDeleteLink(link: LinkItem) { + if (!confirm(`Удалить ссылку "${link.title}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`/api/links/${link.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + if (loading) return
Загрузка...
+ if (error) return
{error}
+ + const totalGroups = groups.length + const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0) + + return ( +
+ {user && ( + + )} + +
+
+
+
Группы ссылок
+ +
+ +
+ {groups.map(group => ( + +
+
+ setExpandedGroup(expandedGroup === group.id ? null : group.id) + } + > + {group.icon && ( + {group.name} + )} + {group.name} + + {group.links.length} + +
+
+ + + +
+
+ + {expandedGroup === group.id && ( +
+
    + {group.links.map(link => ( +
  • +
    + {link.icon && ( + {link.title} + )} + + {link.title} + +
    +
    + + +
    +
  • + ))} +
+
+ )} +
+ ))} +
+
+
+ + {/* Модалка добавления/редактирования группы */} +
+
+
+
+
{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}
+
+
+
+ + setGroupForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+ + {/* Модалка добавления/редактирования ссылки */} +
+
+
+
+
{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}
+
+
+
+ + setLinkForm(f => ({ ...f, title: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, url: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195243.tsx b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195243.tsx new file mode 100644 index 0000000..c16d9b6 --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/(protected)/dashboard/page_20251029195243.tsx @@ -0,0 +1,390 @@ +'use client' + +import React, { useEffect, useState, Fragment } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { ProfileCard } from '../../components/ProfileCard' + +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio?: string + avatar: string + last_login: string + date_joined: string +} + +interface LinkItem { + id: number + title: string + url: string + icon?: string + group: number +} + +interface Group { + id: number + name: string + icon?: string + links: LinkItem[] +} + +export default function DashboardPage() { + const router = useRouter() + const [user, setUser] = useState(null) + const [groups, setGroups] = useState([]) + const [expandedGroup, setExpandedGroup] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // === Для модалок групп === + const [showGroupModal, setShowGroupModal] = useState(false) + const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add') + const [editingGroup, setEditingGroup] = useState(null) + const [groupForm, setGroupForm] = useState<{ name: string; iconFile: File | null }>({ + name: '', + iconFile: null, + }) + + // === Для модалок ссылок === + const [showLinkModal, setShowLinkModal] = useState(false) + const [linkModalMode, setLinkModalMode] = useState<'add' | 'edit'>('add') + const [editingLink, setEditingLink] = useState(null) + const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState(null) + const [linkForm, setLinkForm] = useState<{ title: string; url: string; iconFile: File | null }>({ + title: '', + url: '', + iconFile: null, + }) + + const API = '' + + useEffect(() => { + const token = localStorage.getItem('token') + if (!token) { + router.push('/auth/login') + return + } + // загружаем профиль, группы и ссылки + Promise.all([ + fetch('/api/auth/user', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + .then(async ([uRes, gRes, lRes]) => { + if (!uRes.ok) throw new Error('Не удалось получить профиль') + if (!gRes.ok) throw new Error('Не удалось загрузить группы') + if (!lRes.ok) throw new Error('Не удалось загрузить ссылки') + const userData = await uRes.json() + const groupsData = await gRes.json() + const linksData = await lRes.json() + + // «привязываем» ссылки к группам + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setUser(userData) + setGroups(enrichedGroups) + }) + .catch(err => setError((err as Error).message)) + .finally(() => setLoading(false)) + }, [router]) + + // Перезагрузка списка групп и ссылок + async function reloadData() { + const token = localStorage.getItem('token')! + const [gRes, lRes] = await Promise.all([ + fetch('/api/groups', { headers: { Authorization: `Bearer ${token}` } }), + fetch('/api/links', { headers: { Authorization: `Bearer ${token}` } }), + ]) + const groupsData = await gRes.json() + const linksData = await lRes.json() + const enrichedGroups: Group[] = groupsData.map((grp: any) => ({ + ...grp, + links: linksData.filter((link: LinkItem) => link.group === grp.id), + })) + setGroups(enrichedGroups) + } + + // === Обработчики групп === + function openAddGroup() { + setGroupModalMode('add') + setGroupForm({ name: '', iconFile: null }) + setShowGroupModal(true) + } + function openEditGroup(grp: Group) { + setGroupModalMode('edit') + setEditingGroup(grp) + setGroupForm({ name: grp.name, iconFile: null }) + setShowGroupModal(true) + } + async function handleGroupSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('name', groupForm.name) + if (groupForm.iconFile) fd.append('icon', groupForm.iconFile) + const url = groupModalMode === 'add' + ? '/api/groups' + : `/api/groups/${editingGroup?.id}` + const method = groupModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowGroupModal(false) + await reloadData() + } + async function handleDeleteGroup(grp: Group) { + if (!confirm(`Удалить группу "${grp.name}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`/api/groups/${grp.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + // === Обработчики ссылок === + function openAddLink(grp: Group) { + setLinkModalMode('add') + setCurrentGroupIdForLink(grp.id) + setLinkForm({ title: '', url: '', iconFile: null }) + setShowLinkModal(true) + } + function openEditLink(link: LinkItem) { + setLinkModalMode('edit') + setEditingLink(link) + setCurrentGroupIdForLink(link.group) + setLinkForm({ title: link.title, url: link.url, iconFile: null }) + setShowLinkModal(true) + } + async function handleLinkSubmit() { + const token = localStorage.getItem('token')! + const fd = new FormData() + fd.append('title', linkForm.title) + fd.append('url', linkForm.url) + if (linkForm.iconFile) fd.append('icon', linkForm.iconFile) + fd.append('group', String(currentGroupIdForLink)) + const url = linkModalMode === 'add' + ? '/api/links' + : `/api/links/${editingLink?.id}` + const method = linkModalMode === 'add' ? 'POST' : 'PATCH' + await fetch(url, { + method, + headers: { Authorization: `Bearer ${token}` }, + body: fd, + }) + setShowLinkModal(false) + await reloadData() + } + async function handleDeleteLink(link: LinkItem) { + if (!confirm(`Удалить ссылку "${link.title}"?`)) return + const token = localStorage.getItem('token')! + await fetch(`/api/links/${link.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + await reloadData() + } + + if (loading) return
Загрузка...
+ if (error) return
{error}
+ + const totalGroups = groups.length + const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0) + + return ( +
+ {user && ( + + )} + +
+
+
+
Группы ссылок
+ +
+ +
+ {groups.map(group => ( + +
+
+ setExpandedGroup(expandedGroup === group.id ? null : group.id) + } + > + {group.icon && ( + {group.name} + )} + {group.name} + + {group.links.length} + +
+
+ + + +
+
+ + {expandedGroup === group.id && ( +
+
    + {group.links.map(link => ( +
  • +
    + {link.icon && ( + {link.title} + )} + + {link.title} + +
    +
    + + +
    +
  • + ))} +
+
+ )} +
+ ))} +
+
+
+ + {/* Модалка добавления/редактирования группы */} +
+
+
+
+
{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}
+
+
+
+ + setGroupForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+ + {/* Модалка добавления/редактирования ссылки */} +
+
+
+
+
{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}
+
+
+
+ + setLinkForm(f => ({ ...f, title: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, url: e.target.value }))} + /> +
+
+ + setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))} + /> +
+
+
+ + +
+
+
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195204.tsx b/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195204.tsx new file mode 100644 index 0000000..08ff9d4 --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195204.tsx @@ -0,0 +1,141 @@ +// src/app/[username]/page.tsx +import { notFound } from 'next/navigation' +import Image from 'next/image' +import Link from 'next/link' + +interface LinkItem { + id: number + title: string + url: string + image?: string +} + +interface Group { + id: number + name: string + image?: string + links: LinkItem[] +} + +interface UserGroupsData { + username: string + groups: Group[] +} + +export default async function UserPage({ + params, +}: { + params: Promise<{ username: string }> +}) { + const { username } = await params + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' + + const res = await fetch(`${API}/api/users/${username}/public`, { + cache: 'no-store', + }) + if (res.status === 404) return notFound() + if (!res.ok) throw new Error('Ошибка загрузки публичных данных') + + const data: UserGroupsData = await res.json() + + return ( +
+
+

{data.username}

+ +
+ {data.groups.map((group) => { + const groupId = `group-${group.id}` + + return ( +
+

+ +

+
+
+ {group.links.length > 0 ? ( +
    + {group.links.map((link) => ( +
  • +
    + {link.image && ( + {link.title} + )} + + {link.title} + +
    +
  • + ))} +
+ ) : ( +

+ В этой группе пока нет ссылок. +

+ )} +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195205.tsx b/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195205.tsx new file mode 100644 index 0000000..08ff9d4 --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195205.tsx @@ -0,0 +1,141 @@ +// src/app/[username]/page.tsx +import { notFound } from 'next/navigation' +import Image from 'next/image' +import Link from 'next/link' + +interface LinkItem { + id: number + title: string + url: string + image?: string +} + +interface Group { + id: number + name: string + image?: string + links: LinkItem[] +} + +interface UserGroupsData { + username: string + groups: Group[] +} + +export default async function UserPage({ + params, +}: { + params: Promise<{ username: string }> +}) { + const { username } = await params + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' + + const res = await fetch(`${API}/api/users/${username}/public`, { + cache: 'no-store', + }) + if (res.status === 404) return notFound() + if (!res.ok) throw new Error('Ошибка загрузки публичных данных') + + const data: UserGroupsData = await res.json() + + return ( +
+
+

{data.username}

+ +
+ {data.groups.map((group) => { + const groupId = `group-${group.id}` + + return ( +
+

+ +

+
+
+ {group.links.length > 0 ? ( +
    + {group.links.map((link) => ( +
  • +
    + {link.image && ( + {link.title} + )} + + {link.title} + +
    +
  • + ))} +
+ ) : ( +

+ В этой группе пока нет ссылок. +

+ )} +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195243.tsx b/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195243.tsx new file mode 100644 index 0000000..08ff9d4 --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/[username]/page_20251029195243.tsx @@ -0,0 +1,141 @@ +// src/app/[username]/page.tsx +import { notFound } from 'next/navigation' +import Image from 'next/image' +import Link from 'next/link' + +interface LinkItem { + id: number + title: string + url: string + image?: string +} + +interface Group { + id: number + name: string + image?: string + links: LinkItem[] +} + +interface UserGroupsData { + username: string + groups: Group[] +} + +export default async function UserPage({ + params, +}: { + params: Promise<{ username: string }> +}) { + const { username } = await params + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' + + const res = await fetch(`${API}/api/users/${username}/public`, { + cache: 'no-store', + }) + if (res.status === 404) return notFound() + if (!res.ok) throw new Error('Ошибка загрузки публичных данных') + + const data: UserGroupsData = await res.json() + + return ( +
+
+

{data.username}

+ +
+ {data.groups.map((group) => { + const groupId = `group-${group.id}` + + return ( +
+

+ +

+
+
+ {group.links.length > 0 ? ( +
    + {group.links.map((link) => ( +
  • +
    + {link.image && ( + {link.title} + )} + + {link.title} + +
    +
  • + ))} +
+ ) : ( +

+ В этой группе пока нет ссылок. +

+ )} +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/.history/frontend/linktree-frontend/src/app/components/LayoutWrapper_20251029195017.tsx b/.history/frontend/linktree-frontend/src/app/components/LayoutWrapper_20251029195017.tsx new file mode 100644 index 0000000..d40381a --- /dev/null +++ b/.history/frontend/linktree-frontend/src/app/components/LayoutWrapper_20251029195017.tsx @@ -0,0 +1,157 @@ +// src/components/LayoutWrapper.tsx +'use client' + +import React, { ReactNode, useEffect, useState } from 'react' +import { usePathname, useRouter } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import Script from 'next/script' + +interface User { + username: string + avatar: string +} + +export function LayoutWrapper({ children }: { children: ReactNode }) { + const pathname = usePathname() || '' + const isPublicUserPage = /^\/[^\/]+$/.test(pathname) + const isDashboard = pathname === '/dashboard' + const [user, setUser] = useState(null) + const router = useRouter() + + // При монтировании пробуем загрузить профиль + useEffect(() => { + const token = localStorage.getItem('token') + if (token) { + fetch('/api/auth/user', { + headers: { Authorization: `Bearer ${token}` }, + }) + .then(res => { + if (!res.ok) throw new Error() + return res.json() + }) + .then(data => { + // fullname или username + const name = data.full_name?.trim() || data.username + setUser({ username: name, avatar: data.avatar }) + }) + .catch(() => { + // сбросить некорректный токен + localStorage.removeItem('token') + setUser(null) + }) + } + }, []) + + const handleLogout = () => { + localStorage.removeItem('token') + router.push('/') + } + + return ( + <> + {/* Шапка не выводим на публичных страницах /[username] */} + {!isPublicUserPage && ( +
+
+ + )} + + {/* отступ, чтобы контент не прятался под фиксированным хедером */} + {!isPublicUserPage &&
} + + {children} + + {/* Подвал не выводим на публичных страницах */} + {!isPublicUserPage && ( +
+
+
+
+
    +
  • About
  • +
  • +
  • Contact
  • +
  • +
  • Terms of Use
  • +
  • +
  • Privacy Policy
  • +
+

© CatLink 2025

+
+
+
    +
  • +
  • +
  • +
+
+
+
+
+ )} + + {/* Bootstrap JS */} +