taiwindv4

master
lee 2025-06-24 10:14:38 +08:00
parent 3fbbd85857
commit fe5be43a29
28 changed files with 823 additions and 62 deletions

View File

@ -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一个是exportsproviders是用来提供实例的exports是用来导出模块的
@ -25,6 +26,7 @@ export class AppModule {
CoreModule.forRoot(configure),
FileMOdule.forRoot(),
ChatModule.forRoot(configure),
FriendModule,
],
controllers: [],
exports: [

View File

@ -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: [],

View File

@ -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,
},
});
}

View File

@ -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);
}
}

View File

@ -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"
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,2 @@
export * from "./friend.entity";
export * from "./friendMessage.entity";

View File

@ -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 {}

View File

@ -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,
});
});
});
});

View File

@ -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 };
}
}
}

View File

@ -19,7 +19,7 @@ describe("AppController (e2e)", () => {
await app.init();
});
it("/ (GET)", () => {
it("/v1/users (GET)", () => {
return request(app.getHttpServer())
.get("/")
.expect(200)

View File

@ -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>

View File

@ -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) || "";

View File

@ -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();

View File

@ -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>;
}

View File

@ -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>

View File

@ -0,0 +1,6 @@
export interface MessageWithMe {
from: string;
me: boolean;
message: string;
timestamp: number;
}

View File

@ -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>

View File

@ -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)));
}
}
}

View File

@ -1,7 +1,7 @@
@layer base {
* {
/* * {
@apply tw-border-border;
}
} */
html,
body {

View File

@ -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"
/>
);
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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);
});
}
},

View File

@ -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:*",

View File

@ -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"]
}
}
}