diff --git a/apps/talk/src/app.module.ts b/apps/talk/src/app.module.ts index 61ff170..446f750 100644 --- a/apps/talk/src/app.module.ts +++ b/apps/talk/src/app.module.ts @@ -8,6 +8,7 @@ import { Configure } from "./modules/config/configure"; import { CoreModule } from "./modules/core/core.module"; import { DatabaseModule } from "./modules/database/database.module"; import { FileMOdule } from "./modules/file/file.module"; +import { FriendModule } from "./modules/friends/friend.module"; import { UserModule } from "./modules/user/user.module"; // 当providers 为空时,就会从di容器从import的模块中查找->其他模块需要两个部分一个是providers,一个是exports,providers是用来提供实例的,exports是用来导出模块的 @@ -25,6 +26,7 @@ export class AppModule { CoreModule.forRoot(configure), FileMOdule.forRoot(), ChatModule.forRoot(configure), + FriendModule, ], controllers: [], exports: [ diff --git a/apps/talk/src/modules/chat/chat.module.ts b/apps/talk/src/modules/chat/chat.module.ts index c2a5237..4fd382b 100644 --- a/apps/talk/src/modules/chat/chat.module.ts +++ b/apps/talk/src/modules/chat/chat.module.ts @@ -1,8 +1,11 @@ import { DynamicModule, Module } from "@nestjs/common"; import { JwtModule, JwtService } from "@nestjs/jwt"; import { TypeOrmModule } from "@nestjs/typeorm"; +import * as friendsEntities from "src/modules/friends/entities"; import { Configure } from "../config/configure"; +import { FriendModule } from "../friends/friend.module"; +import { FriendService } from "../friends/services/friend.service"; import { AUTH_JWT_SECRET } from "../user/constants"; import * as entities from "../user/entities"; import { AuthService, TokenService, UserService } from "../user/services"; @@ -16,11 +19,15 @@ export class ChatModule { return { module: ChatModule, imports: [ + FriendModule, JwtModule.register({ secret: jwtSecret, }), UserModule.forRoot(configure), - TypeOrmModule.forFeature(Object.values(entities), "default"), + TypeOrmModule.forFeature( + [...Object.values(entities), ...Object.values(friendsEntities)], + "default", + ), ], providers: [ ChatGateway, @@ -28,6 +35,7 @@ export class ChatModule { JwtService, AuthService, TokenService, + FriendService, { provide: AUTH_JWT_SECRET, useValue: jwtSecret }, ], exports: [], diff --git a/apps/talk/src/modules/chat/gateway/chat.gateway.ts b/apps/talk/src/modules/chat/gateway/chat.gateway.ts index 17f61b2..bef551a 100644 --- a/apps/talk/src/modules/chat/gateway/chat.gateway.ts +++ b/apps/talk/src/modules/chat/gateway/chat.gateway.ts @@ -9,6 +9,8 @@ import { WebSocketServer, } from "@nestjs/websockets"; import { Server, Socket } from "socket.io"; +import { FriendDto } from "src/modules/friends/dtos/friendMessage.dto"; +import { FriendService } from "src/modules/friends/services/friend.service"; import { TokenService } from "src/modules/user/services/token.service"; import { RCode } from "../type"; @@ -38,7 +40,10 @@ interface UserPayload { export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { - constructor(private readonly tokenService: TokenService) {} + constructor( + private readonly tokenService: TokenService, + private readonly friendService: FriendService, + ) {} @WebSocketServer() server: Server; async handleConnection(client: Socket, ..._args: any[]) { @@ -110,9 +115,9 @@ export class ChatGateway } } @SubscribeMessage("chatData") - getAllData(@MessageBody() token: string) { - console.log(token,"chatData"); - + async getAllData(@MessageBody() token: string) { + console.log(token, "chatData"); + const user: UserPayload = this.tokenService.verifyAccessToken(token); if (user) { const onLineUserIds = Array.from( @@ -122,11 +127,31 @@ export class ChatGateway ), ), ); + const friendIds = await this.friendService.getFriends(user.id); + if (friendIds.data === null) { + return friendIds; + } + const friendArr: FriendDto[] = await Promise.all( + friendIds.data.map(async (friendId) => { + const messages = await this.friendService.getFriendMessages({ + userId: user.id, + friendId, + current: 0, + pageSize: 10, + }); + return { + userId: friendId, + messages: messages.data.messageArr, + }; + }), + ); + this.server.to(user.id).emit("chatData", { code: RCode.OK, msg: "获取聊天数据成功", data: { onLineUserIds, + friendArr, }, }); } diff --git a/apps/talk/src/modules/friends/controller/friend.controller.ts b/apps/talk/src/modules/friends/controller/friend.controller.ts new file mode 100644 index 0000000..9595c30 --- /dev/null +++ b/apps/talk/src/modules/friends/controller/friend.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Post, Query } from "@nestjs/common"; + +import { + CreateFriendDto, + GetFriendMessagesDto, +} from "../dtos/friendMessage.dto"; +import { FriendService } from "../services/friend.service"; + +@Controller("friend") +export class FriendController { + constructor(private readonly friendService: FriendService) {} + @Get() + getFriends( + @Query("userId") + data: string, + ) { + console.log("data", data); + + return this.friendService.getFriends(data); + } + + @Get("/friendMessages") + getFriendMessage(@Query() query: GetFriendMessagesDto) { + return this.friendService.getFriendMessages(query); + } + @Post("/addfriend") + addFriend(@Body() data: CreateFriendDto) { + return this.friendService.addFriend(data); + } +} diff --git a/apps/talk/src/modules/friends/controller/friend.http b/apps/talk/src/modules/friends/controller/friend.http new file mode 100644 index 0000000..da11bd8 --- /dev/null +++ b/apps/talk/src/modules/friends/controller/friend.http @@ -0,0 +1,11 @@ +### +@baseurl=http://127.0.0.1:5000/v1/friend + +### +POST {{baseurl}}/addfriend +Content-Type: application/json + +{ +"userId": "0e5d2a48-c480-44a5-a22b-2e3201d4725e", +"friendId": "f76cb877-ac7d-436c-8e91-73820ca1d35e" +} diff --git a/apps/talk/src/modules/friends/dtos/friendMessage.dto.ts b/apps/talk/src/modules/friends/dtos/friendMessage.dto.ts new file mode 100644 index 0000000..92c171d --- /dev/null +++ b/apps/talk/src/modules/friends/dtos/friendMessage.dto.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common"; +import { IsUUID } from "class-validator"; + +import { FriendMessage } from "../entities"; +@Injectable() +export class GetFriendMessagesDto { + userId: string; + friendId: string; + current: number; + pageSize: number; +} + +// 好友 +@Injectable() +export class FriendDto { + userId: string; + messages?: FriendMessage[]; +} +@Injectable() +export class CreateFriendDto { + @IsUUID(undefined, { message: "不是有效的UUID" }) + userId: string; + @IsUUID(undefined, { message: "不是有效的UUID" }) + friendId: string; +} diff --git a/apps/talk/src/modules/friends/entities/friend.entity.ts b/apps/talk/src/modules/friends/entities/friend.entity.ts new file mode 100644 index 0000000..7839fcd --- /dev/null +++ b/apps/talk/src/modules/friends/entities/friend.entity.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity("user_map") +export class UserMap { + @PrimaryColumn({ type: "uuid", generated: "uuid" }) + _id: number; + + @Column({ type: "varchar", length: 255 }) + friendId: string; + + @Column({ type: "varchar", length: 255 }) + userId: string; +} diff --git a/apps/talk/src/modules/friends/entities/friendMessage.entity.ts b/apps/talk/src/modules/friends/entities/friendMessage.entity.ts new file mode 100644 index 0000000..b410d88 --- /dev/null +++ b/apps/talk/src/modules/friends/entities/friendMessage.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity() +export class FriendMessage { + @PrimaryGeneratedColumn() + _id: number; + + @Column() + userId: string; + + @Column() + friendId: string; + + @Column() + content: string; + + @Column() + messageType: string; + + @Column() + time: number; +} diff --git a/apps/talk/src/modules/friends/entities/index.ts b/apps/talk/src/modules/friends/entities/index.ts new file mode 100644 index 0000000..71a7967 --- /dev/null +++ b/apps/talk/src/modules/friends/entities/index.ts @@ -0,0 +1,2 @@ +export * from "./friend.entity"; +export * from "./friendMessage.entity"; diff --git a/apps/talk/src/modules/friends/friend.module.ts b/apps/talk/src/modules/friends/friend.module.ts new file mode 100644 index 0000000..fb9af13 --- /dev/null +++ b/apps/talk/src/modules/friends/friend.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import * as entities from "./entities"; +import { FriendController } from "./controller/friend.controller"; +import { FriendService } from "./services/friend.service"; + +@Module({ + imports: [TypeOrmModule.forFeature(Object.values(entities))], + controllers: [FriendController], + providers: [FriendService], + exports: [FriendService], +}) +export class FriendModule {} diff --git a/apps/talk/src/modules/friends/services/friend.service.spec.ts b/apps/talk/src/modules/friends/services/friend.service.spec.ts new file mode 100644 index 0000000..a4f5175 --- /dev/null +++ b/apps/talk/src/modules/friends/services/friend.service.spec.ts @@ -0,0 +1,52 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; + +import { CreateFriendDto } from "../dtos/friendMessage.dto"; +import { FriendMessage, UserMap } from "../entities"; +import { FriendService } from "./friend.service"; + +describe("friendService", () => { + let service: FriendService; + + beforeEach(async () => { + const mockRepository = { + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockResolvedValue(undefined), + find: jest.fn().mockResolvedValue([]), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FriendService, + { + provide: getRepositoryToken(FriendMessage), + useValue: mockRepository, + }, + { + provide: getRepositoryToken(UserMap), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(FriendService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("addFriend", () => { + it("should add a friend", async () => { + const dto: CreateFriendDto = { + userId: "0e5d2a48-c480-44a5-a22b-2e3201d47e5e1", + friendId: "f76cb877-ac7d-436c-8e91-73820ca1d35e2", + }; + const result = await service.addFriend(dto); + expect(result).toEqual({ + msg: "添加好友成功", + data: null, + }); + }); + }); +}); diff --git a/apps/talk/src/modules/friends/services/friend.service.ts b/apps/talk/src/modules/friends/services/friend.service.ts new file mode 100644 index 0000000..a528a8c --- /dev/null +++ b/apps/talk/src/modules/friends/services/friend.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { + CreateFriendDto, + GetFriendMessagesDto, +} from "../dtos/friendMessage.dto"; +import { FriendMessage, UserMap } from "../entities"; + +@Injectable() +export class FriendService { + constructor( + @InjectRepository(FriendMessage) + private readonly friendMessageRepository: Repository, + @InjectRepository(UserMap) + private readonly friendRepository: Repository, + ) {} + + async getFriends( + userId: string, + ): Promise<{ msg: string; data: string[] | null }> { + try { + const friendMaps = await this.friendRepository.find({ + where: { userId }, + }); + if (friendMaps.length === 0) { + return { msg: "没有找到任何好友", data: null }; + } else { + return { + msg: "", + data: friendMaps.map((friendMap) => friendMap.friendId), + }; + } + } catch (err) { + return { msg: "数据库服务器出错", data: null }; + } + } + + async getFriendMessages(data: GetFriendMessagesDto) { + const { userId, friendId, current, pageSize } = data; + const messages = await this.friendMessageRepository + .createQueryBuilder("friendMessage") + .orderBy("friendMessage.time", "DESC") + .where( + "friendMessage.userId = :userId AND friendMessage.friendId = :friendId", + { userId, friendId }, + ) + .orWhere( + "friendMessage.userId = :friendId AND friendMessage.friendId = :userId", + { userId, friendId }, + ) + .skip(current) + .take(pageSize) + .getMany(); + return { msg: "", data: { messageArr: messages.reverse() } }; + } + async addFriend(data: CreateFriendDto) { + const { userId, friendId } = data; + const friend = await this.friendRepository.findOne({ + where: { userId, friendId }, + }); + if (friend) { + return { msg: "已经是好友了", data: null }; + } + + const friendMap = this.friendRepository.create({ + userId, + friendId, + }); + try { + await this.friendRepository.save(friendMap); + return { msg: "添加好友成功", data: null }; + } catch (err) { + return { msg: "数据库服务器出错", data: err }; + } + } +} diff --git a/apps/talk/test/app.e2e-spec.ts b/apps/talk/test/app.e2e-spec.ts index 1541151..2ee457c 100644 --- a/apps/talk/test/app.e2e-spec.ts +++ b/apps/talk/test/app.e2e-spec.ts @@ -19,7 +19,7 @@ describe("AppController (e2e)", () => { await app.init(); }); - it("/ (GET)", () => { + it("/v1/users (GET)", () => { return request(app.getHttpServer()) .get("/") .expect(200) diff --git a/apps/web/src/app/(pages)/blog/category/[[...slug]]/layout.tsx b/apps/web/src/app/(pages)/blog/category/[[...slug]]/layout.tsx index d00b5cb..63b03b1 100644 --- a/apps/web/src/app/(pages)/blog/category/[[...slug]]/layout.tsx +++ b/apps/web/src/app/(pages)/blog/category/[[...slug]]/layout.tsx @@ -10,7 +10,7 @@ export default async function BlogCategoryLayout({ children, params, }: React.PropsWithChildren & { params: { slug: string[] } }) { - const slug = (await params).slug.at(-1) || ""; + const slug = (await params).slug; const categoryRes = await fetchApi((c) => { const reSlug = (isArray(slug) ? slug.at(-1) : slug) || ""; return c.api.categories.breadcrumb[":latest"].$get({ @@ -56,7 +56,7 @@ export default async function BlogCategoryLayout({
diff --git a/apps/web/src/app/(pages)/blog/category/[[...slug]]/page.tsx b/apps/web/src/app/(pages)/blog/category/[[...slug]]/page.tsx index 9247dc7..060b98b 100644 --- a/apps/web/src/app/(pages)/blog/category/[[...slug]]/page.tsx +++ b/apps/web/src/app/(pages)/blog/category/[[...slug]]/page.tsx @@ -8,7 +8,7 @@ export default async function CategoryPage({ }: { params: { slug: string[] }; }) { - const { slug } = await params; + const slug = (await params).slug || []; const categoryRes = await fetchApi((c) => { const reSlug = (isArray(slug) ? slug.at(-1) : slug) || ""; diff --git a/apps/web/src/app/(pages)/blog/post/[item]/page.tsx b/apps/web/src/app/(pages)/blog/detail/[id]/page.tsx similarity index 86% rename from apps/web/src/app/(pages)/blog/post/[item]/page.tsx rename to apps/web/src/app/(pages)/blog/detail/[id]/page.tsx index 82b8cfe..42614fa 100644 --- a/apps/web/src/app/(pages)/blog/post/[item]/page.tsx +++ b/apps/web/src/app/(pages)/blog/detail/[id]/page.tsx @@ -16,10 +16,11 @@ export const generateMetadata = async ( export default async function Page({ params, }: { - params: Promise<{ item: string }>; + params: Promise<{ id: string }>; }) { + const id = (await params).id; const result = await fetchApi(async (c) => - c.api.posts[":item"].$get({ param: { item: (await params).item } }), + c.api.posts[":item"].$get({ param: { item: id } }), ); if (!result.ok) { return notFound(); diff --git a/apps/web/src/app/(pages)/chat/page.tsx b/apps/web/src/app/(pages)/chat/page.tsx index 2e3a19f..2a6c66c 100644 --- a/apps/web/src/app/(pages)/chat/page.tsx +++ b/apps/web/src/app/(pages)/chat/page.tsx @@ -1,7 +1,9 @@ "use client"; import { useGetCookies } from "cookies-next"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; + +import type { MessageWithMe } from "@/app/_components/chat/type"; import { useAuthStore } from "@/app/_components/auth/store"; import useSocketStory from "@/lib/socket"; @@ -9,26 +11,26 @@ import useSocketStory from "@/lib/socket"; export default function ChatPage() { const cookies = useGetCookies(); const token = cookies()?.auth_token || ""; - const { connect, socket,emit } = useSocketStory((state) => state); + + const { connect, socket, emit } = useSocketStory(); const { auth } = useAuthStore((state) => state); - -useEffect(() => { - if (socket) { - emit("chatData",token) - socket.on("chatData", (data:any) => { - console.log("chatData", data); - }); - // socket 已经可用,可以注册事件或发送消息 - console.log("socket 已连接", socket); - } - return ()=>{ + const [messages, setMessages] = useState([]); // show messages on ScrollArea + + useEffect(() => { if (socket) { - socket.off("chatData"); - console.log("socket 已断开", socket); + emit("chatData", token); + socket.on("chatData", (data: any) => { + console.log("chatData", data); + }); + } + return () => { + if (socket) { + socket.off("chatData"); + console.log("socket 已断开", socket); } - } -}, [socket]); + }; + }, [socket]); // 只负责连接 useEffect(() => { connect({ @@ -38,8 +40,5 @@ useEffect(() => { // 只在 auth/cookies/connect 变化时重新连接 }, [auth, cookies, connect]); - - - return
Chat Page
; } diff --git a/apps/web/src/app/_components/blog/post/item/index.tsx b/apps/web/src/app/_components/blog/post/item/index.tsx index cca2d51..3ac398a 100644 --- a/apps/web/src/app/_components/blog/post/item/index.tsx +++ b/apps/web/src/app/_components/blog/post/item/index.tsx @@ -5,13 +5,16 @@ import { isNil } from "lodash"; import Image from "next/image"; import { MdxRender } from "@/app/_components/mdx/render"; +import { AspectRatio } from "@/app/_components/ui/aspect-ratio"; import { formatChineseTime } from "@/lib/utils"; export const PostItemPage: FC<{ post: PostItem }> = ({ post }) => { return ( -
+
- + + +
diff --git a/apps/web/src/app/_components/chat/type.ts b/apps/web/src/app/_components/chat/type.ts new file mode 100644 index 0000000..875928a --- /dev/null +++ b/apps/web/src/app/_components/chat/type.ts @@ -0,0 +1,6 @@ +export interface MessageWithMe { + from: string; + me: boolean; + message: string; + timestamp: number; +} diff --git a/apps/web/src/app/_components/home/blogcard/blogCard.tsx b/apps/web/src/app/_components/home/blogcard/blogCard.tsx index b6650a7..51899fc 100644 --- a/apps/web/src/app/_components/home/blogcard/blogCard.tsx +++ b/apps/web/src/app/_components/home/blogcard/blogCard.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import type { Date2String } from "@/lib/types"; +import { ShineBorder } from "@/components/magicui/shine-border"; import { fetchApi } from "@/lib/api"; import { AspectRatio } from "../../ui/aspect-ratio"; @@ -34,6 +35,7 @@ export const BlogCard = async ({ item }: { item: Date2String }) => { return ( + }) => { {category.map((value: any) => ( - {value.name} + {value.name} ))} diff --git a/apps/web/src/app/styles/index.css b/apps/web/src/app/styles/index.css index 82d956b..ed9abeb 100644 --- a/apps/web/src/app/styles/index.css +++ b/apps/web/src/app/styles/index.css @@ -34,23 +34,31 @@ --sidebar-border: 240 3.7% 15.9%; --sidebar-ring: 217.2 91.2% 59.8%; } -} -@layer base { - * { - font-family: "outfit", sans-serif; - } - - body { - @apply tw-bg-background tw-text-foreground; + .theme { + --animate-marquee: marquee var(--duration) infinite linear; + --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; } } -@layer base { - * { +@theme inline { + @keyframes marquee { + from { + transform: translateX(0); + } + + to { + transform: translateX(calc(-100% - var(--gap))); + } } - body { - @apply tw-bg-background tw-text-foreground; + @keyframes marquee-vertical { + from { + transform: translateY(0); + } + + to { + transform: translateY(calc(-100% - var(--gap))); + } } } diff --git a/apps/web/src/app/styles/tailwind/base.css b/apps/web/src/app/styles/tailwind/base.css index 9730d75..4108862 100644 --- a/apps/web/src/app/styles/tailwind/base.css +++ b/apps/web/src/app/styles/tailwind/base.css @@ -1,7 +1,7 @@ @layer base { - * { + /* * { @apply tw-border-border; - } + } */ html, body { diff --git a/apps/web/src/components/magicui/icon-cloud.tsx b/apps/web/src/components/magicui/icon-cloud.tsx new file mode 100644 index 0000000..40e52cd --- /dev/null +++ b/apps/web/src/components/magicui/icon-cloud.tsx @@ -0,0 +1,327 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { renderToString } from "react-dom/server"; + +interface Icon { + x: number; + y: number; + z: number; + scale: number; + opacity: number; + id: number; +} + +interface IconCloudProps { + icons?: React.ReactNode[]; + images?: string[]; +} + +function easeOutCubic(t: number): number { + return 1 - (1 - t) ** 3; +} + +export function IconCloud({ icons, images }: IconCloudProps) { + const canvasRef = useRef(null); + const [iconPositions, setIconPositions] = useState([]); + const [rotation, _setRotation] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 }); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const [targetRotation, setTargetRotation] = useState<{ + x: number; + y: number; + startX: number; + startY: number; + distance: number; + startTime: number; + duration: number; + } | null>(null); + const animationFrameRef = useRef(0); + const rotationRef = useRef(rotation); + const iconCanvasesRef = useRef([]); + const imagesLoadedRef = useRef([]); + + // Create icon canvases once when icons/images change + useEffect(() => { + if (!icons && !images) return; + + const items = icons || images || []; + imagesLoadedRef.current = Array.from({ length: items.length }).fill( + false, + ) as boolean[]; + + const newIconCanvases = items.map((item, index) => { + const offscreen = document.createElement("canvas"); + offscreen.width = 40; + offscreen.height = 40; + const offCtx = offscreen.getContext("2d"); + + if (offCtx) { + if (images) { + // Handle image URLs directly + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = items[index] as string; + img.onload = () => { + offCtx.clearRect(0, 0, offscreen.width, offscreen.height); + + // Create circular clipping path + offCtx.beginPath(); + offCtx.arc(20, 20, 20, 0, Math.PI * 2); + offCtx.closePath(); + offCtx.clip(); + + // Draw the image + offCtx.drawImage(img, 0, 0, 40, 40); + + imagesLoadedRef.current[index] = true; + }; + } else { + // Handle SVG icons + offCtx.scale(0.4, 0.4); + const svgString = renderToString(item as React.ReactElement); + const img = new Image(); + img.src = `data:image/svg+xml;base64,${btoa(svgString)}`; + img.onload = () => { + offCtx.clearRect(0, 0, offscreen.width, offscreen.height); + offCtx.drawImage(img, 0, 0); + imagesLoadedRef.current[index] = true; + }; + } + } + return offscreen; + }); + + iconCanvasesRef.current = newIconCanvases; + }, [icons, images]); + + // Generate initial icon positions on a sphere + useEffect(() => { + const items = icons || images || []; + const newIcons: Icon[] = []; + const numIcons = items.length || 20; + + // Fibonacci sphere parameters + const offset = 2 / numIcons; + const increment = Math.PI * (3 - Math.sqrt(5)); + + for (let i = 0; i < numIcons; i++) { + const y = i * offset - 1 + offset / 2; + const r = Math.sqrt(1 - y * y); + const phi = i * increment; + + const x = Math.cos(phi) * r; + const z = Math.sin(phi) * r; + + newIcons.push({ + x: x * 100, + y: y * 100, + z: z * 100, + scale: 1, + opacity: 1, + id: i, + }); + } + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setIconPositions(newIcons); + }, [icons, images]); + + // Handle mouse events + const handleMouseDown = (e: React.MouseEvent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect || !canvasRef.current) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const ctx = canvasRef.current.getContext("2d"); + if (!ctx) return; + + iconPositions.forEach((icon) => { + const cosX = Math.cos(rotationRef.current.x); + const sinX = Math.sin(rotationRef.current.x); + const cosY = Math.cos(rotationRef.current.y); + const sinY = Math.sin(rotationRef.current.y); + + const rotatedX = icon.x * cosY - icon.z * sinY; + const rotatedZ = icon.x * sinY + icon.z * cosY; + const rotatedY = icon.y * cosX + rotatedZ * sinX; + + const screenX = canvasRef.current!.width / 2 + rotatedX; + const screenY = canvasRef.current!.height / 2 + rotatedY; + + const scale = (rotatedZ + 200) / 300; + const radius = 20 * scale; + const dx = x - screenX; + const dy = y - screenY; + + if (dx * dx + dy * dy < radius * radius) { + const targetX = -Math.atan2( + icon.y, + Math.sqrt(icon.x * icon.x + icon.z * icon.z), + ); + const targetY = Math.atan2(icon.x, icon.z); + + const currentX = rotationRef.current.x; + const currentY = rotationRef.current.y; + const distance = Math.sqrt( + (targetX - currentX) ** 2 + (targetY - currentY) ** 2, + ); + + const duration = Math.min(2000, Math.max(800, distance * 1000)); + + setTargetRotation({ + x: targetX, + y: targetY, + startX: currentX, + startY: currentY, + distance, + startTime: performance.now(), + duration, + }); + } + }); + + setIsDragging(true); + setLastMousePos({ x: e.clientX, y: e.clientY }); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + const rect = canvasRef.current?.getBoundingClientRect(); + if (rect) { + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + setMousePos({ x, y }); + } + + if (isDragging) { + const deltaX = e.clientX - lastMousePos.x; + const deltaY = e.clientY - lastMousePos.y; + + rotationRef.current = { + x: rotationRef.current.x + deltaY * 0.002, + y: rotationRef.current.y + deltaX * 0.002, + }; + + setLastMousePos({ x: e.clientX, y: e.clientY }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + // Animation and rendering + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY); + const dx = mousePos.x - centerX; + const dy = mousePos.y - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + const speed = 0.003 + (distance / maxDistance) * 0.01; + + if (targetRotation) { + const elapsed = performance.now() - targetRotation.startTime; + const progress = Math.min(1, elapsed / targetRotation.duration); + const easedProgress = easeOutCubic(progress); + + rotationRef.current = { + x: + targetRotation.startX + + (targetRotation.x - targetRotation.startX) * easedProgress, + y: + targetRotation.startY + + (targetRotation.y - targetRotation.startY) * easedProgress, + }; + + if (progress >= 1) { + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setTargetRotation(null); + } + } else if (!isDragging) { + rotationRef.current = { + x: rotationRef.current.x + (dy / canvas.height) * speed, + y: rotationRef.current.y + (dx / canvas.width) * speed, + }; + } + + iconPositions.forEach((icon, index) => { + const cosX = Math.cos(rotationRef.current.x); + const sinX = Math.sin(rotationRef.current.x); + const cosY = Math.cos(rotationRef.current.y); + const sinY = Math.sin(rotationRef.current.y); + + const rotatedX = icon.x * cosY - icon.z * sinY; + const rotatedZ = icon.x * sinY + icon.z * cosY; + const rotatedY = icon.y * cosX + rotatedZ * sinX; + + const scale = (rotatedZ + 200) / 300; + const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200)); + + ctx.save(); + ctx.translate( + canvas.width / 2 + rotatedX, + canvas.height / 2 + rotatedY, + ); + ctx.scale(scale, scale); + ctx.globalAlpha = opacity; + + if (icons || images) { + // Only try to render icons/images if they exist + if ( + iconCanvasesRef.current[index] && + imagesLoadedRef.current[index] + ) { + ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40); + } + } else { + // Show numbered circles if no icons/images are provided + ctx.beginPath(); + ctx.arc(0, 0, 20, 0, Math.PI * 2); + ctx.fillStyle = "#4444ff"; + ctx.fill(); + ctx.fillStyle = "white"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = "16px Arial"; + ctx.fillText(`${icon.id + 1}`, 0, 0); + } + + ctx.restore(); + }); + animationFrameRef.current = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [icons, images, iconPositions, isDragging, mousePos, targetRotation]); + + return ( + + ); +} diff --git a/apps/web/src/components/magicui/marquee.tsx b/apps/web/src/components/magicui/marquee.tsx new file mode 100644 index 0000000..c2d5bc4 --- /dev/null +++ b/apps/web/src/components/magicui/marquee.tsx @@ -0,0 +1,78 @@ +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils"; + +interface MarqueeProps extends ComponentPropsWithoutRef<"div"> { + /** + * Optional CSS class name to apply custom styles + */ + className?: string; + /** + * Whether to reverse the animation direction + * @default false + */ + reverse?: boolean; + /** + * Whether to pause the animation on hover + * @default false + */ + pauseOnHover?: boolean; + /** + * Content to be displayed in the marquee + */ + children: React.ReactNode; + /** + * Whether to animate vertically instead of horizontally + * @default false + */ + vertical?: boolean; + /** + * Number of times to repeat the content + * @default 4 + */ + repeat?: number; +} + +export function Marquee({ + className, + reverse = false, + pauseOnHover = false, + children, + vertical = false, + repeat = 4, + ...props +}: MarqueeProps) { + return ( +
+ {Array.from({ length: repeat }) + .fill(0) + .map((_, i) => ( +
+ {children} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/magicui/shine-border.tsx b/apps/web/src/components/magicui/shine-border.tsx new file mode 100644 index 0000000..df66a39 --- /dev/null +++ b/apps/web/src/components/magicui/shine-border.tsx @@ -0,0 +1,63 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +interface ShineBorderProps extends React.HTMLAttributes { + /** + * Width of the border in pixels + * @default 1 + */ + borderWidth?: number; + /** + * Duration of the animation in seconds + * @default 14 + */ + duration?: number; + /** + * Color of the border, can be a single color or an array of colors + * @default "#000000" + */ + shineColor?: string | string[]; +} + +/** + * Shine Border + * + * An animated background border effect component with configurable properties. + */ +export function ShineBorder({ + borderWidth = 1, + duration = 14, + shineColor = "#000000", + className, + style, + ...props +}: ShineBorderProps) { + return ( +
+ ); +} diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts index 0442de8..bf65736 100644 --- a/apps/web/src/lib/socket.ts +++ b/apps/web/src/lib/socket.ts @@ -76,7 +76,7 @@ const useSocketStory = createStore((set, get) => { // Update the socket in the global state when connected set(() => ({ socket: newsocket })); - console.log("SOCKET CONNECTED! store",get().socket.id); + console.log("SOCKET CONNECTED! store", get().socket.id); }); } }, diff --git a/package.json b/package.json index 314062a..1baaa34 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,9 @@ "lint": "turbo lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "gen": "turbo gen workspace", - "web2:dev": "turbo dev --filter=web2", - "web2:build": "turbo build --filter=web2", - "talk:dev": "turbo start:dev --filter=talk", - "startall:dev": "turbo run startall:dev" + "talk:dev":"turbo dev --filter=talk", + "web:dev":"turbo dev --filter=web2", + "honoapi:build":"turbo build --filter=@repo/api" }, "devDependencies": { "@repo/typescript-config": "workspace:*", diff --git a/turbo.json b/turbo.json index d7dc284..2d8eba0 100644 --- a/turbo.json +++ b/turbo.json @@ -12,15 +12,8 @@ "cache": false, "persistent": true }, - "web2:dev": { - "cache": false, - "persistent": true, - "env": ["NODE_ENV=development"] - }, - "talk:dev": { - "cache": false, - "persistent": true, - "env": ["NODE_ENV=development"] + "web:dev":{ + "dependsOn": ["honoapi:build"] } } }