refactor. pre-deploy

This commit is contained in:
2025-10-29 20:22:35 +09:00
parent 18497d4343
commit 367e1c932e
113 changed files with 8245 additions and 67 deletions

20
.env.example Normal file
View File

@@ -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

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ __pycache__/
media/ media/
node_modules/ node_modules/
dist/ dist/
.venv/

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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` - Команды для удобного управления

View File

@@ -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` - Команды для удобного управления

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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 <url>
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`.

View File

@@ -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 <url>
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`.

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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/<str:username>/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

View File

@@ -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/<str:username>/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

View File

@@ -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/<str:username>/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

View File

@@ -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/<str:username>/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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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<FormData>()
const [apiError, setApiError] = useState<string | null>(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 (
<div className="d-flex align-items-center justify-content-center" style={{ height: '100vh' }}>
<div className="card shadow-lg border-0" style={{ maxWidth: 800, width: '100%' }}>
<div className="row g-0">
<div className="col-lg-6 d-none d-lg-flex" style={{
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
backgroundSize: 'cover',
backgroundPosition: 'center'
}} />
<div className="col-lg-6">
<div className="p-5">
<h4 className="text-center mb-4">Welcome back!</h4>
{apiError && <p className="text-danger text-center">{apiError}</p>}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
type="text"
placeholder="Enter Username..."
className={`form-control ${errors.username ? 'is-invalid' : ''}`}
{...register('username', { required: 'Введите имя пользователя' })}
/>
{errors.username && <div className="invalid-feedback">{errors.username.message}</div>}
</div>
<div className="mb-3">
<input
type="password"
placeholder="Password"
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
{...register('password', { required: 'Введите пароль' })}
/>
{errors.password && <div className="invalid-feedback">{errors.password.message}</div>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="btn btn-primary w-100"
style={{ background: '#01703E' }}
>
{isSubmitting ? 'Вхожу...' : 'Login'}
</button>
</form>
<div className="text-center mt-3">
<a href="#" className="small text-decoration-none">Forgot Password?</a>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<FormData>()
const [apiError, setApiError] = useState<string | null>(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 (
<div className="d-flex align-items-center justify-content-center" style={{ height: '100vh' }}>
<div className="card shadow-lg border-0" style={{ maxWidth: 800, width: '100%' }}>
<div className="row g-0">
<div className="col-lg-6 d-none d-lg-flex" style={{
backgroundImage: "url('/assets/img/durvill_logo.jpg')",
backgroundSize: 'cover',
backgroundPosition: 'center'
}} />
<div className="col-lg-6">
<div className="p-5">
<h4 className="text-center mb-4">Welcome back!</h4>
{apiError && <p className="text-danger text-center">{apiError}</p>}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<input
type="text"
placeholder="Enter Username..."
className={`form-control ${errors.username ? 'is-invalid' : ''}`}
{...register('username', { required: 'Введите имя пользователя' })}
/>
{errors.username && <div className="invalid-feedback">{errors.username.message}</div>}
</div>
<div className="mb-3">
<input
type="password"
placeholder="Password"
className={`form-control ${errors.password ? 'is-invalid' : ''}`}
{...register('password', { required: 'Введите пароль' })}
/>
{errors.password && <div className="invalid-feedback">{errors.password.message}</div>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="btn btn-primary w-100"
style={{ background: '#01703E' }}
>
{isSubmitting ? 'Вхожу...' : 'Login'}
</button>
</form>
<div className="text-center mt-3">
<a href="#" className="small text-decoration-none">Forgot Password?</a>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// === Для модалок групп ===
const [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [editingGroup, setEditingGroup] = useState<Group | null>(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<LinkItem | null>(null)
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(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 <div className="flex items-center justify-center h-screen">Загрузка...</div>
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
const totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
return (
<div className="pb-8">
{user && (
<ProfileCard
avatar={user.avatar}
full_name={user.full_name}
email={user.email}
bio={user.bio}
last_login={user.last_login}
date_joined={user.date_joined}
totalGroups={totalGroups}
totalLinks={totalLinks}
/>
)}
<section className="mt-5 container">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item d-flex justify-content-between align-items-center">
<div
className="d-flex align-items-center"
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(expandedGroup === group.id ? null : group.id)
}
>
{group.icon && (
<img
src={`${API}${group.icon}`}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2">{group.name}</strong>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{expandedGroup === group.id && (
<div className="list-group-item bg-light">
<ul className="mb-0 ps-3">
{group.links.map(link => (
<li
key={link.id}
className="d-flex justify-content-between align-items-center mb-2"
>
<div className="d-flex align-items-center">
{link.icon && (
<img
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
width={24}
height={24}
className="me-2"
alt={link.title}
/>
)}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.title}
</a>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
{/* Модалка добавления/редактирования группы */}
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<input
type="text"
className="form-control"
value={groupForm.name}
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Модалка добавления/редактирования ссылки */}
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control"
value={linkForm.title}
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={linkForm.url}
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// === Для модалок групп ===
const [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [editingGroup, setEditingGroup] = useState<Group | null>(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<LinkItem | null>(null)
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(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 <div className="flex items-center justify-center h-screen">Загрузка...</div>
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
const totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
return (
<div className="pb-8">
{user && (
<ProfileCard
avatar={user.avatar}
full_name={user.full_name}
email={user.email}
bio={user.bio}
last_login={user.last_login}
date_joined={user.date_joined}
totalGroups={totalGroups}
totalLinks={totalLinks}
/>
)}
<section className="mt-5 container">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item d-flex justify-content-between align-items-center">
<div
className="d-flex align-items-center"
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(expandedGroup === group.id ? null : group.id)
}
>
{group.icon && (
<img
src={`${API}${group.icon}`}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2">{group.name}</strong>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{expandedGroup === group.id && (
<div className="list-group-item bg-light">
<ul className="mb-0 ps-3">
{group.links.map(link => (
<li
key={link.id}
className="d-flex justify-content-between align-items-center mb-2"
>
<div className="d-flex align-items-center">
{link.icon && (
<img
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
width={24}
height={24}
className="me-2"
alt={link.title}
/>
)}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.title}
</a>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
{/* Модалка добавления/редактирования группы */}
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<input
type="text"
className="form-control"
value={groupForm.name}
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Модалка добавления/редактирования ссылки */}
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control"
value={linkForm.title}
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={linkForm.url}
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// === Для модалок групп ===
const [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [editingGroup, setEditingGroup] = useState<Group | null>(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<LinkItem | null>(null)
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(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 <div className="flex items-center justify-center h-screen">Загрузка...</div>
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
const totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
return (
<div className="pb-8">
{user && (
<ProfileCard
avatar={user.avatar}
full_name={user.full_name}
email={user.email}
bio={user.bio}
last_login={user.last_login}
date_joined={user.date_joined}
totalGroups={totalGroups}
totalLinks={totalLinks}
/>
)}
<section className="mt-5 container">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item d-flex justify-content-between align-items-center">
<div
className="d-flex align-items-center"
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(expandedGroup === group.id ? null : group.id)
}
>
{group.icon && (
<img
src={`${API}${group.icon}`}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2">{group.name}</strong>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{expandedGroup === group.id && (
<div className="list-group-item bg-light">
<ul className="mb-0 ps-3">
{group.links.map(link => (
<li
key={link.id}
className="d-flex justify-content-between align-items-center mb-2"
>
<div className="d-flex align-items-center">
{link.icon && (
<img
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
width={24}
height={24}
className="me-2"
alt={link.title}
/>
)}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.title}
</a>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
{/* Модалка добавления/редактирования группы */}
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<input
type="text"
className="form-control"
value={groupForm.name}
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Модалка добавления/редактирования ссылки */}
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control"
value={linkForm.title}
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={linkForm.url}
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// === Для модалок групп ===
const [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [editingGroup, setEditingGroup] = useState<Group | null>(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<LinkItem | null>(null)
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(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 <div className="flex items-center justify-center h-screen">Загрузка...</div>
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
const totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
return (
<div className="pb-8">
{user && (
<ProfileCard
avatar={user.avatar}
full_name={user.full_name}
email={user.email}
bio={user.bio}
last_login={user.last_login}
date_joined={user.date_joined}
totalGroups={totalGroups}
totalLinks={totalLinks}
/>
)}
<section className="mt-5 container">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item d-flex justify-content-between align-items-center">
<div
className="d-flex align-items-center"
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(expandedGroup === group.id ? null : group.id)
}
>
{group.icon && (
<img
src={`${API}${group.icon}`}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2">{group.name}</strong>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{expandedGroup === group.id && (
<div className="list-group-item bg-light">
<ul className="mb-0 ps-3">
{group.links.map(link => (
<li
key={link.id}
className="d-flex justify-content-between align-items-center mb-2"
>
<div className="d-flex align-items-center">
{link.icon && (
<img
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
width={24}
height={24}
className="me-2"
alt={link.title}
/>
)}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.title}
</a>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
{/* Модалка добавления/редактирования группы */}
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<input
type="text"
className="form-control"
value={groupForm.name}
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Модалка добавления/редактирования ссылки */}
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control"
value={linkForm.title}
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={linkForm.url}
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// === Для модалок групп ===
const [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [editingGroup, setEditingGroup] = useState<Group | null>(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<LinkItem | null>(null)
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(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 <div className="flex items-center justify-center h-screen">Загрузка...</div>
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
const totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
return (
<div className="pb-8">
{user && (
<ProfileCard
avatar={user.avatar}
full_name={user.full_name}
email={user.email}
bio={user.bio}
last_login={user.last_login}
date_joined={user.date_joined}
totalGroups={totalGroups}
totalLinks={totalLinks}
/>
)}
<section className="mt-5 container">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item d-flex justify-content-between align-items-center">
<div
className="d-flex align-items-center"
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(expandedGroup === group.id ? null : group.id)
}
>
{group.icon && (
<img
src={`${API}${group.icon}`}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2">{group.name}</strong>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{expandedGroup === group.id && (
<div className="list-group-item bg-light">
<ul className="mb-0 ps-3">
{group.links.map(link => (
<li
key={link.id}
className="d-flex justify-content-between align-items-center mb-2"
>
<div className="d-flex align-items-center">
{link.icon && (
<img
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
width={24}
height={24}
className="me-2"
alt={link.title}
/>
)}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.title}
</a>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
{/* Модалка добавления/редактирования группы */}
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<input
type="text"
className="form-control"
value={groupForm.name}
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Модалка добавления/редактирования ссылки */}
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control"
value={linkForm.title}
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={linkForm.url}
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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<UserProfile | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [expandedGroup, setExpandedGroup] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// === Для модалок групп ===
const [showGroupModal, setShowGroupModal] = useState(false)
const [groupModalMode, setGroupModalMode] = useState<'add' | 'edit'>('add')
const [editingGroup, setEditingGroup] = useState<Group | null>(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<LinkItem | null>(null)
const [currentGroupIdForLink, setCurrentGroupIdForLink] = useState<number | null>(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 <div className="flex items-center justify-center h-screen">Загрузка...</div>
if (error) return <div className="p-6 text-red-600 max-w-xl mx-auto">{error}</div>
const totalGroups = groups.length
const totalLinks = groups.reduce((sum, grp) => sum + grp.links.length, 0)
return (
<div className="pb-8">
{user && (
<ProfileCard
avatar={user.avatar}
full_name={user.full_name}
email={user.email}
bio={user.bio}
last_login={user.last_login}
date_joined={user.date_joined}
totalGroups={totalGroups}
totalLinks={totalLinks}
/>
)}
<section className="mt-5 container">
<div className="card shadow">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="mb-0">Группы ссылок</h5>
<button className="btn btn-sm btn-success" onClick={openAddGroup}>
<i className="bi bi-plus-lg"></i> Добавить группу
</button>
</div>
<div className="list-group list-group-flush">
{groups.map(group => (
<Fragment key={group.id}>
<div className="list-group-item d-flex justify-content-between align-items-center">
<div
className="d-flex align-items-center"
style={{ cursor: 'pointer' }}
onClick={() =>
setExpandedGroup(expandedGroup === group.id ? null : group.id)
}
>
{group.icon && (
<img
src={`${API}${group.icon}`}
width={32}
height={32}
className="me-2 rounded"
alt={group.name}
/>
)}
<strong className="me-2">{group.name}</strong>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openAddLink(group)} className="btn btn-outline-primary">
<i className="bi bi-link-45deg"></i>
</button>
<button onClick={() => openEditGroup(group)} className="btn btn-outline-secondary">
<i className="bi bi-pencil"></i>
</button>
<button onClick={() => handleDeleteGroup(group)} className="btn btn-outline-danger">
<i className="bi bi-trash"></i>
</button>
</div>
</div>
{expandedGroup === group.id && (
<div className="list-group-item bg-light">
<ul className="mb-0 ps-3">
{group.links.map(link => (
<li
key={link.id}
className="d-flex justify-content-between align-items-center mb-2"
>
<div className="d-flex align-items-center">
{link.icon && (
<img
src={link.icon.startsWith('http')
? link.icon
: `${API}${link.icon}`}
width={24}
height={24}
className="me-2"
alt={link.title}
/>
)}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.title}
</a>
</div>
<div className="btn-group btn-group-sm">
<button onClick={() => openEditLink(link)} className="btn btn-outline-secondary">
<i className="bi bi-pencil-fill"></i>
</button>
<button onClick={() => handleDeleteLink(link)} className="btn btn-outline-danger">
<i className="bi bi-trash-fill"></i>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</Fragment>
))}
</div>
</div>
</section>
{/* Модалка добавления/редактирования группы */}
<div className={`modal ${showGroupModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{groupModalMode === 'add' ? 'Добавить группу' : 'Редактировать группу'}</h5>
<button type="button" className="btn-close" onClick={() => setShowGroupModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Название</label>
<input
type="text"
className="form-control"
value={groupForm.name}
onChange={e => setGroupForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setGroupForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowGroupModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleGroupSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
{/* Модалка добавления/редактирования ссылки */}
<div className={`modal ${showLinkModal ? 'd-block' : 'd-none'}`} tabIndex={-1}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{linkModalMode === 'add' ? 'Добавить ссылку' : 'Редактировать ссылку'}</h5>
<button type="button" className="btn-close" onClick={() => setShowLinkModal(false)} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Заголовок</label>
<input
type="text"
className="form-control"
value={linkForm.title}
onChange={e => setLinkForm(f => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
value={linkForm.url}
onChange={e => setLinkForm(f => ({ ...f, url: e.target.value }))}
/>
</div>
<div className="mb-3">
<label className="form-label">Иконка (опционально)</label>
<input
type="file"
className="form-control"
onChange={e => setLinkForm(f => ({ ...f, iconFile: e.target.files?.[0] || null }))}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowLinkModal(false)}>Отмена</button>
<button className="btn btn-primary" onClick={handleLinkSubmit}>
Сохранить
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div
key={group.id}
className="accordion-item mb-3"
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
>
<h2 className="accordion-header" id={`${groupId}-header`}>
<button
className="accordion-button collapsed d-flex align-items-center"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#${groupId}-collapse`}
aria-expanded="false"
aria-controls={`${groupId}-collapse`}
>
{group.image && (
<Image
src={
group.image.startsWith('http')
? group.image
: `${API}${group.image}`
}
alt={group.name}
width={32}
height={32}
className="me-2 rounded"
priority
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 p-2 bg-white rounded shadow-sm"
style={{ marginBottom: 5 }}
>
<div className="d-flex align-items-center">
{link.image && (
<Image
src={
link.image.startsWith('http')
? link.image
: `${API}${link.image}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
priority
/>
)}
<Link
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex-grow-1"
>
{link.title}
</Link>
</div>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
В этой группе пока нет ссылок.
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -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 (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div
key={group.id}
className="accordion-item mb-3"
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
>
<h2 className="accordion-header" id={`${groupId}-header`}>
<button
className="accordion-button collapsed d-flex align-items-center"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#${groupId}-collapse`}
aria-expanded="false"
aria-controls={`${groupId}-collapse`}
>
{group.image && (
<Image
src={
group.image.startsWith('http')
? group.image
: `${API}${group.image}`
}
alt={group.name}
width={32}
height={32}
className="me-2 rounded"
priority
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 p-2 bg-white rounded shadow-sm"
style={{ marginBottom: 5 }}
>
<div className="d-flex align-items-center">
{link.image && (
<Image
src={
link.image.startsWith('http')
? link.image
: `${API}${link.image}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
priority
/>
)}
<Link
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex-grow-1"
>
{link.title}
</Link>
</div>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
В этой группе пока нет ссылок.
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -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 (
<main className="pb-8">
<div className="container">
<h2 className="text-center mb-4">{data.username}</h2>
<div className="accordion" id="groupsAccordion">
{data.groups.map((group) => {
const groupId = `group-${group.id}`
return (
<div
key={group.id}
className="accordion-item mb-3"
style={{ border: '1px solid #dee2e6', borderRadius: 4 }}
>
<h2 className="accordion-header" id={`${groupId}-header`}>
<button
className="accordion-button collapsed d-flex align-items-center"
type="button"
data-bs-toggle="collapse"
data-bs-target={`#${groupId}-collapse`}
aria-expanded="false"
aria-controls={`${groupId}-collapse`}
>
{group.image && (
<Image
src={
group.image.startsWith('http')
? group.image
: `${API}${group.image}`
}
alt={group.name}
width={32}
height={32}
className="me-2 rounded"
priority
/>
)}
<span className="me-2">{group.name}</span>
<span className="badge bg-secondary rounded-pill">
{group.links.length}
</span>
</button>
</h2>
<div
id={`${groupId}-collapse`}
className="accordion-collapse collapse"
aria-labelledby={`${groupId}-header`}
data-bs-parent="#groupsAccordion"
>
<div className="accordion-body">
{group.links.length > 0 ? (
<ul className="list-unstyled">
{group.links.map((link) => (
<li
key={link.id}
className="mb-2 p-2 bg-white rounded shadow-sm"
style={{ marginBottom: 5 }}
>
<div className="d-flex align-items-center">
{link.image && (
<Image
src={
link.image.startsWith('http')
? link.image
: `${API}${link.image}`
}
alt={link.title}
width={24}
height={24}
className="me-2"
priority
/>
)}
<Link
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex-grow-1"
>
{link.title}
</Link>
</div>
</li>
))}
</ul>
) : (
<p className="text-muted mb-0">
В этой группе пока нет ссылок.
</p>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
</main>
)
}

View File

@@ -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<User | null>(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 && (
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
{!user && (
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
)}
{user && (
<div className="ms-auto d-flex align-items-center gap-3">
<Image
src={
user.avatar.startsWith('http')
? user.avatar
: `${process.env.NEXT_PUBLIC_API_URL}${user.avatar}`
}
alt="Avatar"
width={32}
height={32}
className="rounded-circle"
/>
<span>{user.username}</span>
{!isDashboard && (
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
Дашборд
</Link>
)}
<button
onClick={handleLogout}
className="btn btn-outline-danger btn-sm"
>
Выход
</button>
</div>
)}
</div>
</div>
</nav>
)}
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
{!isPublicUserPage && <div style={{ height: 70 }} />}
{children}
{/* Подвал не выводим на публичных страницах */}
{!isPublicUserPage && (
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
</ul>
</div>
</div>
</div>
</footer>
)}
{/* Bootstrap JS */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</>
)
}

View File

@@ -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<User | null>(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 && (
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
{!user && (
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
)}
{user && (
<div className="ms-auto d-flex align-items-center gap-3">
<Image
src={
user.avatar.startsWith('http')
? user.avatar
: `http://localhost:8000${user.avatar}`
}
alt="Avatar"
width={32}
height={32}
className="rounded-circle"
/>
<span>{user.username}</span>
{!isDashboard && (
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
Дашборд
</Link>
)}
<button
onClick={handleLogout}
className="btn btn-outline-danger btn-sm"
>
Выход
</button>
</div>
)}
</div>
</div>
</nav>
)}
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
{!isPublicUserPage && <div style={{ height: 70 }} />}
{children}
{/* Подвал не выводим на публичных страницах */}
{!isPublicUserPage && (
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
</ul>
</div>
</div>
</div>
</footer>
)}
{/* Bootstrap JS */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</>
)
}

View File

@@ -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<User | null>(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 && (
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
<div className="container">
<Link href="/" className="navbar-brand d-flex align-items-center">
<Image
src="/assets/img/CAT.png"
alt="CatLink"
width={89}
height={89}
/>
<span className="ms-2">CatLink</span>
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navcol-1"
/>
<div className="collapse navbar-collapse" id="navcol-1">
{!user && (
<Link href="/auth/login" className="btn btn-primary ms-auto">
<i className="fa fa-user"></i>
<span className="d-none d-sm-inline"> Вход</span>
</Link>
)}
{user && (
<div className="ms-auto d-flex align-items-center gap-3">
<Image
src={
user.avatar.startsWith('http')
? user.avatar
: `http://localhost:8000${user.avatar}`
}
alt="Avatar"
width={32}
height={32}
className="rounded-circle"
/>
<span>{user.username}</span>
{!isDashboard && (
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
Дашборд
</Link>
)}
<button
onClick={handleLogout}
className="btn btn-outline-danger btn-sm"
>
Выход
</button>
</div>
)}
</div>
</div>
</nav>
)}
{/* отступ, чтобы контент не прятался под фиксированным хедером */}
{!isPublicUserPage && <div style={{ height: 70 }} />}
{children}
{/* Подвал не выводим на публичных страницах */}
{!isPublicUserPage && (
<footer className="bg-light footer fixed-bottom border-top">
<div className="container py-2">
<div className="row">
<div className="col-lg-6 text-center text-lg-start mb-2 mb-lg-0">
<ul className="list-inline mb-1">
<li className="list-inline-item"><Link href="#">About</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Contact</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Terms of Use</Link></li>
<li className="list-inline-item"></li>
<li className="list-inline-item"><Link href="#">Privacy Policy</Link></li>
</ul>
<p className="text-muted small mb-0">© CatLink 2025</p>
</div>
<div className="col-lg-6 text-center text-lg-end">
<ul className="list-inline mb-0">
<li className="list-inline-item"><Link href="#"><i className="fa fa-facebook fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-twitter fa-lg fa-fw"></i></Link></li>
<li className="list-inline-item"><Link href="#"><i className="fa fa-instagram fa-lg fa-fw"></i></Link></li>
</ul>
</div>
</div>
</div>
</footer>
)}
{/* Bootstrap JS */}
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
strategy="beforeInteractive"
/>
<Script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
strategy="beforeInteractive"
/>
<Script src="/assets/js/bs-init.js" strategy="lazyOnload" />
</>
)
}

View File

@@ -0,0 +1,12 @@
Django==5.2.*
djangorestframework
djangorestframework-simplejwt
psycopg2-binary
pillow
python-dotenv
django-cors-headers
django-extensions
drf-spectacular
drf-spectacular-sidecar
whitenoise
gunicorn

View File

@@ -0,0 +1,12 @@
Django==5.2.*
djangorestframework
djangorestframework-simplejwt
psycopg2-binary
pillow
python-dotenv
django-cors-headers
django-extensions
drf-spectacular
drf-spectacular-sidecar
whitenoise
gunicorn

View File

@@ -1,5 +1,5 @@
# This file must be used with "source bin/activate" *from bash* # This file must be used with "source bin/activate" *from bash*
# you cannot run it directly # You cannot run it directly
deactivate () { deactivate () {
# reset old environment variables # reset old environment variables
@@ -14,12 +14,9 @@ deactivate () {
unset _OLD_VIRTUAL_PYTHONHOME unset _OLD_VIRTUAL_PYTHONHOME
fi fi
# This should detect bash and zsh, which have a hash command that must # Call hash to forget past commands. Without forgetting
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected # past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then hash -r 2> /dev/null
hash -r 2> /dev/null
fi
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}" PS1="${_OLD_VIRTUAL_PS1:-}"
@@ -38,8 +35,15 @@ deactivate () {
# unset irrelevant variables # unset irrelevant variables
deactivate nondestructive deactivate nondestructive
VIRTUAL_ENV=/home/trevor/links/.venv # on Windows, a path can contain colons and backslashes and has to be converted:
export VIRTUAL_ENV if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath /home/data/links/.venv)
else
# use the path as-is
export VIRTUAL_ENV=/home/data/links/.venv
fi
_OLD_VIRTUAL_PATH="$PATH" _OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH" PATH="$VIRTUAL_ENV/"bin":$PATH"
@@ -61,9 +65,6 @@ if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
export VIRTUAL_ENV_PROMPT export VIRTUAL_ENV_PROMPT
fi fi
# This should detect bash and zsh, which have a hash command that must # Call hash to forget past commands. Without forgetting
# be called to get it to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected # past commands the $PATH changes we made may not be respected
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then hash -r 2> /dev/null
hash -r 2> /dev/null
fi

View File

@@ -1,5 +1,6 @@
# This file must be used with "source bin/activate.csh" *from csh*. # This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly. # You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>. # Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com> # Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
@@ -8,7 +9,7 @@ alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PA
# Unset irrelevant variables. # Unset irrelevant variables.
deactivate nondestructive deactivate nondestructive
setenv VIRTUAL_ENV /home/trevor/links/.venv setenv VIRTUAL_ENV /home/data/links/.venv
set _OLD_VIRTUAL_PATH="$PATH" set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH" setenv PATH "$VIRTUAL_ENV/"bin":$PATH"

View File

@@ -1,5 +1,5 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish* # This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/); you cannot run it directly. # (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment" function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables # reset old environment variables
@@ -33,7 +33,7 @@ end
# Unset irrelevant variables. # Unset irrelevant variables.
deactivate nondestructive deactivate nondestructive
set -gx VIRTUAL_ENV /home/trevor/links/.venv set -gx VIRTUAL_ENV /home/data/links/.venv
set -gx _OLD_VIRTUAL_PATH $PATH set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH set -gx PATH "$VIRTUAL_ENV/"bin $PATH

View File

@@ -1,4 +1,4 @@
#!/home/trevor/links/.venv/bin/python3 #!/home/data/links/.venv/bin/python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
import sys import sys

View File

@@ -1,4 +1,4 @@
#!/home/trevor/links/.venv/bin/python3 #!/home/data/links/.venv/bin/python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
import sys import sys

View File

@@ -1,4 +1,4 @@
#!/home/trevor/links/.venv/bin/python3 #!/home/data/links/.venv/bin/python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
import sys import sys

View File

@@ -1,4 +1,4 @@
#!/home/trevor/links/.venv/bin/python3 #!/home/data/links/.venv/bin/python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
import sys import sys

View File

@@ -1,4 +1,4 @@
#!/home/trevor/links/.venv/bin/python3 #!/home/data/links/.venv/bin/python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
import sys import sys

View File

@@ -1,3 +1,5 @@
home = /usr/bin home = /usr/bin
include-system-site-packages = false include-system-site-packages = false
version = 3.10.12 version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/data/links/.venv

68
DEPLOYMENT.md Normal file
View File

@@ -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` - Команды для удобного управления

112
FIXES.md Normal file
View File

@@ -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
```

109
README.md
View File

@@ -1,3 +1,108 @@
# Клон Linktr.ee на Django # Клон 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 <url>
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`.

27
backend/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -10,15 +10,24 @@ from .views import (
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
router = DefaultRouter() 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('links', LinkViewSet, basename='link')
router.register('groups', LinkGroupViewSet, basename='group') router.register('groups', LinkGroupViewSet, basename='group')
urlpatterns = [ urlpatterns = [
path('auth/register/', RegisterView.as_view(), name='auth_register'), 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'),
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'),
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'), # ← новый
path('auth/user', UserProfileView.as_view(), name='user-profile-no-slash'),
path('users/<str:username>/public/', path('users/<str:username>/public/',
PublicUserGroupsView.as_view(), PublicUserGroupsView.as_view(),
name='public-user-groups' name='public-user-groups'

View File

@@ -5,6 +5,8 @@ from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from django.shortcuts import get_object_or_404 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 drf_spectacular.utils import extend_schema, OpenApiParameter
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@@ -25,6 +27,7 @@ class RegisterView(generics.CreateAPIView):
serializer_class = RegisterSerializer serializer_class = RegisterSerializer
@method_decorator(csrf_exempt, name='dispatch')
class LoginView(TokenObtainPairView): class LoginView(TokenObtainPairView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]

Some files were not shown because too many files have changed in this diff Show More