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:
@@ -18,12 +18,12 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
font-size: 16px;
|
||||
background-color: #ffffff;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
background-color: #1b1b1b;
|
||||
background-color: #181818;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
:size="6"
|
||||
/>
|
||||
|
||||
<div class="modals-container" />
|
||||
<div class="modals-container">
|
||||
<div class="top-notice-container" tabindex="-1" />
|
||||
</div>
|
||||
|
||||
<main class="layout">
|
||||
<div class="left-menu">
|
||||
@@ -65,6 +67,22 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
width: 100dvw;
|
||||
@@ -92,6 +110,7 @@ main.layout {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
padding: 0.5rem;
|
||||
@@ -131,7 +150,11 @@ main.layout {
|
||||
|
||||
.left-menu a:hover,
|
||||
.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 {
|
||||
@@ -140,6 +163,9 @@ main.layout {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
@@ -160,7 +186,9 @@ main.layout {
|
||||
padding: 1.25rem;
|
||||
padding-bottom: 0;
|
||||
overflow: scroll;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-main .route-main.full-route {
|
||||
@@ -256,19 +284,6 @@ function handleError(event: ErrorEvent | PromiseRejectionEvent) {
|
||||
onMounted(async () => {
|
||||
window.addEventListener("error", 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(() => {
|
||||
|
||||
@@ -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"
|
||||
@click='$emit("PleaseClose")'
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
name="再読み込み"
|
||||
color="accent"
|
||||
@click="reload()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -46,4 +53,6 @@ defineProps<{
|
||||
error: any;
|
||||
canClose: boolean;
|
||||
}>();
|
||||
|
||||
const reload = () => window.location.reload();
|
||||
</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 {
|
||||
--bg-color: #f0f0f0;
|
||||
@@ -8,6 +8,7 @@
|
||||
--text-sub-color: #595959;
|
||||
--border-color: #e0e0e0;
|
||||
--error-color: #ff0000;
|
||||
--warn-color: #ff9d00;
|
||||
--success-color: #00ff00;
|
||||
--accent-color: #3ad0a1;
|
||||
--accent-in-text-color: #000000;
|
||||
|
||||
@@ -1,37 +1,46 @@
|
||||
import TopNotice from "@/components/Modal/TopNotice.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: {
|
||||
component: T,
|
||||
isTopNotice?: boolean;
|
||||
style?: Record<string, string>,
|
||||
props?: Record<string, ((...args: any[]) => any) | any>,
|
||||
onClose?: () => void
|
||||
onClose?: (...args: any) => any
|
||||
}) => {
|
||||
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);
|
||||
}
|
||||
layer.value++;
|
||||
|
||||
const container = document.querySelector(".modals-container")!
|
||||
.appendChild(newContainer);
|
||||
let container: HTMLDivElement;
|
||||
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>;
|
||||
|
||||
const close = () => {
|
||||
data.onClose?.();
|
||||
const close = (closeData?: any) => {
|
||||
data.onClose?.(closeData);
|
||||
layer.value--;
|
||||
app.unmount();
|
||||
container.remove();
|
||||
@@ -49,4 +58,14 @@ export const createModal = <T extends Component>(data: {
|
||||
app.mount(container);
|
||||
|
||||
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 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) => {
|
||||
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) => {
|
||||
event.waitUntil(swSelf.clients.claim());
|
||||
event.waitUntil((async () => {
|
||||
await swSelf.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
swSelf.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
|
||||
if (request.url.indexOf("http") !== 0)
|
||||
if (request.method !== "GET")
|
||||
return;
|
||||
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== location.origin)
|
||||
return;
|
||||
|
||||
event.respondWith((async () => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached)
|
||||
return cached;
|
||||
|
||||
try {
|
||||
const res = await fetch(request);
|
||||
return res;
|
||||
} catch (err) {
|
||||
return new Response("Network error", {
|
||||
status: 504,
|
||||
});
|
||||
return await fetch(request);
|
||||
} catch (error) {
|
||||
return new Response("Network error", { status: 504 });
|
||||
}
|
||||
})());
|
||||
});
|
||||
@@ -75,7 +75,7 @@ const router = createRouter({
|
||||
],
|
||||
});
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.path === from.path)
|
||||
if (from.matched.length > 0 && to.path === from.path)
|
||||
return false;
|
||||
|
||||
routerStatus.isLoad = true;
|
||||
|
||||
@@ -9,14 +9,114 @@
|
||||
v-if="!isProcessing && 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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.processing-progress {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.channel {
|
||||
position: relative;
|
||||
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>
|
||||
|
||||
@@ -24,18 +124,18 @@
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { account, presentCommunity, serverInfo } from "@/lib/account";
|
||||
import client from "@/lib/client";
|
||||
import { createModal } from "@/lib/modal";
|
||||
import { createModal, createTopNotice } from "@/lib/modal";
|
||||
import GoHomeError from "@/components/Modal/GoHomeError.vue";
|
||||
import { ref } from "vue";
|
||||
import Progress from "@/components/Progress.vue";
|
||||
import { title } from "@/lib/router";
|
||||
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 router = useRouter();
|
||||
const isProcessing = ref<boolean>(false);
|
||||
|
||||
const channel = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"][number]>();
|
||||
|
||||
if (!serverInfo.value.success) {
|
||||
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 () => {
|
||||
isProcessing.value = true;
|
||||
|
||||
@@ -108,6 +378,27 @@ if (!account.value.success) {
|
||||
document.title = `${channelRes.channel.name} - ${presentCommunity.value.name} | ${serverInfo.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;
|
||||
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
const messagesElem = document.querySelector(".messages")!;
|
||||
messagesElem.scrollTop = messagesElem.scrollHeight;
|
||||
})();
|
||||
</script>
|
||||
@@ -38,27 +38,33 @@
|
||||
}
|
||||
|
||||
.community {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
padding-top: 1rem;
|
||||
box-sizing: border-box;
|
||||
width: 16rem;
|
||||
height: 100%;
|
||||
border-top-right-radius: 2rem;
|
||||
border-bottom-right-radius: 2rem;
|
||||
}
|
||||
|
||||
.channels,
|
||||
.border {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channels a {
|
||||
display: flex;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 1rem;
|
||||
@@ -70,7 +76,7 @@
|
||||
|
||||
.channels a:hover,
|
||||
.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 {
|
||||
@@ -102,6 +108,12 @@
|
||||
padding-top: 1rem;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.community-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -11,20 +11,19 @@ export default defineConfig(({ mode }) => {
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
strategies: "injectManifest",
|
||||
srcDir: "./src/lib",
|
||||
filename: "sw.ts",
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
injectManifest: {
|
||||
injectionPoint: undefined,
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: "module",
|
||||
},
|
||||
}),
|
||||
|
||||
strategies: "injectManifest",
|
||||
srcDir: "./src/lib",
|
||||
filename: "sw.ts",
|
||||
injectRegister: "inline",
|
||||
manifest: false,
|
||||
injectManifest: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: "module",
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
hmr: {
|
||||
|
||||
Reference in New Issue
Block a user