chat ws with pfp is working
This commit is contained in:
@@ -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