taiwindv4
parent
3fbbd85857
commit
fe5be43a29
|
@ -8,6 +8,7 @@ import { Configure } from "./modules/config/configure";
|
||||||
import { CoreModule } from "./modules/core/core.module";
|
import { CoreModule } from "./modules/core/core.module";
|
||||||
import { DatabaseModule } from "./modules/database/database.module";
|
import { DatabaseModule } from "./modules/database/database.module";
|
||||||
import { FileMOdule } from "./modules/file/file.module";
|
import { FileMOdule } from "./modules/file/file.module";
|
||||||
|
import { FriendModule } from "./modules/friends/friend.module";
|
||||||
import { UserModule } from "./modules/user/user.module";
|
import { UserModule } from "./modules/user/user.module";
|
||||||
|
|
||||||
// 当providers 为空时,就会从di容器从import的模块中查找->其他模块需要两个部分一个是providers,一个是exports,providers是用来提供实例的,exports是用来导出模块的
|
// 当providers 为空时,就会从di容器从import的模块中查找->其他模块需要两个部分一个是providers,一个是exports,providers是用来提供实例的,exports是用来导出模块的
|
||||||
|
@ -25,6 +26,7 @@ export class AppModule {
|
||||||
CoreModule.forRoot(configure),
|
CoreModule.forRoot(configure),
|
||||||
FileMOdule.forRoot(),
|
FileMOdule.forRoot(),
|
||||||
ChatModule.forRoot(configure),
|
ChatModule.forRoot(configure),
|
||||||
|
FriendModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
exports: [
|
exports: [
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import { DynamicModule, Module } from "@nestjs/common";
|
import { DynamicModule, Module } from "@nestjs/common";
|
||||||
import { JwtModule, JwtService } from "@nestjs/jwt";
|
import { JwtModule, JwtService } from "@nestjs/jwt";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import * as friendsEntities from "src/modules/friends/entities";
|
||||||
|
|
||||||
import { Configure } from "../config/configure";
|
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 { AUTH_JWT_SECRET } from "../user/constants";
|
||||||
import * as entities from "../user/entities";
|
import * as entities from "../user/entities";
|
||||||
import { AuthService, TokenService, UserService } from "../user/services";
|
import { AuthService, TokenService, UserService } from "../user/services";
|
||||||
|
@ -16,11 +19,15 @@ export class ChatModule {
|
||||||
return {
|
return {
|
||||||
module: ChatModule,
|
module: ChatModule,
|
||||||
imports: [
|
imports: [
|
||||||
|
FriendModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: jwtSecret,
|
secret: jwtSecret,
|
||||||
}),
|
}),
|
||||||
UserModule.forRoot(configure),
|
UserModule.forRoot(configure),
|
||||||
TypeOrmModule.forFeature(Object.values(entities), "default"),
|
TypeOrmModule.forFeature(
|
||||||
|
[...Object.values(entities), ...Object.values(friendsEntities)],
|
||||||
|
"default",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ChatGateway,
|
ChatGateway,
|
||||||
|
@ -28,6 +35,7 @@ export class ChatModule {
|
||||||
JwtService,
|
JwtService,
|
||||||
AuthService,
|
AuthService,
|
||||||
TokenService,
|
TokenService,
|
||||||
|
FriendService,
|
||||||
{ provide: AUTH_JWT_SECRET, useValue: jwtSecret },
|
{ provide: AUTH_JWT_SECRET, useValue: jwtSecret },
|
||||||
],
|
],
|
||||||
exports: [],
|
exports: [],
|
||||||
|
|
|
@ -9,6 +9,8 @@ import {
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
} from "@nestjs/websockets";
|
} from "@nestjs/websockets";
|
||||||
import { Server, Socket } from "socket.io";
|
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 { TokenService } from "src/modules/user/services/token.service";
|
||||||
|
|
||||||
import { RCode } from "../type";
|
import { RCode } from "../type";
|
||||||
|
@ -38,7 +40,10 @@ interface UserPayload {
|
||||||
export class ChatGateway
|
export class ChatGateway
|
||||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||||
{
|
{
|
||||||
constructor(private readonly tokenService: TokenService) {}
|
constructor(
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly friendService: FriendService,
|
||||||
|
) {}
|
||||||
@WebSocketServer() server: Server;
|
@WebSocketServer() server: Server;
|
||||||
|
|
||||||
async handleConnection(client: Socket, ..._args: any[]) {
|
async handleConnection(client: Socket, ..._args: any[]) {
|
||||||
|
@ -110,7 +115,7 @@ export class ChatGateway
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@SubscribeMessage("chatData")
|
@SubscribeMessage("chatData")
|
||||||
getAllData(@MessageBody() token: string) {
|
async getAllData(@MessageBody() token: string) {
|
||||||
console.log(token, "chatData");
|
console.log(token, "chatData");
|
||||||
|
|
||||||
const user: UserPayload = this.tokenService.verifyAccessToken(token);
|
const user: UserPayload = this.tokenService.verifyAccessToken(token);
|
||||||
|
@ -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", {
|
this.server.to(user.id).emit("chatData", {
|
||||||
code: RCode.OK,
|
code: RCode.OK,
|
||||||
msg: "获取聊天数据成功",
|
msg: "获取聊天数据成功",
|
||||||
data: {
|
data: {
|
||||||
onLineUserIds,
|
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();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("/ (GET)", () => {
|
it("/v1/users (GET)", () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get("/")
|
.get("/")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
|
|
|
@ -10,7 +10,7 @@ export default async function BlogCategoryLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
}: React.PropsWithChildren & { params: { slug: string[] } }) {
|
}: React.PropsWithChildren & { params: { slug: string[] } }) {
|
||||||
const slug = (await params).slug.at(-1) || "";
|
const slug = (await params).slug;
|
||||||
const categoryRes = await fetchApi((c) => {
|
const categoryRes = await fetchApi((c) => {
|
||||||
const reSlug = (isArray(slug) ? slug.at(-1) : slug) || "";
|
const reSlug = (isArray(slug) ? slug.at(-1) : slug) || "";
|
||||||
return c.api.categories.breadcrumb[":latest"].$get({
|
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">
|
<div className=" tw-pb-10 tw-w-32 tw-border-r-2 tw-pt-6 tw-ml-4 tw-text-sidebar-foreground">
|
||||||
<Directory
|
<Directory
|
||||||
staticPath="/blog/category"
|
staticPath="/blog/category"
|
||||||
activePath={(await params).slug.join("/")}
|
activePath={slug.join("/") || ""}
|
||||||
data={generateCategoryRouter(categoryList)}
|
data={generateCategoryRouter(categoryList)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@ export default async function CategoryPage({
|
||||||
}: {
|
}: {
|
||||||
params: { slug: string[] };
|
params: { slug: string[] };
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const slug = (await params).slug || [];
|
||||||
|
|
||||||
const categoryRes = await fetchApi((c) => {
|
const categoryRes = await fetchApi((c) => {
|
||||||
const reSlug = (isArray(slug) ? slug.at(-1) : slug) || "";
|
const reSlug = (isArray(slug) ? slug.at(-1) : slug) || "";
|
||||||
|
|
|
@ -16,10 +16,11 @@ export const generateMetadata = async (
|
||||||
export default async function Page({
|
export default async function Page({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ item: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
|
const id = (await params).id;
|
||||||
const result = await fetchApi(async (c) =>
|
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) {
|
if (!result.ok) {
|
||||||
return notFound();
|
return notFound();
|
|
@ -1,7 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useGetCookies } from "cookies-next";
|
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 { useAuthStore } from "@/app/_components/auth/store";
|
||||||
import useSocketStory from "@/lib/socket";
|
import useSocketStory from "@/lib/socket";
|
||||||
|
@ -9,25 +11,25 @@ import useSocketStory from "@/lib/socket";
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const cookies = useGetCookies();
|
const cookies = useGetCookies();
|
||||||
const token = cookies()?.auth_token || "";
|
const token = cookies()?.auth_token || "";
|
||||||
const { connect, socket,emit } = useSocketStory((state) => state);
|
|
||||||
|
const { connect, socket, emit } = useSocketStory();
|
||||||
const { auth } = useAuthStore((state) => state);
|
const { auth } = useAuthStore((state) => state);
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<MessageWithMe[]>([]); // show messages on ScrollArea
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
emit("chatData",token)
|
emit("chatData", token);
|
||||||
socket.on("chatData", (data: any) => {
|
socket.on("chatData", (data: any) => {
|
||||||
console.log("chatData", data);
|
console.log("chatData", data);
|
||||||
});
|
});
|
||||||
// socket 已经可用,可以注册事件或发送消息
|
|
||||||
console.log("socket 已连接", socket);
|
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.off("chatData");
|
socket.off("chatData");
|
||||||
console.log("socket 已断开", socket);
|
console.log("socket 已断开", socket);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
// 只负责连接
|
// 只负责连接
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -38,8 +40,5 @@ useEffect(() => {
|
||||||
// 只在 auth/cookies/connect 变化时重新连接
|
// 只在 auth/cookies/connect 变化时重新连接
|
||||||
}, [auth, cookies, connect]);
|
}, [auth, cookies, connect]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return <div>Chat Page</div>;
|
return <div>Chat Page</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,13 +5,16 @@ import { isNil } from "lodash";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { MdxRender } from "@/app/_components/mdx/render";
|
import { MdxRender } from "@/app/_components/mdx/render";
|
||||||
|
import { AspectRatio } from "@/app/_components/ui/aspect-ratio";
|
||||||
import { formatChineseTime } from "@/lib/utils";
|
import { formatChineseTime } from "@/lib/utils";
|
||||||
|
|
||||||
export const PostItemPage: FC<{ post: PostItem }> = ({ post }) => {
|
export const PostItemPage: FC<{ post: PostItem }> = ({ post }) => {
|
||||||
return (
|
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;">
|
<div className="tw-relative tw-w-full tw-h-36 md:tw-h-48 lg:tw-h-64 tw-block;">
|
||||||
|
<AspectRatio ratio={16 / 3}>
|
||||||
<Image src={post.thumb} fill alt="" priority sizes="100%"></Image>
|
<Image src={post.thumb} fill alt="" priority sizes="100%"></Image>
|
||||||
|
</AspectRatio>
|
||||||
</div>
|
</div>
|
||||||
<div className="tw-p-3 tw-flex tw-flex-col">
|
<div className="tw-p-3 tw-flex tw-flex-col">
|
||||||
<header>
|
<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 type { Date2String } from "@/lib/types";
|
||||||
|
|
||||||
|
import { ShineBorder } from "@/components/magicui/shine-border";
|
||||||
import { fetchApi } from "@/lib/api";
|
import { fetchApi } from "@/lib/api";
|
||||||
|
|
||||||
import { AspectRatio } from "../../ui/aspect-ratio";
|
import { AspectRatio } from "../../ui/aspect-ratio";
|
||||||
|
@ -34,6 +35,7 @@ export const BlogCard = async ({ item }: { item: Date2String<PostItem> }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="tw-w-full ">
|
<Card className="tw-w-full ">
|
||||||
|
<ShineBorder shineColor={["#A07CFE", "#FE8FB5", "#FFBE7B"]} />
|
||||||
<AspectRatio className=" tw-rounded-lg tw-bg-muted" ratio={16 / 9}>
|
<AspectRatio className=" tw-rounded-lg tw-bg-muted" ratio={16 / 9}>
|
||||||
<Image
|
<Image
|
||||||
src={item.thumb}
|
src={item.thumb}
|
||||||
|
@ -57,7 +59,7 @@ export const BlogCard = async ({ item }: { item: Date2String<PostItem> }) => {
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{category.map((value: any) => (
|
{category.map((value: any) => (
|
||||||
<BreadcrumbItem key={value.id}>
|
<BreadcrumbItem key={value.id}>
|
||||||
<Link href={`/blog/category/${value.id}`}>{value.name}</Link>
|
<Link href={`/blog/detail/${value.id}`}>{value.name}</Link>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
))}
|
))}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|
|
@ -34,23 +34,31 @@
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
.theme {
|
||||||
* {
|
--animate-marquee: marquee var(--duration) infinite linear;
|
||||||
font-family: "outfit", sans-serif;
|
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply tw-bg-background tw-text-foreground;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@theme inline {
|
||||||
* {
|
@keyframes marquee {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
to {
|
||||||
@apply tw-bg-background tw-text-foreground;
|
transform: translateX(calc(-100% - var(--gap)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marquee-vertical {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(calc(-100% - var(--gap)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
/* * {
|
||||||
@apply tw-border-border;
|
@apply tw-border-border;
|
||||||
}
|
} */
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -8,10 +8,9 @@
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"gen": "turbo gen workspace",
|
"gen": "turbo gen workspace",
|
||||||
"web2:dev": "turbo dev --filter=web2",
|
"talk:dev":"turbo dev --filter=talk",
|
||||||
"web2:build": "turbo build --filter=web2",
|
"web:dev":"turbo dev --filter=web2",
|
||||||
"talk:dev": "turbo start:dev --filter=talk",
|
"honoapi:build":"turbo build --filter=@repo/api"
|
||||||
"startall:dev": "turbo run startall:dev"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
|
|
11
turbo.json
11
turbo.json
|
@ -12,15 +12,8 @@
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"web2:dev": {
|
"web:dev":{
|
||||||
"cache": false,
|
"dependsOn": ["honoapi:build"]
|
||||||
"persistent": true,
|
|
||||||
"env": ["NODE_ENV=development"]
|
|
||||||
},
|
|
||||||
"talk:dev": {
|
|
||||||
"cache": false,
|
|
||||||
"persistent": true,
|
|
||||||
"env": ["NODE_ENV=development"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue