Complete project

This commit is contained in:
2025-03-17 10:01:32 +04:00
parent bcc5d1daf4
commit 46c040c642
41 changed files with 1951 additions and 127 deletions

View File

@@ -1,14 +1,32 @@
@tailwind base;
/* @tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities; */
@import "tailwindcss";
:root {
font-size: calc((100vw / 1920) * 20);
--color-col1: #fb4934;
--color-col2: #b8bb26;
--color-col3: #fabd2f;
--color-col4: #83a598;
--color-col5: #d3869b;
--color-col6: #8ec07c;
--color-col7: #a89986;
--color-col8: #fe8019;
}
@theme {
--color-bg0: #fbf1c7;
--color-bg1: #ebdbb2;
--color-bg2: #d5c4a1;
--color-bg3: #bdae93;
--color-bg4: #a89984;
--color-fg0: #282828;
--color-fg1: #3c3836;
--color-fg2: #504945;
--color-fg3: #665c54;
--color-fg4: #7c6f64;
--color-ac0: #83a598;
@@ -16,18 +34,19 @@
--color-ac2: #8ec07c;
--color-err: #cc241d;
--app-width: 70%;
font-size: calc((100vw / 1920) * 20);
}
[data-theme="dark"] {
--color-bg0: #282828;
--color-bg1: #3c3836;
--color-bg2: #504945;
--color-bg3: #665c54;
--color-bg4: #7c6f64;
--color-fg0: #fbf1c7;
--color-fg1: #ebdbb2;
--color-fg2: #d5c4a1;
--color-fg3: #bdae93;
--color-fg4: #a89984;
--color-ac0: #076678;
@@ -35,8 +54,19 @@
--color-ac2: #427b58;
--color-err: #cc241d;
--color-col1: #9d0006;
--color-col2: #79740e;
--color-col3: #b57614;
--color-col4: #076678;
--color-col5: #8f3f71;
--color-col6: #427b58;
--color-col7: #7c6f65;
--color-col8: #af3a03;
}
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
html,
body {
padding: 0;
@@ -59,10 +89,6 @@ body * {
scrollbar-width: thin;
}
audio::-webkit-media-controls-panel {
background-color: var(--color-bg1);
}
@media (max-width: 1024px) {
:root {
font-size: calc((100vw / 1920) * 56);
@@ -74,4 +100,4 @@ audio::-webkit-media-controls-panel {
:root {
font-size: calc((100vw / 1920) * 64);
}
}
}

View File

@@ -1,6 +1,6 @@
import { Roboto } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "next-themes";
import "./globals.css";
const roboto = Roboto({ subsets: ["latin"] });
@@ -12,7 +12,7 @@ export default function RootLayout({
return (
<html lang="ru" suppressHydrationWarning>
<body className={roboto.className}>
<ThemeProvider enableSystem={false} defaultTheme="light">
<ThemeProvider enableSystem={false} defaultTheme="dark">
{children}
</ThemeProvider>
</body>

View File

@@ -1,3 +1,4 @@
import ChatWindow from "@/widgets/chat-window";
import { Metadata } from "next";
export const metadata: Metadata = {
@@ -6,5 +7,9 @@ export const metadata: Metadata = {
};
export default async function Root() {
return <></>;
return (
<main className="w-full h-full flex items-center justify-center p-8">
<ChatWindow />
</main>
);
}

View File

@@ -0,0 +1,5 @@
import { MessageService } from "./message.service";
export { type IMessage, messageSchema, messagesSchema } from "./message.schema";
export default MessageService;

View File

@@ -0,0 +1,11 @@
import { object, string, number, InferType, array } from "yup";
export const messageSchema = object({
id: number().required().positive().integer(),
text: string().required(),
timeOfSend: string().required(),
sender: string().required(),
});
export const messagesSchema = array(messageSchema);
export type IMessage = InferType<typeof messageSchema>;

View File

@@ -0,0 +1,16 @@
import HTTPService from "@/shared/services/http";
import { messageSchema, messagesSchema } from "./message.schema";
export abstract class MessageService {
public static getTopOfHistory = () =>
HTTPService.get(`message`, messagesSchema);
public static sendMessage = (message: string, senderUUID: string) =>
HTTPService.post(`message`, messageSchema, {
body: {
text: message,
timeOfSend: new Date().toISOString(),
sender: senderUUID,
},
});
}

View File

@@ -0,0 +1,5 @@
import { UserService } from "./user.service";
export { type IUser, userSchema } from "./user.schema";
export default UserService;

View File

@@ -0,0 +1,8 @@
import { object, string, number, InferType } from "yup";
export const userSchema = object({
uuid: string().uuid().required(),
color: number().min(1).max(8).required(),
});
export type IUser = InferType<typeof userSchema>;

View File

@@ -0,0 +1,25 @@
import { v1 as uuidV1 } from "uuid";
import { IUser } from "./user.schema";
export abstract class UserService {
private static pickedColors: IUser["color"][] = [];
public static generateMe = (): IUser => ({
uuid: uuidV1(),
color: 8,
});
public static generateUser = (uuid: IUser["uuid"]): IUser => {
const newColor = Array.from(Array(7).keys())
.map((i) => i + 1)
.reduce(
(p, c) => (!p && !this.pickedColors.includes(c) ? c : p),
undefined as number | undefined
);
this.pickedColors.push(newColor ?? 1);
return {
uuid: uuid,
color: newColor ?? 1,
};
};
}

View File

@@ -0,0 +1,17 @@
"use client";
import { SunIcon } from "@/shared/assets/icons";
import { useTheme } from "next-themes";
export const ColorSchemeSwitch = () => {
const { theme, setTheme } = useTheme();
return (
<>
<SunIcon
className="h-10 min-w-10 max-w-10 cursor-pointer bg-bg3 p-1 rounded-lg hover:bg-bg4 transition"
onClick={() => setTheme(theme == "light" ? "dark" : "light")}
/>
</>
);
};

View File

@@ -0,0 +1,3 @@
import { ColorSchemeSwitch } from "./colorSchemeSwitch";
export default ColorSchemeSwitch;

View File

@@ -0,0 +1,3 @@
import { MessagesList } from "./messagesList";
export default MessagesList;

View File

@@ -0,0 +1,28 @@
import { IMessage } from "@/entities/message";
import { IUser } from "@/entities/user";
export const Message = ({
message,
color,
align = "right",
}: {
message: IMessage;
color: IUser["color"] | undefined;
align?: "left" | "right";
}) => {
return (
<div
key={message.id}
style={{
background: `var(--color-col${color})`,
borderRadius: `var(--radius-xl)`,
[align === "right"
? "borderBottomRightRadius"
: "borderBottomLeftRadius"]: 0,
}}
className={`max-w-20 p-2`}
>
{message.text}
</div>
);
};

View File

@@ -0,0 +1,73 @@
"use client";
import MessageService from "@/entities/message";
import UserService, { IUser } from "@/entities/user";
import { useEffect, useRef, useState } from "react";
import useSWR from "swr";
import { Message } from "./message";
export const MessagesList = () => {
const { data: users, mutate: mutateUsers } = useSWR<IUser[]>("users");
const { data: messages, mutate: mutateMessages } = useSWR(
"messages",
MessageService.getTopOfHistory,
{ revalidateOnFocus: false }
);
const { data: me } = useSWR<IUser>("me");
const [websocket, updateWebSocket] = useState<WebSocket>();
const bottomRef = useRef<HTMLDivElement>(null);
const getUserColor = (userUUID: IUser["uuid"]) =>
users?.find((user) => user.uuid === userUUID)?.color;
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
const newUsers: IUser[] = [];
messages?.forEach((message) => {
if (
!users?.concat(newUsers).find((user) => user.uuid === message.sender)
) {
const newUser = UserService.generateUser(message.sender);
newUsers.push(newUser);
}
});
mutateUsers([...(users ?? []), ...newUsers]);
}, [messages]);
useEffect(() => {
if (!websocket && messages && process.env.NEXT_PUBLIC_WS_URL) {
const websocket = new WebSocket(process.env.NEXT_PUBLIC_WS_URL);
websocket.onmessage = (event) => {
mutateMessages([...(messages ?? []), JSON.parse(event.data)], {
revalidate: false,
});
};
websocket.onclose = function () {
setTimeout(() => updateWebSocket(undefined), 1000);
};
updateWebSocket(websocket);
}
return () => websocket?.close();
}, [websocket, messages]);
return (
<div className="max-w-full h-full overflow-auto flex flex-col gap-2">
{messages?.map((message) => (
<div
key={message.id}
className="w-full flex"
style={{
justifyContent: message.sender === me?.uuid ? "end" : "start",
}}
>
<Message
message={message}
color={getUserColor(message.sender)}
align={message.sender === me?.uuid ? "right" : "left"}
/>
</div>
))}
<div ref={bottomRef}></div>
</div>
);
};

View File

@@ -0,0 +1,3 @@
import { SendMessage } from "./sendMessage";
export default SendMessage;

View File

@@ -0,0 +1,41 @@
"use client";
import MessageService from "@/entities/message";
import UserService from "@/entities/user";
import { useState } from "react";
import useSWR from "swr";
export const SendMessage = () => {
const [message, updateMessage] = useState<string>("");
const { data: me } = useSWR(`me`, UserService.generateMe);
return (
<div className="w-full h-full flex items-center gap-4">
<input
type="text"
className={
"w-full h-full px-4 py-1 rounded-lg" +
" focus:outline-1 outline-fg4 focus:shadow-md"
}
placeholder="Напиши сообщение..."
value={message}
onChange={(e) => updateMessage(e.target.value)}
/>
<button
className={
"h-full px-4 rounded-lg bg-ac2 disabled:bg-bg3" +
" cursor-pointer hover:shadow-md text-fg0 disabled:text-fg4"
}
disabled={!message || !me}
onClick={() => {
if (!!me) {
MessageService.sendMessage(message, me.uuid);
updateMessage("");
}
}}
>
Отправить
</button>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { SunIcon } from "./sunIcon";
export { SendIcon } from "./sendIcon";

View File

@@ -0,0 +1,33 @@
export const SendIcon = ({
className,
onClick,
}: {
className?: string;
onClick?: () => void;
}) => {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
onClick={onClick}
>
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
{" "}
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M1.265 4.42619C1.04293 2.87167 2.6169 1.67931 4.05323 2.31397L21.8341 10.1706C23.423 10.8727 23.423 13.1273 21.8341 13.8294L4.05323 21.686C2.6169 22.3207 1.04293 21.1283 1.265 19.5738L1.99102 14.4917C2.06002 14.0087 2.41458 13.6156 2.88791 13.4972L8.87688 12L2.88791 10.5028C2.41458 10.3844 2.06002 9.99129 1.99102 9.50829L1.265 4.42619ZM21.0257 12L3.2449 4.14335L3.89484 8.69294L12.8545 10.9328C13.9654 11.2106 13.9654 12.7894 12.8545 13.0672L3.89484 15.3071L3.2449 19.8566L21.0257 12Z"
fill="var(--color-fg0)"
></path>{" "}
</g>
</svg>
);
};

View File

@@ -0,0 +1,31 @@
export const SunIcon = ({
className,
onClick,
}: {
className?: string;
onClick?: () => void;
}) => {
return (
<svg
width="800px"
height="800px"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
onClick={onClick}
>
<path
d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859
6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859
17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091
16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086
8 12 8C14.2091 8 16 9.79086 16 12Z"
stroke="var(--color-fg1)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

View File

@@ -0,0 +1,95 @@
import { AnySchema } from "yup";
export type RequestCacheOptions = {
cache?: RequestCache;
next?: NextFetchRequestConfig;
};
type GetRequestOptions = RequestCacheOptions & {
headers?: HeadersInit;
};
type RequestOptions = GetRequestOptions & {
body?: BodyInit | object;
stringify?: boolean;
};
export abstract class HTTPService {
private static deepUndefinedToNull(o?: object): object | undefined {
if (Array.isArray(o)) return o;
if (o)
return Object.fromEntries(
Object.entries(o).map(([k, v]) => {
if (v === undefined) return [k, null];
if (typeof v === "object") return [k, this.deepUndefinedToNull(v)];
return [k, v];
})
);
}
public static async request<Y extends AnySchema>(
method: "GET" | "POST" | "PUT" | "DELETE",
url: string,
schema: Y,
options?: RequestOptions
) {
return await fetch(
`${process.env.NEXT_PUBLIC_BASE_PROTOCOL}://` +
`${process.env.NEXT_PUBLIC_BASE_DOMAIN}:` +
`${process.env.NEXT_PUBLIC_BASE_PORT}` +
`${process.env.NEXT_PUBLIC_API_PATTERN}${url}`,
{
method: method,
headers: {
accept: "application/json",
...((options?.stringify ?? true) != true
? {}
: { "Content-Type": "application/json" }),
...options?.headers,
},
body:
(options?.stringify ?? true) != true
? (options?.body as BodyInit)
: JSON.stringify(
this.deepUndefinedToNull(options?.body as object | undefined)
),
cache: options?.cache ?? options?.next ? undefined : "no-cache",
next: options?.next ?? {},
}
)
.then((r) => {
if (r && r.ok) return r;
else throw Error("Response ok = false");
})
.then((r) => r.json())
.then(async (d) => await schema.validate(d))
.catch((e) => {
console.error(e);
return null;
});
}
public static async get<Y extends AnySchema>(
url: string,
schema: Y,
options?: GetRequestOptions
) {
return await this.request<Y>("GET", url, schema, options);
}
public static async post<Y extends AnySchema>(
url: string,
schema: Y,
options?: RequestOptions
) {
return await this.request<Y>("POST", url, schema, options);
}
public static async put<Y extends AnySchema>(
url: string,
schema: Y,
options?: RequestOptions
) {
return await this.request<Y>("PUT", url, schema, options);
}
}

View File

@@ -0,0 +1,3 @@
import { HTTPService } from "./http";
export default HTTPService;

View File

@@ -0,0 +1,3 @@
import { ChatWindow } from "./window";
export default ChatWindow;

View File

@@ -0,0 +1,25 @@
import ColorSchemeSwitch from "@/features/colorSchemeSwitch";
import MessagesList from "@/features/messagesList";
import SendMessage from "@/features/sendMessage";
export const ChatWindow = () => {
return (
<div
className={
"w-lg h-full md:size-120 bg-bg1 p-4 rounded-lg " +
"drop-shadow-2xl flex flex-col gap-4 justify-between"
}
>
<MessagesList />
<div
className={
"w-full size-14 bg-bg2 p-2 rounded-lg drop-shadow-md " +
"flex flex-row gap-4 items-center justify-between"
}
>
<ColorSchemeSwitch />
<SendMessage />
</div>
</div>
);
};