taiwindv4
parent
3fbbd85857
commit
fe5be43a29
|
@ -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: [
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./friend.entity";
|
||||
export * from "./friendMessage.entity";
|
|
@ -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 {}
|
|
@ -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>(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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<FriendMessage>,
|
||||
@InjectRepository(UserMap)
|
||||
private readonly friendRepository: Repository<UserMap>,
|
||||
) {}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,7 +19,7 @@ describe("AppController (e2e)", () => {
|
|||
await app.init();
|
||||
});
|
||||
|
||||
it("/ (GET)", () => {
|
||||
it("/v1/users (GET)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/")
|
||||
.expect(200)
|
||||
|
|
|
@ -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({
|
|||
<div className=" tw-pb-10 tw-w-32 tw-border-r-2 tw-pt-6 tw-ml-4 tw-text-sidebar-foreground">
|
||||
<Directory
|
||||
staticPath="/blog/category"
|
||||
activePath={(await params).slug.join("/")}
|
||||
activePath={slug.join("/") || ""}
|
||||
data={generateCategoryRouter(categoryList)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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) || "";
|
||||
|
|
|
@ -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();
|
|
@ -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<MessageWithMe[]>([]); // 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 <div>Chat Page</div>;
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<div className=" tw-page-container tw-bg-white/80 dark:tw-bg-zinc-800/80 tw-flex-auto tw-w-full tw-drop-shadow-lg tw-rounded-md tw-flex tw-flex-col">
|
||||
<div className=" tw-bg-white/80 dark:tw-bg-zinc-800/80 tw-flex-auto tw-w-full tw-drop-shadow-lg tw-rounded-md tw-flex tw-flex-col">
|
||||
<div className="tw-relative tw-w-full tw-h-36 md:tw-h-48 lg:tw-h-64 tw-block;">
|
||||
<Image src={post.thumb} fill alt="" priority sizes="100%"></Image>
|
||||
<AspectRatio ratio={16 / 3}>
|
||||
<Image src={post.thumb} fill alt="" priority sizes="100%"></Image>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
<div className="tw-p-3 tw-flex tw-flex-col">
|
||||
<header>
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export interface MessageWithMe {
|
||||
from: string;
|
||||
me: boolean;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
|
@ -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<PostItem> }) => {
|
|||
|
||||
return (
|
||||
<Card className="tw-w-full ">
|
||||
<ShineBorder shineColor={["#A07CFE", "#FE8FB5", "#FFBE7B"]} />
|
||||
<AspectRatio className=" tw-rounded-lg tw-bg-muted" ratio={16 / 9}>
|
||||
<Image
|
||||
src={item.thumb}
|
||||
|
@ -57,7 +59,7 @@ export const BlogCard = async ({ item }: { item: Date2String<PostItem> }) => {
|
|||
<BreadcrumbList>
|
||||
{category.map((value: any) => (
|
||||
<BreadcrumbItem key={value.id}>
|
||||
<Link href={`/blog/category/${value.id}`}>{value.name}</Link>
|
||||
<Link href={`/blog/detail/${value.id}`}>{value.name}</Link>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@layer base {
|
||||
* {
|
||||
/* * {
|
||||
@apply tw-border-border;
|
||||
}
|
||||
} */
|
||||
|
||||
html,
|
||||
body {
|
||||
|
|
|
@ -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<HTMLCanvasElement>(null);
|
||||
const [iconPositions, setIconPositions] = useState<Icon[]>([]);
|
||||
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<number>(0);
|
||||
const rotationRef = useRef(rotation);
|
||||
const iconCanvasesRef = useRef<HTMLCanvasElement[]>([]);
|
||||
const imagesLoadedRef = useRef<boolean[]>([]);
|
||||
|
||||
// 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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={400}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className="tw-rounded-lg"
|
||||
aria-label="Interactive 3D Icon Cloud"
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"tw-group tw-flex tw-overflow-hidden tw-p-2 [--duration:tw-40s] [--gap:tw-1rem] [gap:tw-var(--gap)]",
|
||||
{
|
||||
"flex-row": !vertical,
|
||||
"flex-col": vertical,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: repeat })
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
className={cn(
|
||||
"tw-flex tw-shrink-0 tw-justify-around [gap:tw-var(--gap)]",
|
||||
{
|
||||
"animate-marquee flex-row": !vertical,
|
||||
"animate-marquee-vertical flex-col": vertical,
|
||||
"group-hover:[animation-play-state:paused]": pauseOnHover,
|
||||
"[animation-direction:reverse]": reverse,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--border-width": `${borderWidth}px`,
|
||||
"--duration": `${duration}s`,
|
||||
backgroundImage: `radial-gradient(transparent,transparent, ${
|
||||
Array.isArray(shineColor) ? shineColor.join(",") : shineColor
|
||||
},transparent,transparent)`,
|
||||
backgroundSize: "300% 300%",
|
||||
mask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
|
||||
WebkitMask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
padding: "var(--border-width)",
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"tw-pointer-events-none tw-absolute tw-inset-0 tw-size-full tw-rounded-[inherit] tw-will-change-[background-position] motion-safe:tw-animate-shine",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -76,7 +76,7 @@ const useSocketStory = createStore<Store>((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);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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:*",
|
||||
|
|
11
turbo.json
11
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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue