Feat: メッセージの送受信 / New: ユーザーのiconプロパティ / New: logエンティティ・リポジトリ / Chg: コミュニティリポジトリのスキーマのiconをoptionalに / Del: 不要なimport / Fix: Vue起動前のindex.htmlの背景色をVueと同期 / Enhance: Service Workerを改善 / Fix: 最初に開いたページが動作しない問題 / Feat: 上部の通知モーダル / Feat: 閉じることができないエラーのモーダルに再読み込みボタンを追加 / Fix: はみ出す挙動などのCSSを修正
This commit is contained in:
@@ -22,12 +22,12 @@
|
|||||||
"@mikro-orm/postgresql": "^6.6.7",
|
"@mikro-orm/postgresql": "^6.6.7",
|
||||||
"@mikro-orm/reflection": "^6.6.7",
|
"@mikro-orm/reflection": "^6.6.7",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"lynqchat-js": "workspace:*",
|
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
"fs": "0.0.1-security",
|
"fs": "0.0.1-security",
|
||||||
|
"lynqchat-js": "workspace:*",
|
||||||
"os": "^0.1.2",
|
"os": "^0.1.2",
|
||||||
"tsc-alias": "^1.8.16",
|
"tsc-alias": "^1.8.16",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const Pick = <T extends Object>(object: T, keys: (keyof T)[]) =>
|
||||||
|
Object.entries(object)
|
||||||
|
.filter(([key]) => keys.includes(key as keyof T))
|
||||||
|
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Entity, EntityRepositoryType, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
|
||||||
|
import { LogRepository } from "@/modules/repositories/Log";
|
||||||
|
import generateUniqueId from "@/lib/id";
|
||||||
|
import { UserEntity } from "@/modules/entities/User";
|
||||||
|
|
||||||
|
@Entity({
|
||||||
|
tableName: "log",
|
||||||
|
repository: () => LogRepository,
|
||||||
|
})
|
||||||
|
export class LogEntity {
|
||||||
|
[OptionalProps]?: "id" | "createdBy" | "createdAt";
|
||||||
|
[EntityRepositoryType]?: LogRepository;
|
||||||
|
|
||||||
|
@PrimaryKey({
|
||||||
|
type: "string",
|
||||||
|
length: 10,
|
||||||
|
onCreate: () => generateUniqueId(),
|
||||||
|
})
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
type: "string",
|
||||||
|
length: 4096
|
||||||
|
})
|
||||||
|
text!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserEntity, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
createdBy?: UserEntity;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
type: "datetime",
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
})
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import generateUniqueId from "@/lib/id";
|
import generateUniqueId from "@/lib/id";
|
||||||
import { Entity, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
|
import { Entity, EntityRepositoryType, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
|
||||||
import { UserEntity } from "@/modules/entities/User";
|
import { UserEntity } from "@/modules/entities/User";
|
||||||
import { ChannelEntity } from "@/modules/entities/Channel";
|
import { ChannelEntity } from "@/modules/entities/Channel";
|
||||||
|
import { MessageRepository } from "@/modules/repositories/Message";
|
||||||
|
|
||||||
@Entity({
|
@Entity({
|
||||||
tableName: "message",
|
tableName: "message",
|
||||||
|
repository: () => MessageRepository,
|
||||||
})
|
})
|
||||||
export class MessageEntity {
|
export class MessageEntity {
|
||||||
|
[EntityRepositoryType]?: MessageRepository;
|
||||||
[OptionalProps]?: "id" | "createdAt";
|
[OptionalProps]?: "id" | "createdAt";
|
||||||
|
|
||||||
@PrimaryKey({
|
@PrimaryKey({
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export class UserEntity {
|
|||||||
})
|
})
|
||||||
username!: string;
|
username!: string;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
type: "string",
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
@Property({
|
@Property({
|
||||||
type: "string",
|
type: "string",
|
||||||
length: 4096,
|
length: 4096,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class CommunityRepository extends EntityRepository<CommunityEntity> {
|
|||||||
name: z.string().trim().min(1).max(20),
|
name: z.string().trim().min(1).max(20),
|
||||||
description: z.string().trim().min(1).max(4096),
|
description: z.string().trim().min(1).max(4096),
|
||||||
userid: UserRepository.schema.shape.userid,
|
userid: UserRepository.schema.shape.userid,
|
||||||
icon: z.string().url(),
|
icon: z.string().url().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async listCommunity(limit: number = 20, sinceData?: string) {
|
async listCommunity(limit: number = 20, sinceData?: string) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||||
|
import type { LogEntity } from "@/modules/entities/Log";
|
||||||
|
import z from "zod/v3";
|
||||||
|
|
||||||
|
export class LogRepository extends EntityRepository<LogEntity> {
|
||||||
|
public static schema = z.string().min(1).max(4096);
|
||||||
|
|
||||||
|
async insertLog(text: string, userid?: string) {
|
||||||
|
const log = this.create({
|
||||||
|
text,
|
||||||
|
createdBy: userid,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.em.persist(log).flush();
|
||||||
|
return log.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||||
|
import type { MessageEntity } from "@/modules/entities/Message";
|
||||||
|
import z from "zod/v3";
|
||||||
|
import { ErrorBase } from "@/errors";
|
||||||
|
|
||||||
|
export class MessageRepository extends EntityRepository<MessageEntity> {
|
||||||
|
public static schema = z.object({
|
||||||
|
message: z.string().trim().min(0).max(4096),
|
||||||
|
channel: z.string().length(10),
|
||||||
|
userid: z.string().length(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
async sendMessage(messageText: string, channel: string, user: string) {
|
||||||
|
const message = this.create({
|
||||||
|
message: messageText,
|
||||||
|
channel,
|
||||||
|
createdBy: user,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.em.persist(message).flush();
|
||||||
|
return message.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMessage(id: string) {
|
||||||
|
const message = this.getReference(id);
|
||||||
|
await this.em.remove(message).flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMessage(channel: string, limit: number = 20, untilData?: string) {
|
||||||
|
let until = untilData ?? new Date();
|
||||||
|
|
||||||
|
if (
|
||||||
|
untilData &&
|
||||||
|
isNaN(new Date(untilData).getTime())
|
||||||
|
) {
|
||||||
|
const itMessage = await this.findOne({ id: untilData });
|
||||||
|
|
||||||
|
if (!itMessage) {
|
||||||
|
return ErrorBase({
|
||||||
|
bad: "client",
|
||||||
|
code: "message_not_found",
|
||||||
|
message: "対象のメッセージが見つかりませんでした。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
until = itMessage.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const findResult = await this.find({
|
||||||
|
channel,
|
||||||
|
createdAt: {
|
||||||
|
$lt: until,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "DESC",
|
||||||
|
},
|
||||||
|
limit: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return findResult ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export class UserRepository extends EntityRepository<UserEntity> {
|
|||||||
public static schema = z.object({
|
public static schema = z.object({
|
||||||
userid: z.string().trim().min(3).max(20),
|
userid: z.string().trim().min(3).max(20),
|
||||||
username: z.string().trim().min(3).max(30),
|
username: z.string().trim().min(3).max(30),
|
||||||
|
icon: z.string().url().optional(),
|
||||||
profile: z.string().max(4096).optional(),
|
profile: z.string().max(4096).optional(),
|
||||||
email: z.string().trim().min(6).max(254).regex(EmailRegex),
|
email: z.string().trim().min(6).max(254).regex(EmailRegex),
|
||||||
password: z.string().trim().min(8),
|
password: z.string().trim().min(8),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { DatabaseError, InputError } from "@/errors";
|
import { DatabaseError, InputError } from "@/errors";
|
||||||
import Logger from "@/lib/logger";
|
import Logger from "@/lib/logger";
|
||||||
import { CommunityEntity } from "@/modules/entities/Community";
|
import { CommunityEntity } from "@/modules/entities/Community";
|
||||||
import { UserEntity } from "@/modules/entities/User";
|
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import z from "zod/v3";
|
import z from "zod/v3";
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Me from "./me";
|
|||||||
import ServerInfo from "./server-info";
|
import ServerInfo from "./server-info";
|
||||||
import Community from "./community";
|
import Community from "./community";
|
||||||
import Channel from "./channel";
|
import Channel from "./channel";
|
||||||
|
import Message from "./message";
|
||||||
|
|
||||||
export default async function Routes(fastify: FastifyInstance) {
|
export default async function Routes(fastify: FastifyInstance) {
|
||||||
await fastify.register(Setup, {
|
await fastify.register(Setup, {
|
||||||
@@ -30,4 +31,8 @@ export default async function Routes(fastify: FastifyInstance) {
|
|||||||
await fastify.register(Channel, {
|
await fastify.register(Channel, {
|
||||||
prefix: "/channel",
|
prefix: "/channel",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await fastify.register(Message, {
|
||||||
|
prefix: "/message",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { DatabaseError, InputError } from "@/errors";
|
||||||
|
import Logger from "@/lib/logger";
|
||||||
|
import { MessageEntity } from "@/modules/entities/Message";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import z from "zod/v3";
|
||||||
|
|
||||||
|
export default async function MessageDelete(fastify: FastifyInstance) {
|
||||||
|
const logger = new Logger("Endpoint | message/delete");
|
||||||
|
|
||||||
|
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 = z.object({
|
||||||
|
id: z.string().length(10),
|
||||||
|
}).safeParse(req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.code(400).send(InputError(result.error.issues));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageRepo = fastify.orm.em.getRepository(MessageEntity);
|
||||||
|
await messageRepo.deleteMessage(result.data.id);
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Database Error:", err);
|
||||||
|
|
||||||
|
return res.code(500).send(DatabaseError());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import MessageList from "./list";
|
||||||
|
import MessageSend from "./send";
|
||||||
|
import MessageDelete from "./delete";
|
||||||
|
|
||||||
|
export default async function Message(fastify: FastifyInstance) {
|
||||||
|
await fastify.register(MessageList, {
|
||||||
|
prefix: "/list",
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.register(MessageSend, {
|
||||||
|
prefix: "/send",
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.register(MessageDelete, {
|
||||||
|
prefix: "/delete",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { DatabaseError, InputError } from "@/errors";
|
||||||
|
import Logger from "@/lib/logger";
|
||||||
|
import { Pick } from "@/lib/object";
|
||||||
|
import { MessageEntity } from "@/modules/entities/Message";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import z from "zod/v3";
|
||||||
|
|
||||||
|
export default async function MessageList(fastify: FastifyInstance) {
|
||||||
|
const logger = new Logger("Endpoint | message/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(),
|
||||||
|
until: z.string().optional(),
|
||||||
|
channel: z.string().length(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = bodySchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.code(400).send(InputError(result.error.issues));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageRepo = fastify.orm.em.getRepository(MessageEntity);
|
||||||
|
|
||||||
|
const findResult = await messageRepo.listMessage(
|
||||||
|
result.data.channel,
|
||||||
|
result.data.limit,
|
||||||
|
result.data.until,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("error" in findResult) {
|
||||||
|
return res.code(400).send(findResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
success: true,
|
||||||
|
messages: findResult.map(message => ({
|
||||||
|
...message,
|
||||||
|
createdBy: Pick(message.createdBy, ["id", "userid", "username", "icon"]),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Database Error:", err);
|
||||||
|
|
||||||
|
return res.code(500).send(DatabaseError());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { DatabaseError, InputError } from "@/errors";
|
||||||
|
import Logger from "@/lib/logger";
|
||||||
|
import { MessageEntity } from "@/modules/entities/Message";
|
||||||
|
import { MessageRepository } from "@/modules/repositories/Message";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
export default async function MessageSend(fastify: FastifyInstance) {
|
||||||
|
const logger = new Logger("Endpoint | message/send");
|
||||||
|
|
||||||
|
fastify.post("/", async (req, res) => {
|
||||||
|
res.header("Content-Type", "application/json");
|
||||||
|
res.header("Connection", "close");
|
||||||
|
|
||||||
|
if ("error" in req.token)
|
||||||
|
return res.code(400).send(req.token);
|
||||||
|
|
||||||
|
const result = MessageRepository.schema
|
||||||
|
.omit({ userid: true })
|
||||||
|
.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.code(400).send(InputError(result.error.issues));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageRepo = fastify.orm.em.getRepository(MessageEntity);
|
||||||
|
const id = await messageRepo.sendMessage(result.data.message, result.data.channel, req.token.user.id);
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
success: true,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Database Error:", err);
|
||||||
|
|
||||||
|
return res.code(500).send(DatabaseError());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,12 +18,12 @@
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
background-color: #ffffff;
|
background-color: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
html, body {
|
html, body {
|
||||||
background-color: #1b1b1b;
|
background-color: #181818;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
:size="6"
|
:size="6"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="modals-container" />
|
<div class="modals-container">
|
||||||
|
<div class="top-notice-container" tabindex="-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<div class="left-menu">
|
<div class="left-menu">
|
||||||
@@ -65,6 +67,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.top-notice-container {
|
||||||
|
transform: translateZ(0);
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100dvw;
|
||||||
|
height: 100dvh;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
main.layout {
|
main.layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100dvw;
|
width: 100dvw;
|
||||||
@@ -92,6 +110,7 @@ main.layout {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -131,7 +150,11 @@ main.layout {
|
|||||||
|
|
||||||
.left-menu a:hover,
|
.left-menu a:hover,
|
||||||
.left-menu a.isActive {
|
.left-menu a.isActive {
|
||||||
background-color: var(--border-color);
|
background-color: var(--bg-sub-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-menu a.isActive {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-main {
|
.content-main {
|
||||||
@@ -140,6 +163,9 @@ main.layout {
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-header {
|
.content-header {
|
||||||
@@ -160,7 +186,9 @@ main.layout {
|
|||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-main .route-main.full-route {
|
.content-main .route-main.full-route {
|
||||||
@@ -256,19 +284,6 @@ function handleError(event: ErrorEvent | PromiseRejectionEvent) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener("error", handleError);
|
window.addEventListener("error", handleError);
|
||||||
window.addEventListener("unhandledrejection", handleError);
|
window.addEventListener("unhandledrejection", handleError);
|
||||||
|
|
||||||
if ("serviceWorker" in navigator) {
|
|
||||||
const swFile = import.meta.env.MODE === "production"
|
|
||||||
? "/sw.js"
|
|
||||||
: "/dev-sw.js?dev-sw";
|
|
||||||
|
|
||||||
navigator.serviceWorker.register(swFile, {
|
|
||||||
type: import.meta.env.MODE === "production"
|
|
||||||
? "classic"
|
|
||||||
: "module",
|
|
||||||
scope: "/",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message">
|
||||||
|
<img
|
||||||
|
v-if="message.createdBy.icon"
|
||||||
|
:src="message.createdBy.icon"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
icon="material-symbols:account-circle"
|
||||||
|
/>
|
||||||
|
<div class="content">
|
||||||
|
<div>
|
||||||
|
<span class="username">{{ message.createdBy.username }}</span>
|
||||||
|
<span class="userid">@{{ message.createdBy.userid }}</span>
|
||||||
|
|
||||||
|
<span class="dot">・</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="time"
|
||||||
|
:title="new Date(message.createdAt).toLocaleString()"
|
||||||
|
>{{ DateParse(message.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="main-text" v-html='message.message.replaceAll("\n", "<br>")' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu">
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:content-copy-rounded"
|
||||||
|
@click="copyMessage()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:delete-rounded"
|
||||||
|
style="color: #ff0000"
|
||||||
|
@click="deleteMessage()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover {
|
||||||
|
background-color: var(--bg-sub-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message > img,
|
||||||
|
.message > svg {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userid {
|
||||||
|
color: var(--text-sub-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-text {
|
||||||
|
width: 100%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.5rem 0.5rem auto auto;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover > .menu {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu > svg {
|
||||||
|
width: 2em;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: auto 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu > svg:hover {
|
||||||
|
background-color: var(--route-bg-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import client from "@/lib/client";
|
||||||
|
import { createModal, createTopNotice } from "@/lib/modal";
|
||||||
|
import { DateParse } from "@/lib/parser";
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
|
||||||
|
import ErrorModal from "@/components/Modal/Error.vue";
|
||||||
|
import Confirm from "@/components/Modal/Confirm.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
message: Extract<ApiMap["message/list"]["response"], { messages: any }>["messages"][number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits();
|
||||||
|
|
||||||
|
const copyMessage = async () => {
|
||||||
|
await navigator.clipboard.writeText(props.message.message);
|
||||||
|
createTopNotice({
|
||||||
|
props: {
|
||||||
|
icon: "material-symbols:check-circle-rounded",
|
||||||
|
iconColor: "var(--success-color)",
|
||||||
|
message: "コピーしました。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMessage = async () => {
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
createModal({
|
||||||
|
component: Confirm,
|
||||||
|
onClose: (data: { result: string }) => {
|
||||||
|
switch (data.result) {
|
||||||
|
case "yes":
|
||||||
|
resolve();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
message: "このメッセージを削除します。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await client.value.request("message/delete", {
|
||||||
|
id: props.message.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
createModal({
|
||||||
|
component: ErrorModal,
|
||||||
|
props: {
|
||||||
|
error: "メッセージの削除に失敗しました。",
|
||||||
|
canClose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("deleteMessage", {
|
||||||
|
id: props.message.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
createTopNotice({
|
||||||
|
props: {
|
||||||
|
icon: "material-symbols:check-circle-rounded",
|
||||||
|
iconColor: "var(--success-color)",
|
||||||
|
message: "削除しました。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="modal">
|
||||||
|
<Icon
|
||||||
|
icon="line-md:alert"
|
||||||
|
class="warning-badge"
|
||||||
|
width="4rem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="modal-title">よろしいですか?</span>
|
||||||
|
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
name="はい"
|
||||||
|
color="accent"
|
||||||
|
@click='$emit("PleaseClose", { result: "yes" })'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
name="いいえ"
|
||||||
|
color="default"
|
||||||
|
@click='$emit("PleaseClose", { result: "no" })'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-badge {
|
||||||
|
color: var(--warn-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import Button from "@/components/Button.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
message: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -16,6 +16,13 @@
|
|||||||
color="accent"
|
color="accent"
|
||||||
@click='$emit("PleaseClose")'
|
@click='$emit("PleaseClose")'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
name="再読み込み"
|
||||||
|
color="accent"
|
||||||
|
@click="reload()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -46,4 +53,6 @@ defineProps<{
|
|||||||
error: any;
|
error: any;
|
||||||
canClose: boolean;
|
canClose: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const reload = () => window.location.reload();
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
:class="fadeOut"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon='icon ?? "material-symbols:info-rounded"'
|
||||||
|
class="error-badge"
|
||||||
|
width="4rem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
animation: fadeIn 200ms ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.fadeOut {
|
||||||
|
animation: fadeOut 200ms ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin: auto 0;
|
||||||
|
color: v-bind(iconColor);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
icon?: string;
|
||||||
|
iconColor?: string;
|
||||||
|
timeout?: number;
|
||||||
|
message: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const iconColor = props.iconColor ?? "var(--text-color)";
|
||||||
|
|
||||||
|
const emit = defineEmits();
|
||||||
|
const fadeOut = ref<string>("");
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
fadeOut.value = "fadeOut";
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 250));
|
||||||
|
emit("PleaseClose");
|
||||||
|
}, props.timeout ?? 1500);
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=BIZ+UDPGothic&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=BIZ+UDPGothic:wght@400;700&display=swap");
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #f0f0f0;
|
--bg-color: #f0f0f0;
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
--text-sub-color: #595959;
|
--text-sub-color: #595959;
|
||||||
--border-color: #e0e0e0;
|
--border-color: #e0e0e0;
|
||||||
--error-color: #ff0000;
|
--error-color: #ff0000;
|
||||||
|
--warn-color: #ff9d00;
|
||||||
--success-color: #00ff00;
|
--success-color: #00ff00;
|
||||||
--accent-color: #3ad0a1;
|
--accent-color: #3ad0a1;
|
||||||
--accent-in-text-color: #000000;
|
--accent-in-text-color: #000000;
|
||||||
|
|||||||
@@ -1,37 +1,46 @@
|
|||||||
|
import TopNotice from "@/components/Modal/TopNotice.vue";
|
||||||
import { createApp, h, ref, type Component } from "vue";
|
import { createApp, h, ref, type Component } from "vue";
|
||||||
|
|
||||||
const layer = ref<number>(0);
|
const layer = ref<number>(1);
|
||||||
|
|
||||||
export const createModal = <T extends Component>(data: {
|
export const createModal = <T extends Component>(data: {
|
||||||
component: T,
|
component: T,
|
||||||
|
isTopNotice?: boolean;
|
||||||
style?: Record<string, string>,
|
style?: Record<string, string>,
|
||||||
props?: Record<string, ((...args: any[]) => any) | any>,
|
props?: Record<string, ((...args: any[]) => any) | any>,
|
||||||
onClose?: () => void
|
onClose?: (...args: any) => any
|
||||||
}) => {
|
}) => {
|
||||||
layer.value++
|
layer.value++;
|
||||||
const newContainer = document.createElement("div");
|
|
||||||
for (const [key, value] of Object.entries({
|
|
||||||
...data.style,
|
|
||||||
transform: `translateZ(${layer.value})`,
|
|
||||||
position: "fixed",
|
|
||||||
inset: "0",
|
|
||||||
display: "flex",
|
|
||||||
"justify-content": "center",
|
|
||||||
"align-items": "center",
|
|
||||||
width: "100dvw",
|
|
||||||
height: "100dvh",
|
|
||||||
"background-color": "#00000080",
|
|
||||||
})) {
|
|
||||||
newContainer.style.setProperty(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.querySelector(".modals-container")!
|
let container: HTMLDivElement;
|
||||||
.appendChild(newContainer);
|
if (!data.isTopNotice) {
|
||||||
|
const newContainer = document.createElement("div");
|
||||||
|
for (const [key, value] of Object.entries({
|
||||||
|
...data.style,
|
||||||
|
"transform": `translateZ(${layer.value})`,
|
||||||
|
"position": "fixed",
|
||||||
|
"inset": "0",
|
||||||
|
"display": "flex",
|
||||||
|
"justify-content": "center",
|
||||||
|
"align-items": "center",
|
||||||
|
"width": "100dvw",
|
||||||
|
"height": "100dvh",
|
||||||
|
"background-color": "#00000080",
|
||||||
|
})) {
|
||||||
|
newContainer.style.setProperty(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
container = document.querySelector(".modals-container")!
|
||||||
|
.appendChild(newContainer);
|
||||||
|
} else {
|
||||||
|
container = document.querySelector(".top-notice-container")!
|
||||||
|
.appendChild(document.createElement("div"));
|
||||||
|
}
|
||||||
|
|
||||||
let app: ReturnType<typeof createApp>;
|
let app: ReturnType<typeof createApp>;
|
||||||
|
|
||||||
const close = () => {
|
const close = (closeData?: any) => {
|
||||||
data.onClose?.();
|
data.onClose?.(closeData);
|
||||||
layer.value--;
|
layer.value--;
|
||||||
app.unmount();
|
app.unmount();
|
||||||
container.remove();
|
container.remove();
|
||||||
@@ -49,4 +58,14 @@ export const createModal = <T extends Component>(data: {
|
|||||||
app.mount(container);
|
app.mount(container);
|
||||||
|
|
||||||
return close;
|
return close;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createTopNotice = (data?: {
|
||||||
|
style?: Record<string, string>,
|
||||||
|
props?: Record<string, ((...args: any[]) => any) | any>,
|
||||||
|
onClose?: (...args: any) => any
|
||||||
|
}) => createModal({
|
||||||
|
...data,
|
||||||
|
component: TopNotice,
|
||||||
|
isTopNotice: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export function DateParse(date: string | Date, startDate: Date = new Date()) {
|
||||||
|
const diffMs = startDate.getTime() - new Date(date).getTime();
|
||||||
|
const diffSec = Math.abs(Math.floor(diffMs / 1000));
|
||||||
|
const diffMin = Math.abs(Math.floor(diffSec / 60));
|
||||||
|
const diffHour = Math.abs(Math.floor(diffMin / 60));
|
||||||
|
const diffDay = Math.abs(Math.floor(diffHour / 24));
|
||||||
|
const diffMonth = Math.abs(Math.floor(diffDay / 30));
|
||||||
|
const diffYear = Math.abs(Math.floor(diffMonth / 12));
|
||||||
|
|
||||||
|
const diffStr = diffMs < 0
|
||||||
|
? "後"
|
||||||
|
: "前";
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case diffSec < 60:
|
||||||
|
return `${diffSec}秒${diffStr}`;
|
||||||
|
case diffMin < 60:
|
||||||
|
return `${diffMin}分${diffStr}`;
|
||||||
|
case diffHour < 24:
|
||||||
|
return `${diffHour}時間${diffStr}`;
|
||||||
|
case diffDay < 30:
|
||||||
|
return `${diffDay}日${diffStr}`;
|
||||||
|
case diffMonth < 12:
|
||||||
|
return `${diffMonth}ヶ月${diffStr}`;
|
||||||
|
default:
|
||||||
|
return `${diffYear}年${diffStr}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,63 @@
|
|||||||
const swSelf = globalThis as unknown as ServiceWorkerGlobalScope;
|
const swSelf = globalThis as unknown as ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
"general": [
|
||||||
|
"/assets/lynqchat.svg",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
const manifest = self.__WB_MANIFEST || [];
|
||||||
|
manifest.forEach((entry: any) => {
|
||||||
|
const url = typeof entry === "string"
|
||||||
|
? entry
|
||||||
|
: entry.url;
|
||||||
|
resources.general.push(url);
|
||||||
|
});
|
||||||
|
|
||||||
swSelf.addEventListener("install", (event) => {
|
swSelf.addEventListener("install", (event) => {
|
||||||
event.waitUntil(swSelf.skipWaiting());
|
swSelf.skipWaiting();
|
||||||
|
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
await Promise.all(Object.entries(resources).map(async ([name, assets]) => {
|
||||||
|
const cache = await caches.open(name);
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
try {
|
||||||
|
await cache.add(asset);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to cache asset: ${asset}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
swSelf.addEventListener("activate", (event) => {
|
swSelf.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(swSelf.clients.claim());
|
event.waitUntil((async () => {
|
||||||
|
await swSelf.clients.claim();
|
||||||
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
swSelf.addEventListener("fetch", (event) => {
|
swSelf.addEventListener("fetch", (event) => {
|
||||||
const request = event.request;
|
const request = event.request;
|
||||||
|
|
||||||
if (request.url.indexOf("http") !== 0)
|
if (request.method !== "GET")
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== location.origin)
|
||||||
|
return;
|
||||||
|
|
||||||
event.respondWith((async () => {
|
event.respondWith((async () => {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached)
|
||||||
|
return cached;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(request);
|
return await fetch(request);
|
||||||
return res;
|
} catch (error) {
|
||||||
} catch (err) {
|
return new Response("Network error", { status: 504 });
|
||||||
return new Response("Network error", {
|
|
||||||
status: 504,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})());
|
})());
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ const router = createRouter({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
router.beforeEach((to, from) => {
|
router.beforeEach((to, from) => {
|
||||||
if (to.path === from.path)
|
if (from.matched.length > 0 && to.path === from.path)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
routerStatus.isLoad = true;
|
routerStatus.isLoad = true;
|
||||||
|
|||||||
@@ -9,14 +9,114 @@
|
|||||||
v-if="!isProcessing && channel"
|
v-if="!isProcessing && channel"
|
||||||
class="channel"
|
class="channel"
|
||||||
>
|
>
|
||||||
|
<div class="messages" @scroll.passive="messagesScroll">
|
||||||
|
<div class="none-message" v-if="messages.length === 0">
|
||||||
|
<span>何ということでしょう!!</span>
|
||||||
|
<span>メッセージがありませんね!!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
v-if="isLoading"
|
||||||
|
:size="10"
|
||||||
|
class="loading-progress"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-for="message of messages"
|
||||||
|
@deleteMessage="deleteMessage"
|
||||||
|
:key="message.id"
|
||||||
|
:message="message"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<textarea
|
||||||
|
@input="resize"
|
||||||
|
@keydown.ctrl.enter="send"
|
||||||
|
class="message-input"
|
||||||
|
rows="1"
|
||||||
|
v-model="message"
|
||||||
|
:disabled="isSending"
|
||||||
|
:placeholder="`${channel.name}に送信`"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
@click="send"
|
||||||
|
:icon='isSending
|
||||||
|
? "line-md:loading-twotone-loop"
|
||||||
|
: "material-symbols:send-rounded"'
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.processing-progress {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.channel {
|
.channel {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.none-message {
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.none-message span:first-child {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-progress {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background-color: var(--bg-sub-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 40dvh;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--bg-sub-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input:disabled {
|
||||||
|
color: oklch(from var(--text-color) calc(l - 0.2) c h);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel form svg {
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -24,18 +124,18 @@
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { account, presentCommunity, serverInfo } from "@/lib/account";
|
import { account, presentCommunity, serverInfo } from "@/lib/account";
|
||||||
import client from "@/lib/client";
|
import client from "@/lib/client";
|
||||||
import { createModal } from "@/lib/modal";
|
import { createModal, createTopNotice } from "@/lib/modal";
|
||||||
import GoHomeError from "@/components/Modal/GoHomeError.vue";
|
import GoHomeError from "@/components/Modal/GoHomeError.vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import Progress from "@/components/Progress.vue";
|
import Progress from "@/components/Progress.vue";
|
||||||
import { title } from "@/lib/router";
|
import { title } from "@/lib/router";
|
||||||
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
|
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import ErrorModal from "@/components/Modal/Error.vue";
|
||||||
|
import Message from "@/components/Message.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isProcessing = ref<boolean>(false);
|
|
||||||
|
|
||||||
const channel = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"][number]>();
|
|
||||||
|
|
||||||
if (!serverInfo.value.success) {
|
if (!serverInfo.value.success) {
|
||||||
throw new Error("サーバー情報の取得に失敗しました。");
|
throw new Error("サーバー情報の取得に失敗しました。");
|
||||||
@@ -55,6 +155,176 @@ if (!account.value.success) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isProcessing = ref<boolean>(false);
|
||||||
|
const isSending = ref<boolean>(false);
|
||||||
|
|
||||||
|
const channel = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"][number]>();
|
||||||
|
const messages = ref<Extract<ApiMap["message/list"]["response"], { messages: any }>["messages"]>([]);
|
||||||
|
|
||||||
|
const message = ref<string>("");
|
||||||
|
|
||||||
|
const isInsideRange = ref(false);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const messagesScroll = async (event: any) => {
|
||||||
|
const handleSize = 200;
|
||||||
|
|
||||||
|
const isCurrentInside = event.target.scrollTop < handleSize;
|
||||||
|
if (isCurrentInside && !isInsideRange.value && !isLoading.value) {
|
||||||
|
isLoading.value = true;
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
document.querySelector(".messages")!.scrollTop = 0;
|
||||||
|
|
||||||
|
if (channel.value === undefined) {
|
||||||
|
isLoading.value = false;
|
||||||
|
createModal({
|
||||||
|
component: ErrorModal,
|
||||||
|
onClose: () => isSending.value = false,
|
||||||
|
props: {
|
||||||
|
error: "チャンネルの情報がありません。",
|
||||||
|
canClose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesRes = await client.value.request("message/list", {
|
||||||
|
channel: channel.value.id,
|
||||||
|
until: messages.value[0]?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!messagesRes.success) {
|
||||||
|
isLoading.value = false;
|
||||||
|
createModal({
|
||||||
|
component: ErrorModal,
|
||||||
|
onClose: async () => await router.push("/"),
|
||||||
|
props: {
|
||||||
|
error: "メッセージが取得できませんでした。",
|
||||||
|
canClose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.value = messagesRes.messages.toReversed().concat(messages.value);
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInsideRange.value = isCurrentInside;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resize = (event: any) => {
|
||||||
|
event.target.style.height = "auto";
|
||||||
|
event.target.style.height = event.target.scrollHeight + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isSending.value = true;
|
||||||
|
|
||||||
|
if (channel.value === undefined) {
|
||||||
|
createModal({
|
||||||
|
component: ErrorModal,
|
||||||
|
onClose: () => isSending.value = false,
|
||||||
|
props: {
|
||||||
|
error: "チャンネルの情報がありません。",
|
||||||
|
canClose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.value.trim().length > 4096) {
|
||||||
|
isSending.value = false;
|
||||||
|
createTopNotice({
|
||||||
|
props: {
|
||||||
|
icon: "material-symbols:warning-rounded",
|
||||||
|
iconColor: "var(--warn-color)",
|
||||||
|
error: "メッセージは4096文字までである必要があります。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.value.trim().length === 0) {
|
||||||
|
isSending.value = false;
|
||||||
|
createTopNotice({
|
||||||
|
props: {
|
||||||
|
icon: "material-symbols:warning-rounded",
|
||||||
|
iconColor: "var(--warn-color)",
|
||||||
|
message: "メッセージを空にすることは出来ません。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = route.params.channelId;
|
||||||
|
if (typeof channelId !== "string") {
|
||||||
|
isSending.value = false;
|
||||||
|
createModal({
|
||||||
|
component: ErrorModal,
|
||||||
|
props: {
|
||||||
|
error: "チャンネルが取得できませんでした。",
|
||||||
|
canClose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account.value.success) {
|
||||||
|
isSending.value = false;
|
||||||
|
createModal({
|
||||||
|
component: ErrorModal,
|
||||||
|
props: {
|
||||||
|
error: "アカウント情報の取得に失敗しました。",
|
||||||
|
canClose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendRes = await client.value.request("message/send", {
|
||||||
|
message: message.value,
|
||||||
|
channel: channel.value.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sendRes.success) {
|
||||||
|
isSending.value = false;
|
||||||
|
createModal({
|
||||||
|
component: ErrorModal,
|
||||||
|
props: {
|
||||||
|
error: "メッセージの送信に失敗しました。",
|
||||||
|
canClose: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.value = [...messages.value, {
|
||||||
|
id: sendRes.id,
|
||||||
|
message: message.value,
|
||||||
|
channel: channelId,
|
||||||
|
createdAt: new Date().toUTCString(),
|
||||||
|
createdBy: {
|
||||||
|
...account.value,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
|
||||||
|
message.value = "";
|
||||||
|
const inputElem = document.querySelector(".message-input") as HTMLTextAreaElement;
|
||||||
|
inputElem.style.height = "auto";
|
||||||
|
isSending.value = false;
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
const messagesElem = document.querySelector(".messages")!;
|
||||||
|
messagesElem.scrollTop = messagesElem.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMessage = (data: { id: string }) => {
|
||||||
|
const index = messages.value.findIndex(msg => msg.id === data.id);
|
||||||
|
if (index !== -1)
|
||||||
|
messages.value = messages.value.toSpliced(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
isProcessing.value = true;
|
isProcessing.value = true;
|
||||||
|
|
||||||
@@ -108,6 +378,27 @@ if (!account.value.success) {
|
|||||||
document.title = `${channelRes.channel.name} - ${presentCommunity.value.name} | ${serverInfo.value.name}`;
|
document.title = `${channelRes.channel.name} - ${presentCommunity.value.name} | ${serverInfo.value.name}`;
|
||||||
title.value = `${channelRes.channel.name} - ${presentCommunity.value.name}`;
|
title.value = `${channelRes.channel.name} - ${presentCommunity.value.name}`;
|
||||||
|
|
||||||
|
const messagesRes = await client.value.request("message/list", {
|
||||||
|
channel: channel.value.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!messagesRes.success) {
|
||||||
|
isProcessing.value = false;
|
||||||
|
createModal({
|
||||||
|
component: GoHomeError,
|
||||||
|
onClose: async () => await router.push("/"),
|
||||||
|
props: {
|
||||||
|
error: "メッセージが取得できませんでした。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.value = messages.value.concat(messagesRes.messages).reverse();
|
||||||
isProcessing.value = false;
|
isProcessing.value = false;
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
const messagesElem = document.querySelector(".messages")!;
|
||||||
|
messagesElem.scrollTop = messagesElem.scrollHeight;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -38,27 +38,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.community {
|
.community {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels {
|
.channels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1rem;
|
padding-top: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
width: 16rem;
|
width: 16rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-top-right-radius: 2rem;
|
border-top-right-radius: 2rem;
|
||||||
border-bottom-right-radius: 2rem;
|
border-bottom-right-radius: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels,
|
||||||
|
.border {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.channels a {
|
.channels a {
|
||||||
display: flex;
|
display: flex;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
width: 100%;
|
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
@@ -70,7 +76,7 @@
|
|||||||
|
|
||||||
.channels a:hover,
|
.channels a:hover,
|
||||||
.channels a.isActive {
|
.channels a.isActive {
|
||||||
background-color: oklch(from var(--route-bg-color) calc(l - 0.02) c h);
|
background-color: var(--bg-sub-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels a.isActive {
|
.channels a.isActive {
|
||||||
@@ -102,6 +108,12 @@
|
|||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.community-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,19 @@ export default defineConfig(({ mode }) => {
|
|||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
strategies: "injectManifest",
|
strategies: "injectManifest",
|
||||||
srcDir: "./src/lib",
|
srcDir: "./src/lib",
|
||||||
filename: "sw.ts",
|
filename: "sw.ts",
|
||||||
injectRegister: false,
|
injectRegister: "inline",
|
||||||
manifest: false,
|
manifest: false,
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
injectionPoint: undefined,
|
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
type: "module",
|
type: "module",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
hmr: {
|
hmr: {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import InputError from "../../modules/error/input";
|
||||||
|
import ErrorBase from "../../modules/error";
|
||||||
|
import DatabaseError from "../../modules/error/database";
|
||||||
|
import UnknownError from "../../modules/error/unknown";
|
||||||
|
import Success from "../../modules/response/success";
|
||||||
|
import Message from "../../modules/message";
|
||||||
|
import YetInitializationError from "../../modules/error/yet_init";
|
||||||
|
import AuthError from "../../modules/error/auth";
|
||||||
|
|
||||||
|
export default interface MessageDelete {
|
||||||
|
"message/delete": {
|
||||||
|
body: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: Success | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import InputError from "../../modules/error/input";
|
||||||
|
import ErrorBase from "../../modules/error";
|
||||||
|
import DatabaseError from "../../modules/error/database";
|
||||||
|
import UnknownError from "../../modules/error/unknown";
|
||||||
|
import Success from "../../modules/response/success";
|
||||||
|
import Message from "../../modules/message";
|
||||||
|
import YetInitializationError from "../../modules/error/yet_init";
|
||||||
|
import AuthError from "../../modules/error/auth";
|
||||||
|
|
||||||
|
export default interface MessageList {
|
||||||
|
"message/list": {
|
||||||
|
body: {
|
||||||
|
channel: string;
|
||||||
|
limit?: number;
|
||||||
|
until?: string | Date;
|
||||||
|
};
|
||||||
|
response: (Success & {
|
||||||
|
messages: Message[];
|
||||||
|
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import InputError from "../../modules/error/input";
|
||||||
|
import ErrorBase from "../../modules/error";
|
||||||
|
import DatabaseError from "../../modules/error/database";
|
||||||
|
import UnknownError from "../../modules/error/unknown";
|
||||||
|
import Success from "../../modules/response/success";
|
||||||
|
import Message from "../../modules/message";
|
||||||
|
import YetInitializationError from "../../modules/error/yet_init";
|
||||||
|
import AuthError from "../../modules/error/auth";
|
||||||
|
|
||||||
|
export default interface MessageSend {
|
||||||
|
"message/send": {
|
||||||
|
body: {
|
||||||
|
message: string;
|
||||||
|
channel: string;
|
||||||
|
};
|
||||||
|
response: (Success & {
|
||||||
|
id: string;
|
||||||
|
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
|
||||||
|
};
|
||||||
|
}
|
||||||
+7
-1
@@ -7,6 +7,9 @@ import CommunityEdit from "./api/community/edit";
|
|||||||
import CommunityGet from "./api/community/get";
|
import CommunityGet from "./api/community/get";
|
||||||
import CommunityList from "./api/community/list";
|
import CommunityList from "./api/community/list";
|
||||||
import Me from "./api/me";
|
import Me from "./api/me";
|
||||||
|
import MessageDelete from "./api/message/delete";
|
||||||
|
import MessageList from "./api/message/list";
|
||||||
|
import MessageSend from "./api/message/send";
|
||||||
import PrimarySignin from "./api/primary/signin";
|
import PrimarySignin from "./api/primary/signin";
|
||||||
import PrimarySignup from "./api/primary/signup";
|
import PrimarySignup from "./api/primary/signup";
|
||||||
import ServerInfo from "./api/server-info";
|
import ServerInfo from "./api/server-info";
|
||||||
@@ -27,6 +30,9 @@ type ApiMap =
|
|||||||
ChannelCreate &
|
ChannelCreate &
|
||||||
ChannelEdit &
|
ChannelEdit &
|
||||||
ChannelGet &
|
ChannelGet &
|
||||||
ChannelList;
|
ChannelList &
|
||||||
|
MessageDelete &
|
||||||
|
MessageSend &
|
||||||
|
MessageList;
|
||||||
|
|
||||||
export default ApiMap;
|
export default ApiMap;
|
||||||
@@ -4,5 +4,5 @@ export default interface Channel {
|
|||||||
description: string;
|
description: string;
|
||||||
community: string;
|
community: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: Date;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -4,5 +4,5 @@ export default interface Community {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdAt: Date;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import UserSchema from "./user";
|
||||||
|
|
||||||
|
export default interface Message {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
channel: string;
|
||||||
|
createdBy: Pick<UserSchema, "id" | "userid" | "username" | "icon">;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
export default interface UserSchema {
|
export default interface UserSchema {
|
||||||
|
id: string;
|
||||||
userid: string;
|
userid: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
icon: string | null;
|
||||||
profile: string;
|
profile: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user