chat ws with pfp is working
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -85,3 +85,5 @@ frontend/.env.*.orig
|
|||||||
|
|
||||||
venv
|
venv
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
certs
|
||||||
33
README.md
33
README.md
@@ -0,0 +1,33 @@
|
|||||||
|
# vontor.cz
|
||||||
|
|
||||||
|
Czech e-marketplace — Django backend + React frontend.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Docker Desktop
|
||||||
|
- Node.js 22+ (frontend dev)
|
||||||
|
- Python 3.12+ (backend dev)
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend dev server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker — corporate / custom CA certificates
|
||||||
|
|
||||||
|
If your network uses SSL inspection (corporate proxy, Tailscale, etc.) the Docker build will fail with SSL errors when fetching packages.
|
||||||
|
|
||||||
|
Fix: export your Windows Root CA store into the build context once:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-Item -Force -ItemType Directory backend\certs | Out-Null; $s = [Security.Cryptography.X509Certificates.X509Store]::new("Root","LocalMachine"); $s.Open("ReadOnly"); $s.Certificates | % { "-----BEGIN CERTIFICATE-----`n" + [Convert]::ToBase64String($_.RawData,"InsertLineBreaks") + "`n-----END CERTIFICATE-----" } | Out-File -Encoding ascii backend\certs\windows-ca-bundle.crt; $s.Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
The file is git-ignored. Re-run the command whenever your CA store changes.
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Trust Windows/corporate root CAs before any network operations
|
||||||
|
COPY certs/windows-ca-bundle.crt /usr/local/share/ca-certificates/windows-ca-bundle.crt
|
||||||
|
RUN update-ca-certificates
|
||||||
|
|
||||||
# Install system dependencies including Node.js for yt-dlp JavaScript runtime
|
# Install system dependencies including Node.js for yt-dlp JavaScript runtime
|
||||||
RUN apt update && apt install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
weasyprint \
|
weasyprint \
|
||||||
libcairo2 \
|
libcairo2 \
|
||||||
pango1.0-tools \
|
|
||||||
libpango-1.0-0 \
|
libpango-1.0-0 \
|
||||||
libgobject-2.0-0 \
|
libgobject-2.0-0 \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
@@ -14,9 +17,9 @@ RUN apt update && apt install -y \
|
|||||||
curl \
|
curl \
|
||||||
libmagic1 \
|
libmagic1 \
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
&& apt install -y nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& update-ca-certificates \
|
&& update-ca-certificates \
|
||||||
&& apt clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
|||||||
"type": "chat.message",
|
"type": "chat.message",
|
||||||
"message_id": message.id,
|
"message_id": message.id,
|
||||||
"message": message.content,
|
"message": message.content,
|
||||||
|
"sender_id": user.id,
|
||||||
"sender": user.username,
|
"sender": user.username,
|
||||||
|
"sender_avatar": user.avatar.url if user.avatar else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
elif msg_type == "new_reply_chat_message":
|
elif msg_type == "new_reply_chat_message":
|
||||||
@@ -66,7 +68,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
|||||||
"message_id": message.id,
|
"message_id": message.id,
|
||||||
"message": message.content,
|
"message": message.content,
|
||||||
"reply_to_id": data.get("reply_to_id"),
|
"reply_to_id": data.get("reply_to_id"),
|
||||||
|
"sender_id": user.id,
|
||||||
"sender": user.username,
|
"sender": user.username,
|
||||||
|
"sender_avatar": user.avatar.url if user.avatar else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
elif msg_type == "reaction":
|
elif msg_type == "reaction":
|
||||||
@@ -116,7 +120,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
|||||||
"type": "new_chat_message",
|
"type": "new_chat_message",
|
||||||
"message_id": event["message_id"],
|
"message_id": event["message_id"],
|
||||||
"message": event["message"],
|
"message": event["message"],
|
||||||
|
"sender_id": event["sender_id"],
|
||||||
"sender": event["sender"],
|
"sender": event["sender"],
|
||||||
|
"sender_avatar": event["sender_avatar"],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async def reply_chat_message(self, event):
|
async def reply_chat_message(self, event):
|
||||||
@@ -125,7 +131,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
|||||||
"message_id": event["message_id"],
|
"message_id": event["message_id"],
|
||||||
"message": event["message"],
|
"message": event["message"],
|
||||||
"reply_to_id": event["reply_to_id"],
|
"reply_to_id": event["reply_to_id"],
|
||||||
|
"sender_id": event["sender_id"],
|
||||||
"sender": event["sender"],
|
"sender": event["sender"],
|
||||||
|
"sender_avatar": event["sender_avatar"],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async def edit_message(self, event):
|
async def edit_message(self, event):
|
||||||
@@ -178,7 +186,8 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
|||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def _is_chat_member(chat_id, user):
|
def _is_chat_member(chat_id, user):
|
||||||
return Chat.objects.filter(pk=chat_id, members=user).exists()
|
from django.db.models import Q
|
||||||
|
return Chat.objects.filter(Q(pk=chat_id), Q(members=user) | Q(owner=user)).exists()
|
||||||
|
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from .models import Chat, ChatReadStatus, Message, MessageFile, MessageHistory, MessageReaction
|
from .models import Chat, ChatReadStatus, Message, MessageFile, MessageHistory, MessageReaction
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSenderSerializer(serializers.ModelSerializer):
|
||||||
|
avatar = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_avatar(self, obj):
|
||||||
|
from django.conf import settings
|
||||||
|
if obj.avatar:
|
||||||
|
return settings.MEDIA_URL + obj.avatar.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = get_user_model()
|
||||||
|
fields = ['id', 'username', 'avatar']
|
||||||
|
read_only_fields = ['id', 'username']
|
||||||
|
|
||||||
|
|
||||||
class MessageFileSerializer(serializers.ModelSerializer):
|
class MessageFileSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MessageFile
|
model = MessageFile
|
||||||
@@ -25,6 +41,7 @@ class MessageHistorySerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class MessageSerializer(serializers.ModelSerializer):
|
class MessageSerializer(serializers.ModelSerializer):
|
||||||
|
sender = MessageSenderSerializer(read_only=True)
|
||||||
media_files = MessageFileSerializer(many=True, read_only=True)
|
media_files = MessageFileSerializer(many=True, read_only=True)
|
||||||
reactions = MessageReactionSerializer(many=True, read_only=True)
|
reactions = MessageReactionSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Q
|
||||||
from rest_framework import status, viewsets
|
from rest_framework import status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
@@ -49,7 +50,7 @@ class ChatViewSet(viewsets.ModelViewSet):
|
|||||||
user = self.request.user
|
user = self.request.user
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return Chat.objects.all()
|
return Chat.objects.all()
|
||||||
return Chat.objects.filter(members=user)
|
return Chat.objects.filter(Q(members=user) | Q(owner=user)).distinct()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(owner=self.request.user)
|
serializer.save(owner=self.request.user)
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ from django.core.asgi import get_asgi_application
|
|||||||
django_asgi_app = get_asgi_application()
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
from channels.auth import AuthMiddlewareStack
|
from social.chat.routing import websocket_urlpatterns as social_ws
|
||||||
import social.chat.routing
|
|
||||||
from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws
|
from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws
|
||||||
|
from vontor_cz.middleware import JWTAuthMiddleware
|
||||||
|
|
||||||
|
websocket_urlpatterns = downloader_ws + social_ws
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
application = ProtocolTypeRouter({
|
||||||
"http": django_asgi_app,
|
"http": django_asgi_app,
|
||||||
"websocket": AuthMiddlewareStack(
|
"websocket": JWTAuthMiddleware(
|
||||||
URLRouter(
|
URLRouter(
|
||||||
downloader_ws + social.chat.routing.websocket_urlpatterns
|
websocket_urlpatterns
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
83
backend/vontor_cz/middleware.py
Normal file
83
backend/vontor_cz/middleware.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from channels.middleware import BaseMiddleware
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- WEBSOCKET JWT AUTH VALIDATION -----------
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_user_from_token(token_string):
|
||||||
|
"""
|
||||||
|
Validate JWT token and return user
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate the token
|
||||||
|
access_token = AccessToken(token_string)
|
||||||
|
|
||||||
|
# Get user ID from token
|
||||||
|
user_id = access_token.get('user_id')
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return AnonymousUser()
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from account.models import CustomUser
|
||||||
|
|
||||||
|
# Get user from database
|
||||||
|
try:
|
||||||
|
user = CustomUser.objects.get(id=user_id)
|
||||||
|
return user
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
return AnonymousUser()
|
||||||
|
|
||||||
|
except (TokenError, InvalidToken, Exception) as e:
|
||||||
|
logger.warning(f"JWT validation failed in websocket: {e}")
|
||||||
|
return AnonymousUser()
|
||||||
|
|
||||||
|
|
||||||
|
class JWTAuthMiddleware(BaseMiddleware):
|
||||||
|
"""
|
||||||
|
Custom middleware to authenticate WebSocket connections using JWT from cookies.
|
||||||
|
Replaces AuthMiddlewareStack / CSRF validation for WebSocket routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
# Get headers from scope
|
||||||
|
headers = dict(scope.get('headers', []))
|
||||||
|
|
||||||
|
# Extract cookies from headers
|
||||||
|
cookie_header = headers.get(b'cookie', b'').decode('utf-8')
|
||||||
|
|
||||||
|
logger.info(f"[WS] cookie header present: {bool(cookie_header)} | keys: {[c.split('=')[0].strip() for c in cookie_header.split('; ') if '=' in c]}")
|
||||||
|
|
||||||
|
# Parse cookies
|
||||||
|
cookies = {}
|
||||||
|
if cookie_header:
|
||||||
|
for cookie in cookie_header.split('; '):
|
||||||
|
if '=' in cookie:
|
||||||
|
key, value = cookie.split('=', 1)
|
||||||
|
cookies[key.strip()] = value
|
||||||
|
|
||||||
|
# Get access_token from cookies
|
||||||
|
token = cookies.get('access_token')
|
||||||
|
|
||||||
|
logger.info(f"[WS] token found: {bool(token)}")
|
||||||
|
|
||||||
|
# Authenticate user
|
||||||
|
if token:
|
||||||
|
scope['user'] = await get_user_from_token(token)
|
||||||
|
else:
|
||||||
|
scope['user'] = AnonymousUser()
|
||||||
|
|
||||||
|
logger.info(f"[WS] authenticated as: {scope['user']} | is_authenticated: {getattr(scope['user'], 'is_authenticated', False)}")
|
||||||
|
|
||||||
|
return await super().__call__(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- END | WEBSOCKET JWT AUTH VALIDATION -----------
|
||||||
@@ -306,12 +306,8 @@ else:
|
|||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
|
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
# In DEBUG keep Session + JWT + your cookie class for convenience
|
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
'account.tokens.CookieJWTAuthentication',
|
'account.tokens.CookieJWTAuthentication',
|
||||||
) if DEBUG else (
|
|
||||||
'account.tokens.CookieJWTAuthentication',
|
|
||||||
),
|
),
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
'rest_framework.permissions.AllowAny',
|
'rest_framework.permissions.AllowAny',
|
||||||
@@ -427,7 +423,6 @@ MIDDLEWARE = [
|
|||||||
'silk.middleware.SilkyMiddleware',
|
'silk.middleware.SilkyMiddleware',
|
||||||
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ services:
|
|||||||
|
|
||||||
nginx: #web server, reverse proxy, serves static files
|
nginx: #web server, reverse proxy, serves static files
|
||||||
container_name: nginx-vontor-cz
|
container_name: nginx-vontor-cz
|
||||||
|
profiles:
|
||||||
|
- production
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.prod
|
dockerfile: Dockerfile.prod
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Base URL of the Django backend (must include /api/ if your axios baseURL expects it).
|
# Base URL of the Django backend (must include /api/ if your axios baseURL expects it).
|
||||||
VITE_BACKEND_URL="http://localhost:8000/api/"
|
VITE_BACKEND_URL="http://localhost:8000/api/"
|
||||||
|
VITE_BACKEND_WS_URL="ws://localhost:8000/"
|
||||||
|
|
||||||
# Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL
|
# Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL
|
||||||
# (the `/api` suffix is stripped automatically; only the host is used).
|
# (the `/api` suffix is stripped automatically; only the host is used).
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ http {
|
|||||||
alias /app/media/;
|
alias /app/media/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# WebSocket proxy
|
||||||
|
# -------------------------
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
# Same-origin proxy for API -> avoids CORS and allows cookies
|
# Same-origin proxy for API -> avoids CORS and allows cookies
|
||||||
location /api {
|
location /api {
|
||||||
return 301 /api/;
|
return 301 /api/;
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export * from "./message";
|
|||||||
export * from "./messageFile";
|
export * from "./messageFile";
|
||||||
export * from "./messageReaction";
|
export * from "./messageReaction";
|
||||||
export * from "./messageSend";
|
export * from "./messageSend";
|
||||||
|
export * from "./messageSender";
|
||||||
export * from "./orderCarrier";
|
export * from "./orderCarrier";
|
||||||
export * from "./orderCreate";
|
export * from "./orderCreate";
|
||||||
export * from "./orderItemCreate";
|
export * from "./orderItemCreate";
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
import type { MessageFile } from "./messageFile";
|
import type { MessageFile } from "./messageFile";
|
||||||
import type { MessageReaction } from "./messageReaction";
|
import type { MessageReaction } from "./messageReaction";
|
||||||
|
import type { MessageSender } from "./messageSender";
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
readonly chat: number;
|
readonly chat: number;
|
||||||
/** @nullable */
|
readonly sender: MessageSender;
|
||||||
readonly sender: number | null;
|
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
readonly reply_to: number | null;
|
readonly reply_to: number | null;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
12
frontend/src/api/generated/private/models/messageSender.ts
Normal file
12
frontend/src/api/generated/private/models/messageSender.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.8.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI spec version: 0.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MessageSender {
|
||||||
|
readonly id: number;
|
||||||
|
/** Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_. */
|
||||||
|
readonly username: string;
|
||||||
|
readonly avatar: string;
|
||||||
|
}
|
||||||
@@ -5,12 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
import type { MessageFile } from "./messageFile";
|
import type { MessageFile } from "./messageFile";
|
||||||
import type { MessageReaction } from "./messageReaction";
|
import type { MessageReaction } from "./messageReaction";
|
||||||
|
import type { MessageSender } from "./messageSender";
|
||||||
|
|
||||||
export interface PatchedMessage {
|
export interface PatchedMessage {
|
||||||
readonly id?: number;
|
readonly id?: number;
|
||||||
readonly chat?: number;
|
readonly chat?: number;
|
||||||
/** @nullable */
|
readonly sender?: MessageSender;
|
||||||
readonly sender?: number | null;
|
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
readonly reply_to?: number | null;
|
readonly reply_to?: number | null;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export * from "./message";
|
|||||||
export * from "./messageFile";
|
export * from "./messageFile";
|
||||||
export * from "./messageReaction";
|
export * from "./messageReaction";
|
||||||
export * from "./messageSend";
|
export * from "./messageSend";
|
||||||
|
export * from "./messageSender";
|
||||||
export * from "./orderCarrier";
|
export * from "./orderCarrier";
|
||||||
export * from "./orderCreate";
|
export * from "./orderCreate";
|
||||||
export * from "./orderItemCreate";
|
export * from "./orderItemCreate";
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
import type { MessageFile } from "./messageFile";
|
import type { MessageFile } from "./messageFile";
|
||||||
import type { MessageReaction } from "./messageReaction";
|
import type { MessageReaction } from "./messageReaction";
|
||||||
|
import type { MessageSender } from "./messageSender";
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
readonly chat: number;
|
readonly chat: number;
|
||||||
/** @nullable */
|
readonly sender: MessageSender;
|
||||||
readonly sender: number | null;
|
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
readonly reply_to: number | null;
|
readonly reply_to: number | null;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
12
frontend/src/api/generated/public/models/messageSender.ts
Normal file
12
frontend/src/api/generated/public/models/messageSender.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.8.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI spec version: 0.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MessageSender {
|
||||||
|
readonly id: number;
|
||||||
|
/** Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_. */
|
||||||
|
readonly username: string;
|
||||||
|
readonly avatar: string;
|
||||||
|
}
|
||||||
@@ -5,12 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
import type { MessageFile } from "./messageFile";
|
import type { MessageFile } from "./messageFile";
|
||||||
import type { MessageReaction } from "./messageReaction";
|
import type { MessageReaction } from "./messageReaction";
|
||||||
|
import type { MessageSender } from "./messageSender";
|
||||||
|
|
||||||
export interface PatchedMessage {
|
export interface PatchedMessage {
|
||||||
readonly id?: number;
|
readonly id?: number;
|
||||||
readonly chat?: number;
|
readonly chat?: number;
|
||||||
/** @nullable */
|
readonly sender?: MessageSender;
|
||||||
readonly sender?: number | null;
|
|
||||||
/** @nullable */
|
/** @nullable */
|
||||||
readonly reply_to?: number | null;
|
readonly reply_to?: number | null;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Derives the WebSocket base URL from env. Mirrors the BE Channels routing,
|
* Derives the WebSocket base URL.
|
||||||
* which lives behind the same host as the REST API.
|
|
||||||
*
|
*
|
||||||
* Set `VITE_WS_URL` to override (e.g. wss://example.com); otherwise we flip the
|
* Priority:
|
||||||
* scheme of `VITE_BACKEND_URL`.
|
* 1. `VITE_WS_URL` env var — explicit override (e.g. wss://example.com)
|
||||||
|
* 2. Current page origin — uses window.location so the request goes through
|
||||||
|
* whatever proxy is in front (Vite dev proxy or nginx in production).
|
||||||
|
* This avoids hardcoding the backend port and bypassing the proxy.
|
||||||
*/
|
*/
|
||||||
export function getChatSocketUrl(chatId: number | string): string {
|
export function getChatSocketUrl(chatId: number | string): string {
|
||||||
const explicit = import.meta.env.VITE_WS_URL as string | undefined;
|
const explicit = import.meta.env.VITE_BACKEND_WS_URL as string | undefined;
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
return `${stripTrailing(explicit)}/ws/chat/${chatId}/`;
|
return `${stripTrailing(explicit)}/ws/chat/${chatId}/`;
|
||||||
}
|
}
|
||||||
const backend = (import.meta.env.VITE_BACKEND_URL as string | undefined)
|
|
||||||
?? "http://localhost:8000";
|
// Derive scheme from page protocol, host from page host (preserves dev port)
|
||||||
const wsBase = backend.replace(/^http/, "ws");
|
const wsScheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
// WS endpoints live at the host root (/ws/chat/<id>/), not under /api/.
|
return `${wsScheme}//${window.location.host}/ws/chat/${chatId}/`;
|
||||||
const hostOnly = wsBase.replace(/\/api\/?$/, "");
|
|
||||||
return `${stripTrailing(hostOnly)}/ws/chat/${chatId}/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripTrailing(s: string): string {
|
function stripTrailing(s: string): string {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FiX, FiSearch, FiCheck } from "react-icons/fi";
|
import { FiX, FiSearch, FiCheck, FiUser, FiUsers } from "react-icons/fi";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
useApiSocialChatsCreate,
|
useApiSocialChatsCreate,
|
||||||
@@ -156,7 +156,7 @@ export default function CreateChatModal({ open, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialog */}
|
{/* Dialog */}
|
||||||
<div className="relative z-10 w-full max-w-md rounded-2xl border border-brand-lines/20 bg-brand-bgLight shadow-2xl">
|
<div className="relative z-10 w-full max-w-md rounded-2xl border border-brand-lines/20 bg-brand-gradient shadow-2xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-brand-lines/15 px-5 py-4">
|
<div className="flex items-center justify-between border-b border-brand-lines/15 px-5 py-4">
|
||||||
<h2 className="text-base font-semibold text-brand-text">
|
<h2 className="text-base font-semibold text-brand-text">
|
||||||
@@ -172,26 +172,29 @@ export default function CreateChatModal({ open, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab switcher */}
|
{/* Type switch */}
|
||||||
<div className="flex border-b border-brand-lines/15 px-5 pt-4 gap-1">
|
<div className="px-5 pt-4 pb-3 border-b border-brand-lines/15">
|
||||||
{(["DM", "GROUP"] as ChatTab[]).map((type) => (
|
<div className="flex rounded-full bg-brand-bg/80 p-0.5 border border-brand-lines/20">
|
||||||
|
{(["DM", "GROUP"] as ChatTab[]).map((type, i) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => switchTab(type)}
|
onClick={() => switchTab(type)}
|
||||||
className={[
|
className={[
|
||||||
"px-4 py-2 text-sm font-medium rounded-t-lg transition-colors",
|
"flex flex-1 items-center justify-center gap-2 px-4 py-2 text-sm font-semibold transition-all duration-200",
|
||||||
|
i === 0 ? "rounded-l-full" : "rounded-r-full",
|
||||||
tab === type
|
tab === type
|
||||||
? "border-b-2 border-brand-accent text-brand-accent"
|
? "text-white shadow-md"
|
||||||
: "text-brand-text/60 hover:text-brand-text",
|
: "text-brand-text/45 hover:text-brand-text/80",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
|
style={tab === type ? { backgroundColor: "var(--c-other)", border: "none" } : { border: "none" }}
|
||||||
>
|
>
|
||||||
{type === "DM"
|
{type === "DM" ? <FiUser size={14} /> : <FiUsers size={14} />}
|
||||||
? t("chat.create.tabDM")
|
{type === "DM" ? t("chat.create.tabDM") : t("chat.create.tabGroup")}
|
||||||
: t("chat.create.tabGroup")}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="px-5 py-5 space-y-4">
|
<form onSubmit={handleSubmit} className="px-5 py-5 space-y-4">
|
||||||
@@ -259,7 +262,10 @@ export default function CreateChatModal({ open, onClose }: Props) {
|
|||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{showDropdown && debouncedQuery.length >= 2 && (
|
{showDropdown && debouncedQuery.length >= 2 && (
|
||||||
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-52 overflow-y-auto rounded-xl border border-brand-lines/20 bg-brand-bg shadow-xl">
|
<div
|
||||||
|
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-52 overflow-y-auto rounded-xl border border-brand-lines/20 shadow-xl"
|
||||||
|
style={{ backgroundColor: "var(--c-background)" }}
|
||||||
|
>
|
||||||
{usersLoading && (
|
{usersLoading && (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Spinner size={18} />
|
<Spinner size={18} />
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ interface Props {
|
|||||||
export default function Message({ message, chat, onReply, onReact }: Props) {
|
export default function Message({ message, chat, onReply, onReact }: Props) {
|
||||||
const { t } = useTranslation("social");
|
const { t } = useTranslation("social");
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isOwn = user?.id != null && message.sender === user.id;
|
const sender = message.sender as { id: number; username: string; avatar: string | null } | null;
|
||||||
|
const isOwn = user?.id != null && sender?.id === user.id;
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!confirm("Smazat zprávu?")) return;
|
if (!confirm("Smazat zprávu?")) return;
|
||||||
@@ -31,11 +32,11 @@ export default function Message({ message, chat, onReply, onReact }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`group flex gap-2 px-4 py-1.5 ${isOwn ? "flex-row-reverse" : ""}`}>
|
<div className={`group flex gap-2 px-4 py-1.5 ${isOwn ? "flex-row-reverse" : ""}`}>
|
||||||
<Avatar name={`user ${message.sender}`} size={28} />
|
<Avatar name={sender?.username} src={sender?.avatar} size={28} />
|
||||||
<div className={`flex max-w-[70%] flex-col ${isOwn ? "items-end" : "items-start"}`}>
|
<div className={`flex max-w-[70%] flex-col ${isOwn ? "items-end" : "items-start"}`}>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap",
|
"rounded-2xl px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap",
|
||||||
isOwn
|
isOwn
|
||||||
? "bg-brand-accent text-brand-bg rounded-br-sm"
|
? "bg-brand-accent text-brand-bg rounded-br-sm"
|
||||||
: "bg-brand-bgLight/70 text-brand-text rounded-bl-sm",
|
: "bg-brand-bgLight/70 text-brand-text rounded-bl-sm",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { getChatSocketUrl } from "@/api/social/ws";
|
|||||||
export type ChatSocketStatus = "idle" | "connecting" | "open" | "closed" | "error";
|
export type ChatSocketStatus = "idle" | "connecting" | "open" | "closed" | "error";
|
||||||
|
|
||||||
export type ChatSocketEvent =
|
export type ChatSocketEvent =
|
||||||
| { type: "new_chat_message"; message_id: number; message: string; sender: string }
|
| { type: "new_chat_message"; message_id: number; message: string; sender_id: number; sender: string; sender_avatar: string | null }
|
||||||
| { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; sender: string }
|
| { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; sender_id: number; sender: string; sender_avatar: string | null }
|
||||||
| { type: "edit_chat_message"; message_id: number; content: string; is_edited: boolean }
|
| { type: "edit_chat_message"; message_id: number; content: string; is_edited: boolean }
|
||||||
| { type: "delete_chat_message"; message_id: number }
|
| { type: "delete_chat_message"; message_id: number }
|
||||||
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }
|
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ h1, h2, h3 {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
@layer base {
|
||||||
|
button {
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
|
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -49,12 +50,13 @@ button {
|
|||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
|
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
border-color: var(--c-other);
|
border-color: var(--c-other);
|
||||||
box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%);
|
box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%);
|
||||||
|
}
|
||||||
|
button:active { transform: translateY(1px) }
|
||||||
}
|
}
|
||||||
button:active { transform: translateY(1px) }
|
|
||||||
|
|
||||||
/* Reusable helpers */
|
/* Reusable helpers */
|
||||||
.glass {
|
.glass {
|
||||||
@@ -157,6 +159,14 @@ button:active { transform: translateY(1px) }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reusable full-page gradient — same background as the login/register pages */
|
||||||
|
.bg-brand-gradient {
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 800px at 10% 10%, var(--c-background-light), transparent 60%),
|
||||||
|
radial-gradient(1000px 700px at 90% 20%, color-mix(in hsl, var(--c-lines), transparent 85%), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--c-background) 0%, color-mix(in hsl, var(--c-background), black 15%) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Use project palette for list markers (bullets) and summary markers */
|
/* Use project palette for list markers (bullets) and summary markers */
|
||||||
li::marker,
|
li::marker,
|
||||||
ol li::marker,
|
ol li::marker,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function ChatRoomPage() {
|
|||||||
appendMessage({
|
appendMessage({
|
||||||
id: event.message_id,
|
id: event.message_id,
|
||||||
chat: chatId,
|
chat: chatId,
|
||||||
sender: null,
|
sender: { id: event.sender_id, username: event.sender, avatar: event.sender_avatar } as never,
|
||||||
reply_to: event.type === "new_reply_chat_message" ? event.reply_to_id : null,
|
reply_to: event.type === "new_reply_chat_message" ? event.reply_to_id : null,
|
||||||
content: event.message,
|
content: event.message,
|
||||||
is_edited: false,
|
is_edited: false,
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
const BACKEND_ORIGIN = (() => {
|
/**
|
||||||
try {
|
* Normalises a media URL so it always resolves through the current origin
|
||||||
return new URL(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8000").origin;
|
* (Vite dev proxy → backend, or nginx in production).
|
||||||
} catch {
|
*
|
||||||
return "http://localhost:8000";
|
* - Full URLs (http/https): strip the origin, keep only the path.
|
||||||
}
|
* - Relative paths without a leading slash: add one.
|
||||||
})();
|
* - blob: / data: URLs: returned unchanged.
|
||||||
|
* - null / undefined / empty: returns null.
|
||||||
|
*/
|
||||||
export function mediaUrl(src: string | null | undefined): string | null {
|
export function mediaUrl(src: string | null | undefined): string | null {
|
||||||
if (!src) return null;
|
if (!src) return null;
|
||||||
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
|
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
|
||||||
try {
|
try {
|
||||||
|
// Full URL — strip origin so the request goes through the proxy/nginx
|
||||||
const url = new URL(src);
|
const url = new URL(src);
|
||||||
if (url.origin === BACKEND_ORIGIN) return url.pathname + url.search;
|
return url.pathname + (url.search || "");
|
||||||
} catch {
|
} catch {
|
||||||
// already a relative path — return as-is
|
// Already a relative path
|
||||||
|
return src.startsWith("/") ? src : `/${src}`;
|
||||||
}
|
}
|
||||||
return src;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
target: backendUrl,
|
target: backendUrl,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/ws': {
|
/*'/ws': {
|
||||||
target: backendUrl,
|
target: 'ws://localhost:8000/',
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
rewriteWsOrigin: true,
|
||||||
|
},*/
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user