New: フロントエンドにL.jsを導入 / Fix: 初期設定前にserver-infoが利用できない問題 / Feat: manifest.json / Chg: isOwnerをisAdminに戻しました / Fix: UserRepositoryのschemaの記述順序を統一 / Feat: server-infoにサーバー名とサーバー説明を記載 / New: manifest.jsonをフロントエンドで読み込み / New: スクロールバーのスタイルを指定 / New: フォントを指定 / New: line-heightを指定 / New: tab-sizeを指定 / New: <label>のwidthをfit-contentとして指定 / New: CSS変数にアクセントカラーなどを追加 / New: <a>のcolorをaccent-colorとして指定 / Feat: ページのレイアウトを作成 / Feat: 各ページのヘッダーでタイトルを表示 / Feat: スマホUI / Feat: セットアップウィザードをフロントエンドに実装 / Feat: NotFoundページ / New: Button,Input,InputPassword,Toggle,Textareaコンポーネントを作成 / Feat: modal機能 / Chg: server-infoをrefに変更 / Fix: L.jsのsetup/initializationがsetup/initilizationになっていたtypoを修正

This commit is contained in:
2026-03-27 19:03:30 +09:00
parent 0ac921ac3e
commit d670e5bf0b
30 changed files with 1530 additions and 45 deletions
+1
View File
@@ -22,6 +22,7 @@
"@mikro-orm/postgresql": "^6.6.7",
"@mikro-orm/reflection": "^6.6.7",
"@types/web-push": "^3.6.4",
"lynqchat-js": "workspace:*",
"argon2": "^0.44.0",
"cross-env": "^10.1.0",
"fastify": "^5.7.4",
+20 -1
View File
@@ -11,6 +11,7 @@ import Authorization from "@/lib/auth";
import { RequestContext } from "@mikro-orm/core";
import { DatabaseError, ErrorBase } from "@/errors";
import { ConfigEntity } from "@/modules/entities/Config";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
process.title = "LynqChat";
@@ -83,7 +84,8 @@ try {
fastify.addHook("onRequest", async (req, res) => {
if (
req.url.startsWith("/api") &&
!req.url.startsWith("/api/setup")
!req.url.startsWith("/api/setup") &&
!req.url.startsWith("/api/server-info")
) {
try {
const configCount = await fastify.orm.em.count(ConfigEntity);
@@ -123,6 +125,23 @@ try {
}
});
fastify.get("/manifest.json", async (req, res) => {
const serverInfo = await (await fastify.inject({
method: "POST",
url: "/api/server-info",
})).json() as ApiMap["server-info"]["response"];
if (!serverInfo.success) {
return res.code(500).send(serverInfo);
}
return res.send({
name: serverInfo.name ?? "LynqChat",
start_url: "/",
display: "standalone",
});
});
await fastify.register(Routes, {
prefix: "/api",
});
@@ -51,7 +51,7 @@ export class UserEntity {
type: "boolean",
default: false,
})
isOwner: boolean = false;
isAdmin: boolean = false;
@Property({
type: "boolean",
@@ -9,7 +9,7 @@ export class UserRepository extends EntityRepository<UserEntity> {
userid: z.string().trim().min(3).max(20),
username: z.string().trim().min(3).max(30),
profile: z.string().max(4096).optional(),
email: z.string().min(6).trim().max(254).regex(EmailRegex),
email: z.string().trim().min(6).max(254).regex(EmailRegex),
password: z.string().trim().min(8),
isAdmin: z.boolean(),
});
@@ -14,8 +14,13 @@ export default async function ServerInfo(fastify: FastifyInstance) {
const configCount = await config.count();
const userCount = await user.count();
const serverName = await config.findOne({ name: "name" });
const serverDescription = await config.findOne({ name: "description" });
return res.send({
success: true,
name: serverName?.value ?? null,
description: serverDescription?.value ?? null,
isInitialized: configCount > 0,
isFirstAdminExists: userCount > 0,
userCount,
+2 -1
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LynqChat</title>
<link rel="icon" href="/assets/lynqchat.svg" type="image/svg+xml">
<link rel="icon" href="/assets/lynqchat.svg" type="image/svg+xml" />
<link rel="manifest" href="/manifest.json" />
</head>
<body>
<style>
+9 -6
View File
@@ -3,20 +3,23 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite dev --host",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"dexie": "^4.3.0",
"vue": "^3.5.24",
"vue-router": "^5.0.2",
"lynqchat-js": "workspace:*",
"@iconify/vue": "^5.0.0",
"@unhead/vue": "3.0.0-beta.9",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"dexie": "^4.3.0",
"lynqchat-js": "workspace:*",
"typescript": "~5.9.3",
"vite": "^8.0.0",
"vue-tsc": "^3.1.4"
"vue": "^3.5.24",
"vue-router": "^5.0.2",
"vue-tsc": "^3.1.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^24.10.1"
+71 -12
View File
@@ -5,6 +5,8 @@
:size="6"
/>
<div class="modals-container" />
<main class="layout">
<div class="left-menu">
<div>
@@ -12,10 +14,22 @@
</div>
</div>
<div class="route-main">
<RouterView
:key="$route.fullPath"
/>
<div class="content-main">
<div class="content-header">
{{
$route.meta.title
?? (serverInfo.success
? serverInfo.name
: null)
?? "LynqChat"
}}
</div>
<div class="route-main">
<RouterView
:key="$route.fullPath"
/>
</div>
</div>
</main>
</template>
@@ -26,7 +40,7 @@ main.layout {
gap: 1rem;
width: 100dvw;
height: 100dvh;
padding: 1rem;
padding: 1.5rem;
box-sizing: border-box;
}
@@ -36,6 +50,8 @@ main.layout {
flex-direction: column;
justify-content: center;
gap: 1rem;
user-select: none;
-webkit-user-select: none;
}
.left-menu div {
@@ -44,26 +60,65 @@ main.layout {
font-weight: bold;
font-size: 1.4rem;
border-radius: 2rem;
padding: 1rem;
transition: all 100ms ease-in;
padding: 1rem 1.5rem;
transition: background-color 250ms ease-out;
}
.left-menu div:hover {
background-color: var(--border-color);
}
.route-main {
.content-main {
border: 1px solid var(--border-color);
padding: 0.75rem;
border-radius: 1rem;
overflow: hidden;
flex-grow: 1;
}
.content-header {
padding: 1.25rem 1.5rem;
font-size: 1.6rem;
font-weight: bold;
border-bottom-left-radius: 1rem;
border-bottom-right-radius: 1rem;
border-bottom: 1px solid var(--border-color);
box-shadow: 0px 1px 10px var(--border-color);
user-select: none;
-webkit-user-select: none;
}
.route-main {
padding: 1.25rem;
padding-bottom: 0;
overflow: scroll;
min-height: 100%;
}
.router-progress {
position: fixed;
inset: 0.5rem 0.5rem 0 auto;
z-index: 9999;
}
.modals-container {
z-index: 9998;
position: relative;
}
@media (max-width: 40rem) {
.layout {
padding: 0 !important;
}
.left-menu {
display: none;
}
.content-main {
border: none;
border-radius: 0;
}
}
</style>
<script lang="ts" setup>
@@ -74,11 +129,15 @@ import serverInfo from "@/lib/account";
const router = useRouter();
if (!serverInfo.success) {
if (!serverInfo.value.success) {
throw new Error();
}
if (!serverInfo.isFirstAdminExists) {
router.replace("/setup");
if (!serverInfo.value.isInitialized) {
router.replace("/setup/initialization");
}
if (!serverInfo.value.isFirstAdminExists) {
router.replace("/setup/create-admin");
}
</script>
+28
View File
@@ -0,0 +1,28 @@
<template>
<div>
<Icon
icon="line-md:question-circle"
width="8rem"
/>
<Button
name="ホームに戻る"
@click='$router.push("/")'
/>
</div>
</template>
<style scoped>
div {
display: flex;
flex-direction: column;
width: fit-content;
align-items: center;
gap: 1rem;
}
</style>
<script lang="ts" setup>
import { Icon } from "@iconify/vue";
import Button from "@/components/Button.vue";
</script>
@@ -0,0 +1,49 @@
<template>
<button
v-bind="$attrs"
:style="size"
:class='[
$attrs.class,
{ accent: color === "accent" },
]'
class="button"
>{{ name }}</button>
</template>
<style scoped>
.button {
width: fit-content;
outline: none;
cursor: pointer;
border: 1px solid var(--border-color);
background-color: var(--bg-sub-color);
color: var(--text-color);
font-size: var(--size);
border-radius: calc(var(--size) * 2);
padding: calc(var(--size) / 2.25) calc(var(--size) * 2);
}
.button.accent {
background-color: var(--accent-color);
color: var(--accent-in-text-color);
}
.button:disabled {
opacity: 50%;
cursor: not-allowed;
}
</style>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{
name: string;
color?: "default" | "accent";
size?: number;
}>();
const size = computed(() => ({
"--size": `${props.size ?? 1}rem`,
}));
</script>
@@ -0,0 +1,58 @@
<template>
<div class="input">
<label :for="id">{{ label }}</label>
<input
type="text"
v-model="model"
v-bind="$attrs"
:class="$attrs.class"
:id="id"
/>
<slot />
</div>
</template>
<style scoped>
.input {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.input label {
font-size: 0.85rem;
user-select: none;
-webkit-user-select: none;
}
.input input {
background-color: var(--bg-sub-color);
color: var(--text-color);
border: 1px solid transparent;
outline: none;
padding: 0.6rem;
border-radius: 0.25rem;
transition: border 200ms ease-out;
}
.input input:hover {
border: 1px solid var(--border-color);
}
.input input:focus {
border: 1px solid var(--accent-color);
}
</style>
<script lang="ts" setup>
import { useId } from "vue";
defineProps<{
label: string;
}>();
const model = defineModel<string>();
const id = useId();
</script>
@@ -0,0 +1,95 @@
<template>
<div class="input password">
<label :for="id">{{ label }}</label>
<div>
<input
v-model="model"
v-bind="$attrs"
autocomplete="new-password"
:class="$attrs.class"
:id="id"
:type='isVisible
? "text"
: "password"
'
>
<Icon
v-show="isVisible"
icon="material-symbols:visibility"
width="1.25rem"
@click="changeVisible"
/>
<Icon
v-show="!isVisible"
icon="material-symbols:visibility-off"
width="1.25rem"
@click="changeVisible"
/>
</input>
</div>
<slot />
</div>
</template>
<style scoped>
.input.password {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.input.password label {
font-size: 0.85rem;
user-select: none;
-webkit-user-select: none;
}
.input.password div {
position: relative;
}
.input.password div input {
background-color: var(--bg-sub-color);
color: var(--text-color);
width: 100%;
box-sizing: border-box;
border: 1px solid transparent;
outline: none;
padding: 0.6rem;
border-radius: 0.25rem;
transition: border 200ms ease-out;
}
.input.password div input:hover {
border: 1px solid var(--border-color);
}
.input.password div input:focus {
border: 1px solid var(--accent-color);
}
.input.password div svg {
position: absolute;
cursor: pointer;
right: 1rem;
top: 50%;
transform: translateY(-50%);
}
</style>
<script lang="ts" setup>
import { Icon } from "@iconify/vue";
import { ref, useId } from "vue";
defineProps<{
label: string;
}>();
const isVisible = ref<boolean>(false);
const model = defineModel<string>();
const id = useId();
const changeVisible = () => isVisible.value = !isVisible.value;
</script>
@@ -0,0 +1,49 @@
<template>
<div class="modal">
<Icon
icon="line-md:close-circle"
class="error-badge"
width="4rem"
/>
<span class="modal-title">問題が発生しました</span>
<p v-html="error" />
<Button
v-if="canClose"
name="閉じる"
color="accent"
@click='$emit("PleaseClose")'
/>
</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;
}
.error-badge {
color: var(--error-color);
}
</style>
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import Button from "@/components/Button.vue";
defineProps<{
error: any;
canClose: boolean;
}>();
</script>
@@ -0,0 +1,19 @@
<template>
<div class="modal">
<Progress :size="16" />
</div>
</template>
<style scoped>
.modal {
display: flex;
justify-content: center;
align-items: center;
padding: 4rem;
}
</style>
<script setup lang="ts">
import Progress from "@/components/Progress.vue";
</script>
@@ -0,0 +1,46 @@
<template>
<div class="modal">
<Icon
icon="line-md:confirm-circle"
class="success-badge"
width="4rem"
/>
<Button
name="閉じる"
color="accent"
@click='$emit("PleaseClose")'
/>
</div>
</template>
<style scoped>
.modal {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 1.25rem;
padding: 4rem;
gap: 0.75rem;
}
.modal-title {
font-weight: bold;
}
.success-badge {
color: var(--success-color);
}
</style>
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import Button from "@/components/Button.vue";
const emit = defineEmits();
setTimeout(() => {
emit("PleaseClose");
}, 1000);
</script>
@@ -4,6 +4,7 @@
<style scoped>
.progress {
flex-shrink: 0;
border: v-bind(borderSize) solid #425C97;
border-radius: 100%;
border-top: v-bind(borderSize) solid #ffffff;
@@ -0,0 +1,60 @@
<template>
<div class="textarea">
<label :for="id">{{ label }}</label>
<textarea
v-model="model"
v-bind="$attrs"
:class="$attrs.class"
:id="id"
/>
<slot />
</div>
</template>
<style scoped>
.textarea {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.textarea label {
font-size: 0.85rem;
user-select: none;
-webkit-user-select: none;
}
.textarea textarea {
background-color: var(--bg-sub-color);
color: var(--text-color);
font-size: 1rem;
resize: vertical;
field-sizing: content;
border: 1px solid transparent;
outline: none;
padding: 0.6rem;
border-radius: 0.25rem;
transition: border 200ms ease-out;
}
.textarea textarea:hover {
border: 1px solid var(--border-color);
}
.textarea textarea:focus {
border: 1px solid var(--accent-color);
}
</style>
<script lang="ts" setup>
import { useId } from "vue";
defineProps<{
label: string;
}>();
const model = defineModel<string>();
const id = useId();
</script>
+119
View File
@@ -0,0 +1,119 @@
<template>
<div
class="toggle"
:class="{ isRow: isRow }"
:style="size"
>
<div>
<p>{{ description }}</p>
<label :for="id">{{ label }}</label>
</div>
<input
v-model="model"
v-bind="$attrs"
:class="$attrs.class"
:id="id"
type="checkbox"
/>
</div>
</template>
<style scoped>
.toggle {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.toggle.isRow {
width: fit-content;
flex-direction: row-reverse;
gap: 0.85rem;
}
.toggle.isRow div {
display: flex;
flex-direction: column-reverse;
gap: 0.25rem;
}
.toggle div label {
font-size: 0.85rem;
margin: auto 0;
user-select: none;
-webkit-user-select: none;
}
.toggle.isRow div label {
font-size: 1rem;
display: inline-block;
cursor: pointer;
--height: calc(var(--size) + var(--size) / 4 * 1.3 * 2);
height: var(--height);
line-height: var(--height);
}
.toggle input {
appearance: none;
cursor: pointer;
flex-shrink: 0;
background-color: var(--bg-sub-color);
color: var(--text-color);
padding: calc(var(--size) / 4 * 1.3);
width: calc(var(--size) * 2);
height: calc(var(--size));
box-sizing: content-box;
outline: none;
border-radius: var(--size);
transition: border 200ms ease-out, background-color 100ms ease-out;
}
.toggle input:checked {
background-color: var(--accent-color);
}
.toggle input::after {
content: "";
display: block;
width: var(--size);
height: var(--size);
border-radius: 100%;
background-color: #fff;
transition: transform 100ms ease-out;
}
.toggle input:checked::after {
transform: translateX(var(--size));
}
.toggle div p {
display: none;
}
.toggle.isRow div p {
display: inherit;
color: var(--text-sub-color);
user-select: none;
-webkit-user-select: none;
}
</style>
<script lang="ts" setup>
import { computed, useId } from "vue";
const props = defineProps<{
label: string;
size?: number;
isRow?: boolean;
description?: string;
}>();
const model = defineModel<boolean>();
const id = useId();
const size = computed(() => ({
"--size": `${props.size ?? 1}rem`,
}));
</script>
+43
View File
@@ -1,13 +1,23 @@
@import url("https://fonts.googleapis.com/css2?family=BIZ+UDPGothic&display=swap");
:root {
--bg-color: #ffffff;
--bg-sub-color: #f1f1f1;
--text-color: #000000;
--text-sub-color: #595959;
--border-color: #e0e0e0;
--error-color: #ff0000;
--success-color: #00ff00;
--accent-color: #3ad0a1;
--accent-in-text-color: #000000;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1b1b1b;
--bg-sub-color: #252525;
--text-color: #ffffff;
--text-sub-color: #a4a4a4;
--border-color: #2c2c2c;
}
}
@@ -15,12 +25,45 @@
* {
margin: 0;
padding: 0;
scrollbar-width: thin;
scrollbar-color: var(--accent-color) var(--border-color);
}
::selection {
background-color: var(--accent-color);
color: var(--accent-in-text-color);
}
html, body {
width: 100dvw;
height: 100dvh;
font-size: 14px;
font-family: "BIZ UDPGothic", sans-serif;
line-height: 1.5rem;
tab-size: 2;
background-color: var(--bg-color);
color: var(--text-color);
}
a {
color: var(--accent-color);
}
label {
width: fit-content;
}
.title {
font-size: 1.6rem;
font-weight: bold;
}
.modal {
background-color: var(--bg-color);
border-radius: 2rem;
padding: 3rem 6rem;
width: fit-content;
height: fit-content;
max-width: 90dvw;
max-height: 90dvh;
}
+7 -1
View File
@@ -1,5 +1,11 @@
import client from "@/lib/client";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
import { ref } from "vue";
const serverInfo = await client.request("server-info");
let serverInfo = ref<ApiMap["server-info"]["response"]>(await client.request("server-info"));
export const reloadServerInfo = async () => {
serverInfo.value = await client.request("server-info");
}
export default serverInfo;
+52
View File
@@ -0,0 +1,52 @@
import { createApp, h, ref, type Component } from "vue";
const layer = ref<number>(0);
export const createModal = <T extends Component>(data: {
component: T,
style?: Record<string, string>,
props?: Record<string, ((...args: any[]) => any) | any>,
onClose?: () => void
}) => {
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")!
.appendChild(newContainer);
let app: ReturnType<typeof createApp>;
const close = () => {
data.onClose?.();
layer.value--;
app.unmount();
container.remove();
};
app = createApp({
render() {
return h(data.component, {
...data.props,
onPleaseClose: close,
});
},
});
app.mount(container);
return close;
}
+8
View File
@@ -0,0 +1,8 @@
import type { SafeParseReturnType } from "zod/v3";
export const getIssueFromPath = (path: string, result: SafeParseReturnType<any, any>) => {
const filtered = result.error?.issues.filter(issue => issue.path.join(".") === path);
const issue = (filtered ?? [])[0];
return issue;
}
+42 -3
View File
@@ -1,30 +1,69 @@
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import routerStatus from "@/lib/router";
import { createHead } from "@unhead/vue/client";
import serverInfo from "@/lib/account";
import "@/global.css";
import Layout from "@/Layout.vue";
const app = createApp(Layout);
app.use(createHead());
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
meta: {
title: "ホーム",
},
component: () => import("@/routes/index.vue"),
},
{
path: "/setup",
component: () => import("@/routes/setup.vue"),
}
redirect: "/setup/initialization",
},
{
path: "/setup/initialization",
meta: {
title: "初期設定",
},
component: () => import("@/routes/setup/initialization.vue"),
},
{
path: "/setup/create-admin",
meta: {
title: "管理者アカウントの作成",
},
component: () => import("@/routes/setup/create-admin.vue"),
},
{
path: "/:NotFound(.*)*",
meta: {
title: "お探しのページは見つかりませんでした。",
},
component: () => import("@/NotFound.vue"),
},
],
});
router.beforeEach(() => {
routerStatus.isLoad = true;
return;
});
router.afterEach(() => {
router.afterEach((to) => {
const title = to.meta.title;
let serverName = "LynqChat";
if (serverInfo.value.success && serverInfo.value.name) {
serverName = serverInfo.value.name;
}
document.title = title
? `${title} | ${serverName}`
: serverName;
routerStatus.isLoad = false;
});
app.use(router);
-18
View File
@@ -1,18 +0,0 @@
<template>
<h1>Welcome setup wizard!!</h1>
</template>
<script lang="ts" setup>
import serverInfo from "@/lib/account";
import { useRouter } from "vue-router";
const router = useRouter();
if (!serverInfo.success) {
throw new Error();
}
if (serverInfo.isFirstAdminExists) {
router.replace("/");
}
</script>
@@ -0,0 +1,198 @@
<template>
<div class="welcome">
<p><!--
-->ここではLynqChatの管理者アカウントの作成を行います<!--
-->管理者アカウントはサーバーに1つだけです<!--
-->今後特権ユーザーを増やしたい場合は柔軟に権限を設定できます
</p>
</div>
<form novalidate @submit="submit">
<Input
label="ユーザーID"
autocomplete="off"
v-model="userid"
>
<span
class="input-issue"
v-if="useridIssue"
>
<Icon icon="material-symbols:error-outline-rounded" />
{{ useridIssue.message }}
</span>
</Input>
<Input
label="メールアドレス"
type="email"
autocomplete="email"
v-model="email"
>
<span
class="input-issue"
v-if="emailIssue"
>
<Icon icon="material-symbols:error-outline-rounded" />
{{ emailIssue.message }}
</span>
</Input>
<InputPassword
label="パスワード"
v-model="password"
>
<span
class="input-issue"
v-if="passwordIssue"
>
<Icon icon="material-symbols:error-outline-rounded" />
{{ passwordIssue.message }}
</span>
</InputPassword>
<Button
name="管理者アカウントを作成"
type="submit"
color="accent"
:disabled="!result.success || isProcessing"
/>
</form>
</template>
<style scoped>
.welcome {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-issue {
display: flex;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--error-color);
user-select: none;
-webkit-user-select: none;
}
.input-issue svg {
font-size: 1rem;
margin: auto 0;
}
</style>
<script lang="ts" setup>
import serverInfo, { reloadServerInfo } from "@/lib/account";
import { useRouter } from "vue-router";
import Input from "@/components/Input.vue";
import Button from "@/components/Button.vue";
import { ref } from "vue";
import z from "zod/v3";
import { computed } from "vue";
import { createModal } from "@/lib/modal";
import Loading from "@/components/Modal/Loading.vue";
import Error from "@/components/Modal/Error.vue";
import Success from "@/components/Modal/Success.vue";
import InputPassword from "@/components/InputPassword.vue";
import client from "@/lib/client";
import { getIssueFromPath } from "@/lib/validation";
import { Icon } from "@iconify/vue";
const router = useRouter();
const isProcessing = ref<boolean>(false);
if (!serverInfo.value.success) {
throw new Error();
}
if (serverInfo.value.isFirstAdminExists) {
router.replace("/");
}
if (!serverInfo.value.isInitialized) {
router.replace("/setup/initialization");
}
const userid = ref<string>("");
const email = ref<string>("");
const password = ref<string>("");
const useridIssue = computed(() => getIssueFromPath("userid", result.value));
const emailIssue = computed(() => getIssueFromPath("email", result.value));
const passwordIssue = computed(() => getIssueFromPath("password", result.value));
const EmailRegex = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:([0-9]{1,3}\.){3}[0-9]{1,3})\])$/i;
const schema = z.object({
userid: z.string({ message: "文字列で入力してください。" })
.trim().min(3, "3文字以上で入力してください。")
.max(20, "20文字以内で入力してください。"),
email: z.string({ message: "文字列で入力してください。" })
.trim().min(6, "6文字以上で入力してください。")
.max(254, "254文字以内で入力してください。")
.regex(EmailRegex, "形式が異なります。"),
password: z.string({ message: "文字列で入力してください。" })
.trim().min(8, "8文字以上で入力してください。"),
});
const result = computed(() => schema.safeParse({
userid: userid.value,
email: email.value,
password: password.value,
}));
const submit = async (e: Event) => {
e.preventDefault();
isProcessing.value = true;
const closeLoadingModal = createModal({
component: Loading,
});
if (!result.value.success) {
const messages = result.value.error.issues.map(issue => issue.message);
closeLoadingModal();
return createModal({
component: Error,
onClose: () => isProcessing.value = false,
props: {
error: `不正な入力です。<br>${messages.join("<br>")}`,
canClose: true,
},
});
}
const response = await client.request("setup/create-admin", result.value.data);
if (!response.success) {
closeLoadingModal();
return createModal({
component: Error,
onClose: () => isProcessing.value = false,
props: {
error: response.error.message,
canClose: true,
},
});
}
closeLoadingModal();
return createModal({
component: Success,
onClose: async () => {
isProcessing.value = false;
await reloadServerInfo();
router.push("/");
}
});
}
</script>
@@ -0,0 +1,192 @@
<template>
<div class="welcome">
<span class="title">LynqChatへようこそ</span>
<p><!--
-->ここではLynqChatの初期設定を行います<!--
-->このページにアクセスできているということは起動に必要な処理の過程に問題はありません<!--
-->LynqChatで技術的な問題がありましたら<!--
--><a href="https://gitea.last2014.com/last2014/lynq-chat/issues">Issue</a><!--
-->にて報告してください
</p>
</div>
<form novalidate @submit="submit">
<Input
label="サーバー名"
autocomplete="off"
v-model="name"
>
<span
class="input-issue"
v-if="nameIssue"
>
<Icon icon="material-symbols:error-outline-rounded" />
{{ nameIssue.message }}
</span>
</Input>
<Textarea
label="サーバー説明"
autocomplete="on"
v-model="description"
>
<span
class="input-issue"
v-if="descriptionIssue"
>
<Icon icon="material-symbols:error-outline-rounded" />
{{ descriptionIssue.message }}
</span>
</Textarea>
<Toggle
label="サインアップに招待コードを必須とする"
description="オンにすることで、サインアップに招待コードの入力が必須となります。招待コードは、初期設定後にいつでも作成・削除ができます。"
:isRow="true"
:size="1"
v-model="requiredInvitationCode"
/>
<Button
name="スタート"
type="submit"
color="accent"
:disabled="!result.success || isProcessing"
/>
</form>
</template>
<style scoped>
.welcome {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-issue {
display: flex;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--error-color);
user-select: none;
-webkit-user-select: none;
}
.input-issue svg {
font-size: 1rem;
margin: auto 0;
}
</style>
<script lang="ts" setup>
import serverInfo, { reloadServerInfo } from "@/lib/account";
import { useRouter } from "vue-router";
import Input from "@/components/Input.vue";
import Toggle from "@/components/Toggle.vue";
import Button from "@/components/Button.vue";
import { ref } from "vue";
import z from "zod/v3";
import { computed } from "vue";
import { createModal } from "@/lib/modal";
import Loading from "@/components/Modal/Loading.vue";
import Error from "@/components/Modal/Error.vue";
import Success from "@/components/Modal/Success.vue";
import client from "@/lib/client";
import { getIssueFromPath } from "@/lib/validation";
import { Icon } from "@iconify/vue";
import Textarea from "@/components/Textarea.vue";
const router = useRouter();
const isProcessing = ref<boolean>(false);
if (!serverInfo.value.success) {
throw new Error();
}
if (serverInfo.value.isInitialized) {
if (serverInfo.value.isFirstAdminExists) {
router.replace("/");
} else {
router.replace("/setup/create-admin");
}
}
const name = ref<string>("");
const description = ref<string>("");
const requiredInvitationCode = ref<boolean>(false);
const nameIssue = computed(() => getIssueFromPath("name", result.value));
const descriptionIssue = computed(() => getIssueFromPath("description", result.value));
const schema = z.object({
name: z.string({ message: "文字列で入力してください。" })
.trim().min(1, "この項目は必須です。")
.max(20, "20文字以内で入力してください。"),
description: z.string({ message: "文字列で入力してください。" })
.trim().min(1, "この項目は必須です。"),
requiredInvitationCode: z.boolean(),
});
const result = computed(() => schema.safeParse({
name: name.value,
description: description.value,
requiredInvitationCode: requiredInvitationCode.value,
}));
const submit = async (e: Event) => {
e.preventDefault();
isProcessing.value = true;
const closeLoadingModal = createModal({
component: Loading,
});
if (!result.value.success) {
const messages = result.value.error.issues.map(issue => issue.message);
closeLoadingModal();
return createModal({
component: Error,
onClose: () => isProcessing.value = false,
props: {
error: `不正な入力です。<br>${messages.join("<br>")}`,
canClose: true,
},
});
}
const response = await client.request("setup/initialization", result.value.data);
if (!response.success) {
closeLoadingModal();
return createModal({
component: Error,
onClose: () => isProcessing.value = false,
props: {
error: response.error.message,
canClose: true,
},
});
}
closeLoadingModal();
return createModal({
component: Success,
onClose: async () => {
isProcessing.value = false;
await reloadServerInfo();
router.push("/setup/create-admin");
},
});
}
</script>
+4
View File
@@ -18,6 +18,10 @@ export default defineConfig(({ mode }) => {
target: `http://localhost:${apiPort}`,
changeOrigin: true,
},
"/manifest.json": {
target: `http://localhost:${apiPort}`,
changeOrigin: true,
},
},
},
resolve: {
@@ -8,6 +8,8 @@ export default interface ServerInfo {
"server-info": {
body: never;
response: (Success & {
name: string | null;
description: string | null;
isInitialized: boolean;
isFirstAdminExists: boolean;
userCount: number;
@@ -5,7 +5,7 @@ import Success from "../../modules/response/success";
import UnknownError from "../../modules/error/unknown";
export default interface SetupInitialization {
"setup/initilization": {
"setup/initialization": {
body: {
name: string;
description: string;