Compare commits
24 Commits
v2.0.0+2
...
169b5c0209
Author | SHA1 | Date | |
---|---|---|---|
|
169b5c0209 | ||
|
276c4f5dfe | ||
|
18d70382ff | ||
|
09154f1359 | ||
|
fc1aef6eb7 | ||
|
5e2c6e6c3b | ||
|
ff9f9b574b | ||
|
86b7fd85af | ||
|
e0995b312c | ||
|
e9e80bdeb5 | ||
|
a8119e8366 | ||
|
43aad8c2d2 | ||
|
c3bfb2069c | ||
|
fbf45dab57 | ||
|
73b1e376a3 | ||
|
012a02751c | ||
|
634fedf17c | ||
|
a5efec89f2 | ||
|
8bb9816cd0 | ||
|
05e8782557 | ||
|
e986ff8c5f | ||
|
c616214c3b | ||
|
f552cdcf74 | ||
|
d187ca0a88 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,3 +29,5 @@ coverage
|
||||
|
||||
*.tsbuildinfo
|
||||
*.lockb
|
||||
|
||||
*dist
|
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-preferences')
|
||||
|
||||
}
|
||||
|
@@ -2,5 +2,8 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
|
27
index.html
27
index.html
@@ -1,13 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/xml+svg" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Solian</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>Solian</title>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -27,6 +27,7 @@
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
730477372BB91A4200A78988 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -73,6 +74,7 @@
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
730477372BB91A4200A78988 /* App.entitlements */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
@@ -345,6 +347,7 @@
|
||||
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
@@ -367,6 +370,7 @@
|
||||
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
|
12
ios/App/App/App.entitlements
Normal file
12
ios/App/App/App.entitlements
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>webcredentials:solsynth.dev</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
@@ -20,8 +20,21 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string></string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Allow Solian use your camera so that you can take photo for your post.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Allow Solian full access your photo library so that you can share photos more easily.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Allow Solian access your photo library so that you can share photos.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -43,15 +56,7 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string></string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Allow Solian full access your photo library so that you can share photos more easily.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Allow Solian access your photo library so that you can share photos.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Allow Solian use your camera so that you can take photo for your post.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications'
|
||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||
end
|
||||
|
||||
|
@@ -2,12 +2,15 @@ PODS:
|
||||
- Capacitor (5.7.4):
|
||||
- CapacitorCordova
|
||||
- CapacitorCordova (5.7.4)
|
||||
- CapacitorLocalNotifications (5.0.7):
|
||||
- Capacitor
|
||||
- CapacitorPreferences (5.0.7):
|
||||
- Capacitor
|
||||
|
||||
DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)"
|
||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
@@ -15,14 +18,17 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorCordova:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorLocalNotifications:
|
||||
:path: "../../node_modules/@capacitor/local-notifications"
|
||||
CapacitorPreferences:
|
||||
:path: "../../node_modules/@capacitor/preferences"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Capacitor: 4fe9adf012caceb4c71ffea2f1f4d005cdcbeea7
|
||||
CapacitorCordova: a6e87fccc0307dee7aec1560ec9398485f2b0ce7
|
||||
CapacitorLocalNotifications: c58afadd159f6bc540ef9b3cbdbc82510a2bf112
|
||||
CapacitorPreferences: 77ac427e98db83bace772455f8ba447430382c4c
|
||||
|
||||
PODFILE CHECKSUM: 769e120bf4dfe4ef1095b83775e36bafeeeb3cdd
|
||||
PODFILE CHECKSUM: 19c3106e1cb0c8c0ae26243bfb70b974f8cfaaf5
|
||||
|
||||
COCOAPODS: 1.15.1
|
||||
|
@@ -16,6 +16,7 @@
|
||||
"@capacitor/android": "^5.7.4",
|
||||
"@capacitor/core": "^5.7.4",
|
||||
"@capacitor/ios": "^5.7.4",
|
||||
"@capacitor/local-notifications": "^5.0.7",
|
||||
"@capacitor/preferences": "^5.0.7",
|
||||
"@fontsource/roboto": "^5.0.12",
|
||||
"@mdi/font": "^7.4.47",
|
||||
|
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 69 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 85 KiB |
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "../icons/icon-48.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "48x48",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-72.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "72x72",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-96.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-128.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "128x128",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-192.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-256.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-512.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
BIN
public/maskable-icon.png
Normal file
BIN
public/maskable-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 75 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
@@ -2,7 +2,6 @@ html,
|
||||
body,
|
||||
#app,
|
||||
.v-application {
|
||||
overflow: auto !important;
|
||||
font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
|
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<v-menu eager :close-on-content-click="false">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" stacked rounded="circle" size="small" variant="text" :loading="loading">
|
||||
<v-badge v-if="pagination.total > 0" color="error" :content="pagination.total">
|
||||
<v-btn v-bind="props" icon size="small" variant="text" :loading="loading">
|
||||
<v-badge v-if="notify.total > 0" color="error" :content="notify.total">
|
||||
<v-icon icon="mdi-bell" />
|
||||
</v-badge>
|
||||
|
||||
@@ -10,20 +10,19 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list v-if="notifications.length <= 0" class="w-[380px]" density="compact">
|
||||
<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>
|
||||
|
||||
<v-list v-else class="w-[380px]" density="compact" lines="three">
|
||||
<v-list-item v-for="item in notifications">
|
||||
<v-list-item v-for="(item, idx) in notify.notifications">
|
||||
<template #title>{{ item.subject }}</template>
|
||||
<template #subtitle>{{ item.content }}</template>
|
||||
|
||||
<template #append>
|
||||
<v-btn icon="mdi-check" size="x-small" variant="text" :disabled="loading" @click="markAsRead(item)" />
|
||||
<v-btn icon="mdi-check" size="x-small" variant="text" :disabled="loading" @click="markAsRead(item, idx)" />
|
||||
</template>
|
||||
|
||||
<div class="flex text-xs gap-1">
|
||||
@@ -40,50 +39,32 @@
|
||||
<script setup lang="ts">
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { reactive, ref } from "vue"
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useNotifications } from "@/stores/notifications";
|
||||
|
||||
const loading = ref(false)
|
||||
const notify = useNotifications()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
const loading = computed(() => notify.loading || submitting.value)
|
||||
|
||||
const notifications = ref<any[]>([])
|
||||
const pagination = reactive({ page: 1, pageSize: 25, total: 0 })
|
||||
|
||||
async function readNotifications() {
|
||||
loading.value = true
|
||||
const res = await request(
|
||||
"identity",
|
||||
"/api/notifications?" +
|
||||
new URLSearchParams({
|
||||
take: pagination.pageSize.toString(),
|
||||
offset: ((pagination.page - 1) * pagination.pageSize).toString()
|
||||
}),
|
||||
{
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
}
|
||||
)
|
||||
if (res.status === 200) {
|
||||
const data = await res.json()
|
||||
notifications.value = data["data"]
|
||||
pagination.total = data["count"]
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
readNotifications()
|
||||
|
||||
async function markAsRead(item: any) {
|
||||
loading.value = true
|
||||
async function markAsRead(item: any, idx: number) {
|
||||
submitting.value = true
|
||||
const res = await request("identity", `/api/notifications/${item.id}/read`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
headers: { Authorization: `Bearer ${getAtk()}` },
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
await readNotifications()
|
||||
notify.remove(idx)
|
||||
error.value = null
|
||||
}
|
||||
loading.value = false
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
notify.list()
|
||||
|
||||
onMounted(() => notify.connect())
|
||||
onUnmounted(() => notify.disconnect())
|
||||
</script>
|
||||
|
212
src/components/chat/ChatEditor.vue
Normal file
212
src/components/chat/ChatEditor.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<v-form class="flex-grow-1" ref="chat" @submit.prevent="sendMessage">
|
||||
<v-expand-transition>
|
||||
<v-alert
|
||||
v-show="channels.related?.messages?.reply_to"
|
||||
class="mb-3 text-sm"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
type="info"
|
||||
>
|
||||
You are about replying a message #{{ channels.related?.messages?.reply_to?.id }}
|
||||
|
||||
<template #prepend>
|
||||
<div class="h-[30px] flex items-center justify-center">
|
||||
<v-icon icon="mdi-reply" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="text"
|
||||
@click="channels.related.messages.reply_to = null"
|
||||
/>
|
||||
</template>
|
||||
</v-alert>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-alert
|
||||
v-show="channels.related?.messages?.edit_to"
|
||||
class="mb-3 text-sm"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
type="info"
|
||||
>
|
||||
You are about editing a message #{{ channels.related?.messages?.edit_to?.id }}
|
||||
|
||||
<template #prepend>
|
||||
<div class="h-[30px] flex items-center justify-center">
|
||||
<v-icon icon="mdi-pencil" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
color="info"
|
||||
variant="text"
|
||||
@click="channels.related.messages.edit_to = null"
|
||||
/>
|
||||
</template>
|
||||
</v-alert>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-textarea
|
||||
auto-grow
|
||||
hide-details
|
||||
class="w-full"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
placeholder="Enter some messages..."
|
||||
:rows="1"
|
||||
:max-rows="6"
|
||||
:loading="loading"
|
||||
v-model="data.content"
|
||||
@keyup.ctrl.enter="sendMessage"
|
||||
@keyup.meta.enter="sendMessage"
|
||||
@paste="pasteMedia"
|
||||
>
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon
|
||||
type="button"
|
||||
color="teal"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="loading"
|
||||
@click="dialogs.attachments = true"
|
||||
>
|
||||
<v-badge v-if="data.attachments.length > 0" :content="data.attachments.length">
|
||||
<v-icon icon="mdi-paperclip" />
|
||||
</v-badge>
|
||||
|
||||
<v-icon v-else icon="mdi-paperclip" />
|
||||
</v-btn>
|
||||
|
||||
<v-btn type="submit" icon="mdi-send" size="small" variant="text" :disabled="loading" />
|
||||
</template>
|
||||
</v-textarea>
|
||||
|
||||
<Attachments
|
||||
ref="attachments"
|
||||
v-model:show="dialogs.attachments"
|
||||
v-model:uploading="uploading"
|
||||
v-model:value="data.attachments"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
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 uploading = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const attachments = ref<any>()
|
||||
|
||||
const dialogs = reactive({
|
||||
attachments: false
|
||||
})
|
||||
|
||||
const data = ref<any>({
|
||||
content: "",
|
||||
reply_id: null,
|
||||
attachments: []
|
||||
})
|
||||
|
||||
async function sendMessage() {
|
||||
if (!data.value.content) return
|
||||
|
||||
const url = channels.related.messages.edit_to
|
||||
? `/api/channels/${channels.current.alias}/messages/${channels.related.messages.edit_to?.id}`
|
||||
: `/api/channels/${channels.current.alias}/messages`
|
||||
const method = channels.related.messages.edit_to ? "PUT" : "POST"
|
||||
|
||||
const payload = data.value
|
||||
payload.reply_to = payload.reply_id
|
||||
|
||||
loading.value = true
|
||||
const res = await request("messaging", url, {
|
||||
method: method,
|
||||
headers: { Authorization: `Bearer ${await getAtk()}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
emits("sent")
|
||||
resetEditor()
|
||||
error.value = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => channels.related.messages.reply_to,
|
||||
(val) => {
|
||||
if (val) {
|
||||
data.value.reply_id = val.id
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => channels.related.messages.edit_to,
|
||||
(val) => {
|
||||
if (val) {
|
||||
data.value = val
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function resetEditor() {
|
||||
chat.value?.reset()
|
||||
channels.related.messages.reply_to = null
|
||||
channels.related.messages.edit_to = null
|
||||
channels.related.messages.delete_to = null
|
||||
data.value = {
|
||||
content: "",
|
||||
attachments: []
|
||||
}
|
||||
}
|
||||
|
||||
function pasteMedia(evt: ClipboardEvent) {
|
||||
const files = evt.clipboardData?.files
|
||||
if (files) {
|
||||
Array.from(files).forEach((item) => {
|
||||
attachments.value.upload(item)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.snackbar-progress {
|
||||
margin: 12px -16px -14px;
|
||||
width: calc(100% + 64px);
|
||||
}
|
||||
</style>
|
20
src/components/chat/ChatList.vue
Normal file
20
src/components/chat/ChatList.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<v-infinite-scroll
|
||||
class="mt-[-16px] overflow-hidden"
|
||||
:onLoad="props.loader"
|
||||
>
|
||||
<template v-for="item in props.messages" :key="item.id">
|
||||
<chat-message class="px-6 py-2" :id="`m${item.id}`" :item="item" />
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<div class="flex-grow-1"></div>
|
||||
</template>
|
||||
</v-infinite-scroll>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChatMessage from "@/components/chat/ChatMessage.vue"
|
||||
|
||||
const props = defineProps<{ loader: (opts: any) => Promise<any>, messages: any[] }>()
|
||||
</script>
|
89
src/components/chat/ChatMessage.vue
Normal file
89
src/components/chat/ChatMessage.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card"
|
||||
:image="props.item?.sender.account.avatar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-bold text-sm">{{ props.item?.sender.account.nick }}</div>
|
||||
<div>{{ props.item?.content }}</div>
|
||||
|
||||
<message-attachment
|
||||
v-if="props.item?.attachments && props.item?.attachments.length > 0"
|
||||
class="mt-1"
|
||||
:attachments="props.item?.attachments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="transition-opacity transition-300 message-action">
|
||||
<v-card>
|
||||
<div class="flex px-2 py-0.5">
|
||||
<v-btn icon="mdi-reply" size="x-small" variant="text" @click="replyMessage" />
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" color="warning" @click="editMessage" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click="deleteMessage" />
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import MessageAttachment from "@/components/chat/renderer/MessageAttachment.vue"
|
||||
|
||||
const channels = useChannels()
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
|
||||
function replyMessage() {
|
||||
channels.related.messages.reply_to = JSON.parse(JSON.stringify(props.item))
|
||||
}
|
||||
|
||||
function editMessage() {
|
||||
channels.related.messages.edit_to = JSON.parse(JSON.stringify(props.item))
|
||||
}
|
||||
|
||||
function deleteMessage() {
|
||||
channels.related.messages.delete_to = JSON.parse(JSON.stringify(props.item))
|
||||
channels.related.messages.delete_to.channel = channels.current
|
||||
channels.show.messages.delete = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rounded-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.message-action {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: -25%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.message-item:hover {
|
||||
background-color: rgba(0, 0, 0, .15);
|
||||
}
|
||||
|
||||
.message-item:hover .message-action {
|
||||
opacity: 100%;
|
||||
}
|
||||
</style>
|
52
src/components/chat/MessageDeletion.vue
Normal file
52
src/components/chat/MessageDeletion.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<v-card title="Delete a message" class="min-h-[540px]" :loading="loading">
|
||||
<template #text>
|
||||
You are deleting a message
|
||||
<b>#{{ channels.related?.messages?.delete_to?.id }}</b> <br />
|
||||
This message will gone and never appear again. But the replies won't affected. Are you confirm?
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="w-full flex justify-end">
|
||||
<v-btn color="grey-darken-3" @click="channels.show.messages.delete = false">Not really</v-btn>
|
||||
<v-btn color="error" :disabled="loading" @click="deleteMessage">Yes</v-btn>
|
||||
</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">
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { ref } from "vue"
|
||||
|
||||
const channels = useChannels()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
async function deleteMessage() {
|
||||
const target = channels.related.messages.delete_to
|
||||
const url = `/api/channels/${target.channel.alias}/messages/${target.id}`
|
||||
|
||||
loading.value = true
|
||||
const res = await request("messaging", url, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
success.value = true
|
||||
channels.show.messages.delete = false
|
||||
channels.related.messages.delete_to = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
42
src/components/chat/channels/ChannelAction.vue
Normal file
42
src/components/chat/channels/ChannelAction.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" icon="mdi-cog" 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>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { computed } from "vue"
|
||||
|
||||
const id = useUserinfo()
|
||||
const channels = useChannels()
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
|
||||
const isOwned = computed(() => props.item?.account_id === id.userinfo.idSet?.messaging)
|
||||
|
||||
function editChannel() {
|
||||
channels.related.edit_to = props.item
|
||||
channels.show.editor = true
|
||||
}
|
||||
|
||||
function manageChannel() {
|
||||
channels.related.manage_to = props.item
|
||||
channels.show.members = true
|
||||
}
|
||||
|
||||
function deleteChannel() {
|
||||
channels.related.delete_to = props.item
|
||||
channels.show.delete = true
|
||||
}
|
||||
</script>
|
61
src/components/chat/channels/ChannelDeletion.vue
Normal file
61
src/components/chat/channels/ChannelDeletion.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<v-card title="Delete a realm" class="min-h-[540px]" :loading="loading">
|
||||
<template #text>
|
||||
You are deleting a channel
|
||||
<b>{{ channels.related.delete_to?.name }}</b> <br />
|
||||
All messaging belonging to this channel will be deleted and never appear again. Are you confirm?
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="w-full flex justify-end">
|
||||
<v-btn color="grey-darken-3" @click="channels.show.delete = false">Not really</v-btn>
|
||||
<v-btn color="error" :disabled="loading" @click="deleteChannel">Yes</v-btn>
|
||||
</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">
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { ref } from "vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const channels = useChannels()
|
||||
|
||||
const emits = defineEmits(["relist"])
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
async function deleteChannel() {
|
||||
const target = channels.related.delete_to
|
||||
const url = `/api/channels/${target.id}`
|
||||
|
||||
loading.value = true
|
||||
const res = await request("messaging", url, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
success.value = true
|
||||
channels.show.delete = false
|
||||
channels.related.delete_to = null
|
||||
emits("relist")
|
||||
if (route.name?.toString()?.includes("channel")) {
|
||||
await router.push({ name: "explore" })
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
77
src/components/chat/channels/ChannelEditor.vue
Normal file
77
src/components/chat/channels/ChannelEditor.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<v-card title="Establish a channel" prepend-icon="mdi-pound-box" class="min-h-[540px]" :loading="loading">
|
||||
<v-form @submit.prevent="submit">
|
||||
<v-card-text>
|
||||
<v-text-field label="Alias" variant="outlined" density="comfortable" hint="Must be unique"
|
||||
v-model="data.alias" />
|
||||
<v-text-field label="Name" variant="outlined" density="comfortable" v-model="data.name" />
|
||||
<v-textarea label="Description" variant="outlined" density="comfortable" v-model="data.description" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn type="reset" color="grey-darken-3" @click="channels.show.editor = false">Cancel</v-btn>
|
||||
<v-btn type="submit" :disabled="loading">Save</v-btn>
|
||||
</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">
|
||||
import { ref, watch } from "vue"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { request } from "@/scripts/request"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
|
||||
const emits = defineEmits(["relist"])
|
||||
|
||||
const channels = useChannels()
|
||||
|
||||
const error = ref<null | string>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const data = ref({
|
||||
alias: "",
|
||||
name: "",
|
||||
description: ""
|
||||
})
|
||||
|
||||
async function submit(evt: SubmitEvent) {
|
||||
const form = evt.target as HTMLFormElement
|
||||
const payload = data.value
|
||||
if (!payload.name) return
|
||||
|
||||
const url = channels.related.edit_to ? `/api/channels/${channels.related.edit_to?.id}` : "/api/channels"
|
||||
const method = channels.related.edit_to ? "PUT" : "POST"
|
||||
|
||||
loading.value = true
|
||||
const res = await request("messaging", url, {
|
||||
method: method,
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
emits("relist")
|
||||
form.reset()
|
||||
channels.done = true
|
||||
channels.show.editor = false
|
||||
channels.related.edit_to = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
channels.related,
|
||||
(val) => {
|
||||
if (val.edit_to) {
|
||||
data.value = JSON.parse(JSON.stringify(val.edit_to))
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
54
src/components/chat/channels/ChannelInvitation.vue
Normal file
54
src/components/chat/channels/ChannelInvitation.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<v-card prepend-icon="mdi-account-plus" title="Invite someone">
|
||||
<v-form @submit.prevent="inviteMember">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
label="Username"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hint="Require username not the nickname"
|
||||
v-model="targetName"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn type="reset" color="grey-darken-3" @click="emits('close')">Cancel</v-btn>
|
||||
<v-btn type="submit" :disabled="loading">Invite</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
const emits = defineEmits(["close", "error", "relist"])
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const targetName = ref("")
|
||||
|
||||
async function inviteMember(evt: SubmitEvent) {
|
||||
const form = evt.target as HTMLFormElement
|
||||
|
||||
loading.value = true
|
||||
const res = await request("messaging", `/api/channels/${props.item?.id}/invite`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
|
||||
body: JSON.stringify({
|
||||
account_name: targetName.value
|
||||
})
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
emits("error", await res.text())
|
||||
} else {
|
||||
form.reset()
|
||||
emits("relist")
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
43
src/components/chat/channels/ChannelList.vue
Normal file
43
src/components/chat/channels/ChannelList.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<v-list-group value="channels">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-chat"
|
||||
title="Channels"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
v-for="item in channels.available"
|
||||
exact
|
||||
append-icon="mdi-pound-box"
|
||||
:to="{ name: 'chat.channel', params: { channel: item.alias } }"
|
||||
:title="item.name"
|
||||
/>
|
||||
|
||||
<v-list-item
|
||||
append-icon="mdi-plus"
|
||||
title="Create a channel"
|
||||
variant="plain"
|
||||
:disabled="!id.userinfo.isLoggedIn"
|
||||
@click="createChannel"
|
||||
/>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { useRealms } from "@/stores/realms"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
|
||||
const id = useUserinfo()
|
||||
const channels = useChannels()
|
||||
|
||||
function createChannel() {
|
||||
channels.related.edit_to = null
|
||||
channels.related.manage_to = null
|
||||
channels.related.delete_to = null
|
||||
channels.show.editor = true
|
||||
}
|
||||
</script>
|
124
src/components/chat/channels/ChannelMembers.vue
Normal file
124
src/components/chat/channels/ChannelMembers.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<v-card title="Channel members" class="min-h-[540px]">
|
||||
<v-list density="comfortable" lines="one">
|
||||
<v-list-item v-for="item in members" :title="item.account.nick">
|
||||
<template #subtitle>@{{ item.account.name }}</template>
|
||||
<template #prepend>
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card me-2"
|
||||
size="small"
|
||||
:image="item?.account.avatar"
|
||||
/>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-account-remove"
|
||||
size="x-small"
|
||||
color="error"
|
||||
variant="text"
|
||||
:disabled="!checkKickable(item)"
|
||||
@click="kickMember(item)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div v-if="isOwned">
|
||||
<v-divider class="mt-2 mb-3 border-opacity-50 mx-[-1rem]" />
|
||||
|
||||
<div class="px-3">
|
||||
<v-dialog class="max-w-[540px]">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" block prepend-icon="mdi-account-plus" variant="plain"> Invite someone </v-btn>
|
||||
</template>
|
||||
|
||||
<template #default="{ isActive }">
|
||||
<channel-invitation
|
||||
:item="props.item"
|
||||
@relist="listMembers"
|
||||
@error="(val) => (error = 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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk, useUserinfo } from "@/stores/userinfo"
|
||||
import { computed } from "vue"
|
||||
import ChannelInvitation from "@/components/chat/channels/ChannelInvitation.vue"
|
||||
|
||||
const id = useUserinfo()
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
|
||||
const members = ref<any[]>([])
|
||||
|
||||
const isOwned = computed(() => {
|
||||
return id.userinfo.idSet?.messaging === props.item?.account_id
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.item,
|
||||
(val) => {
|
||||
if (val?.id) {
|
||||
listMembers(val.id)
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
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()
|
||||
} else {
|
||||
error.value = null
|
||||
members.value = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function kickMember(item: any) {
|
||||
loading.value = true
|
||||
const res = await request("messaging", `/api/channels/${props.item?.id}/kick`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
|
||||
body: JSON.stringify({
|
||||
account_name: item.account.name
|
||||
})
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
await listMembers(props.item?.id)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function checkKickable(item: any) {
|
||||
if (item.account?.id === id.userinfo.idSet?.messaging) return false
|
||||
if (item.account?.id === props.item?.account_id) return false
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.rounded-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
25
src/components/chat/channels/ChannelTools.vue
Normal file
25
src/components/chat/channels/ChannelTools.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<v-bottom-sheet class="max-w-[480px]" v-model="channels.show.editor">
|
||||
<channel-editor @relist="channels.list" />
|
||||
</v-bottom-sheet>
|
||||
<v-bottom-sheet class="max-w-[480px]" v-model="channels.show.members">
|
||||
<channel-members :item="channels.related.manage_to" @relist="channels.list" />
|
||||
</v-bottom-sheet>
|
||||
<v-bottom-sheet class="max-w-[480px]" v-model="channels.show.delete">
|
||||
<channel-deletion @relist="channels.list" />
|
||||
</v-bottom-sheet>
|
||||
|
||||
<v-bottom-sheet class="max-w-[480px]" v-model="channels.show.messages.delete">
|
||||
<message-deletion />
|
||||
</v-bottom-sheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import ChannelEditor from "@/components/chat/channels/ChannelEditor.vue"
|
||||
import ChannelMembers from "@/components/chat/channels/ChannelMembers.vue"
|
||||
import ChannelDeletion from "@/components/chat/channels/ChannelDeletion.vue"
|
||||
import MessageDeletion from "@/components/chat/MessageDeletion.vue"
|
||||
|
||||
const channels = useChannels()
|
||||
</script>
|
141
src/components/chat/parts/ChatAttachments.vue
Normal file
141
src/components/chat/parts/ChatAttachments.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
eager
|
||||
class="max-w-[540px]"
|
||||
:model-value="props.show"
|
||||
@update:model-value="(val) => emits('update:show', val)"
|
||||
>
|
||||
<v-card title="Attachments">
|
||||
<template #text>
|
||||
<v-file-input
|
||||
prepend-icon=""
|
||||
append-icon="mdi-upload"
|
||||
variant="solo-filled"
|
||||
label="File Picker"
|
||||
v-model="picked"
|
||||
:loading="props.uploading"
|
||||
@click:append="upload()"
|
||||
/>
|
||||
|
||||
<h2 class="px-2 mb-1">Media list</h2>
|
||||
<v-card variant="tonal">
|
||||
<v-list>
|
||||
<v-list-item v-for="(item, idx) in props.value" :title="getFileName(item)">
|
||||
<template #subtitle> {{ getFileType(item) }} · {{ formatBytes(item.filesize) }}</template>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click="dispose(idx)" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</template>
|
||||
<template #actions>
|
||||
<v-btn class="ms-auto" text="Ok" @click="emits('update:show', false)"></v-btn>
|
||||
</template>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { request } from "@/scripts/request"
|
||||
import { getAtk } from "@/stores/userinfo"
|
||||
import { ref } from "vue"
|
||||
|
||||
const props = defineProps<{ show: boolean; uploading: boolean; value: any[] }>()
|
||||
const emits = defineEmits(["update:show", "update:uploading", "update:value"])
|
||||
|
||||
const picked = ref<any[]>([])
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function upload(file?: any) {
|
||||
if (props.uploading) return
|
||||
|
||||
const data = new FormData()
|
||||
if (!file) {
|
||||
file = picked.value[0]
|
||||
}
|
||||
|
||||
data.set("attachment", file)
|
||||
data.set("hashcode", await calculateHashCode(file))
|
||||
|
||||
emits("update:uploading", true)
|
||||
const res = await request("messaging", "/api/attachments", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` },
|
||||
body: data
|
||||
})
|
||||
let meta: any
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
meta = await res.json()
|
||||
emits("update:value", props.value.concat([meta.info]))
|
||||
picked.value = []
|
||||
}
|
||||
emits("update:uploading", false)
|
||||
return meta
|
||||
}
|
||||
|
||||
async function dispose(idx: number) {
|
||||
const media = JSON.parse(JSON.stringify(props.value))
|
||||
const item = media.splice(idx)[0]
|
||||
emits("update:value", media)
|
||||
|
||||
const res = await request("messaging", `/api/attachments/${item.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ upload, dispose })
|
||||
|
||||
async function calculateHashCode(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const buffer = reader.result as ArrayBuffer
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
||||
resolve(hashHex)
|
||||
}
|
||||
reader.onerror = () => {
|
||||
reject(reader.error)
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
function getFileName(item: any) {
|
||||
return item.filename.replace(/\.[^/.]+$/, "")
|
||||
}
|
||||
|
||||
function getFileType(item: any) {
|
||||
switch (item.type) {
|
||||
case 1:
|
||||
return "Photo"
|
||||
case 2:
|
||||
return "Video"
|
||||
case 3:
|
||||
return "Audio"
|
||||
default:
|
||||
return "Others"
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number, decimals = 2) {
|
||||
if (!+bytes) return "0 Bytes"
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
|
||||
}
|
||||
</script>
|
102
src/components/chat/renderer/MessageAttachment.vue
Normal file
102
src/components/chat/renderer/MessageAttachment.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<v-chip size="small" variant="tonal" prepend-icon="mdi-paperclip" v-if="props.overview">
|
||||
Attached {{ props.attachments.length }} attachment(s)
|
||||
</v-chip>
|
||||
|
||||
<v-card variant="outlined" class="w-fit max-h-[540px]">
|
||||
<v-carousel
|
||||
hide-delimiter-background
|
||||
height="100%"
|
||||
:hide-delimiters="props.attachments.length <= 1"
|
||||
:show-arrows="false"
|
||||
>
|
||||
<v-carousel-item v-for="(item, idx) in attachments">
|
||||
<img
|
||||
v-if="item.type === 1"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="cursor-zoom-in content-visibility-auto max-h-[540px] object-cover object-c"
|
||||
:src="getUrl(item)"
|
||||
:alt="item.filename"
|
||||
@click="openLightbox(item, idx)"
|
||||
/>
|
||||
<video v-if="item.type === 2" controls class="w-full content-visibility-auto">
|
||||
<source :src="getUrl(item)" />
|
||||
</video>
|
||||
<div v-if="item.type === 3" class="w-[480px] py-12">
|
||||
<div class="text-center">
|
||||
<p class="mb-1">{{ getFileName(item) }}</p>
|
||||
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-[480px] py-12">
|
||||
<div class="text-center">
|
||||
<p>{{ getFileName(item) }}</p>
|
||||
<a class="underline" target="_blank" :href="getUrl(item)">Download</a>
|
||||
</div>
|
||||
</div>
|
||||
</v-carousel-item>
|
||||
</v-carousel>
|
||||
|
||||
<vue-easy-lightbox
|
||||
teleport="#app"
|
||||
:visible="lightbox"
|
||||
:imgs="props.attachments.map((x) => getUrl(x))"
|
||||
v-model:index="currentIndex"
|
||||
@hide="lightbox = false"
|
||||
>
|
||||
<template v-slot:close-btn="{ close }">
|
||||
<v-btn
|
||||
class="fixed left-2 top-2"
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
color="white"
|
||||
:style="`margin-top: ${safeAreaTop}`"
|
||||
@click="close"
|
||||
/>
|
||||
</template>
|
||||
</vue-easy-lightbox>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { buildRequestUrl } from "@/scripts/request"
|
||||
import { computed, ref } from "vue"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import VueEasyLightbox from "vue-easy-lightbox"
|
||||
|
||||
const props = defineProps<{ attachments: any[]; overview?: boolean }>()
|
||||
|
||||
const ui = useUI()
|
||||
|
||||
const lightbox = ref(false)
|
||||
|
||||
const current = ref<any>(null)
|
||||
const currentIndex = ref(0)
|
||||
|
||||
const safeAreaTop = computed(() => {
|
||||
return `${ui.safeArea.top}px`
|
||||
})
|
||||
|
||||
function getFileName(item: any) {
|
||||
return item.filename.replace(/\.[^/.]+$/, "")
|
||||
}
|
||||
|
||||
function getUrl(item: any) {
|
||||
return item.external_url
|
||||
? item.external_url
|
||||
: buildRequestUrl("messaging", `/api/attachments/o/${item.file_id}`)
|
||||
}
|
||||
|
||||
function openLightbox(item: any, idx: number) {
|
||||
current.value = item
|
||||
currentIndex.value = idx
|
||||
lightbox.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.vel-model {
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
@@ -3,21 +3,47 @@
|
||||
Attached {{ props.attachments.length }} attachment(s)
|
||||
</v-chip>
|
||||
|
||||
<v-card v-else variant="outlined" class="max-w-[540px] max-h-[720px]">
|
||||
<v-carousel hide-delimiter-background height="100%" :show-arrows="false">
|
||||
<v-carousel-item v-for="item in attachments">
|
||||
<img v-if="item.type === 1" :src="getUrl(item)" :alt="item.filename" class="cursor-zoom-in"
|
||||
@click="openLightbox" />
|
||||
<video v-if="item.type === 2" controls class="w-full">
|
||||
<v-card v-else variant="outlined" class="w-fit max-h-[540px]">
|
||||
<v-carousel
|
||||
hide-delimiter-background
|
||||
height="100%"
|
||||
:hide-delimiters="props.attachments.length <= 1"
|
||||
:show-arrows="false"
|
||||
>
|
||||
<v-carousel-item v-for="(item, idx) in attachments">
|
||||
<img
|
||||
v-if="item.type === 1"
|
||||
decoding="async"
|
||||
class="cursor-zoom-in max-h-[540px] object-cover object-c"
|
||||
:src="getUrl(item)"
|
||||
:alt="getFileName(item)"
|
||||
@click="openLightbox(item, idx)"
|
||||
/>
|
||||
<video v-else-if="item.type === 2" controls class="w-full content-visibility-auto">
|
||||
<source :src="getUrl(item)" />
|
||||
</video>
|
||||
<div v-if="item.type === 3" class="w-full px-7 py-12">
|
||||
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
|
||||
<div v-else-if="item.type === 3" class="w-[480px] py-12">
|
||||
<div class="text-center">
|
||||
<p class="mb-1">{{ getFileName(item) }}</p>
|
||||
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-[480px] py-12">
|
||||
<div class="text-center">
|
||||
<p>{{ getFileName(item) }}</p>
|
||||
<a class="underline" target="_blank" :href="getUrl(item)">Download</a>
|
||||
</div>
|
||||
</div>
|
||||
</v-carousel-item>
|
||||
</v-carousel>
|
||||
|
||||
<vue-easy-lightbox teleport="#app" :visible="lightbox" :imgs="[getUrl(current)]" @hide="lightbox = false">
|
||||
<vue-easy-lightbox
|
||||
teleport="#app"
|
||||
:visible="lightbox"
|
||||
:imgs="props.attachments.map((x) => getUrl(x))"
|
||||
v-model:index="currentIndex"
|
||||
@hide="lightbox = false"
|
||||
>
|
||||
<template v-slot:close-btn="{ close }">
|
||||
<v-btn
|
||||
class="fixed left-2 top-2"
|
||||
@@ -43,23 +69,28 @@ const props = defineProps<{ attachments: any[]; overview?: boolean }>()
|
||||
const ui = useUI()
|
||||
|
||||
const lightbox = ref(false)
|
||||
const focus = ref(0)
|
||||
|
||||
const current = computed(() => props.attachments[focus.value])
|
||||
const canLightbox = computed(() => current.value.type === 1)
|
||||
const current = ref<any>(null)
|
||||
const currentIndex = ref(0)
|
||||
|
||||
const safeAreaTop = computed(() => {
|
||||
return `${ui.safeArea.top}px`
|
||||
})
|
||||
|
||||
function getUrl(item: any) {
|
||||
return item.external_url ? item.external_url : buildRequestUrl("interactive", `/api/attachments/o/${item.file_id}`)
|
||||
function getFileName(item: any) {
|
||||
return item.filename.replace(/\.[^/.]+$/, "")
|
||||
}
|
||||
|
||||
function openLightbox() {
|
||||
if (canLightbox.value) {
|
||||
lightbox.value = true
|
||||
}
|
||||
function getUrl(item: any) {
|
||||
return item.external_url
|
||||
? item.external_url
|
||||
: buildRequestUrl("interactive", `/api/attachments/o/${item.file_id}`)
|
||||
}
|
||||
|
||||
function openLightbox(item: any, idx: number) {
|
||||
current.value = item
|
||||
currentIndex.value = idx
|
||||
lightbox.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="post-list">
|
||||
<div class="post-list mx-[-8px]">
|
||||
<v-infinite-scroll :items="props.posts" :onLoad="props.loader">
|
||||
<template v-for="(item, idx) in props.posts" :key="item">
|
||||
<div class="mb-3 px-1">
|
||||
<template v-for="(item, idx) in props.posts" :key="item.id">
|
||||
<div class="mb-3 px-[8px]">
|
||||
<v-card>
|
||||
<template #text>
|
||||
<post-item brief :item="item" @update:item="(val) => updateItem(idx, val)" />
|
||||
|
@@ -127,7 +127,7 @@ import { useRealms } from "@/stores/realms"
|
||||
import { computed, reactive, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import PlannedPublish from "@/components/publish/parts/PlannedPublish.vue"
|
||||
import Media from "@/components/publish/parts/Media.vue"
|
||||
import Media from "@/components/publish/parts/PublishMedia.vue"
|
||||
import PublishArea from "@/components/publish/parts/PublishArea.vue"
|
||||
|
||||
const route = useRoute()
|
||||
@@ -262,10 +262,7 @@ watch(
|
||||
}
|
||||
|
||||
.snackbar-progress {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
margin-bottom: -14px;
|
||||
margin-top: 12px;
|
||||
margin: 12px -16px -14px;
|
||||
width: calc(100% + 64px);
|
||||
}
|
||||
</style>
|
||||
|
@@ -93,7 +93,7 @@ import { reactive, ref, watch } from "vue"
|
||||
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/Media.vue"
|
||||
import Media from "@/components/publish/parts/PublishMedia.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const editor = useEditor()
|
||||
@@ -188,10 +188,7 @@ watch(
|
||||
|
||||
<style>
|
||||
.snackbar-progress {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
margin-bottom: -14px;
|
||||
margin-top: 12px;
|
||||
margin: 12px -16px -14px;
|
||||
width: calc(100% + 64px);
|
||||
}
|
||||
</style>
|
||||
|
@@ -22,7 +22,7 @@ const realms = useRealms()
|
||||
|
||||
const props = defineProps<{ item: any }>()
|
||||
|
||||
const isOwned = computed(() => props.item?.account_id === id.userinfo.data.id)
|
||||
const isOwned = computed(() => props.item?.account_id === id.userinfo.idSet?.interactive)
|
||||
|
||||
function editRealm() {
|
||||
realms.related.edit_to = props.item
|
||||
|
@@ -3,12 +3,12 @@
|
||||
<template #text>
|
||||
You are deleting a realm
|
||||
<b>{{ realms.related.delete_to?.name }}</b> <br />
|
||||
All posts belonging to this domain will be deleted and never appear again. Are you confirm?
|
||||
All posts belonging to this realm will be deleted and never appear again. Are you confirm?
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="w-full flex justify-end">
|
||||
<v-btn color="grey-darken-3" @click="realms.show.delete = false">Not really</v-btn>
|
||||
<v-btn color="error" :disabled="loading" @click="deletePost">Yes</v-btn>
|
||||
<v-btn color="error" :disabled="loading" @click="deleteRealm">Yes</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-card>
|
||||
@@ -36,7 +36,7 @@ const error = ref<string | null>(null)
|
||||
const success = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
async function deletePost() {
|
||||
async function deleteRealm() {
|
||||
const target = realms.related.delete_to
|
||||
const url = `/api/realms/${target.id}`
|
||||
|
||||
@@ -52,8 +52,8 @@ async function deletePost() {
|
||||
realms.show.delete = false
|
||||
realms.related.delete_to = null
|
||||
emits("relist")
|
||||
if (route.name?.toString()?.startsWith("realm")) {
|
||||
router.push({ name: "explore" })
|
||||
if (route.name?.toString()?.includes("realm")) {
|
||||
await router.push({ name: "explore" })
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
|
@@ -1,27 +1,29 @@
|
||||
<template>
|
||||
<v-list density="comfortable">
|
||||
<v-list-subheader>
|
||||
Realms
|
||||
<v-badge color="warning" content="Alpha" inline />
|
||||
</v-list-subheader>
|
||||
<v-list-group value="realms">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-account-box-multiple"
|
||||
title="Realms"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
v-for="item in realms.available"
|
||||
exact
|
||||
prepend-icon="mdi-account-multiple"
|
||||
append-icon="mdi-account-multiple"
|
||||
:to="{ name: 'realms.page', params: { realmId: item.id } }"
|
||||
:title="item.name"
|
||||
/>
|
||||
|
||||
<v-divider v-if="realms.available.length > 0" class="border-opacity-75 my-2" />
|
||||
|
||||
<v-list-item
|
||||
prepend-icon="mdi-plus"
|
||||
append-icon="mdi-plus"
|
||||
title="Create a realm"
|
||||
variant="plain"
|
||||
:disabled="!id.userinfo.isLoggedIn"
|
||||
@click="createRealm"
|
||||
/>
|
||||
</v-list>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
@@ -111,7 +111,7 @@ async function kickMember(item: any) {
|
||||
}
|
||||
|
||||
function checkKickable(item: any) {
|
||||
if (item.account?.id === id.userinfo.data?.id) return false
|
||||
if (item.account?.id === id.userinfo.idSet?.interactive) return false
|
||||
if (item.account?.id === props.item?.account_id) return false
|
||||
return true
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<v-bottom-sheet v-model="realms.show.editor">
|
||||
<v-bottom-sheet class="max-w-[480px]" v-model="realms.show.editor">
|
||||
<realm-editor @relist="realms.list" />
|
||||
</v-bottom-sheet>
|
||||
<v-bottom-sheet v-model="realms.show.delete">
|
||||
<v-bottom-sheet class="max-w-[480px]" v-model="realms.show.delete">
|
||||
<realm-deletion @relist="realms.list" />
|
||||
</v-bottom-sheet>
|
||||
</template>
|
||||
|
@@ -3,6 +3,9 @@
|
||||
<v-system-bar v-show="ui.safeArea.top > 0" color="primary" :order="1" :height="ui.safeArea.top" />
|
||||
|
||||
<router-view />
|
||||
|
||||
<realm-tools />
|
||||
<channel-tools />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +13,8 @@
|
||||
import { onMounted, ref } from "vue"
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import RealmTools from "@/components/realms/RealmTools.vue"
|
||||
import ChannelTools from "@/components/chat/channels/ChannelTools.vue"
|
||||
|
||||
const ui = useUI()
|
||||
|
||||
|
72
src/layouts/chat.vue
Normal file
72
src/layouts/chat.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<v-app-bar :order="5" 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" />
|
||||
|
||||
<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 v-if="channels.current">
|
||||
<channel-action :item="channels.current" />
|
||||
</div>
|
||||
</div>
|
||||
</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 { useChannels } from "@/stores/channels"
|
||||
import ChannelAction from "@/components/chat/channels/ChannelAction.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const channels = useChannels()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const loading = 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()
|
||||
} else {
|
||||
error.value = null
|
||||
channels.current = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.channel,
|
||||
(val) => {
|
||||
if (val) {
|
||||
channels.messages = []
|
||||
readMetadata()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(() => channels.done, (val) => {
|
||||
if (val) {
|
||||
readMetadata().then(() => {
|
||||
channels.messages = []
|
||||
channels.done = false
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
channels.current = null
|
||||
channels.messages = []
|
||||
})
|
||||
</script>
|
@@ -6,33 +6,46 @@
|
||||
:rail="drawerMini"
|
||||
:rail-width="58"
|
||||
:order="0"
|
||||
floating
|
||||
@click="drawerMini = false"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center justify-between px-3 pb-2.5 border-opacity-15 min-h-[64px]"
|
||||
style="border-bottom-width: thin"
|
||||
:style="`padding-top: max(${safeAreaTop}, 10px)`">
|
||||
<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="36" height="36" class="block" />
|
||||
<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>
|
||||
|
||||
<v-btn
|
||||
v-show="!drawerMini"
|
||||
icon="mdi-arrow-collapse-left"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="drawerMini = true"
|
||||
/>
|
||||
</div>
|
||||
<v-spacer />
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<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 />
|
||||
</div>
|
||||
</v-list>
|
||||
|
||||
<!-- User info -->
|
||||
<v-list class="border-opacity-15 h-[64px]" style="border-top-width: thin"
|
||||
:style="`margin-bottom: ${safeAreaBottom}`">
|
||||
<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" />
|
||||
@@ -44,8 +57,12 @@
|
||||
</template>
|
||||
|
||||
<v-list density="compact">
|
||||
<v-list-item title="Solarpass" prepend-icon="mdi-passport-biometric" target="_blank"
|
||||
:href="passportUrl" />
|
||||
<v-list-item
|
||||
title="Solarpass"
|
||||
prepend-icon="mdi-passport-biometric"
|
||||
target="_blank"
|
||||
:href="passportUrl"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
@@ -75,36 +92,19 @@
|
||||
<v-main id="main">
|
||||
<router-view />
|
||||
</v-main>
|
||||
|
||||
<v-menu open-on-hover open-on-click :open-delay="0" :close-delay="0" location="top"
|
||||
transition="scroll-y-reverse-transition">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-fab v-bind="props" appear class="editor-fab" icon="mdi-pencil" color="primary" size="64"
|
||||
:active="id.userinfo.isLoggedIn" />
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 mb-4">
|
||||
<v-btn variant="elevated" color="secondary" icon="mdi-newspaper-variant" @click="editor.show.article = true" />
|
||||
<v-btn variant="elevated" color="accent" icon="mdi-camera-iris" @click="editor.show.moment = true" />
|
||||
</div>
|
||||
</v-menu>
|
||||
|
||||
<post-tools />
|
||||
<realm-tools />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import { useEditor } from "@/stores/editor"
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import { useWellKnown } from "@/stores/wellKnown"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import PostTools from "@/components/publish/PostTools.vue"
|
||||
import RealmTools from "@/components/realms/RealmTools.vue"
|
||||
import RealmList from "@/components/realms/RealmList.vue"
|
||||
import NotificationList from "@/components/NotificationList.vue"
|
||||
import ChannelList from "@/components/chat/channels/ChannelList.vue"
|
||||
|
||||
const ui = useUI()
|
||||
const expanded = ref<string[]>(["channels"])
|
||||
|
||||
const safeAreaTop = computed(() => {
|
||||
return `${ui.safeArea.top}px`
|
||||
@@ -115,7 +115,6 @@ const safeAreaBottom = computed(() => {
|
||||
})
|
||||
|
||||
const id = useUserinfo()
|
||||
const editor = useEditor()
|
||||
|
||||
const username = computed(() => {
|
||||
if (id.userinfo.isLoggedIn) {
|
||||
@@ -146,10 +145,3 @@ const drawerOpen = ref(true)
|
||||
const drawerMini = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-fab {
|
||||
position: fixed !important;
|
||||
bottom: 16px;
|
||||
right: 20px;
|
||||
}
|
||||
</style>
|
||||
|
63
src/layouts/plaza.vue
Normal file
63
src/layouts/plaza.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<router-view />
|
||||
|
||||
<v-fab
|
||||
appear
|
||||
class="editor-fab"
|
||||
color="primary"
|
||||
size="64"
|
||||
:active="id.userinfo.isLoggedIn"
|
||||
>
|
||||
<v-icon icon="mdi-pencil" />
|
||||
|
||||
<v-speed-dial
|
||||
target=".editor-fab"
|
||||
activator="parent"
|
||||
location="top center"
|
||||
class="editor-speed-dial"
|
||||
transition="slide-y-reverse-transition"
|
||||
open-on-hover
|
||||
open-on-click
|
||||
>
|
||||
<v-btn
|
||||
key="article"
|
||||
variant="elevated"
|
||||
color="secondary"
|
||||
icon="mdi-newspaper-variant"
|
||||
@click="editor.show.article = true"
|
||||
/>
|
||||
<v-btn
|
||||
key="moment"
|
||||
variant="elevated"
|
||||
color="accent"
|
||||
icon="mdi-camera-iris"
|
||||
@click="editor.show.moment = true"
|
||||
/>
|
||||
</v-speed-dial>
|
||||
</v-fab>
|
||||
|
||||
<post-tools />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEditor } from "@/stores/editor"
|
||||
import { useUserinfo } from "@/stores/userinfo"
|
||||
import PostTools from "@/components/publish/PostTools.vue"
|
||||
|
||||
const id = useUserinfo()
|
||||
const editor = useEditor()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-fab {
|
||||
position: fixed !important;
|
||||
bottom: 16px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.editor-speed-dial {
|
||||
position: fixed !important;
|
||||
bottom: 16px;
|
||||
right: 20px;
|
||||
}
|
||||
</style>
|
@@ -10,25 +10,43 @@ const router = createRouter({
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
name: "explore",
|
||||
component: () => import("@/views/explore.vue")
|
||||
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")
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
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")
|
||||
path: "/chat/:channel",
|
||||
component: () => import("@/layouts/chat.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "chat.channel",
|
||||
component: () => import("@/views/chat/page.vue"),
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
|
@@ -3,7 +3,8 @@ import { Preferences } from "@capacitor/preferences"
|
||||
|
||||
const serviceMap: { [id: string]: string } = {
|
||||
interactive: "https://co.solsynth.dev",
|
||||
identity: "https://id.solsynth.dev"
|
||||
identity: "https://id.solsynth.dev",
|
||||
messaging: "https://im.solsynth.dev",
|
||||
}
|
||||
|
||||
export async function request(service: string, input: string, init?: RequestInit, noRetry?: boolean) {
|
||||
|
105
src/stores/channels.ts
Normal file
105
src/stores/channels.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
|
||||
import { buildRequestUrl, request } from "@/scripts/request"
|
||||
import { useRoute } from "vue-router"
|
||||
|
||||
export const useChannels = defineStore("channels", () => {
|
||||
let socket: WebSocket
|
||||
|
||||
const done = ref(false)
|
||||
|
||||
const show = reactive({
|
||||
members: false,
|
||||
editor: false,
|
||||
delete: false,
|
||||
|
||||
messages: {
|
||||
delete: false
|
||||
}
|
||||
})
|
||||
|
||||
const related = reactive<{ [id: string]: any }>({
|
||||
edit_to: null,
|
||||
manage_to: null,
|
||||
delete_to: null,
|
||||
|
||||
messages: {
|
||||
edit_to: null,
|
||||
reply_to: null,
|
||||
delete_to: null
|
||||
}
|
||||
})
|
||||
|
||||
const available = ref<any[]>([])
|
||||
const current = ref<any>(null)
|
||||
const messages = ref<any[]>([])
|
||||
|
||||
const route = useRoute()
|
||||
watch(
|
||||
() => route.params.channel,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
messages.value = []
|
||||
current.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function list() {
|
||||
if (!(await checkLoggedIn())) return
|
||||
|
||||
const res = await request("messaging", "/api/channels/me/available", {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
throw new Error(await res.text())
|
||||
} else {
|
||||
available.value = await res.json()
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!(await checkLoggedIn())) return
|
||||
|
||||
const uri = buildRequestUrl("messaging", "/api/unified").replace("http", "ws")
|
||||
|
||||
socket = new WebSocket(uri + `?tk=${await getAtk() as string}`)
|
||||
|
||||
socket.addEventListener("open", (event) => {
|
||||
console.log("[MESSAGING] The unified websocket has been established... ", event.type)
|
||||
})
|
||||
socket.addEventListener("close", (event) => {
|
||||
console.warn("[MESSAGING] The unified websocket is disconnected... ", event.reason, event.code)
|
||||
})
|
||||
socket.addEventListener("message", (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
const payload = data["p"]
|
||||
if (payload?.channel_id === current.value.id) {
|
||||
switch (data["w"]) {
|
||||
case "messages.new":
|
||||
messages.value.unshift(payload)
|
||||
break
|
||||
case "messages.update":
|
||||
messages.value = messages.value.map((x) => {
|
||||
if (x.id === payload.id) return payload
|
||||
else return x
|
||||
})
|
||||
break
|
||||
case "messages.burnt":
|
||||
messages.value = messages.value.filter((x) => {
|
||||
return x.id !== payload.id
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
socket.close()
|
||||
}
|
||||
|
||||
return { done, show, related, available, current, messages, list, connect, disconnect }
|
||||
})
|
85
src/stores/notifications.ts
Normal file
85
src/stores/notifications.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { ref } from "vue"
|
||||
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
|
||||
import { buildRequestUrl, request } from "@/scripts/request"
|
||||
import { LocalNotifications } from "@capacitor/local-notifications"
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
|
||||
export const useNotifications = defineStore("notifications", () => {
|
||||
let socket: WebSocket
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const notifications = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
async function list() {
|
||||
loading.value = true
|
||||
const res = await request(
|
||||
"identity",
|
||||
"/api/notifications?" +
|
||||
new URLSearchParams({
|
||||
take: (25).toString(),
|
||||
offset: (0).toString()
|
||||
}),
|
||||
{
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
}
|
||||
)
|
||||
if (res.status === 200) {
|
||||
const data = await res.json()
|
||||
notifications.value = data["data"]
|
||||
total.value = data["count"]
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
notifications.value.splice(idx, 1)
|
||||
total.value--
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!(await checkLoggedIn())) return
|
||||
|
||||
const uri = buildRequestUrl("identity", "/api/notifications/listen").replace("http", "ws")
|
||||
|
||||
socket = new WebSocket(uri + `?tk=${await getAtk() as string}`)
|
||||
|
||||
socket.addEventListener("open", (event) => {
|
||||
console.log("[NOTIFICATIONS] The listen websocket has been established... ", event.type)
|
||||
})
|
||||
socket.addEventListener("close", (event) => {
|
||||
console.warn("[NOTIFICATIONS] The listen websocket is disconnected... ", event.reason, event.code)
|
||||
})
|
||||
socket.addEventListener("message", (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
notifications.value.push(data)
|
||||
total.value++
|
||||
|
||||
if (Capacitor.getPlatform() === "web") {
|
||||
new Notification(data["subject"], {
|
||||
body: data["content"]
|
||||
})
|
||||
} else {
|
||||
LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{ id: data["id"], title: data["subject"], body: data["content"] }
|
||||
]
|
||||
}).then((res) => console.log(res))
|
||||
}
|
||||
})
|
||||
|
||||
if (Capacitor.getPlatform() === "web") {
|
||||
await Notification.requestPermission()
|
||||
} else {
|
||||
await LocalNotifications.requestPermissions()
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
socket.close()
|
||||
}
|
||||
|
||||
return { loading, notifications, total, list, remove, connect, disconnect }
|
||||
})
|
@@ -11,7 +11,7 @@ export const useRealms = defineStore("realms", () => {
|
||||
delete: false
|
||||
})
|
||||
|
||||
const related_to = reactive<{ edit_to: any; delete_to: any }>({
|
||||
const related = reactive<{ edit_to: any; delete_to: any }>({
|
||||
edit_to: null,
|
||||
delete_to: null
|
||||
})
|
||||
@@ -31,7 +31,5 @@ export const useRealms = defineStore("realms", () => {
|
||||
}
|
||||
}
|
||||
|
||||
list().then(() => console.log("[STARTUP HOOK] Fetch available realm successes."))
|
||||
|
||||
return { done, show, related: related_to, available, list }
|
||||
return { done, show, related, available, list }
|
||||
})
|
||||
|
@@ -2,11 +2,14 @@ import { defineStore } from "pinia"
|
||||
import { ref } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { Preferences } from "@capacitor/preferences"
|
||||
import { useRealms } from "@/stores/realms"
|
||||
import { useChannels } from "@/stores/channels"
|
||||
|
||||
export interface Userinfo {
|
||||
isReady: boolean
|
||||
isLoggedIn: boolean
|
||||
displayName: string
|
||||
idSet: { [id: string]: number }
|
||||
data: any
|
||||
}
|
||||
|
||||
@@ -14,6 +17,7 @@ const defaultUserinfo: Userinfo = {
|
||||
isReady: false,
|
||||
isLoggedIn: false,
|
||||
displayName: "Citizen",
|
||||
idSet: {},
|
||||
data: null
|
||||
}
|
||||
|
||||
@@ -30,6 +34,10 @@ export async function checkLoggedIn(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export const useUserinfo = defineStore("userinfo", () => {
|
||||
const userinfoHooks = {
|
||||
after: [useRealms().list, useChannels().list, useChannels().connect]
|
||||
}
|
||||
|
||||
const userinfo = ref(defaultUserinfo)
|
||||
const isReady = ref(false)
|
||||
|
||||
@@ -41,19 +49,33 @@ export const useUserinfo = defineStore("userinfo", () => {
|
||||
const res = await request("identity", "/api/users/me", {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
|
||||
if (res.status !== 200) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
const federationResp = await Promise.all([
|
||||
request("interactive", "/api/users/me", {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
}),
|
||||
request("messaging", "/api/users/me", {
|
||||
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||
})
|
||||
])
|
||||
|
||||
userinfo.value = {
|
||||
isReady: true,
|
||||
isLoggedIn: true,
|
||||
displayName: data["nick"],
|
||||
idSet: {
|
||||
interactive: (await federationResp[0].json())["id"],
|
||||
messaging: (await federationResp[1].json())["id"]
|
||||
},
|
||||
data: data
|
||||
}
|
||||
|
||||
userinfoHooks.after.forEach((call) => call())
|
||||
}
|
||||
|
||||
return { userinfo, isReady, readProfiles }
|
||||
|
101
src/views/chat/page.vue
Normal file
101
src/views/chat/page.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<v-container fluid class="px-0">
|
||||
<div class="message-list">
|
||||
<chat-list :loader="readMore" :messages="channels.messages" />
|
||||
</div>
|
||||
</v-container>
|
||||
|
||||
<v-footer
|
||||
app
|
||||
class="flex items-center border-opacity-15 min-h-[64px]"
|
||||
style="border-top-width: thin"
|
||||
:style="`padding-bottom: max(${safeAreaBottom}, 8px)`"
|
||||
>
|
||||
<chat-editor class="flex-grow-1" @sent="scrollTop" />
|
||||
</v-footer>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChannels } from "@/stores/channels"
|
||||
import { request } from "@/scripts/request"
|
||||
import { useUI } from "@/stores/ui"
|
||||
import { computed, onUnmounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import ChatList from "@/components/chat/ChatList.vue"
|
||||
import ChatEditor from "@/components/chat/ChatEditor.vue"
|
||||
|
||||
const ui = useUI()
|
||||
const route = useRoute()
|
||||
const channels = useChannels()
|
||||
|
||||
const safeAreaBottom = computed(() => {
|
||||
return `${ui.safeArea.bottom}px`
|
||||
})
|
||||
|
||||
const chatList = ref<HTMLDivElement>()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
async function readHistory() {
|
||||
loading.value = true
|
||||
const res = await request(
|
||||
"messaging",
|
||||
`/api/channels/${route.params.channel}/messages?` + new URLSearchParams({
|
||||
take: pagination.pageSize.toString(),
|
||||
offset: ((pagination.page - 1) * pagination.pageSize).toString()
|
||||
})
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
pagination.total = data["count"]
|
||||
channels.messages.push(...(data["data"] ?? []))
|
||||
error.value = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function readMore({ done }: any) {
|
||||
// Reach the end of data
|
||||
if (pagination.total === 0) {
|
||||
done("ok")
|
||||
return
|
||||
}
|
||||
if (pagination.total <= pagination.page * pagination.pageSize) {
|
||||
done("empty")
|
||||
return
|
||||
}
|
||||
|
||||
pagination.page++
|
||||
await readHistory()
|
||||
|
||||
if (error.value != null) done("error")
|
||||
else {
|
||||
if (pagination.total > 0) done("ok")
|
||||
else done("empty")
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => channels.current,
|
||||
(val) => {
|
||||
if (val) {
|
||||
pagination.page = 1
|
||||
pagination.total = 0
|
||||
readHistory()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function scrollTop() {
|
||||
window.scroll({ top: 0 })
|
||||
}
|
||||
</script>
|
@@ -4,7 +4,7 @@
|
||||
<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 w-full h-full md:min-w-[320px] md:max-w-[320px] 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,8 +16,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import PostList from "@/components/posts/PostList.vue"
|
||||
import { reactive, ref } from "vue"
|
||||
import { request } from "@/scripts/request"
|
||||
import { reactive, ref } from "vue"
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
@@ -105,6 +105,8 @@ watch(
|
||||
() => route.params.realmId,
|
||||
() => {
|
||||
posts.value = []
|
||||
pagination.page = 1
|
||||
pagination.total = 0
|
||||
readMetadata()
|
||||
readPosts()
|
||||
},
|
||||
|
@@ -17,10 +17,29 @@ export default defineConfig({
|
||||
useCredentials: true,
|
||||
manifest: {
|
||||
name: "Solian",
|
||||
short_name: "Solian",
|
||||
description: "The Solar Network entrypoint.",
|
||||
description: "The Solar Network Application",
|
||||
theme_color: "#4b5094",
|
||||
display: "standalone",
|
||||
icons: [
|
||||
{
|
||||
src: "icon.png",
|
||||
sizes: "1024x1024",
|
||||
type: "image/png",
|
||||
purpose: "any"
|
||||
},
|
||||
{
|
||||
src: "maskable-icon.png",
|
||||
sizes: "1024x1024",
|
||||
type: "image/png",
|
||||
purpose: "maskable"
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
sourcemap: true,
|
||||
cleanupOutdatedCaches: true,
|
||||
globPatterns: ['**/*.{js,css,ico,png,svg}'],
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
|
Reference in New Issue
Block a user