diff --git a/package.json b/package.json index 07164d4..7c6efc8 100755 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "start": "pnpm -F backend start", "dev": "pnpm -r --parallel --no-bail dev", "build": "pnpm -r --parallel build", + "build:sdk": "pnpm -F lynqchat-js build", "migrator": "pnpm -F backend migrator" }, - "packageManager": "pnpm@10.29.1" + "packageManager": "pnpm@10.29.1", + "dependencies": { + "vite-plugin-pwa": "^1.3.0" + } } diff --git a/packages/backend/src/modules/entities/Channel.ts b/packages/backend/src/modules/entities/Channel.ts index 110956b..4e9844d 100644 --- a/packages/backend/src/modules/entities/Channel.ts +++ b/packages/backend/src/modules/entities/Channel.ts @@ -2,6 +2,7 @@ import generateUniqueId from "@/lib/id"; import { Entity, EntityRepositoryType, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core"; import { UserEntity } from "@/modules/entities/User"; import { ChannelRepository } from "@/modules/repositories/Channel"; +import { CommunityEntity } from "@/modules/entities/Community"; @Entity({ tableName: "channel", @@ -32,6 +33,9 @@ export class ChannelEntity { }) description!: string; + @ManyToOne(() => CommunityEntity) + community!: CommunityEntity; + @ManyToOne(() => UserEntity) createdBy!: UserEntity; diff --git a/packages/backend/src/modules/entities/Community.ts b/packages/backend/src/modules/entities/Community.ts new file mode 100644 index 0000000..896b982 --- /dev/null +++ b/packages/backend/src/modules/entities/Community.ts @@ -0,0 +1,49 @@ +import generateUniqueId from "@/lib/id"; +import { Entity, EntityRepositoryType, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core"; +import { UserEntity } from "@/modules/entities/User"; +import { CommunityRepository } from "@/modules/repositories/Community"; + +@Entity({ + tableName: "community", + repository: () => CommunityRepository, +}) +export class CommunityEntity { + [OptionalProps]?: "id" | "createdAt"; + [EntityRepositoryType]?: CommunityRepository; + + @PrimaryKey({ + type: "string", + length: 10, + onCreate: () => generateUniqueId(), + }) + id!: string; + + @Property({ + type: "string", + length: 20, + unique: true, + }) + @Index() + name!: string; + + @Property({ + type: "string", + length: 4096, + }) + description!: string; + + @Property({ + type: "string", + nullable: true, + }) + icon?: string; + + @ManyToOne(() => UserEntity) + createdBy!: UserEntity; + + @Property({ + type: "datetime", + onCreate: () => new Date(), + }) + createdAt!: Date; +} \ No newline at end of file diff --git a/packages/backend/src/modules/repositories/Channel.ts b/packages/backend/src/modules/repositories/Channel.ts index 7036833..c44ad64 100644 --- a/packages/backend/src/modules/repositories/Channel.ts +++ b/packages/backend/src/modules/repositories/Channel.ts @@ -9,9 +9,10 @@ export class ChannelRepository extends EntityRepository { name: z.string().trim().min(1).max(20), description: z.string().trim().min(1).max(4096), userid: UserRepository.schema.shape.userid, + community: z.string().length(10), }); - async findChannel(limit: number = 20, sinceData?: string) { + async listChannel(community: string, limit: number = 20, sinceData?: string) { let since = sinceData ?? new Date(); if ( @@ -32,6 +33,7 @@ export class ChannelRepository extends EntityRepository { } const findResult = await this.find({ + community, createdAt: { $lt: since, }, @@ -57,7 +59,7 @@ export class ChannelRepository extends EntityRepository { async editChannel( target: ChannelEntity, - data: Partial, "userid">> + data: Partial, "userid" | "community">> ) { await this.nativeUpdate(target, data); return target.id; diff --git a/packages/backend/src/modules/repositories/Community.ts b/packages/backend/src/modules/repositories/Community.ts new file mode 100644 index 0000000..6ff32fc --- /dev/null +++ b/packages/backend/src/modules/repositories/Community.ts @@ -0,0 +1,66 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; +import { ErrorBase } from "@/errors"; +import z from "zod/v3"; +import { UserRepository } from "@/modules/repositories/User"; +import type { CommunityEntity } from "@/modules/entities/Community"; + +export class CommunityRepository extends EntityRepository { + public static schema = z.object({ + name: z.string().trim().min(1).max(20), + description: z.string().trim().min(1).max(4096), + userid: UserRepository.schema.shape.userid, + icon: z.string().url(), + }); + + async listCommunity(limit: number = 20, sinceData?: string) { + let since = sinceData ?? new Date(); + + if ( + sinceData && + !isNaN(new Date(sinceData).getTime()) + ) { + const itCommunity = await this.findOne({ id: sinceData }); + + if (!itCommunity) { + return ErrorBase({ + bad: "client", + code: "community_not_found", + message: "対象のコミュニティが見つかりませんでした。", + }); + } + + since = itCommunity.createdAt; + } + + const findResult = await this.find({ + createdAt: { + $lt: since, + }, + }, { + orderBy: { + createdAt: "DESC", + }, + limit: limit, + }); + + return findResult ?? []; + } + + async createCommunity(data: Omit, "icon">) { + const community = this.create({ + ...data, + createdBy: data.userid, + }); + + await this.em.persist(community).flush(); + return community.id; + } + + async editCommunity( + target: CommunityEntity, + data: Partial, "userid">> + ) { + await this.nativeUpdate(target, data); + return target.id; + } +} diff --git a/packages/backend/src/routes/channel/edit.ts b/packages/backend/src/routes/channel/edit.ts index d6a7754..3857772 100644 --- a/packages/backend/src/routes/channel/edit.ts +++ b/packages/backend/src/routes/channel/edit.ts @@ -15,7 +15,7 @@ export default async function ChannelEdit(fastify: FastifyInstance) { return res.code(400).send(req.token); const result = ChannelRepository.schema - .omit({ userid: true }).partial() + .omit({ userid: true, community: true }).partial() .merge(z.object({ id: z.string().length(10) })) .refine(data => !Object.keys(data).length diff --git a/packages/backend/src/routes/channel/get.ts b/packages/backend/src/routes/channel/get.ts new file mode 100644 index 0000000..c60becf --- /dev/null +++ b/packages/backend/src/routes/channel/get.ts @@ -0,0 +1,51 @@ +import { DatabaseError, ErrorBase, InputError } from "@/errors"; +import Logger from "@/lib/logger"; +import { ChannelEntity } from "@/modules/entities/Channel"; +import type { FastifyInstance } from "fastify"; +import z from "zod/v3"; + +export default async function ChannelGet(fastify: FastifyInstance) { + const logger = new Logger("Endpoint | channel/get"); + + fastify.post("/", async (req, res) => { + res.header("Content-Type", "application/json"); + + if ("error" in req.token) + return res.code(400).send(req.token); + + const bodySchema = z.object({ + id: z.string().length(10), + }); + + const result = bodySchema.safeParse(req.body); + + if (!result.success) { + return res.code(400).send(InputError(result.error.issues)); + } + + try { + const channelRepo = fastify.orm.em.getRepository(ChannelEntity); + + const findResult = await channelRepo.find({ + id: result.data.id, + }); + + if (findResult[0] === undefined) { + return res.code(400).send(ErrorBase({ + bad: "client", + code: "channel_not_found", + message: "対象のチャンネルが見つかりませんでした。", + })); + } + + return res.send({ + success: true, + channel: findResult[0], + }); + } catch (err) { + logger.error("Database Error:", err); + + return res.code(500).send(DatabaseError()); + } + }); +} \ No newline at end of file diff --git a/packages/backend/src/routes/channel/index.ts b/packages/backend/src/routes/channel/index.ts index 760a972..6fbb5b6 100644 --- a/packages/backend/src/routes/channel/index.ts +++ b/packages/backend/src/routes/channel/index.ts @@ -2,8 +2,9 @@ import type { FastifyInstance } from "fastify"; import ChannelList from "./list"; import ChannelCreate from "./create"; import ChannelEdit from "./edit"; +import ChannelGet from "./get"; -export default async function Setup(fastify: FastifyInstance) { +export default async function Channel(fastify: FastifyInstance) { await fastify.register(ChannelList, { prefix: "/list", }); @@ -15,4 +16,8 @@ export default async function Setup(fastify: FastifyInstance) { await fastify.register(ChannelEdit, { prefix: "/edit", }); + + await fastify.register(ChannelGet, { + prefix: "/get", + }); } \ No newline at end of file diff --git a/packages/backend/src/routes/channel/list.ts b/packages/backend/src/routes/channel/list.ts index d1a1927..5293802 100644 --- a/packages/backend/src/routes/channel/list.ts +++ b/packages/backend/src/routes/channel/list.ts @@ -16,6 +16,7 @@ export default async function ChannelList(fastify: FastifyInstance) { const bodySchema = z.object({ limit: z.number().optional(), since: z.string().optional(), + community: z.string().length(10), }); const result = bodySchema.safeParse(req.body); @@ -27,10 +28,11 @@ export default async function ChannelList(fastify: FastifyInstance) { try { const channelRepo = fastify.orm.em.getRepository(ChannelEntity); - const findResult = await channelRepo.findChannel( + const findResult = await channelRepo.listChannel( + result.data.community, result.data.limit, result.data.since, - ) ?? []; + ); if ("error" in findResult) { return res.code(400).send(findResult); diff --git a/packages/backend/src/routes/community/create.ts b/packages/backend/src/routes/community/create.ts new file mode 100644 index 0000000..b15666e --- /dev/null +++ b/packages/backend/src/routes/community/create.ts @@ -0,0 +1,55 @@ +import { DatabaseError, InputError } from "@/errors"; +import Logger from "@/lib/logger"; +import { CommunityEntity } from "@/modules/entities/Community"; +import { CommunityRepository } from "@/modules/repositories/Community"; +import type { FastifyInstance } from "fastify"; + +export default async function CommunityCreate(fastify: FastifyInstance) { + const logger = new Logger("Endpoint | community/create"); + + fastify.post("/", async (req, res) => { + res.header("Content-Type", "application/json"); + + if ("error" in req.token) + return res.code(400).send(req.token); + + const result = CommunityRepository.schema.omit({ icon: true }).safeParse({ + ...req.body as any, + userid: req.token.user.userid, + }); + + if (!result.success) { + return res.code(400).send(InputError(result.error.issues)); + } + + try { + const communityRepo = fastify.orm.em.getRepository(CommunityEntity); + + const id = await communityRepo.createCommunity(result.data); + + return res.send({ + success: true, + id, + }); + } catch (err: any) { + if (err.name === "UniqueConstraintViolationException") { + const duplicate = err.constraint.replace("community_", "").replace("_unique", ""); + + if (duplicate !== "id") { + return res.code(400).send(InputError([ + { + validation: "regex", + code: "invalid_string", + message: "Duplicate", + path: [duplicate], + }, + ])); + } + } + + logger.error("Database Error:", err); + + return res.code(500).send(DatabaseError()); + } + }); +} \ No newline at end of file diff --git a/packages/backend/src/routes/community/edit.ts b/packages/backend/src/routes/community/edit.ts new file mode 100644 index 0000000..c369b60 --- /dev/null +++ b/packages/backend/src/routes/community/edit.ts @@ -0,0 +1,52 @@ +import { DatabaseError, ErrorBase, InputError } from "@/errors"; +import Logger from "@/lib/logger"; +import { CommunityEntity } from "@/modules/entities/Community"; +import { CommunityRepository } from "@/modules/repositories/Community"; +import type { FastifyInstance } from "fastify"; +import z from "zod/v3"; + +export default async function CommunityEdit(fastify: FastifyInstance) { + const logger = new Logger("Endpoint | community/edit"); + + fastify.post("/", async (req, res) => { + res.header("Content-Type", "application/json"); + + if ("error" in req.token) + return res.code(400).send(req.token); + + const result = CommunityRepository.schema + .omit({ userid: true }).partial() + .merge(z.object({ id: z.string().length(10) })) + .refine(data => + !Object.keys(data).length + ).safeParse(req.body); + + if (!result.success) { + return res.code(400).send(InputError(result.error.issues)); + } + + try { + const communityRepo = fastify.orm.em.getRepository(CommunityEntity); + const itCommunity = await communityRepo.findOne({ id: result.data.id }); + + if (!itCommunity) { + return res.code(400).send(ErrorBase({ + bad: "client", + code: "community_not_found", + message: "対象のコミュニティが見つかりませんでした。", + })); + } + + const id = await communityRepo.editCommunity(itCommunity, result.data); + + return res.send({ + success: true, + id, + }); + } catch (err) { + logger.error("Database Error:", err); + + return res.code(500).send(DatabaseError()); + } + }); +} \ No newline at end of file diff --git a/packages/backend/src/routes/community/get.ts b/packages/backend/src/routes/community/get.ts new file mode 100644 index 0000000..bd99dc5 --- /dev/null +++ b/packages/backend/src/routes/community/get.ts @@ -0,0 +1,51 @@ +import { DatabaseError, ErrorBase, InputError } from "@/errors"; +import Logger from "@/lib/logger"; +import { CommunityEntity } from "@/modules/entities/Community"; +import type { FastifyInstance } from "fastify"; +import z from "zod/v3"; + +export default async function CommunityGet(fastify: FastifyInstance) { + const logger = new Logger("Endpoint | community/get"); + + fastify.post("/", async (req, res) => { + res.header("Content-Type", "application/json"); + + if ("error" in req.token) + return res.code(400).send(req.token); + + const bodySchema = z.object({ + id: z.string().length(10), + }); + + const result = bodySchema.safeParse(req.body); + + if (!result.success) { + return res.code(400).send(InputError(result.error.issues)); + } + + try { + const communityRepo = fastify.orm.em.getRepository(CommunityEntity); + + const findResult = await communityRepo.find({ + id: result.data.id, + }); + + if (findResult[0] === undefined) { + return res.code(400).send(ErrorBase({ + bad: "client", + code: "community_not_found", + message: "対象のコミュニティが見つかりませんでした。", + })); + } + + return res.send({ + success: true, + community: findResult[0], + }); + } catch (err) { + logger.error("Database Error:", err); + + return res.code(500).send(DatabaseError()); + } + }); +} \ No newline at end of file diff --git a/packages/backend/src/routes/community/index.ts b/packages/backend/src/routes/community/index.ts new file mode 100644 index 0000000..4e0b5db --- /dev/null +++ b/packages/backend/src/routes/community/index.ts @@ -0,0 +1,23 @@ +import type { FastifyInstance } from "fastify"; +import CommunityList from "./list"; +import CommunityCreate from "./create"; +import CommunityEdit from "./edit"; +import CommunityGet from "./get"; + +export default async function Community(fastify: FastifyInstance) { + await fastify.register(CommunityList, { + prefix: "/list", + }); + + await fastify.register(CommunityCreate, { + prefix: "/create", + }); + + await fastify.register(CommunityEdit, { + prefix: "/edit", + }); + + await fastify.register(CommunityGet, { + prefix: "/get", + }); +} \ No newline at end of file diff --git a/packages/backend/src/routes/community/list.ts b/packages/backend/src/routes/community/list.ts new file mode 100644 index 0000000..ea15540 --- /dev/null +++ b/packages/backend/src/routes/community/list.ts @@ -0,0 +1,58 @@ +import { DatabaseError, InputError } from "@/errors"; +import Logger from "@/lib/logger"; +import { CommunityEntity } from "@/modules/entities/Community"; +import { UserEntity } from "@/modules/entities/User"; +import type { FastifyInstance } from "fastify"; +import z from "zod/v3"; + +export default async function CommunityList(fastify: FastifyInstance) { + const logger = new Logger("Endpoint | community/list"); + + fastify.post("/", async (req, res) => { + res.header("Content-Type", "application/json"); + + if ("error" in req.token) + return res.code(400).send(req.token); + + const bodySchema = z.object({ + limit: z.number().optional(), + since: z.string().optional(), + }); + + const result = bodySchema.safeParse(req.body); + + if (!result.success) { + return res.code(400).send(InputError(result.error.issues)); + } + + try { + const communityRepo = fastify.orm.em.getRepository(CommunityEntity); + + const findResult = await communityRepo.listCommunity( + result.data.limit, + result.data.since, + ); + + if ("error" in findResult) { + return res.code(400).send(findResult); + } + + findResult.map((community) => ({ + ...community, + createdBy: community.createdBy.userid, + })); + + return res.send({ + success: true, + communitys: findResult.map(community => ({ + ...community, + createdBy: community.createdBy.userid, + })), + }); + } catch (err) { + logger.error("Database Error:", err); + + return res.code(500).send(DatabaseError()); + } + }); +} \ No newline at end of file diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 8208214..9381e90 100755 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -3,6 +3,8 @@ import Setup from "./setup"; import Primary from "./primary"; import Me from "./me"; import ServerInfo from "./server-info"; +import Community from "./community"; +import Channel from "./channel"; export default async function Routes(fastify: FastifyInstance) { await fastify.register(Setup, { @@ -20,4 +22,12 @@ export default async function Routes(fastify: FastifyInstance) { await fastify.register(Me, { prefix: "/me", }); + + await fastify.register(Community, { + prefix: "/community", + }); + + await fastify.register(Channel, { + prefix: "/channel", + }); } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 85d145d..bfdbc17 100755 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,10 +19,12 @@ "vue": "^3.5.24", "vue-router": "^5.0.2", "vue-tsc": "^3.1.4", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zxcvbn": "^4.4.2" }, "devDependencies": { - "@types/node": "^24.10.1" + "@types/node": "^24.10.1", + "@types/zxcvbn": "^4.4.5" }, "packageManager": "pnpm@10.29.1" } diff --git a/packages/frontend/src/Layout.vue b/packages/frontend/src/Layout.vue index 39977c1..7c2b4a5 100755 --- a/packages/frontend/src/Layout.vue +++ b/packages/frontend/src/Layout.vue @@ -7,21 +7,42 @@
-
+
-
- -
+ + + -
- -
+ + + +
{{ - $route.meta.title + title ?? (serverInfo.success ? serverInfo.name : null) @@ -61,7 +82,8 @@ main.layout { -webkit-user-select: none; } -.left-menu div { +.left-menu a { + display: block; position: relative; z-index: 0; border-radius: 0.5rem; @@ -71,19 +93,20 @@ main.layout { transition: background-color 200ms ease-out; } -.left-menu div * { +.left-menu a * { font-size: 3rem; padding: 0.25rem; border-radius: 0.5rem; width: 3rem; height: 3rem; + color: var(--text-color); display: block; overflow: hidden; object-fit: contain; box-sizing: border-box; } -.left-menu div.isActive::before { +.left-menu a.isActive::before { content: ""; display: block; position: absolute; @@ -96,8 +119,8 @@ main.layout { background-color: var(--text-color); } -.left-menu div:hover, -.left-menu div.isActive { +.left-menu a:hover, +.left-menu a.isActive { background-color: var(--border-color); } @@ -122,6 +145,7 @@ main.layout { } .route-main { + display: flex; padding: 1.25rem; padding-bottom: 0; overflow: scroll; @@ -156,16 +180,25 @@ main.layout { \ No newline at end of file diff --git a/packages/frontend/src/components/Modal/GoHomeError.vue b/packages/frontend/src/components/Modal/GoHomeError.vue new file mode 100644 index 0000000..c5a55e6 --- /dev/null +++ b/packages/frontend/src/components/Modal/GoHomeError.vue @@ -0,0 +1,47 @@ + + + + + \ No newline at end of file diff --git a/packages/frontend/src/components/Textarea.vue b/packages/frontend/src/components/Textarea.vue index 86840db..61f0a54 100644 --- a/packages/frontend/src/components/Textarea.vue +++ b/packages/frontend/src/components/Textarea.vue @@ -3,6 +3,7 @@