Compare commits
31 Commits
e36fc53df8
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
f8a838f5d7 | ||
|
ff55062850 | ||
|
c94dd8b761 | ||
|
06f8b9da85 | ||
|
bbe6dbb2ca | ||
|
3c02691511 | ||
|
76367bbd25 | ||
|
8eb28f0115 | ||
|
79cd1129fd | ||
|
4c929a14fa | ||
|
2d3f8a8bd7 | ||
|
cbcb007517 | ||
|
f5603ad884 | ||
|
202b6c1a10 | ||
|
c1f42ed4f7 | ||
|
634347a958 | ||
|
9039dfb34e | ||
|
0b24b7cc05 | ||
|
4e4bc3345d | ||
|
4a2ff8fce6 | ||
|
3a42c58013 | ||
|
b6f50bbf53 | ||
|
21b2f1e555 | ||
|
7e01edffbe | ||
|
054e349e6b | ||
|
9bc387cb86 | ||
|
49bd6ea363 | ||
|
9ad11f4297 | ||
|
8af78a26ba | ||
|
031c3dee3b | ||
|
1f3f4a7370 |
41
README.md
41
README.md
@@ -1,39 +1,6 @@
|
||||
# @hydrogen/solaragent
|
||||
# SolarAgent
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
Hola! This is Project Hydrogen's universal frontend.
|
||||
Integrated support for Identity, Interactive and Messaging!
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
bun build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
bun lint
|
||||
```
|
||||
Also provide a mobile version that powered by capacitor!
|
@@ -5,6 +5,7 @@
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" type="image/png" href="/apple-touch-icon.png" sizes="1024x1024">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<script src="https://meet.element.io/external_api.js"></script>
|
||||
<title>Solian</title>
|
||||
</head>
|
||||
<body>
|
||||
|
@@ -20,12 +20,15 @@
|
||||
"@capacitor/preferences": "^5.0.7",
|
||||
"@fontsource/roboto": "^5.0.12",
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.0.11",
|
||||
"marked": "^12.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
"universal-cookie": "^7.1.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-advanced-cropper": "^2.8.8",
|
||||
"vue-easy-lightbox": "^1.19.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"vuetify": "^3.5.12"
|
||||
@@ -38,6 +41,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^20.11.28",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/pulltorefreshjs": "^0.1.7",
|
||||
"@unocss/reset": "^0.58.7",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
@@ -48,6 +52,7 @@
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"npm-run-all2": "^6.1.2",
|
||||
"prettier": "^3.0.3",
|
||||
"pulltorefreshjs": "^0.1.22",
|
||||
"typescript": "~5.4.0",
|
||||
"unocss": "^0.58.7",
|
||||
"vite": "^5.1.6",
|
||||
|
@@ -11,6 +11,7 @@ body,
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
html, body {
|
||||
|
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn flat exact v-bind="props" icon>
|
||||
<v-avatar color="transparent" icon="mdi-account-circle" :image="'/api/avatar/' + id.userinfo.data?.avatar" />
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list density="compact" v-if="!id.userinfo.isLoggedIn">
|
||||
<v-list-item title="Sign in" prepend-icon="mdi-login-variant" :to="{ name: 'auth.sign-in' }" />
|
||||
<v-list-item title="Create account" prepend-icon="mdi-account-plus" :to="{ name: 'auth.sign-up' }" />
|
||||
</v-list>
|
||||
<v-list density="compact" v-else>
|
||||
<v-list-item :title="nickname" :subtitle="username" />
|
||||
|
||||
<v-divider class="border-opacity-50 my-2" />
|
||||
|
||||
<v-list-item title="User Center" prepend-icon="mdi-account-supervisor" exact :to="{ name: 'dashboard' }" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { computed } from "vue"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
const username = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
return "@" + id.userinfo.data?.name
|
||||
} else {
|
||||
return "@vistor"
|
||||
}
|
||||
})
|
||||
const nickname = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
return id.userinfo.data?.nick
|
||||
} else {
|
||||
return "Anonymous"
|
||||
}
|
||||
})
|
||||
</script>
|
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<v-form class="flex-grow-1" ref="chat" @submit.prevent="sendMessage">
|
||||
<v-form ref="chat" @submit.prevent="sendMessage">
|
||||
<v-expand-transition>
|
||||
<v-alert
|
||||
v-show="channels.related?.messages?.reply_to"
|
||||
class="mb-3 text-sm"
|
||||
variant="tonal"
|
||||
class="mb-2 text-sm"
|
||||
variant="elevated"
|
||||
density="compact"
|
||||
type="info"
|
||||
>
|
||||
@@ -20,7 +20,7 @@
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
color="info"
|
||||
color="white"
|
||||
variant="text"
|
||||
@click="channels.related.messages.reply_to = null"
|
||||
/>
|
||||
@@ -31,8 +31,8 @@
|
||||
<v-expand-transition>
|
||||
<v-alert
|
||||
v-show="channels.related?.messages?.edit_to"
|
||||
class="mb-3 text-sm"
|
||||
variant="tonal"
|
||||
class="mb-2 text-sm"
|
||||
variant="elevated"
|
||||
density="compact"
|
||||
type="info"
|
||||
>
|
||||
@@ -48,7 +48,7 @@
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
color="info"
|
||||
color="white"
|
||||
variant="text"
|
||||
@click="channels.related.messages.edit_to = null"
|
||||
/>
|
||||
@@ -60,18 +60,16 @@
|
||||
auto-grow
|
||||
hide-details
|
||||
class="w-full"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
density="comfortable"
|
||||
placeholder="Enter some messages..."
|
||||
:rows="1"
|
||||
:max-rows="6"
|
||||
:loading="loading"
|
||||
v-model="data.content"
|
||||
@keyup.ctrl.enter="sendMessage"
|
||||
@keyup.meta.enter="sendMessage"
|
||||
@keydown="onEditorKeydown"
|
||||
@paste="pasteMedia"
|
||||
>
|
||||
<template #append>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
icon
|
||||
type="button"
|
||||
@@ -79,7 +77,7 @@
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="loading"
|
||||
@click="dialogs.attachments = true"
|
||||
@click.stop="dialogs.attachments = true"
|
||||
>
|
||||
<v-badge v-if="data.attachments.length > 0" :content="data.attachments.length">
|
||||
<v-icon icon="mdi-paperclip" />
|
||||
@@ -88,7 +86,7 @@
|
||||
<v-icon v-else icon="mdi-paperclip" />
|
||||
</v-btn>
|
||||
|
||||
<v-btn type="submit" icon="mdi-send" size="small" variant="text" :disabled="loading" />
|
||||
<v-btn type="submit" icon="mdi-send" size="small" variant="text" @click.stop :disabled="loading" />
|
||||
</template>
|
||||
</v-textarea>
|
||||
|
||||
@@ -103,9 +101,6 @@
|
||||
Uploading your media, please stand by...
|
||||
<v-progress-linear class="snackbar-progress" indeterminate />
|
||||
</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
@@ -114,15 +109,15 @@ import { reactive, ref, watch } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import Attachments from "@/components/chat/parts/ChatAttachments.vue"
|
||||
import Media from "@/components/publish/parts/PublishMedia.vue"
|
||||
|
||||
const emits = defineEmits(["sent"])
|
||||
|
||||
const chat = ref<HTMLFormElement>()
|
||||
const channels = useChannels()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const { showErrorSnackbar } = useUI()
|
||||
const uploading = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -139,7 +134,7 @@ const data = ref<any>({
|
||||
})
|
||||
|
||||
async function sendMessage() {
|
||||
if (!data.value.content) return
|
||||
if (!data.value.content && !data.value.attachments) return
|
||||
|
||||
const url = channels.related.messages.edit_to
|
||||
? `/api/channels/${channels.current.alias}/messages/${channels.related.messages.edit_to?.id}`
|
||||
@@ -156,20 +151,27 @@ async function sendMessage() {
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
emits("sent")
|
||||
resetEditor()
|
||||
error.value = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function onEditorKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "enter") {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => channels.related.messages.reply_to,
|
||||
(val) => {
|
||||
if (val) {
|
||||
data.value.reply_id = val.id
|
||||
} else {
|
||||
data.value.reply_id = null
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -179,6 +181,8 @@ watch(
|
||||
(val) => {
|
||||
if (val) {
|
||||
data.value = val
|
||||
} else {
|
||||
resetEditor()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -203,10 +207,3 @@ function pasteMedia(evt: ClipboardEvent) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.snackbar-progress {
|
||||
margin: 12px -16px -14px;
|
||||
width: calc(100% + 64px);
|
||||
}
|
||||
</style>
|
||||
|
@@ -2,11 +2,9 @@
|
||||
<div class="relative transition-colors transition-300 message-item">
|
||||
<a v-if="props.item?.reply_to" :href="`#m${props.item?.reply_to.id}`">
|
||||
<div class="pl-2 mb-0.5 text-sm opacity-80 flex items-center">
|
||||
<v-icon icon="mdi-reply" class="me-2" />
|
||||
<v-icon icon="mdi-reply" class="me-2 mb-1" />
|
||||
<v-avatar size="18" class="me-1.5" :image="replyingFromPicture"></v-avatar>
|
||||
<span class="me-1 text-xs overflow-hidden ws-nowarp text-ellipsis">{{ props.item?.reply_to?.content }}</span>
|
||||
<span class="text-xs overflow-hidden ws-nowarp text-ellipsis">
|
||||
from {{ props.item?.reply_to?.sender.account.name }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -21,8 +19,17 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-bold text-sm">{{ props.item?.sender.account.nick }}</div>
|
||||
<div>{{ props.item?.content }}</div>
|
||||
<div class="flex gap-1.25 text-sm items-baseline">
|
||||
<span class="font-bold">{{ props.item?.sender.account.nick }}</span>
|
||||
<span class="opacity-80">{{ createdAt }}</span>
|
||||
<span class="opacity-60 text-xs">#{{ props.item?.id }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.item?.content"
|
||||
class="prose prose-message max-w-none"
|
||||
v-html="parseContent(props.item?.content ?? '')"
|
||||
/>
|
||||
|
||||
<message-attachment
|
||||
v-if="props.item?.attachments && props.item?.attachments.length > 0"
|
||||
@@ -35,8 +42,10 @@
|
||||
<v-card>
|
||||
<div class="flex px-2 py-0.5">
|
||||
<v-btn icon="mdi-reply" size="x-small" variant="text" @click="replyMessage" />
|
||||
<v-btn v-if="isOwned" icon="mdi-pencil" size="x-small" variant="text" color="warning" @click="editMessage" />
|
||||
<v-btn v-if="isOwned" icon="mdi-delete" size="x-small" variant="text" color="error" @click="deleteMessage" />
|
||||
<v-btn v-if="isOwned" icon="mdi-pencil" size="x-small" variant="text" color="warning"
|
||||
@click="editMessage" />
|
||||
<v-btn v-if="isOwned" icon="mdi-delete" size="x-small" variant="text" color="error"
|
||||
@click="deleteMessage" />
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
@@ -48,6 +57,10 @@
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { computed } from "vue"
|
||||
import { parse } from "marked"
|
||||
import dayjs from "dayjs"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import dompurify from "dompurify"
|
||||
import MessageAttachment from "@/components/chat/renderer/MessageAttachment.vue"
|
||||
|
||||
const id = useUserinfo()
|
||||
@@ -55,7 +68,15 @@ const channels = useChannels()
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
|
||||
const isOwned = computed(() => props.item?.sender?.id === id.userinfo.idSet.messaging)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const isOwned = computed(() => props.item?.sender?.account_id === id.userinfo.idSet.messaging)
|
||||
const createdAt = computed(() => dayjs(props.item?.created_at).fromNow())
|
||||
|
||||
const replyingFromPicture = computed(() => props.item?.reply_to.sender.account?.avatar ?
|
||||
props.item?.reply_to.sender.account?.avatar :
|
||||
null
|
||||
)
|
||||
|
||||
function replyMessage() {
|
||||
channels.related.messages.reply_to = JSON.parse(JSON.stringify(props.item))
|
||||
@@ -70,6 +91,10 @@ function deleteMessage() {
|
||||
channels.related.messages.delete_to.channel = channels.current
|
||||
channels.show.messages.delete = true
|
||||
}
|
||||
|
||||
function parseContent(src: string): string {
|
||||
return dompurify().sanitize(parse(src) as string)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -80,7 +105,7 @@ function deleteMessage() {
|
||||
.message-action {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: -25%;
|
||||
top: -18px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -92,3 +117,9 @@ function deleteMessage() {
|
||||
opacity: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.prose.prose-message p {
|
||||
margin: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
@@ -12,11 +12,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar v-model="success" :timeout="3000">The realm has been deleted.</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -24,11 +19,11 @@ import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { ref } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const channels = useChannels()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
|
||||
async function deleteMessage() {
|
||||
@@ -41,9 +36,9 @@ async function deleteMessage() {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
success.value = true
|
||||
showSnackbar("The message has been deleted.")
|
||||
channels.show.messages.delete = false
|
||||
channels.related.messages.delete_to = null
|
||||
}
|
||||
|
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" icon="mdi-cog" variant="text" />
|
||||
<v-btn v-bind="props" icon="mdi-cog" size="small" variant="text" />
|
||||
</template>
|
||||
|
||||
<v-list density="compact" lines="one">
|
||||
<v-list-item disabled append-icon="mdi-flag" title="Report" />
|
||||
<v-list-item v-if="isOwned" append-icon="mdi-pencil" title="Edit" @click="editChannel" />
|
||||
<v-list-item v-if="isOwned" append-icon="mdi-account-supervisor-circle" title="Members" @click="manageChannel" />
|
||||
<v-list-item v-if="isOwned" append-icon="mdi-delete" title="Delete" @click="deleteChannel" />
|
||||
<v-list-item v-if="isOwned" append-icon="mdi-account-supervisor-circle" title="Members" @click="manageChannel" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
@@ -12,11 +12,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar v-model="success" :timeout="3000">The realm has been deleted.</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -25,6 +20,7 @@ import { getAtk } from "@/stores/userinfo"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { ref } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -32,8 +28,7 @@ const channels = useChannels()
|
||||
|
||||
const emits = defineEmits(["relist"])
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
|
||||
async function deleteChannel() {
|
||||
@@ -46,9 +41,9 @@ async function deleteChannel() {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
success.value = true
|
||||
showSnackbar("The channel has been deleted.")
|
||||
channels.show.delete = false
|
||||
channels.related.delete_to = null
|
||||
emits("relist")
|
||||
|
@@ -15,9 +15,6 @@
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -25,12 +22,13 @@ import { ref, watch } from "vue"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { request } from "@/scripts/request"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const emits = defineEmits(["relist"])
|
||||
|
||||
const channels = useChannels()
|
||||
|
||||
const error = ref<null | string>(null)
|
||||
const {showErrorSnackbar} = useUI()
|
||||
const loading = ref(false)
|
||||
|
||||
const data = ref({
|
||||
@@ -54,7 +52,7 @@ async function submit(evt: SubmitEvent) {
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
emits("relist")
|
||||
form.reset()
|
||||
|
@@ -2,11 +2,13 @@
|
||||
<v-card prepend-icon="mdi-account-plus" title="Invite someone">
|
||||
<v-form @submit.prevent="inviteMember">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
<v-autocomplete
|
||||
label="Username"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hint="Require username not the nickname"
|
||||
autocomplete="off"
|
||||
hide-selected
|
||||
:items="friends.available.map(x => getOtherside(x).name)"
|
||||
v-model="targetName"
|
||||
/>
|
||||
</v-card-text>
|
||||
@@ -23,11 +25,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { getAtk, useUserinfo } from "@/stores/userinfo"
|
||||
import { useFriends } from "@/stores/friends"
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
const emits = defineEmits(["close", "error", "relist"])
|
||||
|
||||
const id = useUserinfo()
|
||||
const friends = useFriends()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const targetName = ref("")
|
||||
@@ -51,4 +57,12 @@ async function inviteMember(evt: SubmitEvent) {
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function getOtherside(item: any) {
|
||||
if (item.account_id === id.userinfo.data?.id) {
|
||||
return item.related
|
||||
} else {
|
||||
return item.account
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-list-group value="channels">
|
||||
<v-list-group class="channels-list" value="channels">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
@@ -28,7 +28,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { useRealms } from "@/stores/realms"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
@@ -38,16 +38,13 @@
|
||||
<channel-invitation
|
||||
:item="props.item"
|
||||
@relist="listMembers"
|
||||
@error="(val) => (error = val)"
|
||||
@error="(val) => (showErrorSnackbar(val))"
|
||||
@close="isActive.value = false"
|
||||
/>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -57,6 +54,7 @@ import { request } from "@/scripts/request"
|
||||
import { getAtk, useUserinfo } from "@/stores/userinfo"
|
||||
import { computed } from "vue"
|
||||
import ChannelInvitation from "@/components/chat/channels/ChannelInvitation.vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
@@ -68,8 +66,8 @@ const isOwned = computed(() => {
|
||||
return id.userinfo.idSet?.messaging === props.item?.account_id
|
||||
})
|
||||
|
||||
const { showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.item,
|
||||
@@ -85,9 +83,8 @@ async function listMembers(id: number) {
|
||||
loading.value = true
|
||||
const res = await request("messaging", `/api/channels/${id}/members`)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
error.value = null
|
||||
members.value = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
@@ -103,7 +100,7 @@ async function kickMember(item: any) {
|
||||
})
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
await listMembers(props.item?.id)
|
||||
}
|
||||
|
@@ -23,13 +23,13 @@
|
||||
<video v-else-if="item.type === 2" controls class="w-full content-visibility-auto">
|
||||
<source :src="getUrl(item)" />
|
||||
</video>
|
||||
<div v-else-if="item.type === 3" class="w-[480px] py-12">
|
||||
<div v-else-if="item.type === 3" class="py-5 px-2">
|
||||
<div class="text-center">
|
||||
<p class="mb-1">{{ getFileName(item) }}</p>
|
||||
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
|
||||
<audio controls :src="getUrl(item)" class="mx-auto max-w-[85%]"></audio>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-[480px] py-12">
|
||||
<div v-else class="py-5 px-2">
|
||||
<div class="text-center">
|
||||
<p>{{ getFileName(item) }}</p>
|
||||
<a class="underline" target="_blank" :href="getUrl(item)">Download</a>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="text-xs text-center opacity-80">
|
||||
<p>Copyright © {{ new Date().getFullYear() }} Solsynth</p>
|
||||
<p>Powered by <a class="underline" href="#">Hydrogen.Identity</a></p>
|
||||
<p>Powered by <a class="underline" href="#">Hydrogen</a></p>
|
||||
</div>
|
||||
</template>
|
28
src/components/common/SnackbarProvider.vue
Normal file
28
src/components/common/SnackbarProvider.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<v-snackbar v-model="ui.snackbar" v-bind="ui.snackbar">
|
||||
<div v-html="ui.snackbar.content"></div>
|
||||
<v-progress-linear v-if="ui.snackbar.loading" class="snackbar-progress" indeterminate />
|
||||
</v-snackbar>
|
||||
|
||||
<v-snackbar v-model="ui.reconnection.messages">
|
||||
<div>Reconnecting with messaging server...</div>
|
||||
<v-progress-linear v-if="ui.snackbar.loading" class="snackbar-progress" indeterminate />
|
||||
</v-snackbar>
|
||||
<v-snackbar v-model="ui.reconnection.notifications">
|
||||
<div>Reconnecting with notifications server...</div>
|
||||
<v-progress-linear v-if="ui.snackbar.loading" class="snackbar-progress" indeterminate />
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const ui = useUI()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.snackbar-progress {
|
||||
margin: 12px -16px -14px;
|
||||
width: calc(100% + 64px);
|
||||
}
|
||||
</style>
|
63
src/components/friends/FriendListItem.vue
Normal file
63
src/components/friends/FriendListItem.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<v-list-item :title="otherside.nick">
|
||||
<template #subtitle>@{{ otherside.name }}</template>
|
||||
<template #prepend>
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card me-2"
|
||||
size="small"
|
||||
:image="othersidePicture"
|
||||
/>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-check"
|
||||
size="x-small"
|
||||
color="success"
|
||||
variant="text"
|
||||
:disabled="!canApprove"
|
||||
@click="emits('approve')"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
color="error"
|
||||
variant="text"
|
||||
:disabled="!canDecline"
|
||||
@click="emits('decline')"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { buildRequestUrl } from "@/scripts/request"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
const emits = defineEmits(["approve", "decline"])
|
||||
|
||||
const canApprove = computed(() => {
|
||||
return props.item.status === 2 ||
|
||||
(props.item.status === 0 && props.item.related_id === id.userinfo.data?.id)
|
||||
})
|
||||
const canDecline = computed(() => {
|
||||
return props.item.status !== 2
|
||||
})
|
||||
|
||||
const otherside = computed(() => {
|
||||
if (props.item.account_id === id.userinfo.data?.id) {
|
||||
return props.item.related
|
||||
} else {
|
||||
return props.item.account
|
||||
}
|
||||
})
|
||||
const othersidePicture = computed(() => otherside.value?.avatar ?
|
||||
buildRequestUrl("identity", `/api/avatar/${otherside.value?.avatar}`) :
|
||||
undefined
|
||||
)
|
||||
</script>
|
150
src/components/navigation/NavigationDrawer.vue
Normal file
150
src/components/navigation/NavigationDrawer.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
v-model="ui.drawer.open"
|
||||
floating
|
||||
color="grey-lighten-5"
|
||||
width="320"
|
||||
:permanent="isLargeScreen"
|
||||
:rail="ui.drawer.mini"
|
||||
:rail-width="58"
|
||||
:order="0"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<v-toolbar
|
||||
class="flex items-center justify-between px-[14px] border-opacity-15"
|
||||
color="primary"
|
||||
height="64"
|
||||
:style="`padding-top: ${safeAreaTop}`"
|
||||
@click="expandDrawer"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<img src="/favicon.png" alt="Logo" width="32" height="32" class="block" />
|
||||
<div v-show="!ui.drawer.mini" class="ms-6 font-medium">Solar Network</div>
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<div>
|
||||
<v-btn
|
||||
v-if="isLargeScreen"
|
||||
v-show="!ui.drawer.mini"
|
||||
icon="mdi-arrow-collapse-left"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="ui.drawer.mini = true"
|
||||
/>
|
||||
</div>
|
||||
</v-toolbar>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<v-list
|
||||
class="nav-list"
|
||||
density="compact"
|
||||
:opened="ui.drawer.mini ? [] : expanded.nav"
|
||||
@update:opened="(val) => expanded.nav = val"
|
||||
>
|
||||
<v-list-item
|
||||
exact
|
||||
title="Explore"
|
||||
prepend-icon="mdi-compass"
|
||||
:to="{ name: 'explore' }"
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="border-opacity-75 my-2" />
|
||||
|
||||
<v-list
|
||||
class="resources-list"
|
||||
density="compact"
|
||||
:opened="ui.drawer.mini ? [] : expanded.resources"
|
||||
@update:opened="(val) => expanded.resources = val"
|
||||
@click="expandDrawer"
|
||||
>
|
||||
<channel-list />
|
||||
<realm-list />
|
||||
</v-list>
|
||||
</div>
|
||||
|
||||
<!-- User info -->
|
||||
<v-list
|
||||
class="bg-grey-lighten-3"
|
||||
:style="`margin-bottom: ${safeAreaBottom}`"
|
||||
@click="expandDrawer"
|
||||
>
|
||||
<v-list-item :subtitle="username" :title="nickname">
|
||||
<template #prepend>
|
||||
<v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" />
|
||||
</template>
|
||||
<template #append>
|
||||
<notification-list v-if="id.userinfo.isLoggedIn" />
|
||||
<user-menu />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from "vue"
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import { useRealms } from "@/stores/realms"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { useMediaQuery } from "@vueuse/core"
|
||||
import PullToRefresh from "pulltorefreshjs"
|
||||
import UserMenu from "@/components/users/UserMenu.vue"
|
||||
import RealmList from "@/components/realms/RealmList.vue"
|
||||
import ChannelList from "@/components/chat/channels/ChannelList.vue"
|
||||
import NotificationList from "@/components/users/NotificationList.vue"
|
||||
|
||||
const ui = useUI()
|
||||
const expanded = reactive<{ [id: string]: string[] }>({
|
||||
nav: [],
|
||||
resources: ["channels", "realms"]
|
||||
})
|
||||
|
||||
const isLargeScreen = useMediaQuery("(min-width: 768px)")
|
||||
|
||||
const safeAreaTop = computed(() => {
|
||||
return `${ui.safeArea.top}px`
|
||||
})
|
||||
|
||||
const safeAreaBottom = computed(() => {
|
||||
return `${ui.safeArea.bottom}px`
|
||||
})
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
const username = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
return "@" + id.userinfo.data?.name
|
||||
} else {
|
||||
return "@vistor"
|
||||
}
|
||||
})
|
||||
const nickname = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
return id.userinfo.data?.nick
|
||||
} else {
|
||||
return "Anonymous"
|
||||
}
|
||||
})
|
||||
|
||||
function expandDrawer() {
|
||||
ui.drawer.mini = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
PullToRefresh.init({
|
||||
mainElement: ".resources-list",
|
||||
triggerElement: ".resources-list",
|
||||
onRefresh() {
|
||||
return Promise.all([
|
||||
useRealms().list(),
|
||||
useChannels().list()
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
@@ -22,7 +22,7 @@ const editor = useEditor()
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
|
||||
const isOwned = computed(() => props.item?.author_id === id.userinfo.data.id)
|
||||
const isOwned = computed(() => props.item?.author_id === id.userinfo.idSet.interactive)
|
||||
|
||||
function editPost() {
|
||||
editor.related.edit_to = JSON.parse(JSON.stringify(props.item))
|
||||
|
@@ -22,13 +22,13 @@
|
||||
<video v-else-if="item.type === 2" controls class="w-full content-visibility-auto">
|
||||
<source :src="getUrl(item)" />
|
||||
</video>
|
||||
<div v-else-if="item.type === 3" class="w-[480px] py-12">
|
||||
<div v-else-if="item.type === 3" class="py-5 px-2">
|
||||
<div class="text-center">
|
||||
<p class="mb-1">{{ getFileName(item) }}</p>
|
||||
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
|
||||
<audio controls :src="getUrl(item)" class="mx-auto max-w-[85%]"></audio>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-[480px] py-12">
|
||||
<div v-else class="py-5 px-2">
|
||||
<div class="text-center">
|
||||
<p>{{ getFileName(item) }}</p>
|
||||
<a class="underline" target="_blank" :href="getUrl(item)">Download</a>
|
||||
|
@@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<div class="flex gap-3">
|
||||
<div>
|
||||
<router-link :to="{ name: 'users.page', params: { alias: props.item?.author.name ?? 'ghost' } }">
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card"
|
||||
:image="props.item?.author.avatar"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<v-infinite-scroll :items="props.posts" :onLoad="props.loader">
|
||||
<template v-for="(item, idx) in props.posts" :key="item.id">
|
||||
<div class="mb-3 px-[8px]">
|
||||
<v-card>
|
||||
<v-card :variant="props.variant ?? 'elevated'">
|
||||
<template #text>
|
||||
<post-item brief :item="item" @update:item="(val) => updateItem(idx, val)" />
|
||||
</template>
|
||||
@@ -17,7 +17,7 @@
|
||||
<script setup lang="ts">
|
||||
import PostItem from "@/components/posts/PostItem.vue"
|
||||
|
||||
const props = defineProps<{ posts: any[]; loader: (opts: any) => Promise<any> }>()
|
||||
const props = defineProps<{ variant?: any, posts: any[]; loader: (opts: any) => Promise<any> }>()
|
||||
const emits = defineEmits(["update:posts"])
|
||||
|
||||
function updateItem(idx: number, data: any) {
|
||||
|
@@ -26,12 +26,6 @@
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-snackbar v-model="status.added" :timeout="3000">Your react has been added into post.</v-snackbar>
|
||||
<v-snackbar v-model="status.removed" :timeout="3000">Your react has been removed from post.</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,8 +33,10 @@
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk, useUserinfo } from "@/stores/userinfo"
|
||||
import { reactive, ref } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const id = useUserinfo()
|
||||
const {showSnackbar, showErrorSnackbar} = useUI()
|
||||
|
||||
const emits = defineEmits(["update"])
|
||||
const props = defineProps<{
|
||||
@@ -62,9 +58,6 @@ function pickColor(): string {
|
||||
return colors[randomIndex]
|
||||
}
|
||||
|
||||
const status = reactive({ added: false, removed: false })
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function reactPost(symbol: string, attitude: number) {
|
||||
const res = await request("interactive", `/api/p/${props.model}/${props.item?.id}/react`, {
|
||||
method: "POST",
|
||||
@@ -72,13 +65,13 @@ async function reactPost(symbol: string, attitude: number) {
|
||||
body: JSON.stringify({ symbol, attitude })
|
||||
})
|
||||
if (res.status === 201) {
|
||||
status.added = true
|
||||
showSnackbar("Your react has been added onto the post.")
|
||||
emits("update", symbol, 1)
|
||||
} else if (res.status === 204) {
|
||||
status.removed = true
|
||||
showSnackbar("Your react has been removed from the post.")
|
||||
emits("update", symbol, -1)
|
||||
} else {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@@ -109,14 +109,10 @@
|
||||
<media ref="media" v-model:show="dialogs.media" v-model:uploading="uploading" v-model:value="data.attachments" />
|
||||
<publish-area v-model:show="dialogs.area" v-model:value="data.realm_id" />
|
||||
|
||||
<v-snackbar v-model="success" :timeout="3000">Your article has been published.</v-snackbar>
|
||||
<v-snackbar v-model="uploading" :timeout="-1">
|
||||
Uploading your media, please stand by...
|
||||
<v-progress-linear class="snackbar-progress" indeterminate />
|
||||
</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -129,6 +125,7 @@ import { useRoute, useRouter } from "vue-router"
|
||||
import PlannedPublish from "@/components/publish/parts/PlannedPublish.vue"
|
||||
import Media from "@/components/publish/parts/PublishMedia.vue"
|
||||
import PublishArea from "@/components/publish/parts/PublishArea.vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const route = useRoute()
|
||||
const realms = useRealms()
|
||||
@@ -160,10 +157,9 @@ const currentRealm = computed(() => {
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const reverting = ref(false)
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
const reverting = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
async function postArticle(evt: SubmitEvent) {
|
||||
@@ -187,15 +183,15 @@ async function postArticle(evt: SubmitEvent) {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status === 200) {
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
const data = await res.json()
|
||||
success.value = true
|
||||
showSnackbar("Your article has been published.")
|
||||
editor.show.article = false
|
||||
|
||||
resetEditor(form)
|
||||
router.push({ name: "posts.details.articles", params: { alias: data.alias } })
|
||||
} else {
|
||||
error.value = await res.text()
|
||||
await router.push({ name: "posts.details.articles", params: { alias: data.alias } })
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
@@ -19,11 +19,6 @@
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar v-model="success" :timeout="3000">Your comment has been published.</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -31,7 +26,9 @@ import { request } from "@/scripts/request"
|
||||
import { useEditor } from "@/stores/editor"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const editor = useEditor()
|
||||
|
||||
const target = computed<any>(() => editor.related.comment_to)
|
||||
@@ -43,8 +40,6 @@ const postIdentifier = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const data = ref<any>({
|
||||
@@ -70,10 +65,10 @@ async function postComment(evt: SubmitEvent) {
|
||||
})
|
||||
if (res.status === 200) {
|
||||
form.reset()
|
||||
success.value = true
|
||||
showSnackbar("Your comment has been published.")
|
||||
editor.show.comment = false
|
||||
} else {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
}
|
||||
loading.value = false
|
||||
editor.done = true
|
||||
|
@@ -75,14 +75,10 @@
|
||||
<media ref="media" v-model:show="dialogs.media" v-model:uploading="uploading" v-model:value="data.attachments" />
|
||||
<publish-area v-model:show="dialogs.area" v-model:value="data.realm_id" />
|
||||
|
||||
<v-snackbar v-model="success" :timeout="3000">Your post has been published.</v-snackbar>
|
||||
<v-snackbar v-model="uploading" :timeout="-1">
|
||||
Uploading your media, please stand by...
|
||||
<v-progress-linear class="snackbar-progress" indeterminate />
|
||||
</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -94,6 +90,7 @@ import { useRoute, useRouter } from "vue-router"
|
||||
import PlannedPublish from "@/components/publish/parts/PlannedPublish.vue"
|
||||
import PublishArea from "@/components/publish/parts/PublishArea.vue"
|
||||
import Media from "@/components/publish/parts/PublishMedia.vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const route = useRoute()
|
||||
const editor = useEditor()
|
||||
@@ -111,8 +108,7 @@ const data = ref<any>({
|
||||
attachments: []
|
||||
})
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
@@ -135,14 +131,15 @@ async function postMoment(evt: SubmitEvent) {
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status === 200) {
|
||||
resetEditor(form)
|
||||
const data = await res.json()
|
||||
success.value = true
|
||||
editor.show.moment = false
|
||||
|
||||
resetEditor(form)
|
||||
router.push({ name: "posts.details.moments", params: { alias: data.alias } })
|
||||
showSnackbar("Your post has been published.")
|
||||
|
||||
await router.push({ name: "posts.details.moments", params: { alias: data.alias } })
|
||||
} else {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
@@ -12,11 +12,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar v-model="success" :timeout="3000">The post has been deleted.</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -24,11 +19,11 @@ import { request } from "@/scripts/request"
|
||||
import { useEditor } from "@/stores/editor"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { ref } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const editor = useEditor()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const {showSnackbar, showErrorSnackbar} = useUI()
|
||||
const loading = ref(false)
|
||||
|
||||
async function deletePost() {
|
||||
@@ -41,9 +36,9 @@ async function deletePost() {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
success.value = true
|
||||
showSnackbar("The post has been deleted.")
|
||||
editor.show.delete = false
|
||||
editor.related.delete_to = null
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
eager
|
||||
class="max-w-[540px]"
|
||||
:model-value="props.show"
|
||||
@update:model-value="(val) => emits('update:show', val)"
|
||||
|
@@ -12,11 +12,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar v-model="success" :timeout="3000">The realm has been deleted.</v-snackbar>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -25,15 +20,15 @@ import { useRealms } from "@/stores/realms"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { ref } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const realms = useRealms()
|
||||
|
||||
const emits = defineEmits(["relist"])
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
async function deleteRealm() {
|
||||
@@ -46,9 +41,9 @@ async function deleteRealm() {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
success.value = true
|
||||
showSnackbar("The realm has been deleted.")
|
||||
realms.show.delete = false
|
||||
realms.related.delete_to = null
|
||||
emits("relist")
|
||||
|
@@ -22,9 +22,6 @@
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -32,6 +29,7 @@ import { ref, watch } from "vue"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { useRealms } from "@/stores/realms"
|
||||
import { request } from "@/scripts/request"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const emits = defineEmits(["relist"])
|
||||
|
||||
@@ -43,7 +41,7 @@ const realmTypeOptions = [
|
||||
{ label: "Private Realm", value: 2 }
|
||||
]
|
||||
|
||||
const error = ref<null | string>(null)
|
||||
const { showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
|
||||
const data = ref({
|
||||
@@ -67,7 +65,7 @@ async function submit(evt: SubmitEvent) {
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
emits("relist")
|
||||
form.reset()
|
||||
|
@@ -2,11 +2,13 @@
|
||||
<v-card prepend-icon="mdi-account-plus" title="Invite someone">
|
||||
<v-form @submit.prevent="inviteMember">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
<v-autocomplete
|
||||
label="Username"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hint="Require username not the nickname"
|
||||
autocomplete="off"
|
||||
hide-selected
|
||||
:items="friends.available.map(x => getOtherside(x).name)"
|
||||
v-model="targetName"
|
||||
/>
|
||||
</v-card-text>
|
||||
@@ -23,11 +25,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { getAtk, useUserinfo } from "@/stores/userinfo"
|
||||
import { useFriends } from "@/stores/friends"
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
const emits = defineEmits(["close", "error", "relist"])
|
||||
|
||||
const id = useUserinfo()
|
||||
const friends = useFriends()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const targetName = ref("")
|
||||
@@ -51,4 +57,12 @@ async function inviteMember(evt: SubmitEvent) {
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function getOtherside(item: any) {
|
||||
if (item.account_id === id.userinfo.data?.id) {
|
||||
return item.related
|
||||
} else {
|
||||
return item.account
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-list-group value="realms">
|
||||
<v-list-group class="realms-list" value="realms">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
|
@@ -38,16 +38,13 @@
|
||||
<realm-invitation
|
||||
:item="props.item"
|
||||
@relist="listMembers"
|
||||
@error="(val) => (error = val)"
|
||||
@error="(val) => (showErrorSnackbar(val))"
|
||||
@close="isActive.value = false"
|
||||
/>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,6 +54,7 @@ import { request } from "@/scripts/request"
|
||||
import { getAtk, useUserinfo } from "@/stores/userinfo"
|
||||
import { computed } from "vue"
|
||||
import RealmInvitation from "@/components/realms/RealmInvitation.vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
@@ -65,11 +63,11 @@ const props = defineProps<{ item: any }>()
|
||||
const members = ref<any[]>([])
|
||||
|
||||
const isOwned = computed(() => {
|
||||
return id.userinfo.data?.id === props.item?.account_id
|
||||
return id.userinfo.idSet?.interactive === props.item?.account_id
|
||||
})
|
||||
|
||||
const { showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.item,
|
||||
@@ -85,9 +83,8 @@ async function listMembers(id: number) {
|
||||
loading.value = true
|
||||
const res = await request("interactive", `/api/realms/${id}/members`)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
error.value = null
|
||||
members.value = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
@@ -103,7 +100,7 @@ async function kickMember(item: any) {
|
||||
})
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
await listMembers(props.item?.id)
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<v-menu eager :close-on-content-click="false">
|
||||
<v-menu location="top" :close-on-content-click="false">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" icon size="small" variant="text" :loading="loading">
|
||||
<v-btn v-bind="props" icon size="small" color="teal" variant="text" :loading="loading">
|
||||
<v-badge v-if="notify.total > 0" color="error" :content="notify.total">
|
||||
<v-icon icon="mdi-bell" />
|
||||
</v-badge>
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
<v-list v-if="notify.notifications.length <= 0" class="w-[380px]" density="compact">
|
||||
<v-list-item>
|
||||
<v-alert class="text-sm" variant="tonal" type="info">You are done! There is no unread notifications for you.</v-alert>
|
||||
<v-alert class="text-sm" variant="tonal" type="info">You are done! There is no unread notifications for you.
|
||||
</v-alert>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
@@ -31,34 +32,36 @@
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useNotifications } from "@/stores/notifications";
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue"
|
||||
import { useNotifications } from "@/stores/notifications"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const notify = useNotifications()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const { showErrorSnackbar } = useUI()
|
||||
const submitting = ref(false)
|
||||
const loading = computed(() => notify.loading || submitting.value)
|
||||
|
||||
async function markAsRead(item: any, idx: number) {
|
||||
if (item.is_realtime) {
|
||||
notify.remove(idx)
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const res = await request("identity", `/api/notifications/${item.id}/read`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
notify.remove(idx)
|
||||
error.value = null
|
||||
}
|
||||
submitting.value = false
|
||||
}
|
32
src/components/users/UserMenu.vue
Normal file
32
src/components/users/UserMenu.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn icon="mdi-menu-up" size="small" variant="text" v-bind="props" />
|
||||
</template>
|
||||
|
||||
<v-list class="w-[280px]" density="compact" v-if="!id.userinfo.isLoggedIn">
|
||||
<v-list-item title="Sign in" prepend-icon="mdi-login-variant" :to="{ name: 'auth.sign-in' }" />
|
||||
<v-list-item title="Create account" prepend-icon="mdi-account-plus" :to="{ name: 'auth.sign-up' }" />
|
||||
</v-list>
|
||||
|
||||
<v-list class="w-[280px]" density="compact" v-else>
|
||||
<v-list-item title="Settings" prepend-icon="mdi-cog" exact :to="{ name: 'settings' }" />
|
||||
|
||||
<v-divider class="border-opacity-50 my-2" />
|
||||
|
||||
<v-list-item title="Sign out" prepend-icon="mdi-logout-variant" @click="signout" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { signout as signoutAccount, useUserinfo } from "@/stores/userinfo"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
async function signout() {
|
||||
signoutAccount().then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
</script>
|
@@ -4,17 +4,19 @@
|
||||
|
||||
<router-view />
|
||||
|
||||
<snackbar-provider />
|
||||
|
||||
<realm-tools />
|
||||
<channel-tools />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue"
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
import { onMounted } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import RealmTools from "@/components/realms/RealmTools.vue"
|
||||
import ChannelTools from "@/components/chat/channels/ChannelTools.vue"
|
||||
import SnackbarProvider from "@/components/common/SnackbarProvider.vue"
|
||||
|
||||
const ui = useUI()
|
||||
|
||||
|
@@ -1,50 +1,94 @@
|
||||
<template>
|
||||
<v-app-bar :order="5" color="grey-lighten-3">
|
||||
<v-app-bar :order="5" scroll-behavior="hide" color="grey-lighten-3">
|
||||
<div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center max-w-full">
|
||||
<v-app-bar-nav-icon icon="mdi-chat" :loading="loading" />
|
||||
<v-app-bar-nav-icon icon="mdi-chat" :loading="loading" :to="{ name: 'explore' }" />
|
||||
|
||||
<h2 class="ml-2 text-lg font-500 overflow-hidden ws-nowrap text-clip">{{ channels.current?.name }}</h2>
|
||||
<p class="ml-3 text-xs opacity-80 overflow-hidden ws-nowrap text-clip">{{ channels.current?.description }}</p>
|
||||
|
||||
<v-spacer />
|
||||
</div>
|
||||
|
||||
<div v-if="channels.current">
|
||||
<template v-if="channels.current" #append>
|
||||
<v-btn
|
||||
v-if="channels.call"
|
||||
icon="mdi-phone-hangup"
|
||||
size="small"
|
||||
variant="text"
|
||||
:loading="calling"
|
||||
@click="endsCall"
|
||||
/>
|
||||
<v-btn
|
||||
v-else
|
||||
icon="mdi-phone-plus"
|
||||
variant="text"
|
||||
size="small"
|
||||
:loading="calling"
|
||||
@click="makeCall"
|
||||
/>
|
||||
|
||||
<div class="me-5">
|
||||
<channel-action :item="channels.current" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
|
||||
<router-view />
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { request } from "@/scripts/request"
|
||||
import { useRoute } from "vue-router"
|
||||
import { onMounted, ref, watch } from "vue"
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import ChannelAction from "@/components/chat/channels/ChannelAction.vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
|
||||
const { showErrorSnackbar } = useUI()
|
||||
|
||||
const ui = useUI()
|
||||
const route = useRoute()
|
||||
const channels = useChannels()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const calling = ref(false)
|
||||
|
||||
async function readMetadata() {
|
||||
loading.value = true
|
||||
const res = await request("messaging", `/api/channels/${route.params.channel}`)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
error.value = null
|
||||
channels.current = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function makeCall() {
|
||||
calling.value = true
|
||||
const res = await request("messaging", `/api/channels/${route.params.channel}/calls`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
}
|
||||
calling.value = false
|
||||
}
|
||||
|
||||
async function endsCall() {
|
||||
calling.value = true
|
||||
const res = await request("messaging", `/api/channels/${route.params.channel}/calls/ongoing`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
}
|
||||
calling.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.channel,
|
||||
(val) => {
|
||||
@@ -65,8 +109,17 @@ watch(() => channels.done, (val) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(
|
||||
() => channels.current,
|
||||
(val) => {
|
||||
ui.appbar.show = !val
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
channels.current = null
|
||||
channels.messages = []
|
||||
})
|
||||
|
||||
onUnmounted(() => ui.appbar.show = true)
|
||||
</script>
|
@@ -1,96 +1,15 @@
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
v-model="drawerOpen"
|
||||
color="grey-lighten-5"
|
||||
width="320"
|
||||
:rail="drawerMini"
|
||||
:rail-width="58"
|
||||
:order="0"
|
||||
floating
|
||||
@click="drawerMini = false"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<v-toolbar
|
||||
class="flex items-center justify-between px-[14px] border-opacity-15"
|
||||
color="primary"
|
||||
height="64"
|
||||
:style="`padding-top: ${safeAreaTop}`"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<img src="/favicon.png" alt="Logo" width="32" height="32" class="block" />
|
||||
<div v-show="!drawerMini" class="ms-6 font-medium">Solar Network</div>
|
||||
</div>
|
||||
<NavigationDrawer />
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<div>
|
||||
<v-btn
|
||||
v-show="!drawerMini"
|
||||
icon="mdi-arrow-collapse-left"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="drawerMini = true"
|
||||
/>
|
||||
</div>
|
||||
</v-toolbar>
|
||||
|
||||
<v-list class="flex-grow-1" :opened="drawerMini ? [] : expanded" @update:opened="(val) => expanded = val">
|
||||
<channel-list />
|
||||
<v-divider class="border-opacity-75 my-2" />
|
||||
<realm-list />
|
||||
</v-list>
|
||||
|
||||
<!-- User info -->
|
||||
<v-list
|
||||
class="border-opacity-15 h-[64px]"
|
||||
style="border-top-width: thin"
|
||||
:style="`margin-bottom: ${safeAreaBottom}`"
|
||||
>
|
||||
<v-list-item :subtitle="username" :title="nickname">
|
||||
<template #prepend>
|
||||
<v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" />
|
||||
</template>
|
||||
<template #append>
|
||||
<v-menu v-if="id.userinfo.isLoggedIn">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" icon="mdi-menu-down" size="small" variant="text" />
|
||||
</template>
|
||||
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
title="Sign out"
|
||||
prepend-icon="mdi-logout-variant"
|
||||
@click="signout"
|
||||
/>
|
||||
<v-list-item
|
||||
title="Solarpass"
|
||||
prepend-icon="mdi-passport-biometric"
|
||||
target="_blank"
|
||||
:href="passportUrl"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<v-btn v-else icon="mdi-login-variant" size="small" variant="text" :to="{ name: 'auth.sign-in' }" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar height="64" color="primary" scroll-behavior="hide" :order="2" flat>
|
||||
<v-app-bar v-if="!isLargeScreen && ui.appbar.show" height="64" color="primary" scroll-behavior="hide" :order="2">
|
||||
<div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center">
|
||||
<v-app-bar-nav-icon variant="text" @click.stop="drawerOpen = !drawerOpen" />
|
||||
<v-app-bar-nav-icon variant="text" @click.stop="ui.drawer.open = !ui.drawer.open" />
|
||||
|
||||
<router-link :to="{ name: 'explore' }">
|
||||
<h2 class="ml-2 text-lg font-500">Solian</h2>
|
||||
</router-link>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<div v-if="id.userinfo.isLoggedIn">
|
||||
<notification-list />
|
||||
</div>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
|
||||
@@ -100,59 +19,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import { useUserinfo, signout as signoutAccount } from "@/stores/userinfo"
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { useWellKnown } from "@/stores/wellKnown"
|
||||
import { useMediaQuery } from "@vueuse/core"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import RealmList from "@/components/realms/RealmList.vue"
|
||||
import NotificationList from "@/components/NotificationList.vue"
|
||||
import ChannelList from "@/components/chat/channels/ChannelList.vue"
|
||||
import NavigationDrawer from "@/components/navigation/NavigationDrawer.vue"
|
||||
|
||||
const ui = useUI()
|
||||
const expanded = ref<string[]>(["channels"])
|
||||
|
||||
const safeAreaTop = computed(() => {
|
||||
return `${ui.safeArea.top}px`
|
||||
})
|
||||
const isLargeScreen = useMediaQuery("(min-width: 768px)")
|
||||
|
||||
const safeAreaBottom = computed(() => {
|
||||
return `${ui.safeArea.bottom}px`
|
||||
})
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
const username = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
return "@" + id.userinfo.data?.name
|
||||
} else {
|
||||
return "@vistor"
|
||||
}
|
||||
})
|
||||
const nickname = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
return id.userinfo.data?.nick
|
||||
} else {
|
||||
return "Anonymous"
|
||||
}
|
||||
})
|
||||
|
||||
id.readProfiles()
|
||||
|
||||
const meta = useWellKnown()
|
||||
|
||||
const passportUrl = computed(() => {
|
||||
return meta.wellKnown?.components?.identity
|
||||
})
|
||||
|
||||
meta.readWellKnown()
|
||||
|
||||
const drawerOpen = ref(true)
|
||||
const drawerMini = ref(false)
|
||||
|
||||
async function signout() {
|
||||
signoutAccount().then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
useUserinfo().readProfiles()
|
||||
useWellKnown().readWellKnown()
|
||||
</script>
|
||||
|
||||
|
55
src/layouts/settings.vue
Normal file
55
src/layouts/settings.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<v-container class="wrapper pt-6 px-6">
|
||||
<div class="content min-w-0">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<div class="aside-nav max-md:order-first">
|
||||
<v-card prepend-icon="mdi-cog" title="Settings">
|
||||
<v-list density="comfortable" class="overflow-auto">
|
||||
<v-list-item title="Basis" prepend-icon="mdi-network" exact :to="{ name: 'settings' }" />
|
||||
|
||||
<v-divider class="border-[#000] my-2" />
|
||||
|
||||
<v-list-item title="Friends" prepend-icon="mdi-handshake" :to="{ name: 'settings.account.friends' }" />
|
||||
|
||||
<v-divider class="border-[#000] my-2" />
|
||||
|
||||
<v-list-item title="Personalize" prepend-icon="mdi-card-bulleted-outline" :to="{ name: 'settings.account.personalize' }" />
|
||||
<v-list-item title="Personal Page" prepend-icon="mdi-sitemap" :to="{ name: 'settings.account.personal-page' }" />
|
||||
<v-list-item title="Security" prepend-icon="mdi-security" :to="{ name: 'settings.account.security' }" />
|
||||
|
||||
<v-divider class="border-[#000] my-2" />
|
||||
|
||||
<v-list-item title="Solarpass" prepend-icon="mdi-passport-biometric" append-icon="mdi-launch" target="_blank" :href="passportUrl" />
|
||||
</v-list>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWellKnown } from "@/stores/wellKnown"
|
||||
import { computed } from "vue"
|
||||
|
||||
const meta = useWellKnown()
|
||||
|
||||
const passportUrl = computed(() => {
|
||||
return meta.wellKnown?.components?.identity
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
14
src/router/auth.ts
Normal file
14
src/router/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const authRouter = [
|
||||
{
|
||||
path: "sign-in",
|
||||
name: "auth.sign-in",
|
||||
component: () => import("@/views/auth/sign-in.vue"),
|
||||
meta: { public: true, title: "Sign in" }
|
||||
},
|
||||
{
|
||||
path: "sign-up",
|
||||
name: "auth.sign-up",
|
||||
component: () => import("@/views/auth/sign-up.vue"),
|
||||
meta: { public: true, title: "Sign up" }
|
||||
}
|
||||
]
|
7
src/router/chat.ts
Normal file
7
src/router/chat.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const chatRouter = [
|
||||
{
|
||||
path: "",
|
||||
name: "chat.channel",
|
||||
component: () => import("@/views/chat/page.vue"),
|
||||
}
|
||||
]
|
@@ -3,6 +3,11 @@ import MasterLayout from "@/layouts/master.vue"
|
||||
|
||||
import nprogress from "nprogress";
|
||||
|
||||
import { authRouter } from "@/router/auth"
|
||||
import { plazaRouter } from "@/router/plaza"
|
||||
import { chatRouter } from "@/router/chat"
|
||||
import { settingRouter } from "@/router/settings"
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
@@ -10,63 +15,33 @@ const router = createRouter({
|
||||
path: "/",
|
||||
component: MasterLayout,
|
||||
children: [
|
||||
{
|
||||
path: "/u/:alias",
|
||||
name: "users.page",
|
||||
component: () => import("@/views/users/page.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/settings",
|
||||
component: () => import("@/layouts/settings.vue"),
|
||||
children: settingRouter,
|
||||
},
|
||||
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/layouts/plaza.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
name: "explore",
|
||||
component: () => import("@/views/explore.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/p/moments/:alias",
|
||||
name: "posts.details.moments",
|
||||
component: () => import("@/views/posts/moments.vue")
|
||||
},
|
||||
{
|
||||
path: "/p/articles/:alias",
|
||||
name: "posts.details.articles",
|
||||
component: () => import("@/views/posts/articles.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/realms/:realmId",
|
||||
name: "realms.page",
|
||||
component: () => import("@/views/realms/page.vue")
|
||||
}
|
||||
]
|
||||
children: plazaRouter,
|
||||
},
|
||||
|
||||
{
|
||||
path: "/chat/:channel",
|
||||
component: () => import("@/layouts/chat.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "chat.channel",
|
||||
component: () => import("@/views/chat/page.vue"),
|
||||
}
|
||||
]
|
||||
children: chatRouter,
|
||||
},
|
||||
|
||||
{
|
||||
path: "/auth",
|
||||
children: [
|
||||
{
|
||||
path: "sign-in",
|
||||
name: "auth.sign-in",
|
||||
component: () => import("@/views/auth/sign-in.vue"),
|
||||
meta: { public: true, title: "Sign in" }
|
||||
},
|
||||
{
|
||||
path: "sign-up",
|
||||
name: "auth.sign-up",
|
||||
component: () => import("@/views/auth/sign-up.vue"),
|
||||
meta: { public: true, title: "Sign up" }
|
||||
}
|
||||
]
|
||||
children: authRouter,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
24
src/router/plaza.ts
Normal file
24
src/router/plaza.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const plazaRouter = [
|
||||
{
|
||||
path: "/",
|
||||
name: "explore",
|
||||
component: () => import("@/views/explore.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/p/moments/:alias",
|
||||
name: "posts.details.moments",
|
||||
component: () => import("@/views/posts/moments.vue")
|
||||
},
|
||||
{
|
||||
path: "/p/articles/:alias",
|
||||
name: "posts.details.articles",
|
||||
component: () => import("@/views/posts/articles.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/realms/:realmId",
|
||||
name: "realms.page",
|
||||
component: () => import("@/views/realms/page.vue")
|
||||
}
|
||||
]
|
29
src/router/settings.ts
Normal file
29
src/router/settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const settingRouter = [
|
||||
{
|
||||
path: "",
|
||||
name: "settings",
|
||||
component: () => import("@/views/settings.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "account/friends",
|
||||
name: "settings.account.friends",
|
||||
component: () => import("@/views/users/me/friends.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "account/personalize",
|
||||
name: "settings.account.personalize",
|
||||
component: () => import("@/views/users/me/personalize.vue")
|
||||
},
|
||||
{
|
||||
path: "account/personal-page",
|
||||
name: "settings.account.personal-page",
|
||||
component: () => import("@/views/users/me/personal-page.vue")
|
||||
},
|
||||
{
|
||||
path: "account/security",
|
||||
name: "settings.account.security",
|
||||
component: () => import("@/views/users/me/security.vue")
|
||||
}
|
||||
]
|
@@ -4,14 +4,10 @@ import { Preferences } from "@capacitor/preferences"
|
||||
const serviceMap: { [id: string]: string } = {
|
||||
interactive: "https://co.solsynth.dev",
|
||||
identity: "https://id.solsynth.dev",
|
||||
messaging: "https://im.solsynth.dev",
|
||||
messaging: "https://im.solsynth.dev"
|
||||
}
|
||||
|
||||
export async function request(service: string, input: string, init?: RequestInit, noRetry?: boolean) {
|
||||
const url = buildRequestUrl(service, input)
|
||||
const res = await fetch(url, init)
|
||||
|
||||
if (res.status === 401 && !noRetry) {
|
||||
export async function refreshToken() {
|
||||
const res = await request("identity", "/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -34,11 +30,20 @@ export async function request(service: string, input: string, init?: RequestInit
|
||||
value: data["refresh_token"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function request(service: string, input: string, init?: RequestInit, noRetry?: boolean) {
|
||||
const url = buildRequestUrl(service, input)
|
||||
const res = await fetch(url, init)
|
||||
|
||||
if (res.status === 401 && !noRetry) {
|
||||
await refreshToken()
|
||||
|
||||
console.info("[REQUEST] Auth context has been refreshed.")
|
||||
return await request(service, input, Object.assign(init as any, {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
}), true)
|
||||
return await request(service, input, {
|
||||
...init,
|
||||
headers: { ...init?.headers, Authorization: `Bearer ${await getAtk()}` }
|
||||
}, true)
|
||||
}
|
||||
|
||||
return res
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
|
||||
import { buildRequestUrl, request } from "@/scripts/request"
|
||||
import { buildRequestUrl, refreshToken, request } from "@/scripts/request"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
export const useChannels = defineStore("channels", () => {
|
||||
let socket: WebSocket
|
||||
let reconnectCount = 0
|
||||
|
||||
const done = ref(false)
|
||||
|
||||
@@ -33,13 +35,16 @@ export const useChannels = defineStore("channels", () => {
|
||||
|
||||
const available = ref<any[]>([])
|
||||
const current = ref<any>(null)
|
||||
|
||||
const messages = ref<any[]>([])
|
||||
const call = ref<any>(null)
|
||||
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route.params.channel,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
call.value = null
|
||||
messages.value = []
|
||||
current.value = null
|
||||
}
|
||||
@@ -60,6 +65,24 @@ export const useChannels = defineStore("channels", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const ui = useUI()
|
||||
|
||||
async function exchangeCallToken() {
|
||||
if (!(await checkLoggedIn())) return
|
||||
if (!current.value) return
|
||||
|
||||
const res = await request("messaging", `/api/channels/${current.value.alias}/calls/ongoing/token`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
ui.showErrorSnackbar(`unable to exchange call token: ${await res.text()}`)
|
||||
return null
|
||||
} else {
|
||||
return await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!(await checkLoggedIn())) return
|
||||
|
||||
@@ -72,6 +95,22 @@ export const useChannels = defineStore("channels", () => {
|
||||
})
|
||||
socket.addEventListener("close", (event) => {
|
||||
console.warn("[MESSAGING] The unified websocket is disconnected... ", event.reason, event.code)
|
||||
const reconnect = () => {
|
||||
reconnectCount = 0
|
||||
refreshToken().then(() => {
|
||||
connect().then(() => {
|
||||
ui.reconnection.messages = false
|
||||
reconnectCount++
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.reconnection.messages = true
|
||||
if (reconnectCount <= 3) {
|
||||
reconnect()
|
||||
} else {
|
||||
setTimeout(() => reconnect(), 3000)
|
||||
}
|
||||
|
||||
})
|
||||
socket.addEventListener("message", (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
@@ -92,6 +131,13 @@ export const useChannels = defineStore("channels", () => {
|
||||
return x.id !== payload.id
|
||||
})
|
||||
break
|
||||
|
||||
case "calls.new":
|
||||
call.value = payload
|
||||
break
|
||||
case "calls.end":
|
||||
call.value = null
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -101,5 +147,5 @@ export const useChannels = defineStore("channels", () => {
|
||||
socket.close()
|
||||
}
|
||||
|
||||
return { done, show, related, available, current, messages, list, connect, disconnect }
|
||||
return { done, show, related, available, current, messages, call, list, exchangeCallToken, connect, disconnect }
|
||||
})
|
23
src/stores/friends.ts
Normal file
23
src/stores/friends.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { reactive, ref } from "vue"
|
||||
import { defineStore } from "pinia"
|
||||
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
|
||||
import { request } from "@/scripts/request"
|
||||
|
||||
export const useFriends = defineStore("friends", () => {
|
||||
const available = ref<any[]>([])
|
||||
|
||||
async function list() {
|
||||
if (!(await checkLoggedIn())) return
|
||||
|
||||
const res = await request("identity", "/api/users/me/friends?status=1", {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
throw new Error(await res.text())
|
||||
} else {
|
||||
available.value = await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
return { available, list }
|
||||
})
|
@@ -1,12 +1,14 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { ref } from "vue"
|
||||
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
|
||||
import { buildRequestUrl, request } from "@/scripts/request"
|
||||
import { buildRequestUrl, refreshToken, request } from "@/scripts/request"
|
||||
import { LocalNotifications } from "@capacitor/local-notifications"
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
export const useNotifications = defineStore("notifications", () => {
|
||||
let socket: WebSocket
|
||||
let reconnectCount = 0
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -39,6 +41,8 @@ export const useNotifications = defineStore("notifications", () => {
|
||||
total.value--
|
||||
}
|
||||
|
||||
const ui = useUI()
|
||||
|
||||
async function connect() {
|
||||
if (!(await checkLoggedIn())) return
|
||||
|
||||
@@ -51,11 +55,28 @@ export const useNotifications = defineStore("notifications", () => {
|
||||
})
|
||||
socket.addEventListener("close", (event) => {
|
||||
console.warn("[NOTIFICATIONS] The listen websocket is disconnected... ", event.reason, event.code)
|
||||
const reconnect = () => {
|
||||
reconnectCount = 0
|
||||
refreshToken().then(() => {
|
||||
connect().then(() => {
|
||||
ui.reconnection.notifications = false
|
||||
reconnectCount++
|
||||
})
|
||||
})
|
||||
}
|
||||
ui.reconnection.notifications = true
|
||||
if (reconnectCount <= 3) {
|
||||
reconnect()
|
||||
} else {
|
||||
setTimeout(() => reconnect(), 3000)
|
||||
}
|
||||
})
|
||||
socket.addEventListener("message", (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (!data.is_realtime) {
|
||||
notifications.value.push(data)
|
||||
total.value++
|
||||
}
|
||||
|
||||
if (Capacitor.getPlatform() === "web") {
|
||||
new Notification(data["subject"], {
|
||||
|
@@ -1,11 +1,39 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { reactive } from "vue"
|
||||
import { reactive, ref } from "vue"
|
||||
|
||||
export const useUI = defineStore("ui", () => {
|
||||
const safeArea = reactive({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
const snackbar = ref<any>(null)
|
||||
const reconnection = reactive({
|
||||
notifications: false,
|
||||
messages: false,
|
||||
})
|
||||
|
||||
return { safeArea }
|
||||
const appbar = reactive({
|
||||
show: true,
|
||||
})
|
||||
const drawer = reactive({
|
||||
open: true,
|
||||
mini: false,
|
||||
})
|
||||
|
||||
const safeArea = reactive({
|
||||
top: 0,
|
||||
bottom: 0
|
||||
})
|
||||
|
||||
function showSnackbar(content: string, opts?: any, timeout = 3000) {
|
||||
snackbar.value = {
|
||||
...opts,
|
||||
content,
|
||||
timeout
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorSnackbar(content: string, opts?: any) {
|
||||
showSnackbar(`Something went wrong... ${content}`, {
|
||||
...opts
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
return { safeArea, snackbar, appbar, drawer, reconnection, showSnackbar, showErrorSnackbar }
|
||||
})
|
||||
|
@@ -4,6 +4,7 @@ import { request } from "@/scripts/request"
|
||||
import { Preferences } from "@capacitor/preferences"
|
||||
import { useRealms } from "@/stores/realms"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { useFriends } from "@/stores/friends"
|
||||
|
||||
export interface Userinfo {
|
||||
isReady: boolean
|
||||
@@ -51,7 +52,7 @@ export async function signout() {
|
||||
|
||||
export const useUserinfo = defineStore("userinfo", () => {
|
||||
const userinfoHooks = {
|
||||
after: [useRealms().list, useChannels().list, useChannels().connect]
|
||||
after: [useRealms().list, useChannels().list, useChannels().connect, useFriends().list]
|
||||
}
|
||||
|
||||
const userinfo = ref(defaultUserinfo)
|
||||
|
@@ -45,7 +45,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, type Component } from "vue"
|
||||
import Copyright from "@/components/Copyright.vue"
|
||||
import Copyright from "@/components/common/Copyright.vue"
|
||||
import CallbackNotify from "@/components/auth/CallbackNotify.vue"
|
||||
import AccountLocator from "@/components/auth/AccountLocator.vue"
|
||||
import FactorPicker from "@/components/auth/FactorPicker.vue"
|
||||
|
@@ -97,7 +97,7 @@
|
||||
import { ref } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import Copyright from "@/components/Copyright.vue"
|
||||
import Copyright from "@/components/common/Copyright.vue"
|
||||
import CallbackNotify from "@/components/auth/CallbackNotify.vue"
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
|
@@ -7,15 +7,34 @@
|
||||
|
||||
<v-footer
|
||||
app
|
||||
class="flex items-center border-opacity-15 min-h-[64px]"
|
||||
style="border-top-width: thin"
|
||||
:style="`padding-bottom: max(${safeAreaBottom}, 8px)`"
|
||||
class="footer-section flex-col gap-2 min-h-[64px]"
|
||||
:style="`padding-bottom: max(${safeAreaBottom}, 6px)`"
|
||||
>
|
||||
<chat-editor class="flex-grow-1" @sent="scrollTop" />
|
||||
</v-footer>
|
||||
<v-expand-transition>
|
||||
<v-expansion-panels v-show="channels.call">
|
||||
<v-expansion-panel
|
||||
eager
|
||||
icon="mdi-phone"
|
||||
title="Call is ongoing"
|
||||
elevation="1"
|
||||
class="call-expansion"
|
||||
@group:selected="(val) => val && mountJitsi()"
|
||||
>
|
||||
<template #text>
|
||||
<v-expand-transition>
|
||||
<v-progress-linear v-show="joining" indeterminate />
|
||||
</v-expand-transition>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
<div class="call-container w-full h-[360px]">
|
||||
<div id="call" class="h-full w-full" v-if="channels.call"></div>
|
||||
</div>
|
||||
</template>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-expand-transition>
|
||||
|
||||
<chat-editor class="w-full" @sent="scrollTop" />
|
||||
</v-footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -24,10 +43,12 @@ import { request } from "@/scripts/request"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import { computed, onUnmounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import ChatList from "@/components/chat/ChatList.vue"
|
||||
import ChatEditor from "@/components/chat/ChatEditor.vue"
|
||||
|
||||
const ui = useUI()
|
||||
const id = useUserinfo()
|
||||
const route = useRoute()
|
||||
const channels = useChannels()
|
||||
|
||||
@@ -37,11 +58,26 @@ const safeAreaBottom = computed(() => {
|
||||
|
||||
const chatList = ref<HTMLDivElement>()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const { showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
const joining = ref(false)
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function readCall() {
|
||||
loading.value = true
|
||||
const res = await request(
|
||||
"messaging",
|
||||
`/api/channels/${route.params.channel}/calls/ongoing`
|
||||
)
|
||||
if (res.status !== 200 && res.status !== 404) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else if (res.status !== 404) {
|
||||
channels.call = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function readHistory() {
|
||||
loading.value = true
|
||||
const res = await request(
|
||||
@@ -52,12 +88,12 @@ async function readHistory() {
|
||||
})
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
throw new Error()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
pagination.total = data["count"]
|
||||
channels.messages.push(...(data["data"] ?? []))
|
||||
error.value = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
@@ -74,22 +110,26 @@ async function readMore({ done }: any) {
|
||||
}
|
||||
|
||||
pagination.page++
|
||||
await readHistory()
|
||||
|
||||
if (error.value != null) done("error")
|
||||
else {
|
||||
try {
|
||||
await readHistory()
|
||||
} catch {
|
||||
done("error")
|
||||
}
|
||||
|
||||
if (pagination.total > 0) done("ok")
|
||||
else done("empty")
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => channels.current,
|
||||
(val) => {
|
||||
if (val) {
|
||||
unmountJitsi()
|
||||
pagination.page = 1
|
||||
pagination.total = 0
|
||||
readHistory()
|
||||
readCall()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -98,4 +138,83 @@ watch(
|
||||
function scrollTop() {
|
||||
window.scroll({ top: 0 })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => channels.call,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
if (jitsiInstance) {
|
||||
jitsiInstance.executeCommand("endConference")
|
||||
jitsiInstance.executeCommand("hangup")
|
||||
}
|
||||
unmountJitsi()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let mounted = false
|
||||
let jitsiInstance: any
|
||||
|
||||
async function mountJitsi() {
|
||||
if (mounted) return false
|
||||
if (!channels.call) return
|
||||
|
||||
joining.value = true
|
||||
const tk = await channels.exchangeCallToken()
|
||||
joining.value = false
|
||||
if (!tk) return
|
||||
|
||||
const domain = tk.endpoint.replace("http://", "").replace("https://", "")
|
||||
const options = {
|
||||
roomName: channels.call.external_id,
|
||||
parentNode: document.querySelector("#call"),
|
||||
jwt: tk.token,
|
||||
userInfo: {
|
||||
avatar: id.userinfo.data?.picture,
|
||||
displayName: id.userinfo.displayName
|
||||
},
|
||||
configOverwrite: {
|
||||
prejoinPageEnabled: true,
|
||||
startWithAudioMuted: false,
|
||||
startWithVideoMuted: true
|
||||
}
|
||||
}
|
||||
// This class imported by the script tag in index.html
|
||||
// @ts-ignore
|
||||
jitsiInstance = new JitsiMeetExternalAPI(domain, options)
|
||||
mounted = true
|
||||
}
|
||||
|
||||
function unmountJitsi() {
|
||||
mounted = false
|
||||
if (jitsiInstance) {
|
||||
jitsiInstance.dispose()
|
||||
jitsiInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => unmountJitsi())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.footer-section {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
|
||||
padding-top: 8px !important;
|
||||
padding-left: 8px !important;
|
||||
padding-right: 8px !important;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.call-expansion .v-expansion-panel-text__wrapper {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<v-container class="flex max-md:flex-col gap-3 overflow-auto h-auto no-scrollbar">
|
||||
<div class="timeline flex-grow-1 mt-[-16px]">
|
||||
<v-container class="wrapper h-auto no-scrollbar">
|
||||
<div class="timeline min-w-0 mt-[-16px]">
|
||||
<post-list v-model:posts="posts" :loader="readMore" />
|
||||
</div>
|
||||
|
||||
<div class="aside w-full h-full md:min-w-[320px] md:max-w-[320px] max-md:order-first">
|
||||
<div class="aside min-w-0 w-full max-md:order-first">
|
||||
<v-card title="Categories">
|
||||
<v-list density="compact">
|
||||
<v-list-item title="All" prepend-icon="mdi-apps" active></v-list-item>
|
||||
@@ -16,12 +16,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import PostList from "@/components/posts/PostList.vue"
|
||||
import { onMounted, onUnmounted, reactive, ref } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { reactive, ref } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import PullToRefresh, { type PullToRefreshInstance } from "pulltorefreshjs"
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const { showErrorSnackbar } = useUI()
|
||||
const posts = ref<any[]>([])
|
||||
|
||||
async function readPosts() {
|
||||
@@ -34,9 +36,9 @@ async function readPosts() {
|
||||
})
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
showErrorSnackbar(await res.text())
|
||||
throw new Error()
|
||||
} else {
|
||||
error.value = null
|
||||
const data = await res.json()
|
||||
pagination.total = data["count"]
|
||||
posts.value.push(...(data["data"] ?? []))
|
||||
@@ -51,14 +53,45 @@ async function readMore({ done }: any) {
|
||||
}
|
||||
|
||||
pagination.page++
|
||||
try {
|
||||
await readPosts()
|
||||
} catch {
|
||||
done("error")
|
||||
}
|
||||
|
||||
if (error.value != null) done("error")
|
||||
else {
|
||||
if (pagination.total > 0) done("ok")
|
||||
else done("empty")
|
||||
}
|
||||
}
|
||||
|
||||
readPosts()
|
||||
|
||||
let refresher: PullToRefreshInstance
|
||||
|
||||
onMounted(() => {
|
||||
refresher = PullToRefresh.init({
|
||||
mainElement: ".wrapper",
|
||||
triggerElement: ".wrapper",
|
||||
onRefresh() {
|
||||
posts.value = []
|
||||
pagination.page = 0
|
||||
return readPosts()
|
||||
}
|
||||
})
|
||||
})
|
||||
onUnmounted(() => refresher.destroy())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -18,7 +18,12 @@
|
||||
<v-divider class="my-5 mx-[-16px] border-opacity-50" />
|
||||
|
||||
<div class="px-3 text-xs opacity-80 flex gap-1">
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{ name: 'users.page', params: { alias: post?.author.name ?? 'ghost' } }"
|
||||
>
|
||||
<span>Written by {{ post?.author?.nick }}</span>
|
||||
</router-link>
|
||||
<span>·</span>
|
||||
<span>Published at {{ new Date(post?.created_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
|
@@ -6,12 +6,14 @@
|
||||
<v-card-text>
|
||||
<div class="flex justify-between px-3">
|
||||
<div class="flex gap-1">
|
||||
<router-link :to="{ name: 'users.page', params: { alias: post?.author.name ?? 'ghost' } }">
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card me-2"
|
||||
:image="post?.author.avatar"
|
||||
/>
|
||||
</router-link>
|
||||
|
||||
<div>
|
||||
<p class="font-bold">{{ post?.author.nick }}</p>
|
||||
@@ -26,6 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<v-divider class="mb-5 mt-3.5 mx-[-16px] border-opacity-50" />
|
||||
|
||||
<div class="px-3">
|
||||
|
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<v-container class="flex max-md:flex-col gap-3 overflow-auto h-auto no-scrollbar">
|
||||
<div class="timeline flex-grow-1 mt-[-16px]">
|
||||
<v-container class="wrapper h-auto no-scrollbar">
|
||||
<div class="timeline mt-[-16px]">
|
||||
<post-list v-model:posts="posts" :loader="readMore" />
|
||||
</div>
|
||||
|
||||
<div class="aside md:sticky top-0 w-full h-fit md:min-w-[280px] md:max-w-[320px] max-md:order-first">
|
||||
<div class="aside max-md:order-first">
|
||||
<v-card :loading="loading">
|
||||
<template #title>
|
||||
<div class="flex justify-between">
|
||||
@@ -32,7 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import { onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { useRealms } from "@/stores/realms"
|
||||
import { useRoute } from "vue-router"
|
||||
@@ -41,6 +41,7 @@ import dompurify from "dompurify"
|
||||
import PostList from "@/components/posts/PostList.vue"
|
||||
import RealmAction from "@/components/realms/RealmAction.vue"
|
||||
import RealmMembers from "@/components/realms/RealmMembers.vue"
|
||||
import PullToRefresh, { type PullToRefreshInstance } from "pulltorefreshjs"
|
||||
|
||||
const route = useRoute()
|
||||
const realms = useRealms()
|
||||
@@ -122,4 +123,34 @@ watch(realms, (val) => {
|
||||
function parseContent(src: string): string {
|
||||
return dompurify().sanitize(parse(src) as string)
|
||||
}
|
||||
|
||||
let refresher: PullToRefreshInstance
|
||||
|
||||
onMounted(() => {
|
||||
refresher = PullToRefresh.init({
|
||||
mainElement: ".wrapper",
|
||||
triggerElement: ".wrapper",
|
||||
onRefresh() {
|
||||
posts.value = []
|
||||
pagination.page = 0
|
||||
return readPosts()
|
||||
}
|
||||
})
|
||||
})
|
||||
onUnmounted(() => refresher.destroy())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
17
src/views/settings.vue
Normal file
17
src/views/settings.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<v-card prepend-icon="mdi-server" title="Host">
|
||||
<template #text>
|
||||
<p>You currently connected to</p>
|
||||
<p class="text-lg"><b>Solar Networks</b></p>
|
||||
<p>Didn't support editing now</p>
|
||||
</template>
|
||||
</v-card>
|
||||
|
||||
<Copyright />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Copyright from "@/components/common/Copyright.vue"
|
||||
</script>
|
144
src/views/users/me/friends.vue
Normal file
144
src/views/users/me/friends.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<v-card prepend-icon="mdi-account-plus" title="Add a new friend" :loading="submitting">
|
||||
<v-form @submit.prevent="sendRequest">
|
||||
<div class="pl-4.5 pr-5.5 pt-0.5 pb-4">
|
||||
<v-text-field
|
||||
label="Username or email"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hint="Username not nickname. You need wait your friend accept your request to establish friend relationship."
|
||||
append-icon="mdi-plus"
|
||||
persistent-hint
|
||||
clearable
|
||||
v-model="username"
|
||||
@click:append="sendRequest"
|
||||
/>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<v-card prepend-icon="mdi-account-multiple" title="Friend list" :loading="loading">
|
||||
<v-list>
|
||||
<v-list-subheader>FRIENDS ({{ friends.available.length }})</v-list-subheader>
|
||||
<friend-list-item
|
||||
v-for="item in friends.available"
|
||||
:item="item"
|
||||
@decline="upgradeFriendship(item, 2)"
|
||||
/>
|
||||
|
||||
<v-list-subheader>PENDING ({{ pending.length }})</v-list-subheader>
|
||||
<friend-list-item
|
||||
v-for="item in pending"
|
||||
:item="item"
|
||||
@approve="upgradeFriendship(item, 1)"
|
||||
@decline="upgradeFriendship(item, 2)"
|
||||
/>
|
||||
|
||||
<v-list-subheader>BLOCKED ({{ blocked.length }})</v-list-subheader>
|
||||
<friend-list-item
|
||||
v-for="item in blocked"
|
||||
:item="item"
|
||||
@approve="upgradeFriendship(item, 1)"
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { checkLoggedIn, getAtk, useUserinfo } from "@/stores/userinfo"
|
||||
import { request } from "@/scripts/request"
|
||||
import { useFriends } from "@/stores/friends"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import FriendListItem from "@/components/friends/FriendListItem.vue"
|
||||
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const id = useUserinfo()
|
||||
|
||||
const submitting = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const username = ref("")
|
||||
|
||||
const friends = useFriends()
|
||||
const pending = ref<any[]>([])
|
||||
const blocked = ref<any[]>([])
|
||||
|
||||
async function sendRequest() {
|
||||
if (submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
const res = await request("identity", "/api/users/me/friends?" + new URLSearchParams({
|
||||
related: username.value
|
||||
}), {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
showSnackbar("You have sent the friend invitation, go reach your friend out.")
|
||||
await Promise.all([readFriendships(), friends.list()])
|
||||
}
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
async function upgradeFriendship(item: any, status: number) {
|
||||
if (submitting.value) return
|
||||
|
||||
let otherside: any;
|
||||
if (item.account_id === id.userinfo.data?.id) {
|
||||
otherside = item.related
|
||||
} else {
|
||||
otherside = item.account
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
const res = await request("identity", `/api/users/me/friends/${otherside.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
|
||||
body: JSON.stringify({
|
||||
status
|
||||
})
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
showSnackbar("Friendship status has been updated.")
|
||||
await Promise.all([readFriendships(), friends.list()])
|
||||
}
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
async function readFriendships() {
|
||||
if (!(await checkLoggedIn())) return
|
||||
|
||||
let res: Response
|
||||
|
||||
loading.value = true
|
||||
|
||||
res = await request("identity", "/api/users/me/friends?status=0", {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
pending.value = await res.json()
|
||||
}
|
||||
|
||||
res = await request("identity", "/api/users/me/friends?status=2", {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
blocked.value = await res.json()
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
readFriendships()
|
||||
</script>
|
65
src/views/users/me/personal-page.vue
Normal file
65
src/views/users/me/personal-page.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card class="mb-3" title="Design" prepend-icon="mdi-pencil-ruler" :loading="loading">
|
||||
<template #text>
|
||||
<v-form class="mt-1" @submit.prevent="submit">
|
||||
<v-row dense>
|
||||
<v-col :cols="12">
|
||||
<v-textarea hide-details label="Content" density="comfortable" variant="outlined"
|
||||
v-model="data.content" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-btn type="submit" class="mt-2" variant="text" prepend-icon="mdi-content-save" :disabled="loading">
|
||||
Apply Changes
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { request } from "@/scripts/request"
|
||||
import { useUI } from "@/stores/ui"
|
||||
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
|
||||
const data = ref<any>({})
|
||||
|
||||
async function read() {
|
||||
loading.value = true
|
||||
const res = await request("identity", "/api/users/me/page", {
|
||||
headers: { Authorization: `Bearer ${(await getAtk())}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
data.value = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const payload = data.value
|
||||
|
||||
loading.value = true
|
||||
const res = await request("identity", "/api/users/me/page", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
await read()
|
||||
showSnackbar("Your personal page has been updated.")
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
read()
|
||||
</script>
|
249
src/views/users/me/personalize.vue
Normal file
249
src/views/users/me/personalize.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card class="mb-3" title="Information" prepend-icon="mdi-face-man-profile" :loading="loading">
|
||||
<template #text>
|
||||
<v-form class="mt-1" @submit.prevent="submit">
|
||||
<v-row dense>
|
||||
<v-col :xs="12" :md="6">
|
||||
<v-text-field readonly hide-details label="Username" density="comfortable" variant="outlined"
|
||||
v-model="data.name" />
|
||||
</v-col>
|
||||
<v-col :xs="12" :md="6">
|
||||
<v-text-field hide-details label="Nickname" density="comfortable" variant="outlined"
|
||||
v-model="data.nick" />
|
||||
</v-col>
|
||||
<v-col :cols="12">
|
||||
<v-textarea hide-details label="Description" density="comfortable" variant="outlined"
|
||||
v-model="data.description" />
|
||||
</v-col>
|
||||
<v-col :xs="12" :md="6" :lg="4">
|
||||
<v-text-field hide-details label="First Name" density="comfortable" variant="outlined"
|
||||
v-model="data.first_name" />
|
||||
</v-col>
|
||||
<v-col :xs="12" :md="6" :lg="4">
|
||||
<v-text-field hide-details label="Last Name" density="comfortable" variant="outlined"
|
||||
v-model="data.last_name" />
|
||||
</v-col>
|
||||
<v-col :xs="12" :lg="4">
|
||||
<v-text-field hide-details label="Birthday" density="comfortable" variant="outlined" type="datetime-local"
|
||||
v-model="data.birthday" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-btn type="submit" class="mt-2" variant="text" prepend-icon="mdi-content-save" :disabled="loading">
|
||||
Apply Changes
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
</v-card>
|
||||
|
||||
<v-card>
|
||||
<section v-if="canEditImage">
|
||||
<v-card-text class="flex items-center gap-3">
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card"
|
||||
size="large"
|
||||
:image="accountPicture"
|
||||
/>
|
||||
<v-file-input
|
||||
clearable
|
||||
hide-details
|
||||
label="New Avatar"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
accept="image/*"
|
||||
prepend-icon=""
|
||||
@input="(val: InputEvent) => loadImage(val, 'avatar')"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-img
|
||||
cover
|
||||
class="bg-grey-lighten-2"
|
||||
max-height="280px"
|
||||
:aspect-ratio="16 / 9"
|
||||
:src="accountBanner"
|
||||
/>
|
||||
|
||||
<v-card-text>
|
||||
<v-file-input
|
||||
clearable
|
||||
hide-details
|
||||
label="New Banner"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
accept="image/*"
|
||||
prepend-icon=""
|
||||
@input="(val: InputEvent) => loadImage(val, 'banner')"
|
||||
/>
|
||||
</v-card-text>
|
||||
</section>
|
||||
|
||||
<v-card-text v-else>
|
||||
<v-alert variant="tonal" type="info" class="text-sm">
|
||||
Due to limitations of some browsers (such as Safari). You cannot edit your personal images in this browser.
|
||||
We recommend Chrome or any Chromium-based browser. You can also use a PWA for a better experience!
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-bottom-sheet class="max-w-[480px]" v-model="cropping">
|
||||
<v-card prepend-icon="mdi-crop" title="Crop the image" class="no-scrollbar">
|
||||
<div class="pt-3">
|
||||
<vue-cropper
|
||||
ref="cropper"
|
||||
class="w-ful"
|
||||
:src="image.url"
|
||||
:stencil-props="{ aspectRatio: image.ratio }"
|
||||
/>
|
||||
</div>
|
||||
<v-card-actions class="pb-16">
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn color="grey-darken-3" @click="cropping = false">Cancel</v-btn>
|
||||
<v-btn :disabled="loading" @click="applyImage">Apply</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-bottom-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from "vue"
|
||||
import { useUserinfo, getAtk } from "@/stores/userinfo"
|
||||
import { buildRequestUrl, request } from "@/scripts/request"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import { Cropper as VueCropper } from "vue-advanced-cropper"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
const { showSnackbar, showErrorSnackbar } = useUI()
|
||||
const loading = ref(false)
|
||||
|
||||
const data = ref<any>({})
|
||||
const image = ref<any>({
|
||||
url: null,
|
||||
type: null,
|
||||
ratio: 1
|
||||
})
|
||||
|
||||
const cropper = ref<any>()
|
||||
const cropping = ref(false)
|
||||
|
||||
const canEditImage = computed(() => navigator.userAgent.toLowerCase().indexOf("safari") > -1)
|
||||
|
||||
watch(
|
||||
id.userinfo,
|
||||
(val) => {
|
||||
if (val.isReady) {
|
||||
data.value.name = id.userinfo.data.name
|
||||
data.value.nick = id.userinfo.data.nick
|
||||
data.value.description = id.userinfo.data.description
|
||||
data.value.first_name = id.userinfo.data.profile.first_name
|
||||
data.value.last_name = id.userinfo.data.profile.last_name
|
||||
data.value.birthday = id.userinfo.data.profile.birthday
|
||||
|
||||
if (data.value.birthday) data.value.birthday = data.value.birthday.substring(0, 16)
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
const payload = data.value
|
||||
if (payload.birthday) payload.birthday = new Date(payload.birthday).toISOString()
|
||||
|
||||
loading.value = true
|
||||
const res = await request("identity", "/api/users/me", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
await id.readProfiles()
|
||||
showSnackbar("Your personal information has been updated.")
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function loadImage(event: InputEvent, type: string) {
|
||||
const { files } = event.target as HTMLInputElement
|
||||
if (!(files && files[0])) {
|
||||
return
|
||||
}
|
||||
|
||||
if (image.value.url) URL.revokeObjectURL(image.value.url)
|
||||
const blob = URL.createObjectURL(files[0])
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("load", () => image.value.url = blob)
|
||||
reader.readAsArrayBuffer(files[0])
|
||||
|
||||
if (type === "avatar") image.value.ratio = 1
|
||||
if (type === "banner") image.value.ratio = 16 / 9
|
||||
|
||||
image.value.type = type
|
||||
cropping.value = true
|
||||
}
|
||||
|
||||
const accountPicture = computed(() => id.userinfo.data?.avatar ?
|
||||
buildRequestUrl("identity", `/api/avatar/${id.userinfo.data?.avatar}`) :
|
||||
undefined
|
||||
)
|
||||
const accountBanner = computed(() => id.userinfo.data?.banner ?
|
||||
buildRequestUrl("identity", `/api/avatar/${id.userinfo.data?.banner}`) :
|
||||
undefined
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (image.value.url) URL.revokeObjectURL(image.value.url)
|
||||
})
|
||||
|
||||
async function applyImage() {
|
||||
if (loading.value) return
|
||||
if (!image.value.url || !image.value.type) return
|
||||
|
||||
const { canvas }: { canvas: HTMLCanvasElement } = cropper.value.getResult()
|
||||
|
||||
const payload = new FormData()
|
||||
payload.set(image.value.type, await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((data: Blob | null) => {
|
||||
if (data == null) {
|
||||
showErrorSnackbar("Cannot get blob data through canvas, please try again in desktop browser.")
|
||||
reject("Cannot get blob data")
|
||||
} else {
|
||||
resolve(data)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
cropping.value = false
|
||||
|
||||
loading.value = true
|
||||
const res = await request("identity", `/api/users/me/${image.value.type}`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` },
|
||||
body: payload
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
showErrorSnackbar(await res.text())
|
||||
} else {
|
||||
await id.readProfiles()
|
||||
showSnackbar("Your avatar has been updated.")
|
||||
image.value.url = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "vue-advanced-cropper/dist/style.css";
|
||||
@import "vue-advanced-cropper/dist/theme.compact.css";
|
||||
|
||||
.rounded-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
270
src/views/users/me/security.vue
Normal file
270
src/views/users/me/security.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-expansion-panels>
|
||||
<v-expansion-panel eager title="Challenges">
|
||||
<template #text>
|
||||
<v-card :loading="reverting.challenges" variant="outlined">
|
||||
<v-data-table-server
|
||||
:headers="dataDefinitions.challenges"
|
||||
:items="challenges"
|
||||
:items-length="pagination.challenges.total"
|
||||
:loading="reverting.challenges"
|
||||
v-model:items-per-page="pagination.challenges.pageSize"
|
||||
@update:options="readChallenges"
|
||||
item-value="id"
|
||||
>
|
||||
<template v-slot:item="{ item }: { item: any }">
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.ip_address }}</td>
|
||||
<td>
|
||||
<v-tooltip :text="item.user_agent" location="top">
|
||||
<template #activator="{ props }">
|
||||
<div v-bind="props" class="text-ellipsis whitespace-nowrap overflow-hidden max-w-[280px]">
|
||||
{{ item.user_agent }}
|
||||
</div>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</td>
|
||||
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-expansion-panel>
|
||||
|
||||
<v-expansion-panel eager title="Sessions">
|
||||
<template #text>
|
||||
<v-card :loading="reverting.sessions" variant="outlined">
|
||||
<v-data-table-server
|
||||
:headers="dataDefinitions.sessions"
|
||||
:items="sessions"
|
||||
:items-length="pagination.sessions.total"
|
||||
:loading="reverting.sessions"
|
||||
v-model:items-per-page="pagination.sessions.pageSize"
|
||||
@update:options="readSessions"
|
||||
item-value="id"
|
||||
>
|
||||
<template v-slot:item="{ item }: { item: any }">
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<v-chip v-for="value in item.audiences" size="x-small" color="warning" class="capitalize">
|
||||
{{ value }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<v-chip v-for="value in item.claims" size="x-small" color="info" class="font-mono">
|
||||
{{ value }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
|
||||
<td>
|
||||
<v-tooltip text="Sign out">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="error"
|
||||
icon="mdi-logout-variant"
|
||||
@click="killSession(item)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-expansion-panel>
|
||||
|
||||
<v-expansion-panel eager title="Events">
|
||||
<template #text>
|
||||
<v-card :loading="reverting.events" variant="outlined">
|
||||
<v-data-table-server
|
||||
:headers="dataDefinitions.events"
|
||||
:items="events"
|
||||
:items-length="pagination.events.total"
|
||||
:loading="reverting.events"
|
||||
v-model:items-per-page="pagination.events.pageSize"
|
||||
@update:options="readEvents"
|
||||
item-value="id"
|
||||
>
|
||||
<template v-slot:item="{ item }: { item: any }">
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.type }}</td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.ip_address }}</td>
|
||||
<td>
|
||||
<v-tooltip :text="item.user_agent" location="top">
|
||||
<template #activator="{ props }">
|
||||
<div v-bind="props" class="text-ellipsis whitespace-nowrap overflow-hidden max-w-[180px]">
|
||||
{{ item.user_agent }}
|
||||
</div>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</td>
|
||||
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk, useUserinfo } from "@/stores/userinfo"
|
||||
import { reactive, ref } from "vue"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const dataDefinitions: { [id: string]: any[] } = {
|
||||
challenges: [
|
||||
{ align: "start", key: "id", title: "ID" },
|
||||
{ align: "start", key: "ip_address", title: "IP Address" },
|
||||
{ align: "start", key: "user_agent", title: "User Agent" },
|
||||
{ align: "start", key: "created_at", title: "Issued At" }
|
||||
],
|
||||
sessions: [
|
||||
{ align: "start", key: "id", title: "ID" },
|
||||
{ align: "start", key: "audiences", title: "Audiences" },
|
||||
{ align: "start", key: "claims", title: "Claims" },
|
||||
{ align: "start", key: "created_at", title: "Issued At" },
|
||||
{ align: "start", key: "actions", title: "Actions", sortable: false }
|
||||
],
|
||||
events: [
|
||||
{ align: "start", key: "id", title: "ID" },
|
||||
{ align: "start", key: "type", title: "Type" },
|
||||
{ align: "start", key: "target", title: "Affected Object" },
|
||||
{ align: "start", key: "ip_address", title: "IP Address" },
|
||||
{ align: "start", key: "user_agent", title: "User Agent" },
|
||||
{ align: "start", key: "created_at", title: "Performed At" }
|
||||
]
|
||||
}
|
||||
|
||||
const challenges = ref<any>([])
|
||||
const sessions = ref<any>([])
|
||||
const events = ref<any>([])
|
||||
|
||||
const reverting = reactive({ challenges: false, sessions: false, events: false })
|
||||
const pagination = reactive({
|
||||
challenges: { page: 1, pageSize: 5, total: 0 },
|
||||
sessions: { page: 1, pageSize: 5, total: 0 },
|
||||
events: { page: 1, pageSize: 5, total: 0 }
|
||||
})
|
||||
|
||||
async function readChallenges({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
|
||||
if (itemsPerPage) pagination.challenges.pageSize = itemsPerPage
|
||||
if (page) pagination.challenges.page = page
|
||||
|
||||
reverting.challenges = true
|
||||
const res = await request(
|
||||
"identity",
|
||||
"/api/users/me/challenges?" +
|
||||
new URLSearchParams({
|
||||
take: pagination.challenges.pageSize.toString(),
|
||||
offset: ((pagination.challenges.page - 1) * pagination.challenges.pageSize).toString()
|
||||
}),
|
||||
{
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
}
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
challenges.value = data["data"]
|
||||
pagination.challenges.total = data["count"]
|
||||
}
|
||||
reverting.challenges = false
|
||||
}
|
||||
|
||||
async function readSessions({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
|
||||
if (itemsPerPage) pagination.sessions.pageSize = itemsPerPage
|
||||
if (page) pagination.sessions.page = page
|
||||
|
||||
reverting.sessions = true
|
||||
const res = await request(
|
||||
"identity",
|
||||
"/api/users/me/sessions?" +
|
||||
new URLSearchParams({
|
||||
take: pagination.sessions.pageSize.toString(),
|
||||
offset: ((pagination.sessions.page - 1) * pagination.sessions.pageSize).toString()
|
||||
}),
|
||||
{
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
}
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
sessions.value = data["data"]
|
||||
pagination.sessions.total = data["count"]
|
||||
}
|
||||
reverting.sessions = false
|
||||
}
|
||||
|
||||
async function readEvents({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
|
||||
if (itemsPerPage) pagination.events.pageSize = itemsPerPage
|
||||
if (page) pagination.events.page = page
|
||||
|
||||
reverting.events = true
|
||||
const res = await request(
|
||||
"identity",
|
||||
"/api/users/me/events?" +
|
||||
new URLSearchParams({
|
||||
take: pagination.events.pageSize.toString(),
|
||||
offset: ((pagination.events.page - 1) * pagination.events.pageSize).toString()
|
||||
}),
|
||||
{
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
}
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
events.value = data["data"]
|
||||
pagination.events.total = data["count"]
|
||||
}
|
||||
reverting.events = false
|
||||
}
|
||||
|
||||
Promise.all([readChallenges({}), readSessions({}), readEvents({})])
|
||||
|
||||
async function killSession(item: any) {
|
||||
reverting.sessions = true
|
||||
const res = await request("identity", `/api/users/me/sessions/${item.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
await readSessions({})
|
||||
error.value = null
|
||||
}
|
||||
reverting.sessions = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.rounded-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
203
src/views/users/page.vue
Normal file
203
src/views/users/page.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<v-container class="wrapper h-auto no-scrollbar">
|
||||
<div class="min-w-0 name-card md:col-span-2 max-md:order-first">
|
||||
<v-card class="w-full">
|
||||
<v-img v-if="accountBanner" cover max-height="280px" :aspect-ratio="16 / 9" :src="accountBanner" />
|
||||
|
||||
<v-card-text class="flex px-5 gap-1">
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card me-2"
|
||||
:image="accountPicture"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-medium">{{ metadata?.nick }}</h2>
|
||||
<span class="text-sm opacity-80">@{{ metadata?.name }}</span>
|
||||
</div>
|
||||
<p v-if="metadata?.description" class="mt-[-4px]">{{ metadata?.description }}</p>
|
||||
<p v-else class="mt-[-4px] italic">No description yet.</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<v-card class="min-w-0 browser h-fit">
|
||||
<v-tabs v-model="tab" align-tabs="center" bg-color="grey-lighten-4">
|
||||
<v-tab value="page">Personal Page</v-tab>
|
||||
<v-tab value="timeline">Timeline</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-card-text class="content">
|
||||
<v-window v-model="tab" content-class="px-5">
|
||||
<v-window-item value="page">
|
||||
<div class="px-3">
|
||||
<article v-if="page?.content" class="prose max-w-none" v-html="parseContent(page?.content)" />
|
||||
<article v-else>
|
||||
<v-alert variant="tonal" type="info">
|
||||
The user didn't customize its personal page.
|
||||
</v-alert>
|
||||
</article>
|
||||
</div>
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="timeline">
|
||||
<post-list class="mt-[-16px]" variant="outlined" :loader="readMore" v-model:posts="posts" />
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="min-w-0 aside h-fit max-md:order-first">
|
||||
<v-card prepend-icon="mdi-account-details" title="Bio">
|
||||
<v-card-text class="flex flex-col gap-2.5">
|
||||
<div>
|
||||
<h3 class="font-bold">Power level</h3>
|
||||
<v-chip :color="parsePowerLevel(metadata?.power_level).color" size="small">
|
||||
<span>{{ parsePowerLevel(metadata?.power_level).title }}</span>
|
||||
<span> · </span>
|
||||
<span class="font-mono">{{ metadata?.power_level }}</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold">Joined at</h3>
|
||||
<p>{{ new Date(metadata?.created_at).toLocaleString() }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold">UID</h3>
|
||||
<p class="text-mono opacity-90">#{{ metadata?.id.toString().padStart(12, "0") }}</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue"
|
||||
import { buildRequestUrl, request } from "@/scripts/request"
|
||||
import { useRoute } from "vue-router"
|
||||
import PostList from "@/components/posts/PostList.vue"
|
||||
import { parse } from "marked"
|
||||
import Articles from "@/views/posts/articles.vue"
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const error = ref<null | string>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const tab = ref("page")
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 5, total: 0 })
|
||||
|
||||
const metadata = ref<any>(null)
|
||||
const page = ref<any>(null)
|
||||
const posts = ref<any[]>([])
|
||||
|
||||
const accountPicture = computed(() => metadata.value?.avatar ?
|
||||
buildRequestUrl("identity", `/api/avatar/${metadata.value?.avatar}`) :
|
||||
undefined
|
||||
)
|
||||
const accountBanner = computed(() => metadata.value?.banner ?
|
||||
buildRequestUrl("identity", `/api/avatar/${metadata.value?.banner}`) :
|
||||
undefined
|
||||
)
|
||||
|
||||
async function readMetadata() {
|
||||
loading.value = true
|
||||
const res = await request("identity", `/api/users/${route.params.alias}`)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
metadata.value = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function readPage() {
|
||||
loading.value = true
|
||||
const res = await request("identity", `/api/users/${route.params.alias}/page`)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
page.value = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function readPosts() {
|
||||
const res = await request(
|
||||
"interactive",
|
||||
`/api/feed?` +
|
||||
new URLSearchParams({
|
||||
take: pagination.pageSize.toString(),
|
||||
offset: ((pagination.page - 1) * pagination.pageSize).toString(),
|
||||
authorId: route.params.alias as string
|
||||
})
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
error.value = null
|
||||
const data = await res.json()
|
||||
pagination.total = data["count"]
|
||||
posts.value.push(...(data["data"] ?? []))
|
||||
}
|
||||
}
|
||||
|
||||
async function readMore({ done }: any) {
|
||||
// Reach the end of data
|
||||
if (pagination.total <= pagination.page * pagination.pageSize) {
|
||||
done("empty")
|
||||
return
|
||||
}
|
||||
|
||||
pagination.page++
|
||||
await readPosts()
|
||||
|
||||
if (error.value != null) done("error")
|
||||
else {
|
||||
if (pagination.total > 0) done("ok")
|
||||
else done("empty")
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all([readMetadata(), readPage(), readPosts()])
|
||||
|
||||
function parsePowerLevel(level: number): { color: string, title: string } {
|
||||
if (level < 50) {
|
||||
return { color: "green", title: "User" }
|
||||
} else if (level < 100) {
|
||||
return { color: "orange", title: "Moderator" }
|
||||
} else {
|
||||
return { color: "red", title: "Administrator" }
|
||||
}
|
||||
}
|
||||
|
||||
function parseContent(src: string): string {
|
||||
return parse(src) as string
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rounded-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user