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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user