chat ws with pfp is working
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -84,4 +84,6 @@ frontend/.env.*.orig
|
||||
.env
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
RUN apt update && apt install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
weasyprint \
|
||||
libcairo2 \
|
||||
pango1.0-tools \
|
||||
libpango-1.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
ffmpeg \
|
||||
@@ -14,9 +17,9 @@ RUN apt update && apt install -y \
|
||||
curl \
|
||||
libmagic1 \
|
||||
&& 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 \
|
||||
&& apt clean \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
@@ -51,7 +51,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"type": "chat.message",
|
||||
"message_id": message.id,
|
||||
"message": message.content,
|
||||
"sender_id": user.id,
|
||||
"sender": user.username,
|
||||
"sender_avatar": user.avatar.url if user.avatar else None,
|
||||
})
|
||||
|
||||
elif msg_type == "new_reply_chat_message":
|
||||
@@ -66,7 +68,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"message_id": message.id,
|
||||
"message": message.content,
|
||||
"reply_to_id": data.get("reply_to_id"),
|
||||
"sender_id": user.id,
|
||||
"sender": user.username,
|
||||
"sender_avatar": user.avatar.url if user.avatar else None,
|
||||
})
|
||||
|
||||
elif msg_type == "reaction":
|
||||
@@ -116,7 +120,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"type": "new_chat_message",
|
||||
"message_id": event["message_id"],
|
||||
"message": event["message"],
|
||||
"sender_id": event["sender_id"],
|
||||
"sender": event["sender"],
|
||||
"sender_avatar": event["sender_avatar"],
|
||||
}))
|
||||
|
||||
async def reply_chat_message(self, event):
|
||||
@@ -125,7 +131,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
"message_id": event["message_id"],
|
||||
"message": event["message"],
|
||||
"reply_to_id": event["reply_to_id"],
|
||||
"sender_id": event["sender_id"],
|
||||
"sender": event["sender"],
|
||||
"sender_avatar": event["sender_avatar"],
|
||||
}))
|
||||
|
||||
async def edit_message(self, event):
|
||||
@@ -178,7 +186,8 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
|
||||
@database_sync_to_async
|
||||
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
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
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 Meta:
|
||||
model = MessageFile
|
||||
@@ -25,6 +41,7 @@ class MessageHistorySerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class MessageSerializer(serializers.ModelSerializer):
|
||||
sender = MessageSenderSerializer(read_only=True)
|
||||
media_files = MessageFileSerializer(many=True, read_only=True)
|
||||
reactions = MessageReactionSerializer(many=True, read_only=True)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
@@ -49,7 +50,7 @@ class ChatViewSet(viewsets.ModelViewSet):
|
||||
user = self.request.user
|
||||
if user.is_superuser:
|
||||
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):
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
@@ -16,15 +16,17 @@ from django.core.asgi import get_asgi_application
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
import social.chat.routing
|
||||
from social.chat.routing import websocket_urlpatterns as social_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({
|
||||
"http": django_asgi_app,
|
||||
"websocket": AuthMiddlewareStack(
|
||||
"websocket": JWTAuthMiddleware(
|
||||
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 = {
|
||||
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
# In DEBUG keep Session + JWT + your cookie class for convenience
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'account.tokens.CookieJWTAuthentication',
|
||||
) if DEBUG else (
|
||||
'account.tokens.CookieJWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.AllowAny',
|
||||
@@ -427,7 +423,6 @@ MIDDLEWARE = [
|
||||
'silk.middleware.SilkyMiddleware',
|
||||
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
|
||||
@@ -91,6 +91,8 @@ services:
|
||||
|
||||
nginx: #web server, reverse proxy, serves static files
|
||||
container_name: nginx-vontor-cz
|
||||
profiles:
|
||||
- production
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Base URL of the Django backend (must include /api/ if your axios baseURL expects it).
|
||||
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
|
||||
# (the `/api` suffix is stripped automatically; only the host is used).
|
||||
|
||||
@@ -43,6 +43,21 @@ http {
|
||||
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
|
||||
location /api {
|
||||
return 301 /api/;
|
||||
|
||||
@@ -67,6 +67,7 @@ export * from "./message";
|
||||
export * from "./messageFile";
|
||||
export * from "./messageReaction";
|
||||
export * from "./messageSend";
|
||||
export * from "./messageSender";
|
||||
export * from "./orderCarrier";
|
||||
export * from "./orderCreate";
|
||||
export * from "./orderItemCreate";
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
*/
|
||||
import type { MessageFile } from "./messageFile";
|
||||
import type { MessageReaction } from "./messageReaction";
|
||||
import type { MessageSender } from "./messageSender";
|
||||
|
||||
export interface Message {
|
||||
readonly id: number;
|
||||
readonly chat: number;
|
||||
/** @nullable */
|
||||
readonly sender: number | null;
|
||||
readonly sender: MessageSender;
|
||||
/** @nullable */
|
||||
readonly reply_to: number | null;
|
||||
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 { MessageReaction } from "./messageReaction";
|
||||
import type { MessageSender } from "./messageSender";
|
||||
|
||||
export interface PatchedMessage {
|
||||
readonly id?: number;
|
||||
readonly chat?: number;
|
||||
/** @nullable */
|
||||
readonly sender?: number | null;
|
||||
readonly sender?: MessageSender;
|
||||
/** @nullable */
|
||||
readonly reply_to?: number | null;
|
||||
content?: string;
|
||||
|
||||
@@ -49,6 +49,7 @@ export * from "./message";
|
||||
export * from "./messageFile";
|
||||
export * from "./messageReaction";
|
||||
export * from "./messageSend";
|
||||
export * from "./messageSender";
|
||||
export * from "./orderCarrier";
|
||||
export * from "./orderCreate";
|
||||
export * from "./orderItemCreate";
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
*/
|
||||
import type { MessageFile } from "./messageFile";
|
||||
import type { MessageReaction } from "./messageReaction";
|
||||
import type { MessageSender } from "./messageSender";
|
||||
|
||||
export interface Message {
|
||||
readonly id: number;
|
||||
readonly chat: number;
|
||||
/** @nullable */
|
||||
readonly sender: number | null;
|
||||
readonly sender: MessageSender;
|
||||
/** @nullable */
|
||||
readonly reply_to: number | null;
|
||||
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 { MessageReaction } from "./messageReaction";
|
||||
import type { MessageSender } from "./messageSender";
|
||||
|
||||
export interface PatchedMessage {
|
||||
readonly id?: number;
|
||||
readonly chat?: number;
|
||||
/** @nullable */
|
||||
readonly sender?: number | null;
|
||||
readonly sender?: MessageSender;
|
||||
/** @nullable */
|
||||
readonly reply_to?: number | null;
|
||||
content?: string;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* Derives the WebSocket base URL from env. Mirrors the BE Channels routing,
|
||||
* which lives behind the same host as the REST API.
|
||||
* Derives the WebSocket base URL.
|
||||
*
|
||||
* Set `VITE_WS_URL` to override (e.g. wss://example.com); otherwise we flip the
|
||||
* scheme of `VITE_BACKEND_URL`.
|
||||
* Priority:
|
||||
* 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 {
|
||||
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) {
|
||||
return `${stripTrailing(explicit)}/ws/chat/${chatId}/`;
|
||||
}
|
||||
const backend = (import.meta.env.VITE_BACKEND_URL as string | undefined)
|
||||
?? "http://localhost:8000";
|
||||
const wsBase = backend.replace(/^http/, "ws");
|
||||
// WS endpoints live at the host root (/ws/chat/<id>/), not under /api/.
|
||||
const hostOnly = wsBase.replace(/\/api\/?$/, "");
|
||||
return `${stripTrailing(hostOnly)}/ws/chat/${chatId}/`;
|
||||
|
||||
// Derive scheme from page protocol, host from page host (preserves dev port)
|
||||
const wsScheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${wsScheme}//${window.location.host}/ws/chat/${chatId}/`;
|
||||
}
|
||||
|
||||
function stripTrailing(s: string): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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 {
|
||||
useApiSocialChatsCreate,
|
||||
@@ -156,7 +156,7 @@ export default function CreateChatModal({ open, onClose }: Props) {
|
||||
/>
|
||||
|
||||
{/* 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 */}
|
||||
<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">
|
||||
@@ -172,25 +172,28 @@ export default function CreateChatModal({ open, onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex border-b border-brand-lines/15 px-5 pt-4 gap-1">
|
||||
{(["DM", "GROUP"] as ChatTab[]).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => switchTab(type)}
|
||||
className={[
|
||||
"px-4 py-2 text-sm font-medium rounded-t-lg transition-colors",
|
||||
tab === type
|
||||
? "border-b-2 border-brand-accent text-brand-accent"
|
||||
: "text-brand-text/60 hover:text-brand-text",
|
||||
].join(" ")}
|
||||
>
|
||||
{type === "DM"
|
||||
? t("chat.create.tabDM")
|
||||
: t("chat.create.tabGroup")}
|
||||
</button>
|
||||
))}
|
||||
{/* Type switch */}
|
||||
<div className="px-5 pt-4 pb-3 border-b border-brand-lines/15">
|
||||
<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
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => switchTab(type)}
|
||||
className={[
|
||||
"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
|
||||
? "text-white shadow-md"
|
||||
: "text-brand-text/45 hover:text-brand-text/80",
|
||||
].join(" ")}
|
||||
style={tab === type ? { backgroundColor: "var(--c-other)", border: "none" } : { border: "none" }}
|
||||
>
|
||||
{type === "DM" ? <FiUser size={14} /> : <FiUsers size={14} />}
|
||||
{type === "DM" ? t("chat.create.tabDM") : t("chat.create.tabGroup")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
@@ -259,7 +262,10 @@ export default function CreateChatModal({ open, onClose }: Props) {
|
||||
|
||||
{/* Dropdown */}
|
||||
{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 && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner size={18} />
|
||||
|
||||
@@ -19,7 +19,8 @@ interface Props {
|
||||
export default function Message({ message, chat, onReply, onReact }: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
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() {
|
||||
if (!confirm("Smazat zprávu?")) return;
|
||||
@@ -31,11 +32,11 @@ export default function Message({ message, chat, onReply, onReact }: Props) {
|
||||
|
||||
return (
|
||||
<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={[
|
||||
"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
|
||||
? "bg-brand-accent text-brand-bg rounded-br-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 ChatSocketEvent =
|
||||
| { type: "new_chat_message"; message_id: number; message: string; sender: string }
|
||||
| { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; 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_id: number; sender: string; sender_avatar: string | null }
|
||||
| { type: "edit_chat_message"; message_id: number; content: string; is_edited: boolean }
|
||||
| { type: "delete_chat_message"; message_id: number }
|
||||
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }
|
||||
|
||||
@@ -41,20 +41,22 @@ h1, h2, h3 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
|
||||
font: inherit;
|
||||
background-color: color-mix(in hsl, var(--c-background-light), black 15%);
|
||||
color: var(--c-text);
|
||||
cursor: pointer;
|
||||
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
|
||||
@layer base {
|
||||
button {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
|
||||
font: inherit;
|
||||
background-color: color-mix(in hsl, var(--c-background-light), black 15%);
|
||||
color: var(--c-text);
|
||||
cursor: pointer;
|
||||
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--c-other);
|
||||
box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%);
|
||||
}
|
||||
button:active { transform: translateY(1px) }
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--c-other);
|
||||
box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%);
|
||||
}
|
||||
button:active { transform: translateY(1px) }
|
||||
|
||||
/* Reusable helpers */
|
||||
.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 */
|
||||
li::marker,
|
||||
ol li::marker,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function ChatRoomPage() {
|
||||
appendMessage({
|
||||
id: event.message_id,
|
||||
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,
|
||||
content: event.message,
|
||||
is_edited: false,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
const BACKEND_ORIGIN = (() => {
|
||||
try {
|
||||
return new URL(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8000").origin;
|
||||
} catch {
|
||||
return "http://localhost:8000";
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Normalises a media URL so it always resolves through the current origin
|
||||
* (Vite dev proxy → backend, or nginx in production).
|
||||
*
|
||||
* - 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 {
|
||||
if (!src) return null;
|
||||
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
|
||||
try {
|
||||
// Full URL — strip origin so the request goes through the proxy/nginx
|
||||
const url = new URL(src);
|
||||
if (url.origin === BACKEND_ORIGIN) return url.pathname + url.search;
|
||||
return url.pathname + (url.search || "");
|
||||
} 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,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: backendUrl,
|
||||
/*'/ws': {
|
||||
target: 'ws://localhost:8000/',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
rewriteWsOrigin: true,
|
||||
},*/
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user