✨ Add SolarAgent launcher
🗑️ Remove the embed frontend
			
			
This commit is contained in:
		
							
								
								
									
										3
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								go.mod
									
									
									
									
									
								
							@@ -27,6 +27,9 @@ require (
 | 
			
		||||
	github.com/go-playground/locales v0.14.1 // indirect
 | 
			
		||||
	github.com/go-playground/universal-translator v0.18.1 // indirect
 | 
			
		||||
	github.com/go-sql-driver/mysql v1.7.1 // indirect
 | 
			
		||||
	github.com/gofiber/template v1.8.3 // indirect
 | 
			
		||||
	github.com/gofiber/template/html/v2 v2.1.1 // indirect
 | 
			
		||||
	github.com/gofiber/utils v1.1.0 // indirect
 | 
			
		||||
	github.com/golang/protobuf v1.5.3 // indirect
 | 
			
		||||
	github.com/hashicorp/hcl v1.0.0 // indirect
 | 
			
		||||
	github.com/jackc/pgpassfile v1.0.0 // indirect
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.sum
									
									
									
									
									
								
							@@ -27,6 +27,12 @@ github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9
 | 
			
		||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 | 
			
		||||
github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE=
 | 
			
		||||
github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
 | 
			
		||||
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
 | 
			
		||||
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
 | 
			
		||||
github.com/gofiber/template/html/v2 v2.1.1 h1:QEy3O3EBkvwDthy5bXVGUseOyO6ldJoiDxlF4+MJiV8=
 | 
			
		||||
github.com/gofiber/template/html/v2 v2.1.1/go.mod h1:2G0GHHOUx70C1LDncoBpe4T6maQbNa4x1CVNFW0wju0=
 | 
			
		||||
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
 | 
			
		||||
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
 | 
			
		||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
 | 
			
		||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 | 
			
		||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								pkg/embed.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								pkg/embed.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
package pkg
 | 
			
		||||
 | 
			
		||||
import "embed"
 | 
			
		||||
 | 
			
		||||
//go:embed views/*
 | 
			
		||||
var FS embed.FS
 | 
			
		||||
@@ -1,17 +1,18 @@
 | 
			
		||||
package server
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"git.solsynth.dev/hydrogen/interactive/pkg"
 | 
			
		||||
	"github.com/gofiber/fiber/v2/middleware/favicon"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.solsynth.dev/hydrogen/interactive/pkg/views"
 | 
			
		||||
	"github.com/gofiber/fiber/v2"
 | 
			
		||||
	"github.com/gofiber/fiber/v2/middleware/cache"
 | 
			
		||||
	"github.com/gofiber/fiber/v2/middleware/cors"
 | 
			
		||||
	"github.com/gofiber/fiber/v2/middleware/filesystem"
 | 
			
		||||
	"github.com/gofiber/fiber/v2/middleware/idempotency"
 | 
			
		||||
	"github.com/gofiber/fiber/v2/middleware/logger"
 | 
			
		||||
	"github.com/gofiber/template/html/v2"
 | 
			
		||||
	jsoniter "github.com/json-iterator/go"
 | 
			
		||||
	"github.com/rs/zerolog/log"
 | 
			
		||||
	"github.com/spf13/viper"
 | 
			
		||||
@@ -20,6 +21,8 @@ import (
 | 
			
		||||
var A *fiber.App
 | 
			
		||||
 | 
			
		||||
func NewServer() {
 | 
			
		||||
	templates := html.NewFileSystem(http.FS(pkg.FS), ".gohtml")
 | 
			
		||||
 | 
			
		||||
	A = fiber.New(fiber.Config{
 | 
			
		||||
		DisableStartupMessage: true,
 | 
			
		||||
		EnableIPValidation:    true,
 | 
			
		||||
@@ -30,6 +33,8 @@ func NewServer() {
 | 
			
		||||
		JSONDecoder:           jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
 | 
			
		||||
		BodyLimit:             50 * 1024 * 1024,
 | 
			
		||||
		EnablePrintRoutes:     viper.GetBool("debug.print_routes"),
 | 
			
		||||
		Views:                 templates,
 | 
			
		||||
		ViewsLayout:           "views/index",
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	A.Use(idempotency.New())
 | 
			
		||||
@@ -121,15 +126,17 @@ func NewServer() {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	A.Use("/", cache.New(cache.Config{
 | 
			
		||||
		Expiration:   24 * time.Hour,
 | 
			
		||||
		CacheControl: true,
 | 
			
		||||
	}), filesystem.New(filesystem.Config{
 | 
			
		||||
		Root:         http.FS(views.FS),
 | 
			
		||||
		PathPrefix:   "dist",
 | 
			
		||||
		Index:        "index.html",
 | 
			
		||||
		NotFoundFile: "dist/index.html",
 | 
			
		||||
	A.Use(favicon.New(favicon.Config{
 | 
			
		||||
		FileSystem: http.FS(pkg.FS),
 | 
			
		||||
		File:       "views/favicon.png",
 | 
			
		||||
		URL:        "/favicon.png",
 | 
			
		||||
	}))
 | 
			
		||||
 | 
			
		||||
	A.Get("/", func(c *fiber.Ctx) error {
 | 
			
		||||
		return c.Render("views/open", fiber.Map{
 | 
			
		||||
			"frontend": viper.GetString("frontend"),
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Listen() {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +0,0 @@
 | 
			
		||||
/* eslint-env node */
 | 
			
		||||
require("@rushstack/eslint-patch/modern-module-resolution")
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  root: true,
 | 
			
		||||
  extends: [
 | 
			
		||||
    "plugin:vue/vue3-essential",
 | 
			
		||||
    "eslint:recommended",
 | 
			
		||||
    "@vue/eslint-config-typescript",
 | 
			
		||||
    "@vue/eslint-config-prettier/skip-formatting"
 | 
			
		||||
  ],
 | 
			
		||||
  parserOptions: {
 | 
			
		||||
    ecmaVersion: "latest"
 | 
			
		||||
  },
 | 
			
		||||
  rules: {
 | 
			
		||||
    "vue/multi-word-component-names": "off",
 | 
			
		||||
    "vue/valid-v-for": "off",
 | 
			
		||||
    "vue/require-v-for-key": "off"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										33
									
								
								pkg/views/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										33
									
								
								pkg/views/.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,33 +0,0 @@
 | 
			
		||||
# Logs
 | 
			
		||||
logs
 | 
			
		||||
*.log
 | 
			
		||||
npm-debug.log*
 | 
			
		||||
yarn-debug.log*
 | 
			
		||||
yarn-error.log*
 | 
			
		||||
pnpm-debug.log*
 | 
			
		||||
lerna-debug.log*
 | 
			
		||||
 | 
			
		||||
node_modules
 | 
			
		||||
.DS_Store
 | 
			
		||||
dist
 | 
			
		||||
dist-ssr
 | 
			
		||||
coverage
 | 
			
		||||
*.local
 | 
			
		||||
 | 
			
		||||
/cypress/videos/
 | 
			
		||||
/cypress/screenshots/
 | 
			
		||||
 | 
			
		||||
# Editor directories and files
 | 
			
		||||
.vscode/*
 | 
			
		||||
!.vscode/extensions.json
 | 
			
		||||
.idea
 | 
			
		||||
*.suo
 | 
			
		||||
*.ntvs*
 | 
			
		||||
*.njsproj
 | 
			
		||||
*.sln
 | 
			
		||||
*.sw?
 | 
			
		||||
 | 
			
		||||
*.tsbuildinfo
 | 
			
		||||
 | 
			
		||||
*.lockb
 | 
			
		||||
*.lock
 | 
			
		||||
@@ -1,8 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://json.schemastore.org/prettierrc",
 | 
			
		||||
  "semi": false,
 | 
			
		||||
  "tabWidth": 2,
 | 
			
		||||
  "singleQuote": false,
 | 
			
		||||
  "printWidth": 120,
 | 
			
		||||
  "trailingComma": "none"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								pkg/views/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								pkg/views/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							@@ -1,8 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "recommendations": [
 | 
			
		||||
    "Vue.volar",
 | 
			
		||||
    "Vue.vscode-typescript-vue-plugin",
 | 
			
		||||
    "dbaeumer.vscode-eslint",
 | 
			
		||||
    "esbenp.prettier-vscode"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
@@ -1,46 +0,0 @@
 | 
			
		||||
# views
 | 
			
		||||
 | 
			
		||||
This template should help get you started developing with Vue 3 in Vite.
 | 
			
		||||
 | 
			
		||||
## Recommended IDE Setup
 | 
			
		||||
 | 
			
		||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
 | 
			
		||||
 | 
			
		||||
## 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 [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
 | 
			
		||||
 | 
			
		||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
 | 
			
		||||
 | 
			
		||||
1. Disable the built-in TypeScript Extension
 | 
			
		||||
   1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
 | 
			
		||||
   2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
 | 
			
		||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
 | 
			
		||||
 | 
			
		||||
## Customize configuration
 | 
			
		||||
 | 
			
		||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
 | 
			
		||||
 | 
			
		||||
## Project Setup
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm install
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Compile and Hot-Reload for Development
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run dev
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Type-Check, Compile and Minify for Production
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Lint with [ESLint](https://eslint.org/)
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run lint
 | 
			
		||||
```
 | 
			
		||||
@@ -1,6 +0,0 @@
 | 
			
		||||
package views
 | 
			
		||||
 | 
			
		||||
import "embed"
 | 
			
		||||
 | 
			
		||||
//go:embed all:dist
 | 
			
		||||
var FS embed.FS
 | 
			
		||||
							
								
								
									
										1
									
								
								pkg/views/env.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								pkg/views/env.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -1 +0,0 @@
 | 
			
		||||
/// <reference types="vite/client" />
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								pkg/views/favicon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								pkg/views/favicon.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 71 KiB  | 
							
								
								
									
										28
									
								
								pkg/views/index.gohtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								pkg/views/index.gohtml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
    <meta charset="UTF-8">
 | 
			
		||||
    <meta name="viewport"
 | 
			
		||||
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
 | 
			
		||||
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
 | 
			
		||||
 | 
			
		||||
    <link rel="icon" type="image/png" href="favicon.png">
 | 
			
		||||
 | 
			
		||||
    <link rel="preconnect" href="https://fonts.googleapis.com">
 | 
			
		||||
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 | 
			
		||||
    <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet">
 | 
			
		||||
 | 
			
		||||
    <title>Hydrogen.Interactive</title>
 | 
			
		||||
 | 
			
		||||
    <style>
 | 
			
		||||
        html, body {
 | 
			
		||||
            padding: 0;
 | 
			
		||||
            margin: 0;
 | 
			
		||||
            font-family: Roboto Mono, monospace;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
{{embed}}
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
@@ -1,13 +0,0 @@
 | 
			
		||||
<!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" />
 | 
			
		||||
    <title>Solarplaza</title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
    <div id="app"></div>
 | 
			
		||||
    <script type="module" src="/src/main.ts"></script>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										60
									
								
								pkg/views/open.gohtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								pkg/views/open.gohtml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
			
		||||
<div class="container">
 | 
			
		||||
    <div>
 | 
			
		||||
        <img src="/favicon.png" width="128" height="128" alt="Icon"/>
 | 
			
		||||
 | 
			
		||||
        <p class="caption text-blinking">Launching Solian... 🚀</p>
 | 
			
		||||
        <p class="description">
 | 
			
		||||
            Hold on a second... <br/>
 | 
			
		||||
            We are redirecting you to our application...
 | 
			
		||||
        </p>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
    function redirect() {
 | 
			
		||||
        window.location.href = {{ .frontend }}
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setTimeout(() => redirect(), 1850)
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
    .container {
 | 
			
		||||
        width: 100vw;
 | 
			
		||||
        height: 100vh;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        text-align: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .caption {
 | 
			
		||||
        margin-top: 4px;
 | 
			
		||||
        font-weight: 600;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .text-blinking {
 | 
			
		||||
        animation: text-blinking ease-in-out infinite 1.5s;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .description {
 | 
			
		||||
        margin-top: 4px;
 | 
			
		||||
        font-size: 0.85rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    p {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @keyframes text-blinking {
 | 
			
		||||
        0% {
 | 
			
		||||
            opacity: 100%;
 | 
			
		||||
        }
 | 
			
		||||
        50% {
 | 
			
		||||
            opacity: 10%;
 | 
			
		||||
        }
 | 
			
		||||
        100% {
 | 
			
		||||
            opacity: 100%;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,48 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "views",
 | 
			
		||||
  "version": "0.0.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "vite",
 | 
			
		||||
    "build": "run-p type-check \"build-only {@}\" --",
 | 
			
		||||
    "preview": "vite preview",
 | 
			
		||||
    "build-only": "vite build",
 | 
			
		||||
    "type-check": "vue-tsc --build --force",
 | 
			
		||||
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
 | 
			
		||||
    "format": "prettier --write src/"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@fontsource/roboto": "^5.0.8",
 | 
			
		||||
    "@mdi/font": "^7.4.47",
 | 
			
		||||
    "@unocss/reset": "^0.58.5",
 | 
			
		||||
    "dompurify": "^3.0.9",
 | 
			
		||||
    "marked": "^12.0.0",
 | 
			
		||||
    "pinia": "^2.1.7",
 | 
			
		||||
    "universal-cookie": "^7.1.0",
 | 
			
		||||
    "unocss": "^0.58.5",
 | 
			
		||||
    "vue": "^3.4.15",
 | 
			
		||||
    "vue-easy-lightbox": "next",
 | 
			
		||||
    "vue-router": "^4.2.5",
 | 
			
		||||
    "vuetify": "^3.5.7"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@rushstack/eslint-patch": "^1.3.3",
 | 
			
		||||
    "@tsconfig/node20": "^20.1.2",
 | 
			
		||||
    "@types/dompurify": "^3.0.5",
 | 
			
		||||
    "@types/node": "^20.11.10",
 | 
			
		||||
    "@unocss/preset-typography": "^0.58.5",
 | 
			
		||||
    "@vitejs/plugin-vue": "^5.0.3",
 | 
			
		||||
    "@vitejs/plugin-vue-jsx": "^3.1.0",
 | 
			
		||||
    "@vue/eslint-config-prettier": "^8.0.0",
 | 
			
		||||
    "@vue/eslint-config-typescript": "^12.0.0",
 | 
			
		||||
    "@vue/tsconfig": "^0.5.1",
 | 
			
		||||
    "eslint": "^8.49.0",
 | 
			
		||||
    "eslint-plugin-vue": "^9.17.0",
 | 
			
		||||
    "npm-run-all2": "^6.1.1",
 | 
			
		||||
    "prettier": "^3.0.3",
 | 
			
		||||
    "typescript": "~5.3.0",
 | 
			
		||||
    "vite": "^5.0.11",
 | 
			
		||||
    "vue-tsc": "^1.8.27"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 64 KiB  | 
@@ -1,15 +0,0 @@
 | 
			
		||||
html,
 | 
			
		||||
body,
 | 
			
		||||
#app,
 | 
			
		||||
.v-application {
 | 
			
		||||
  overflow: auto !important;
 | 
			
		||||
  font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.no-scrollbar {
 | 
			
		||||
  scrollbar-width: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.no-scrollbar::-webkit-scrollbar {
 | 
			
		||||
  width: 0;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,82 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-if="loading" class="text-center flex items-center justify-center">
 | 
			
		||||
    <v-progress-circular indeterminate />
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div v-else class="flex flex-col gap-5 mt-3">
 | 
			
		||||
    <div v-for="(item, idx) in props.comments" class="text-sm">
 | 
			
		||||
      <post-item :item="item" @update:item="(val) => updateItem(idx, val)" />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <v-divider class="mt-2 mb-3 border-opacity-50 mx-[-1rem]" />
 | 
			
		||||
 | 
			
		||||
  <v-btn block prepend-icon="mdi-pencil" variant="plain" :disabled="!id.userinfo.isLoggedIn" @click="leaveComment">
 | 
			
		||||
    Leave your comment
 | 
			
		||||
  </v-btn>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { reactive, ref, watch } from "vue"
 | 
			
		||||
import { useEditor } from "@/stores/editor"
 | 
			
		||||
import { useUserinfo } from "@/stores/userinfo"
 | 
			
		||||
import PostItem from "@/components/posts/PostItem.vue"
 | 
			
		||||
 | 
			
		||||
const id = useUserinfo()
 | 
			
		||||
const editor = useEditor()
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
  comments: any[]
 | 
			
		||||
  model: string
 | 
			
		||||
  dataset: string
 | 
			
		||||
  alias: any
 | 
			
		||||
  item: any
 | 
			
		||||
}>()
 | 
			
		||||
const emits = defineEmits(["update:comments"])
 | 
			
		||||
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
 | 
			
		||||
const pagination = reactive({ page: 0, pageSize: 10, total: 0 })
 | 
			
		||||
 | 
			
		||||
async function readComments() {
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(
 | 
			
		||||
    `/api/p/${props.dataset}/${props.alias}/comments?` +
 | 
			
		||||
      new URLSearchParams({
 | 
			
		||||
        take: pagination.pageSize.toString(),
 | 
			
		||||
        offset: (pagination.page * pagination.pageSize).toString()
 | 
			
		||||
      })
 | 
			
		||||
  )
 | 
			
		||||
  if (res.status !== 200) {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  } else {
 | 
			
		||||
    error.value = null
 | 
			
		||||
    const data = await res.json()
 | 
			
		||||
    pagination.total = data["total"]
 | 
			
		||||
    emits("update:comments", data["data"])
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
readComments()
 | 
			
		||||
 | 
			
		||||
function updateItem(idx: number, data: any) {
 | 
			
		||||
  const comments = JSON.parse(JSON.stringify(props.comments))
 | 
			
		||||
  comments[idx] = data
 | 
			
		||||
  emits("update:comments", comments)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(editor, (val) => {
 | 
			
		||||
  if (val.done) {
 | 
			
		||||
    readComments().then(() => (val.done = false))
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function leaveComment() {
 | 
			
		||||
  editor.related.comment_to = JSON.parse(JSON.stringify(props.item))
 | 
			
		||||
  editor.related.comment_to.model_type = props.dataset
 | 
			
		||||
  editor.show.comment = true
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,27 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <component
 | 
			
		||||
    :is="props.brief ? RouterLink : 'div'"
 | 
			
		||||
    :to="{ name: 'posts.details.articles', params: { alias: props.item?.alias } }"
 | 
			
		||||
  >
 | 
			
		||||
    <section v-if="!props.contentOnly" class="mb-2">
 | 
			
		||||
      <h1 class="text-lg font-bold">{{ props.item?.title }}</h1>
 | 
			
		||||
      <div class="text-sm">{{ props.item?.description }}</div>
 | 
			
		||||
    </section>
 | 
			
		||||
 | 
			
		||||
    <div v-else>
 | 
			
		||||
      <article class="prose max-w-none" v-html="parseContent(props.item?.content ?? '')" />
 | 
			
		||||
    </div>
 | 
			
		||||
  </component>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import dompurify from "dompurify"
 | 
			
		||||
import { parse } from "marked"
 | 
			
		||||
import { RouterLink } from "vue-router"
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ item: any; brief?: boolean; contentOnly?: boolean }>()
 | 
			
		||||
 | 
			
		||||
function parseContent(src: string): string {
 | 
			
		||||
  return dompurify().sanitize(parse(src) as string)
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,20 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <article class="prose prose-comment" v-html="parseContent(props.item?.content ?? '')" />
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import dompurify from "dompurify"
 | 
			
		||||
import { parse } from "marked"
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ item: any }>()
 | 
			
		||||
 | 
			
		||||
function parseContent(src: string): string {
 | 
			
		||||
  return dompurify().sanitize(parse(src) as string)
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.prose.prose-comment p {
 | 
			
		||||
  margin: 0 !important;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,26 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <component
 | 
			
		||||
    :is="props.brief ? RouterLink : 'div'"
 | 
			
		||||
    :to="{ name: 'posts.details.moments', params: { alias: props.item?.alias } }"
 | 
			
		||||
  >
 | 
			
		||||
    <article class="prose prose-moment max-w-none" v-html="parseContent(props.item?.content ?? '')" />
 | 
			
		||||
  </component>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import dompurify from "dompurify"
 | 
			
		||||
import { parse } from "marked"
 | 
			
		||||
import { RouterLink } from "vue-router"
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ item: any; brief?: boolean }>()
 | 
			
		||||
 | 
			
		||||
function parseContent(src: string): string {
 | 
			
		||||
  return dompurify().sanitize(parse(src) as string)
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.prose.prose-moment p {
 | 
			
		||||
  margin: 0 !important;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,44 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-menu>
 | 
			
		||||
    <template #activator="{ props }">
 | 
			
		||||
      <v-btn v-bind="props" icon="mdi-dots-vertical" variant="text" size="x-small" />
 | 
			
		||||
    </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="editPost" />
 | 
			
		||||
      <v-list-item v-if="isOwned" append-icon="mdi-delete" title="Delete" @click="deletePost" />
 | 
			
		||||
    </v-list>
 | 
			
		||||
  </v-menu>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { useEditor } from "@/stores/editor"
 | 
			
		||||
import { useUserinfo } from "@/stores/userinfo"
 | 
			
		||||
import { computed } from "vue"
 | 
			
		||||
 | 
			
		||||
const id = useUserinfo()
 | 
			
		||||
const editor = useEditor()
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ item: any }>()
 | 
			
		||||
 | 
			
		||||
const isOwned = computed(() => props.item?.author_id === id.userinfo.data.id)
 | 
			
		||||
 | 
			
		||||
function editPost() {
 | 
			
		||||
  editor.related.edit_to = JSON.parse(JSON.stringify(props.item))
 | 
			
		||||
  // eslint-disable-next-line
 | 
			
		||||
  if (editor.show.hasOwnProperty(props.item.model_type)) {
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    editor.show[props.item.model_type] = true
 | 
			
		||||
  }
 | 
			
		||||
  if (props.item.model_type === "comment") {
 | 
			
		||||
    editor.related.comment_to = JSON.parse(JSON.stringify(props.item))
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function deletePost() {
 | 
			
		||||
  editor.related.delete_to = JSON.parse(JSON.stringify(props.item))
 | 
			
		||||
  editor.related.delete_to.model_type = props.item.model_type + "s"
 | 
			
		||||
  editor.show.delete = true
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
<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 v-else variant="outlined" class="max-w-[540px]">
 | 
			
		||||
    <v-carousel hide-delimiters progress="primary" show-arrows="hover" height="100%">
 | 
			
		||||
      <v-carousel-item v-for="item in attachments">
 | 
			
		||||
        <img v-if="item.type === 1" :src="getUrl(item)" class="cursor-zoom-in" @click="openLightbox" />
 | 
			
		||||
        <video v-if="item.type === 2" controls class="w-full">
 | 
			
		||||
          <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-carousel-item>
 | 
			
		||||
    </v-carousel>
 | 
			
		||||
 | 
			
		||||
    <vue-easy-lightbox teleport="#app" :visible="lightbox" :imgs="[getUrl(current)]" @hide="lightbox = false">
 | 
			
		||||
      <template v-slot:close-btn="{ close }">
 | 
			
		||||
        <v-btn class="fixed left-2 top-2" icon="mdi-close" variant="text" color="white" @click="close" />
 | 
			
		||||
      </template>
 | 
			
		||||
    </vue-easy-lightbox>
 | 
			
		||||
  </v-card>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { computed, ref } from "vue"
 | 
			
		||||
import VueEasyLightbox from "vue-easy-lightbox"
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ attachments: any[]; overview?: boolean }>()
 | 
			
		||||
 | 
			
		||||
const lightbox = ref(false)
 | 
			
		||||
const focus = ref(0)
 | 
			
		||||
 | 
			
		||||
const current = computed(() => props.attachments[focus.value])
 | 
			
		||||
const canLightbox = computed(() => current.value.type === 1)
 | 
			
		||||
 | 
			
		||||
function getUrl(item: any) {
 | 
			
		||||
  return item.external_url ? item.external_url : `/api/attachments/o/${item.file_id}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function openLightbox() {
 | 
			
		||||
  if (canLightbox.value) {
 | 
			
		||||
    lightbox.value = true
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.vel-model {
 | 
			
		||||
  z-index: 10;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,87 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex gap-3">
 | 
			
		||||
    <div>
 | 
			
		||||
      <v-avatar
 | 
			
		||||
        color="grey-lighten-2"
 | 
			
		||||
        icon="mdi-account-circle"
 | 
			
		||||
        class="rounded-card"
 | 
			
		||||
        :image="props.item?.author.avatar"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="flex-grow-1">
 | 
			
		||||
      <div class="font-bold">{{ props.item?.author.nick }}</div>
 | 
			
		||||
 | 
			
		||||
      <div v-if="props.item?.model_type === 'article'" class="text-xs text-grey-darken-4 mb-2">
 | 
			
		||||
        Published an article
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <component :is="renderer[props.item?.model_type]" v-bind="props" />
 | 
			
		||||
 | 
			
		||||
      <post-attachment
 | 
			
		||||
        v-if="props.item?.attachments"
 | 
			
		||||
        class="mt-1.5"
 | 
			
		||||
        :overview="props.item?.model_type !== 'moment'"
 | 
			
		||||
        :attachments="props.item?.attachments"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <post-reaction
 | 
			
		||||
        size="small"
 | 
			
		||||
        :item="props.item"
 | 
			
		||||
        :model="props.item?.model_type ? props.item?.model_type + 's' : 'articles'"
 | 
			
		||||
        :reactions="props.item?.reaction_list ?? {}"
 | 
			
		||||
        @update="updateReactions"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <div class="mt-3 text-xs opacity-80 flex items-center">
 | 
			
		||||
        <span>Posted at {{ new Date(props.item?.created_at).toLocaleString() }}</span>
 | 
			
		||||
        <section v-if="props.item?.realm_id">
 | 
			
		||||
           · <span>In realm #{{ props.item?.realm_id }}</span>
 | 
			
		||||
        </section>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div>
 | 
			
		||||
      <post-action :item="props.item" />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { type Component } from "vue"
 | 
			
		||||
import ArticleContent from "@/components/posts/ArticleContent.vue"
 | 
			
		||||
import MomentContent from "@/components/posts/MomentContent.vue"
 | 
			
		||||
import CommentContent from "@/components/posts/CommentContent.vue"
 | 
			
		||||
import PostAttachment from "@/components/posts/PostAttachment.vue"
 | 
			
		||||
import PostReaction from "@/components/posts/PostReaction.vue"
 | 
			
		||||
import PostAction from "@/components/posts/PostAction.vue"
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ item: any; brief?: boolean }>()
 | 
			
		||||
const emits = defineEmits(["update:item"])
 | 
			
		||||
 | 
			
		||||
const renderer: { [id: string]: Component } = {
 | 
			
		||||
  article: ArticleContent,
 | 
			
		||||
  moment: MomentContent,
 | 
			
		||||
  comment: CommentContent
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateReactions(symbol: string, num: number) {
 | 
			
		||||
  const item = JSON.parse(JSON.stringify(props.item))
 | 
			
		||||
  if (item.reaction_list == null) {
 | 
			
		||||
    item.reaction_list = {}
 | 
			
		||||
  }
 | 
			
		||||
  // eslint-disable-next-line
 | 
			
		||||
  if (item.reaction_list.hasOwnProperty(symbol)) {
 | 
			
		||||
    item.reaction_list[symbol] += num
 | 
			
		||||
  } else {
 | 
			
		||||
    item.reaction_list[symbol] = num
 | 
			
		||||
  }
 | 
			
		||||
  emits("update:item", item)
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.rounded-card {
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,28 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="post-list">
 | 
			
		||||
    <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">
 | 
			
		||||
          <v-card>
 | 
			
		||||
            <template #text>
 | 
			
		||||
              <post-item brief :item="item" @update:item="(val) => updateItem(idx, val)" />
 | 
			
		||||
            </template>
 | 
			
		||||
          </v-card>
 | 
			
		||||
        </div>
 | 
			
		||||
      </template>
 | 
			
		||||
    </v-infinite-scroll>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import PostItem from "@/components/posts/PostItem.vue"
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ posts: any[]; loader: (opts: any) => Promise<any> }>()
 | 
			
		||||
const emits = defineEmits(["update:posts"])
 | 
			
		||||
 | 
			
		||||
function updateItem(idx: number, data: any) {
 | 
			
		||||
  const posts = JSON.parse(JSON.stringify(props.posts))
 | 
			
		||||
  posts[idx] = data
 | 
			
		||||
  emits("update:posts", posts)
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,84 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex gap-[8px] my-[8px]">
 | 
			
		||||
    <v-chip
 | 
			
		||||
      v-for="[k, v] in Object.entries(props.reactions)"
 | 
			
		||||
      :color="pickColor()"
 | 
			
		||||
      :size="props.size"
 | 
			
		||||
      @click="reactPost(k, emojis[k].attitude)"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="ms-2">{{ v }}</div>
 | 
			
		||||
      <template #prepend>{{ emojis[k].icon }}</template>
 | 
			
		||||
    </v-chip>
 | 
			
		||||
 | 
			
		||||
    <v-menu v-if="!props.readonly" location="bottom center">
 | 
			
		||||
      <template v-slot:activator="{ props: binding }">
 | 
			
		||||
        <v-chip v-if="id.userinfo.isLoggedIn" v-bind="binding" :size="props.size" prepend-icon="mdi-emoticon-plus">
 | 
			
		||||
          React
 | 
			
		||||
        </v-chip>
 | 
			
		||||
      </template>
 | 
			
		||||
 | 
			
		||||
      <v-list density="compact" lines="one">
 | 
			
		||||
        <v-list-item v-for="[k, v] in Object.entries(emojis)" @click="reactPost(k, v.attitude)">
 | 
			
		||||
          <v-list-item-title class="font-mono">:{{ k }}:</v-list-item-title>
 | 
			
		||||
          <template #prepend>
 | 
			
		||||
            <div class="me-3">{{ v.icon }}</div>
 | 
			
		||||
          </template>
 | 
			
		||||
        </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>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { getAtk, useUserinfo } from "@/stores/userinfo"
 | 
			
		||||
import { reactive, ref } from "vue"
 | 
			
		||||
 | 
			
		||||
const id = useUserinfo()
 | 
			
		||||
 | 
			
		||||
const emits = defineEmits(["update"])
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
  size?: string
 | 
			
		||||
  readonly?: boolean
 | 
			
		||||
  model: any
 | 
			
		||||
  item: any
 | 
			
		||||
  reactions: { [id: string]: number }
 | 
			
		||||
}>()
 | 
			
		||||
 | 
			
		||||
const emojis: { [id: string]: { icon: string; attitude: number } } = {
 | 
			
		||||
  thumb_up: { icon: "👍", attitude: 1 },
 | 
			
		||||
  clap: { icon: "👏", attitude: 1 }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function pickColor(): string {
 | 
			
		||||
  const colors = ["blue", "green", "purple"]
 | 
			
		||||
  const randomIndex = Math.floor(Math.random() * colors.length)
 | 
			
		||||
  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(`/api/p/${props.model}/${props.item?.id}/react`, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: { Authorization: `Bearer ${getAtk()}`, "Content-Type": "application/json" },
 | 
			
		||||
    body: JSON.stringify({ symbol, attitude })
 | 
			
		||||
  })
 | 
			
		||||
  if (res.status === 201) {
 | 
			
		||||
    status.added = true
 | 
			
		||||
    emits("update", symbol, 1)
 | 
			
		||||
  } else if (res.status === 204) {
 | 
			
		||||
    status.removed = true
 | 
			
		||||
    emits("update", symbol, -1)
 | 
			
		||||
  } else {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,271 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-card :rounded="false">
 | 
			
		||||
    <v-form @submit.prevent="postArticle">
 | 
			
		||||
      <v-toolbar>
 | 
			
		||||
        <div class="article-toolbar">
 | 
			
		||||
          <div class="flex">
 | 
			
		||||
            <v-btn type="button" icon="mdi-close" @click="editor.show.article = false"></v-btn>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <v-toolbar-title>Write an article</v-toolbar-title>
 | 
			
		||||
 | 
			
		||||
          <div class="flex justify-end items-center">
 | 
			
		||||
            <v-tooltip text="Publish">
 | 
			
		||||
              <template #activator="{ props: binding }">
 | 
			
		||||
                <v-btn type="submit" icon="mdi-publish" v-bind="binding" :loading="loading" />
 | 
			
		||||
              </template>
 | 
			
		||||
            </v-tooltip>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </v-toolbar>
 | 
			
		||||
 | 
			
		||||
      <v-card-text>
 | 
			
		||||
        <v-container class="article-container">
 | 
			
		||||
          <v-alert v-if="editor.related.edit_to" class="mb-5" type="info" variant="tonal">
 | 
			
		||||
            You are editing a post with alias <b class="font-mono">{{ editor.related.edit_to?.alias }}</b>
 | 
			
		||||
          </v-alert>
 | 
			
		||||
 | 
			
		||||
          <v-textarea
 | 
			
		||||
            required
 | 
			
		||||
            class="mb-3"
 | 
			
		||||
            variant="outlined"
 | 
			
		||||
            label="Content"
 | 
			
		||||
            hint="The content supports markdown syntax"
 | 
			
		||||
            v-model="data.content"
 | 
			
		||||
            @paste="pasteMedia"
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <v-expansion-panels>
 | 
			
		||||
            <v-expansion-panel title="Brief describe">
 | 
			
		||||
              <template #text>
 | 
			
		||||
                <div class="mt-1">
 | 
			
		||||
                  <v-text-field
 | 
			
		||||
                    required
 | 
			
		||||
                    variant="solo-filled"
 | 
			
		||||
                    density="comfortable"
 | 
			
		||||
                    label="Title"
 | 
			
		||||
                    :loading="reverting"
 | 
			
		||||
                    v-model="data.title"
 | 
			
		||||
                  />
 | 
			
		||||
 | 
			
		||||
                  <v-textarea
 | 
			
		||||
                    required
 | 
			
		||||
                    auto-grow
 | 
			
		||||
                    variant="solo-filled"
 | 
			
		||||
                    density="comfortable"
 | 
			
		||||
                    label="Description"
 | 
			
		||||
                    v-model="data.description"
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
              </template>
 | 
			
		||||
            </v-expansion-panel>
 | 
			
		||||
 | 
			
		||||
            <v-expansion-panel title="Planned publish">
 | 
			
		||||
              <template #text>
 | 
			
		||||
                <div class="flex justify-between items-center">
 | 
			
		||||
                  <div>
 | 
			
		||||
                    <p class="text-xs">Your content will visible for public at</p>
 | 
			
		||||
                    <p class="text-lg font-medium">
 | 
			
		||||
                      {{
 | 
			
		||||
                        data.published_at ? new Date(data.published_at).toLocaleString() : new Date().toLocaleString()
 | 
			
		||||
                      }}
 | 
			
		||||
                    </p>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <v-btn size="small" icon="mdi-pencil" variant="text" @click="dialogs.plan = true" />
 | 
			
		||||
                </div>
 | 
			
		||||
              </template>
 | 
			
		||||
            </v-expansion-panel>
 | 
			
		||||
 | 
			
		||||
            <v-expansion-panel title="Media">
 | 
			
		||||
              <template #text>
 | 
			
		||||
                <div class="flex justify-between items-center">
 | 
			
		||||
                  <div>
 | 
			
		||||
                    <p class="text-xs">This article attached</p>
 | 
			
		||||
                    <p class="text-lg font-medium">{{ data.attachments.length }} attachment(s)</p>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <v-btn size="small" icon="mdi-camera-plus" variant="text" @click="dialogs.media = true" />
 | 
			
		||||
                </div>
 | 
			
		||||
              </template>
 | 
			
		||||
            </v-expansion-panel>
 | 
			
		||||
 | 
			
		||||
            <v-expansion-panel title="Publish area">
 | 
			
		||||
              <template #text>
 | 
			
		||||
                <div class="flex justify-between items-center">
 | 
			
		||||
                  <div>
 | 
			
		||||
                    <p class="text-xs">This article will publish in</p>
 | 
			
		||||
                    <p class="text-lg font-medium">{{ currentRealm?.name ?? "No realm" }}</p>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <v-btn size="small" icon="mdi-account-group" variant="text" @click="dialogs.area = true" />
 | 
			
		||||
                </div>
 | 
			
		||||
              </template>
 | 
			
		||||
            </v-expansion-panel>
 | 
			
		||||
          </v-expansion-panels>
 | 
			
		||||
        </v-container>
 | 
			
		||||
      </v-card-text>
 | 
			
		||||
    </v-form>
 | 
			
		||||
  </v-card>
 | 
			
		||||
 | 
			
		||||
  <planned-publish v-model:show="dialogs.plan" v-model:value="data.published_at" />
 | 
			
		||||
  <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">
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { useEditor } from "@/stores/editor"
 | 
			
		||||
import { getAtk } from "@/stores/userinfo"
 | 
			
		||||
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 PublishArea from "@/components/publish/parts/PublishArea.vue"
 | 
			
		||||
 | 
			
		||||
const route = useRoute()
 | 
			
		||||
const realms = useRealms()
 | 
			
		||||
const editor = useEditor()
 | 
			
		||||
 | 
			
		||||
const dialogs = reactive({
 | 
			
		||||
  plan: false,
 | 
			
		||||
  categories: false,
 | 
			
		||||
  media: false,
 | 
			
		||||
  area: false
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const data = ref<any>({
 | 
			
		||||
  title: "",
 | 
			
		||||
  content: "",
 | 
			
		||||
  description: "",
 | 
			
		||||
  realm_id: null,
 | 
			
		||||
  published_at: null,
 | 
			
		||||
  attachments: []
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const currentRealm = computed(() => {
 | 
			
		||||
  if (data.value.realm_id) {
 | 
			
		||||
    return realms.available.find((e: any) => e.id === data.value.realm_id)
 | 
			
		||||
  } else {
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
const success = ref(false)
 | 
			
		||||
const reverting = ref(false)
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
const uploading = ref(false)
 | 
			
		||||
 | 
			
		||||
async function postArticle(evt: SubmitEvent) {
 | 
			
		||||
  const form = evt.target as HTMLFormElement
 | 
			
		||||
 | 
			
		||||
  if (uploading.value) return
 | 
			
		||||
 | 
			
		||||
  const payload = data.value
 | 
			
		||||
  console.log(payload)
 | 
			
		||||
  if (!payload.content) return
 | 
			
		||||
  if (!payload.title || !payload.description) return
 | 
			
		||||
  if (!payload.published_at) payload.published_at = new Date().toISOString()
 | 
			
		||||
  if (!payload.realm_id) payload.realm_id = undefined
 | 
			
		||||
 | 
			
		||||
  const url = editor.related.edit_to ? `/api/p/articles/${editor.related.edit_to?.id}` : "/api/p/articles"
 | 
			
		||||
  const method = editor.related.edit_to ? "PUT" : "POST"
 | 
			
		||||
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(url, {
 | 
			
		||||
    method: method,
 | 
			
		||||
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAtk()}` },
 | 
			
		||||
    body: JSON.stringify(payload)
 | 
			
		||||
  })
 | 
			
		||||
  if (res.status === 200) {
 | 
			
		||||
    const data = await res.json()
 | 
			
		||||
    success.value = true
 | 
			
		||||
    editor.show.article = false
 | 
			
		||||
 | 
			
		||||
    resetEditor(form)
 | 
			
		||||
    router.push({ name: "posts.details.articles", params: { alias: data.alias } })
 | 
			
		||||
  } else {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function resetEditor(target: HTMLFormElement) {
 | 
			
		||||
  target.reset()
 | 
			
		||||
  data.value = {
 | 
			
		||||
    title: "",
 | 
			
		||||
    content: "",
 | 
			
		||||
    description: "",
 | 
			
		||||
    realm_id: null,
 | 
			
		||||
    published_at: null,
 | 
			
		||||
    attachments: []
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const media = ref<any>(null)
 | 
			
		||||
 | 
			
		||||
function pasteMedia(evt: ClipboardEvent) {
 | 
			
		||||
  const files = evt.clipboardData?.files
 | 
			
		||||
  if (files) {
 | 
			
		||||
    Array.from(files).forEach((item) => {
 | 
			
		||||
      media.value.upload(item).then((meta: any) => {
 | 
			
		||||
        if (meta) {
 | 
			
		||||
          data.value.content += `\n`
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(editor.related, (val) => {
 | 
			
		||||
  if (val.edit_to && val.edit_to.model_type === "article") {
 | 
			
		||||
    request(`/api/p/articles/${val.edit_to.alias}`).then(async (res) => {
 | 
			
		||||
      data.value = await res.json()
 | 
			
		||||
      data.value.attachments = data.value.attachments ?? []
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  () => route.params.realmId,
 | 
			
		||||
  (val) => {
 | 
			
		||||
    if (val) {
 | 
			
		||||
      data.value.realm_id = parseInt(val as string)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  { deep: true, immediate: true }
 | 
			
		||||
)
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.article-toolbar {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  flex-grow: 1;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin: 0 16px;
 | 
			
		||||
 | 
			
		||||
  grid-template-columns: 1fr auto 1fr;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.article-container {
 | 
			
		||||
  max-width: 720px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.snackbar-progress {
 | 
			
		||||
  margin-left: -16px;
 | 
			
		||||
  margin-right: -16px;
 | 
			
		||||
  margin-bottom: -14px;
 | 
			
		||||
  margin-top: 12px;
 | 
			
		||||
  width: calc(100% + 64px);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,87 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-card title="Leave your comment" :loading="loading">
 | 
			
		||||
    <v-form @submit.prevent="postComment">
 | 
			
		||||
      <v-card-text>
 | 
			
		||||
        <v-alert v-if="editor.related.edit_to" class="mb-5" type="info" variant="tonal">
 | 
			
		||||
          You are editing a comment with alias <b class="font-mono">{{ editor.related.edit_to?.alias }}</b>
 | 
			
		||||
        </v-alert>
 | 
			
		||||
 | 
			
		||||
        <v-textarea required hide-details variant="outlined" label="What do you want to say?" v-model="data.content" />
 | 
			
		||||
 | 
			
		||||
        <p class="px-2 mt-1 text-body-2 opacity-80">Your comment will leave below {{ postIdentifier }}</p>
 | 
			
		||||
      </v-card-text>
 | 
			
		||||
 | 
			
		||||
      <v-card-actions>
 | 
			
		||||
        <v-spacer></v-spacer>
 | 
			
		||||
 | 
			
		||||
        <v-btn type="reset" color="grey-darken-3" @click="editor.show.comment = false">Cancel</v-btn>
 | 
			
		||||
        <v-btn type="submit" :disabled="loading">Publish</v-btn>
 | 
			
		||||
      </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">
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { useEditor } from "@/stores/editor"
 | 
			
		||||
import { getAtk } from "@/stores/userinfo"
 | 
			
		||||
import { computed, ref, watch } from "vue"
 | 
			
		||||
 | 
			
		||||
const editor = useEditor()
 | 
			
		||||
 | 
			
		||||
const target = computed<any>(() => editor.related.comment_to)
 | 
			
		||||
const postIdentifier = computed(() => {
 | 
			
		||||
  if (editor.related.comment_to?.title) {
 | 
			
		||||
    return `${editor.related.comment_to.title}`
 | 
			
		||||
  } else {
 | 
			
		||||
    return `#${editor.related.comment_to?.alias}`
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
const success = ref(false)
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
 | 
			
		||||
const data = ref<any>({
 | 
			
		||||
  content: ""
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
async function postComment(evt: SubmitEvent) {
 | 
			
		||||
  const form = evt.target as HTMLFormElement
 | 
			
		||||
  const payload = data.value
 | 
			
		||||
 | 
			
		||||
  if (!payload.content) return
 | 
			
		||||
 | 
			
		||||
  const url = editor.related.edit_to
 | 
			
		||||
    ? `/api/p/comments/${editor.related.edit_to?.id}`
 | 
			
		||||
    : `/api/p/${target.value?.model_type}/${target.value?.alias}/comments`
 | 
			
		||||
  const method = editor.related.edit_to ? "PUT" : "POST"
 | 
			
		||||
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(url, {
 | 
			
		||||
    method: method,
 | 
			
		||||
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAtk()}` },
 | 
			
		||||
    body: JSON.stringify(payload)
 | 
			
		||||
  })
 | 
			
		||||
  if (res.status === 200) {
 | 
			
		||||
    form.reset()
 | 
			
		||||
    success.value = true
 | 
			
		||||
    editor.show.comment = false
 | 
			
		||||
  } else {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
  editor.done = true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(editor.related, (val) => {
 | 
			
		||||
  if (val.edit_to && val.edit_to.model_type === "comment") {
 | 
			
		||||
    data.value = val.edit_to
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,197 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-card title="Record a moment" :loading="loading">
 | 
			
		||||
    <v-form @submit.prevent="postMoment">
 | 
			
		||||
      <v-card-text>
 | 
			
		||||
        <v-alert v-if="editor.related.edit_to" class="mb-5" type="info" variant="tonal">
 | 
			
		||||
          You are editing a post with alias <b class="font-mono">{{ editor.related.edit_to?.alias }}</b>
 | 
			
		||||
        </v-alert>
 | 
			
		||||
 | 
			
		||||
        <v-textarea
 | 
			
		||||
          required
 | 
			
		||||
          persistent-counter
 | 
			
		||||
          variant="outlined"
 | 
			
		||||
          label="What's happened?!"
 | 
			
		||||
          counter="1024"
 | 
			
		||||
          v-model="data.content"
 | 
			
		||||
          @paste="pasteMedia"
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <div class="flex mt-[-18px]">
 | 
			
		||||
          <v-tooltip text="Planned publish" location="start">
 | 
			
		||||
            <template #activator="{ props }">
 | 
			
		||||
              <v-btn
 | 
			
		||||
                v-bind="props"
 | 
			
		||||
                type="button"
 | 
			
		||||
                variant="text"
 | 
			
		||||
                icon="mdi-calendar"
 | 
			
		||||
                size="small"
 | 
			
		||||
                @click="dialogs.plan = true"
 | 
			
		||||
              />
 | 
			
		||||
            </template>
 | 
			
		||||
          </v-tooltip>
 | 
			
		||||
          <v-tooltip text="Media" location="start">
 | 
			
		||||
            <template #activator="{ props }">
 | 
			
		||||
              <v-btn
 | 
			
		||||
                v-bind="props"
 | 
			
		||||
                icon
 | 
			
		||||
                class="text-none"
 | 
			
		||||
                type="button"
 | 
			
		||||
                variant="text"
 | 
			
		||||
                size="small"
 | 
			
		||||
                @click="dialogs.media = true"
 | 
			
		||||
              >
 | 
			
		||||
                <v-badge v-if="data.attachments.length > 0" :content="data.attachments.length">
 | 
			
		||||
                  <v-icon icon="mdi-camera" />
 | 
			
		||||
                </v-badge>
 | 
			
		||||
 | 
			
		||||
                <v-icon v-else icon="mdi-camera" />
 | 
			
		||||
              </v-btn>
 | 
			
		||||
            </template>
 | 
			
		||||
          </v-tooltip>
 | 
			
		||||
          <v-tooltip text="Publish area" location="start">
 | 
			
		||||
            <template #activator="{ props }">
 | 
			
		||||
              <v-btn v-bind="props" icon type="button" variant="text" size="small" @click="dialogs.area = true">
 | 
			
		||||
                <v-badge v-if="data.realm_id" dot>
 | 
			
		||||
                  <v-icon icon="mdi-account-group" />
 | 
			
		||||
                </v-badge>
 | 
			
		||||
 | 
			
		||||
                <v-icon v-else icon="mdi-account-group" />
 | 
			
		||||
              </v-btn>
 | 
			
		||||
            </template>
 | 
			
		||||
          </v-tooltip>
 | 
			
		||||
        </div>
 | 
			
		||||
      </v-card-text>
 | 
			
		||||
 | 
			
		||||
      <v-card-actions>
 | 
			
		||||
        <v-spacer></v-spacer>
 | 
			
		||||
 | 
			
		||||
        <v-btn type="reset" color="grey-darken-3" @click="editor.show.moment = false">Cancel</v-btn>
 | 
			
		||||
        <v-btn type="submit" :disabled="loading">Publish</v-btn>
 | 
			
		||||
      </v-card-actions>
 | 
			
		||||
    </v-form>
 | 
			
		||||
  </v-card>
 | 
			
		||||
 | 
			
		||||
  <planned-publish v-model:show="dialogs.plan" v-model:value="data.published_at" />
 | 
			
		||||
  <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">
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { useEditor } from "@/stores/editor"
 | 
			
		||||
import { getAtk } from "@/stores/userinfo"
 | 
			
		||||
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"
 | 
			
		||||
 | 
			
		||||
const route = useRoute()
 | 
			
		||||
const editor = useEditor()
 | 
			
		||||
 | 
			
		||||
const dialogs = reactive({
 | 
			
		||||
  plan: false,
 | 
			
		||||
  media: false,
 | 
			
		||||
  area: false
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const data = ref<any>({
 | 
			
		||||
  content: "",
 | 
			
		||||
  realm_id: null,
 | 
			
		||||
  published_at: null,
 | 
			
		||||
  attachments: []
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
const success = ref(false)
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
const uploading = ref(false)
 | 
			
		||||
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
 | 
			
		||||
async function postMoment(evt: SubmitEvent) {
 | 
			
		||||
  const form = evt.target as HTMLFormElement
 | 
			
		||||
  const payload = data.value
 | 
			
		||||
  if (!payload.content) return
 | 
			
		||||
  if (!payload.published_at) payload.published_at = new Date().toISOString()
 | 
			
		||||
  if (!payload.realm_id) payload.realm_id = undefined
 | 
			
		||||
 | 
			
		||||
  const url = editor.related.edit_to ? `/api/p/moments/${editor.related.edit_to?.id}` : "/api/p/moments"
 | 
			
		||||
  const method = editor.related.edit_to ? "PUT" : "POST"
 | 
			
		||||
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(url, {
 | 
			
		||||
    method: method,
 | 
			
		||||
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAtk()}` },
 | 
			
		||||
    body: JSON.stringify(payload)
 | 
			
		||||
  })
 | 
			
		||||
  if (res.status === 200) {
 | 
			
		||||
    const data = await res.json()
 | 
			
		||||
    success.value = true
 | 
			
		||||
    editor.show.moment = false
 | 
			
		||||
 | 
			
		||||
    resetEditor(form)
 | 
			
		||||
    router.push({ name: "posts.details.moments", params: { alias: data.alias } })
 | 
			
		||||
  } else {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function resetEditor(target: HTMLFormElement) {
 | 
			
		||||
  target.reset()
 | 
			
		||||
  data.value = {
 | 
			
		||||
    content: "",
 | 
			
		||||
    realm_id: null,
 | 
			
		||||
    published_at: null,
 | 
			
		||||
    attachments: []
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const media = ref<any>(null)
 | 
			
		||||
 | 
			
		||||
function pasteMedia(evt: ClipboardEvent) {
 | 
			
		||||
  const files = evt.clipboardData?.files
 | 
			
		||||
  if (files) {
 | 
			
		||||
    Array.from(files).forEach((item) => {
 | 
			
		||||
      media.value.upload(item)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(editor.related, (val) => {
 | 
			
		||||
  if (val.edit_to && val.edit_to.model_type === "moment") {
 | 
			
		||||
    data.value = val.edit_to
 | 
			
		||||
    data.value.attachments = val.edit_to.attachments ?? []
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  () => route.params.realmId,
 | 
			
		||||
  (val) => {
 | 
			
		||||
    if (val) {
 | 
			
		||||
      data.value.realm_id = parseInt(val as string)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  { deep: true, immediate: true }
 | 
			
		||||
)
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.snackbar-progress {
 | 
			
		||||
  margin-left: -16px;
 | 
			
		||||
  margin-right: -16px;
 | 
			
		||||
  margin-bottom: -14px;
 | 
			
		||||
  margin-top: 12px;
 | 
			
		||||
  width: calc(100% + 64px);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,52 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-card title="Delete a post" :loading="loading">
 | 
			
		||||
    <template #text>
 | 
			
		||||
      You are deleting a post with alias
 | 
			
		||||
      <b class="font-mono">{{ editor.related.delete_to?.alias }}</b>
 | 
			
		||||
      Are you confirm?
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #actions>
 | 
			
		||||
      <div class="w-full flex justify-end">
 | 
			
		||||
        <v-btn color="grey-darken-3" @click="editor.show.delete = false">Not really</v-btn>
 | 
			
		||||
        <v-btn color="error" :disabled="loading" @click="deletePost">Yes</v-btn>
 | 
			
		||||
      </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">
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { useEditor } from "@/stores/editor"
 | 
			
		||||
import { getAtk } from "@/stores/userinfo"
 | 
			
		||||
import { ref } from "vue"
 | 
			
		||||
 | 
			
		||||
const editor = useEditor()
 | 
			
		||||
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
const success = ref(false)
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
 | 
			
		||||
async function deletePost() {
 | 
			
		||||
  const target = editor.related.delete_to
 | 
			
		||||
  const url = `/api/p/${target.model_type}/${target.id}`
 | 
			
		||||
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(url, {
 | 
			
		||||
    method: "DELETE",
 | 
			
		||||
    headers: { Authorization: `Bearer ${getAtk()}` }
 | 
			
		||||
  })
 | 
			
		||||
  if (res.status !== 200) {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  } else {
 | 
			
		||||
    success.value = true
 | 
			
		||||
    editor.show.delete = false
 | 
			
		||||
    editor.related.delete_to = null
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,25 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-dialog v-model="editor.show.comment" class="max-w-[540px]" eager>
 | 
			
		||||
    <comment-editor />
 | 
			
		||||
  </v-dialog>
 | 
			
		||||
  <v-dialog v-model="editor.show.moment" class="max-w-[540px]" eager>
 | 
			
		||||
    <moment-editor />
 | 
			
		||||
  </v-dialog>
 | 
			
		||||
  <v-dialog v-model="editor.show.article" transition="dialog-bottom-transition" fullscreen eager>
 | 
			
		||||
    <article-editor />
 | 
			
		||||
  </v-dialog>
 | 
			
		||||
 | 
			
		||||
  <v-dialog v-model="editor.show.delete" class="max-w-[540px]" eager>
 | 
			
		||||
    <post-deletion />
 | 
			
		||||
  </v-dialog>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { useEditor } from "@/stores/editor"
 | 
			
		||||
import MomentEditor from "@/components/publish/MomentEditor.vue"
 | 
			
		||||
import CommentEditor from "@/components/publish/CommentEditor.vue"
 | 
			
		||||
import ArticleEditor from "@/components/publish/ArticleEditor.vue"
 | 
			
		||||
import PostDeletion from "@/components/publish/PostDeletion.vue"
 | 
			
		||||
 | 
			
		||||
const editor = useEditor()
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,144 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-dialog
 | 
			
		||||
    eager
 | 
			
		||||
    class="max-w-[540px]"
 | 
			
		||||
    :model-value="props.show"
 | 
			
		||||
    @update:model-value="(val) => emits('update:show', val)"
 | 
			
		||||
  >
 | 
			
		||||
    <v-card title="Media management">
 | 
			
		||||
      <template #text>
 | 
			
		||||
        <v-file-input
 | 
			
		||||
          prepend-icon=""
 | 
			
		||||
          append-icon="mdi-upload"
 | 
			
		||||
          variant="solo-filled"
 | 
			
		||||
          label="File Picker"
 | 
			
		||||
          v-model="picked"
 | 
			
		||||
          :accept="['image/*', 'video/*', 'audio/*']"
 | 
			
		||||
          :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) {
 | 
			
		||||
    if (!picked.value) return
 | 
			
		||||
    data.set("attachment", picked.value[0])
 | 
			
		||||
  } else {
 | 
			
		||||
    data.set("attachment", file)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  data.set("hashcode", await calculateHashCode(picked.value[0]))
 | 
			
		||||
 | 
			
		||||
  emits("update:uploading", true)
 | 
			
		||||
  const res = await request("/api/attachments", {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: { Authorization: `Bearer ${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(`/api/attachments/${item.id}`, {
 | 
			
		||||
    method: "DELETE",
 | 
			
		||||
    headers: { Authorization: `Bearer ${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>
 | 
			
		||||
@@ -1,31 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-dialog
 | 
			
		||||
    eager
 | 
			
		||||
    class="max-w-[540px]"
 | 
			
		||||
    :model-value="props.show"
 | 
			
		||||
    @update:model-value="(val) => emits('update:show', val)"
 | 
			
		||||
  >
 | 
			
		||||
    <v-card title="Plan your publish">
 | 
			
		||||
      <template #text>
 | 
			
		||||
        <v-text-field
 | 
			
		||||
          clearable
 | 
			
		||||
          class="mt-2"
 | 
			
		||||
          label="Publish date"
 | 
			
		||||
          hint="Your post will hidden for public before this time. Leave blank will publish immediately"
 | 
			
		||||
          variant="solo-filled"
 | 
			
		||||
          type="datetime-local"
 | 
			
		||||
          :model-value="props.value"
 | 
			
		||||
          @update:model-value="(val) => emits('update:value', val)"
 | 
			
		||||
        />
 | 
			
		||||
      </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">
 | 
			
		||||
const props = defineProps<{ show: boolean; value: string | null }>()
 | 
			
		||||
const emits = defineEmits(["update:show", "update:value"])
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,37 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-dialog
 | 
			
		||||
    eager
 | 
			
		||||
    class="max-w-[540px]"
 | 
			
		||||
    :model-value="props.show"
 | 
			
		||||
    @update:model-value="(val) => emits('update:show', val)"
 | 
			
		||||
  >
 | 
			
		||||
    <v-card title="Change your audiences">
 | 
			
		||||
      <template #text>
 | 
			
		||||
        <v-select
 | 
			
		||||
          clearable
 | 
			
		||||
          class="mt-2"
 | 
			
		||||
          label="Realm"
 | 
			
		||||
          hint="This field will only show realms you joined. Leave blank to publish this post in public area."
 | 
			
		||||
          variant="solo-filled"
 | 
			
		||||
          item-title="name"
 | 
			
		||||
          item-value="id"
 | 
			
		||||
          :items="realms.available"
 | 
			
		||||
          :model-value="props.value"
 | 
			
		||||
          @update:model-value="(val) => emits('update:value', val)"
 | 
			
		||||
        />
 | 
			
		||||
      </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 { useRealms } from "@/stores/realms"
 | 
			
		||||
 | 
			
		||||
const realms = useRealms()
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ show: boolean; value: string | null }>()
 | 
			
		||||
const emits = defineEmits(["update:show", "update:value"])
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,36 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-menu>
 | 
			
		||||
    <template #activator="{ props }">
 | 
			
		||||
      <v-btn v-bind="props" icon="mdi-dots-vertical" variant="text" size="x-small" />
 | 
			
		||||
    </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="editRealm" />
 | 
			
		||||
      <v-list-item v-if="isOwned" append-icon="mdi-delete" title="Delete" @click="deleteRealm" />
 | 
			
		||||
    </v-list>
 | 
			
		||||
  </v-menu>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { useRealms } from "@/stores/realms"
 | 
			
		||||
import { useUserinfo } from "@/stores/userinfo"
 | 
			
		||||
import { computed } from "vue"
 | 
			
		||||
 | 
			
		||||
const id = useUserinfo()
 | 
			
		||||
const realms = useRealms()
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ item: any }>()
 | 
			
		||||
 | 
			
		||||
const isOwned = computed(() => props.item?.account_id === id.userinfo.data.id)
 | 
			
		||||
 | 
			
		||||
function editRealm() {
 | 
			
		||||
  realms.related.edit_to = props.item
 | 
			
		||||
  realms.show.editor = true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function deleteRealm() {
 | 
			
		||||
  realms.related.delete_to = props.item
 | 
			
		||||
  realms.show.delete = true
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,61 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-card title="Delete a realm" :loading="loading">
 | 
			
		||||
    <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?
 | 
			
		||||
    </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>
 | 
			
		||||
      </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 { useRealms } from "@/stores/realms"
 | 
			
		||||
import { getAtk } from "@/stores/userinfo"
 | 
			
		||||
import { useRoute, useRouter } from "vue-router"
 | 
			
		||||
import { ref } from "vue"
 | 
			
		||||
 | 
			
		||||
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 deletePost() {
 | 
			
		||||
  const target = realms.related.delete_to
 | 
			
		||||
  const url = `/api/realms/${target.id}`
 | 
			
		||||
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(url, {
 | 
			
		||||
    method: "DELETE",
 | 
			
		||||
    headers: { Authorization: `Bearer ${getAtk()}` }
 | 
			
		||||
  })
 | 
			
		||||
  if (res.status !== 200) {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  } else {
 | 
			
		||||
    success.value = true
 | 
			
		||||
    realms.show.delete = false
 | 
			
		||||
    realms.related.delete_to = null
 | 
			
		||||
    emits("relist")
 | 
			
		||||
    if (route.name?.toString()?.startsWith("realm")) {
 | 
			
		||||
      router.push({ name: "explore" })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,90 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-card title="Organize a realm" prepend-icon="mdi-account-multiple" :loading="loading">
 | 
			
		||||
    <v-form @submit.prevent="submit">
 | 
			
		||||
      <v-card-text>
 | 
			
		||||
        <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-select
 | 
			
		||||
          label="Realm type"
 | 
			
		||||
          item-title="label"
 | 
			
		||||
          item-value="value"
 | 
			
		||||
          variant="outlined"
 | 
			
		||||
          density="comfortable"
 | 
			
		||||
          :items="realmTypeOptions"
 | 
			
		||||
          v-model="data.realm_type"
 | 
			
		||||
        />
 | 
			
		||||
      </v-card-text>
 | 
			
		||||
      <v-card-actions>
 | 
			
		||||
        <v-spacer></v-spacer>
 | 
			
		||||
 | 
			
		||||
        <v-btn type="reset" color="grey-darken-3" @click="realms.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 { useRealms } from "@/stores/realms"
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
 | 
			
		||||
const emits = defineEmits(["relist"])
 | 
			
		||||
 | 
			
		||||
const realms = useRealms()
 | 
			
		||||
 | 
			
		||||
const realmTypeOptions = [
 | 
			
		||||
  { label: "Public Realm", value: 0 },
 | 
			
		||||
  { label: "Restricted Realm", value: 1 },
 | 
			
		||||
  { label: "Private Realm", value: 2 }
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const error = ref<null | string>(null)
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
 | 
			
		||||
const data = ref({
 | 
			
		||||
  name: "",
 | 
			
		||||
  description: "",
 | 
			
		||||
  realm_type: 0
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
async function submit(evt: SubmitEvent) {
 | 
			
		||||
  const form = evt.target as HTMLFormElement
 | 
			
		||||
  const payload = data.value
 | 
			
		||||
  if (!payload.name) return
 | 
			
		||||
 | 
			
		||||
  const url = realms.related.edit_to ? `/api/realms/${realms.related.edit_to?.id}` : "/api/realms"
 | 
			
		||||
  const method = realms.related.edit_to ? "PUT" : "POST"
 | 
			
		||||
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(url, {
 | 
			
		||||
    method: method,
 | 
			
		||||
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAtk()}` },
 | 
			
		||||
    body: JSON.stringify(payload)
 | 
			
		||||
  })
 | 
			
		||||
  if (res.status !== 200) {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  } else {
 | 
			
		||||
    emits("relist")
 | 
			
		||||
    form.reset()
 | 
			
		||||
    realms.done = true
 | 
			
		||||
    realms.show.editor = false
 | 
			
		||||
    realms.related.edit_to = null
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  realms.related,
 | 
			
		||||
  (val) => {
 | 
			
		||||
    if (val.edit_to) {
 | 
			
		||||
      data.value = JSON.parse(JSON.stringify(val.edit_to))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  { immediate: true }
 | 
			
		||||
)
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
<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(`/api/realms/${props.item?.id}/invite`, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${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>
 | 
			
		||||
@@ -1,39 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-list density="comfortable">
 | 
			
		||||
    <v-list-subheader>
 | 
			
		||||
      Realms
 | 
			
		||||
      <v-badge color="warning" content="Alpha" inline />
 | 
			
		||||
    </v-list-subheader>
 | 
			
		||||
 | 
			
		||||
    <v-list-item
 | 
			
		||||
      v-for="item in realms.available"
 | 
			
		||||
      exact
 | 
			
		||||
      prepend-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"
 | 
			
		||||
      title="Create a realm"
 | 
			
		||||
      :disabled="!id.userinfo.isLoggedIn"
 | 
			
		||||
      @click="createRealm"
 | 
			
		||||
    />
 | 
			
		||||
  </v-list>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { useUserinfo } from "@/stores/userinfo"
 | 
			
		||||
import { useRealms } from "@/stores/realms"
 | 
			
		||||
 | 
			
		||||
const id = useUserinfo()
 | 
			
		||||
const realms = useRealms()
 | 
			
		||||
 | 
			
		||||
function createRealm() {
 | 
			
		||||
  realms.related.edit_to = null
 | 
			
		||||
  realms.related.delete_to = null
 | 
			
		||||
  realms.show.editor = true
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,124 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div>
 | 
			
		||||
    <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 }">
 | 
			
		||||
            <realm-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>
 | 
			
		||||
  </div>
 | 
			
		||||
</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 RealmInvitation from "@/components/realms/RealmInvitation.vue"
 | 
			
		||||
 | 
			
		||||
const id = useUserinfo()
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{ item: any }>()
 | 
			
		||||
 | 
			
		||||
const members = ref<any[]>([])
 | 
			
		||||
 | 
			
		||||
const isOwned = computed(() => {
 | 
			
		||||
  return id.userinfo.data?.id === 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(`/api/realms/${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(`/api/realms/${props.item?.id}/kick`, {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${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.data?.id) return false
 | 
			
		||||
  if (item.account?.id === props.item?.account_id) return false
 | 
			
		||||
  return true
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.rounded-card {
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,16 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-dialog v-model="realms.show.editor" class="max-w-[540px]">
 | 
			
		||||
    <realm-editor @relist="realms.list" />
 | 
			
		||||
  </v-dialog>
 | 
			
		||||
  <v-dialog v-model="realms.show.delete" class="max-w-[540px]">
 | 
			
		||||
    <realm-deletion @relist="realms.list" />
 | 
			
		||||
  </v-dialog>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { useRealms } from "@/stores/realms"
 | 
			
		||||
import RealmEditor from "@/components/realms/RealmEditor.vue"
 | 
			
		||||
import RealmDeletion from "./RealmDeletion.vue"
 | 
			
		||||
 | 
			
		||||
const realms = useRealms()
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-app>
 | 
			
		||||
    <router-view />
 | 
			
		||||
  </v-app>
 | 
			
		||||
</template>
 | 
			
		||||
@@ -1,149 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-navigation-drawer v-model="drawerOpen" color="grey-lighten-5" floating>
 | 
			
		||||
    <div class="flex flex-col h-full">
 | 
			
		||||
      <v-list class="border-b border-opacity-15 h-[64px]" style="border-bottom-width: thin">
 | 
			
		||||
        <v-list-item :subtitle="username" :title="nickname">
 | 
			
		||||
          <template #prepend>
 | 
			
		||||
            <v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.avatar" />
 | 
			
		||||
          </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="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" :href="signinUrl" />
 | 
			
		||||
          </template>
 | 
			
		||||
        </v-list-item>
 | 
			
		||||
      </v-list>
 | 
			
		||||
 | 
			
		||||
      <div class="flex-grow-1">
 | 
			
		||||
        <realm-list />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div>
 | 
			
		||||
        <v-alert type="info" variant="tonal" class="text-sm">
 | 
			
		||||
          We just released the brand new design system and user interface!
 | 
			
		||||
          <a class="underline" href="https://tally.so/r/w2NM7g" target="_blank">Take a survey</a>
 | 
			
		||||
        </v-alert>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </v-navigation-drawer>
 | 
			
		||||
 | 
			
		||||
  <v-app-bar height="64" color="primary" scroll-behavior="elevate" flat>
 | 
			
		||||
    <div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center">
 | 
			
		||||
      <v-app-bar-nav-icon variant="text" @click.stop="toggleDrawer" />
 | 
			
		||||
 | 
			
		||||
      <router-link :to="{ name: 'explore' }">
 | 
			
		||||
        <h2 class="ml-2 text-lg font-500">Solarplaza</h2>
 | 
			
		||||
      </router-link>
 | 
			
		||||
 | 
			
		||||
      <v-spacer />
 | 
			
		||||
 | 
			
		||||
      <v-tooltip v-for="item in navigationMenu" :text="item.name" location="bottom">
 | 
			
		||||
        <template #activator="{ props }">
 | 
			
		||||
          <v-btn flat exact v-bind="props" :to="{ name: item.to }" size="small" :icon="item.icon" />
 | 
			
		||||
        </template>
 | 
			
		||||
      </v-tooltip>
 | 
			
		||||
    </div>
 | 
			
		||||
  </v-app-bar>
 | 
			
		||||
 | 
			
		||||
  <v-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 PostTools from "@/components/publish/PostTools.vue"
 | 
			
		||||
import RealmTools from "@/components/realms/RealmTools.vue"
 | 
			
		||||
import RealmList from "@/components/realms/RealmList.vue"
 | 
			
		||||
 | 
			
		||||
const id = useUserinfo()
 | 
			
		||||
const editor = useEditor()
 | 
			
		||||
const navigationMenu = [{ name: "Explore", icon: "mdi-compass", to: "explore" }]
 | 
			
		||||
 | 
			
		||||
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 signinUrl = computed(() => {
 | 
			
		||||
  return meta.wellKnown?.components?.identity + `/auth/sign-in?redirect_uri=${encodeURIComponent(location.href)}`
 | 
			
		||||
})
 | 
			
		||||
const passportUrl = computed(() => {
 | 
			
		||||
  return meta.wellKnown?.components?.identity
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
meta.readWellKnown()
 | 
			
		||||
 | 
			
		||||
const drawerOpen = ref(true)
 | 
			
		||||
 | 
			
		||||
function toggleDrawer() {
 | 
			
		||||
  drawerOpen.value = !drawerOpen.value
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.editor-fab {
 | 
			
		||||
  position: fixed !important;
 | 
			
		||||
  bottom: 16px;
 | 
			
		||||
  right: 20px;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
import "virtual:uno.css"
 | 
			
		||||
 | 
			
		||||
import "./assets/utils.css"
 | 
			
		||||
 | 
			
		||||
import { createApp } from "vue"
 | 
			
		||||
import { createPinia } from "pinia"
 | 
			
		||||
 | 
			
		||||
import "vuetify/styles"
 | 
			
		||||
import { createVuetify } from "vuetify"
 | 
			
		||||
import { md3 } from "vuetify/blueprints"
 | 
			
		||||
import * as components from "vuetify/components"
 | 
			
		||||
import * as labsComponents from "vuetify/labs/components"
 | 
			
		||||
import * as directives from "vuetify/directives"
 | 
			
		||||
 | 
			
		||||
import "@mdi/font/css/materialdesignicons.min.css"
 | 
			
		||||
import "@fontsource/roboto/latin.css"
 | 
			
		||||
import "@unocss/reset/tailwind.css"
 | 
			
		||||
 | 
			
		||||
import index from "./index.vue"
 | 
			
		||||
import router from "./router"
 | 
			
		||||
 | 
			
		||||
const app = createApp(index)
 | 
			
		||||
 | 
			
		||||
app.use(
 | 
			
		||||
  createVuetify({
 | 
			
		||||
    directives,
 | 
			
		||||
    components: {
 | 
			
		||||
      ...components,
 | 
			
		||||
      ...labsComponents
 | 
			
		||||
    },
 | 
			
		||||
    blueprint: md3,
 | 
			
		||||
    theme: {
 | 
			
		||||
      defaultTheme: "original",
 | 
			
		||||
      themes: {
 | 
			
		||||
        original: {
 | 
			
		||||
          colors: {
 | 
			
		||||
            primary: "#4a5099",
 | 
			
		||||
            secondary: "#2196f3",
 | 
			
		||||
            accent: "#009688",
 | 
			
		||||
            error: "#f44336",
 | 
			
		||||
            warning: "#ff9800",
 | 
			
		||||
            info: "#03a9f4",
 | 
			
		||||
            success: "#4caf50"
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
app.use(createPinia())
 | 
			
		||||
app.use(router)
 | 
			
		||||
 | 
			
		||||
app.mount("#app")
 | 
			
		||||
@@ -1,38 +0,0 @@
 | 
			
		||||
import { createRouter, createWebHistory } from "vue-router"
 | 
			
		||||
import MasterLayout from "@/layouts/master.vue"
 | 
			
		||||
 | 
			
		||||
const router = createRouter({
 | 
			
		||||
  history: createWebHistory(import.meta.env.BASE_URL),
 | 
			
		||||
  routes: [
 | 
			
		||||
    {
 | 
			
		||||
      path: "/",
 | 
			
		||||
      component: MasterLayout,
 | 
			
		||||
      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")
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export default router
 | 
			
		||||
@@ -1,10 +0,0 @@
 | 
			
		||||
declare global {
 | 
			
		||||
  interface Window {
 | 
			
		||||
    __LAUNCHPAD_TARGET__?: string
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function request(input: string, init?: RequestInit) {
 | 
			
		||||
  const prefix = window.__LAUNCHPAD_TARGET__ ?? ""
 | 
			
		||||
  return await fetch(prefix + input, init)
 | 
			
		||||
}
 | 
			
		||||
@@ -1,29 +0,0 @@
 | 
			
		||||
import { defineStore } from "pinia"
 | 
			
		||||
import { reactive, ref } from "vue"
 | 
			
		||||
 | 
			
		||||
export const useEditor = defineStore("editor", () => {
 | 
			
		||||
  const done = ref(false)
 | 
			
		||||
 | 
			
		||||
  const show = reactive({
 | 
			
		||||
    moment: false,
 | 
			
		||||
    article: false,
 | 
			
		||||
    comment: false,
 | 
			
		||||
    delete: false
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const related = reactive<{
 | 
			
		||||
    edit_to: any
 | 
			
		||||
    comment_to: any
 | 
			
		||||
    reply_to: any
 | 
			
		||||
    repost_to: any
 | 
			
		||||
    delete_to: any
 | 
			
		||||
  }>({
 | 
			
		||||
    edit_to: null,
 | 
			
		||||
    comment_to: null,
 | 
			
		||||
    reply_to: null,
 | 
			
		||||
    repost_to: null,
 | 
			
		||||
    delete_to: null
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return { show, related, done }
 | 
			
		||||
})
 | 
			
		||||
@@ -1,37 +0,0 @@
 | 
			
		||||
import { reactive, ref } from "vue"
 | 
			
		||||
import { defineStore } from "pinia"
 | 
			
		||||
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
 | 
			
		||||
export const useRealms = defineStore("realms", () => {
 | 
			
		||||
  const done = ref(false)
 | 
			
		||||
 | 
			
		||||
  const show = reactive({
 | 
			
		||||
    editor: false,
 | 
			
		||||
    delete: false
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const related_to = reactive<{ edit_to: any; delete_to: any }>({
 | 
			
		||||
    edit_to: null,
 | 
			
		||||
    delete_to: null
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const available = ref<any[]>([])
 | 
			
		||||
 | 
			
		||||
  async function list() {
 | 
			
		||||
    if (!checkLoggedIn()) return
 | 
			
		||||
 | 
			
		||||
    const res = await request("/api/realms/me/available", {
 | 
			
		||||
      headers: { Authorization: `Bearer ${getAtk()}` }
 | 
			
		||||
    })
 | 
			
		||||
    if (res.status !== 200) {
 | 
			
		||||
      throw new Error(await res.text())
 | 
			
		||||
    } else {
 | 
			
		||||
      available.value = await res.json()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  list().then(() => console.log("[STARTUP HOOK] Fetch available realm successes."))
 | 
			
		||||
 | 
			
		||||
  return { done, show, related: related_to, available, list }
 | 
			
		||||
})
 | 
			
		||||
@@ -1,56 +0,0 @@
 | 
			
		||||
import Cookie from "universal-cookie"
 | 
			
		||||
import { defineStore } from "pinia"
 | 
			
		||||
import { ref } from "vue"
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
 | 
			
		||||
export interface Userinfo {
 | 
			
		||||
  isReady: boolean
 | 
			
		||||
  isLoggedIn: boolean
 | 
			
		||||
  displayName: string
 | 
			
		||||
  data: any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const defaultUserinfo: Userinfo = {
 | 
			
		||||
  isReady: false,
 | 
			
		||||
  isLoggedIn: false,
 | 
			
		||||
  displayName: "Citizen",
 | 
			
		||||
  data: null
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getAtk(): string {
 | 
			
		||||
  return new Cookie().get("identity_auth_key")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function checkLoggedIn(): boolean {
 | 
			
		||||
  return new Cookie().get("identity_auth_key")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useUserinfo = defineStore("userinfo", () => {
 | 
			
		||||
  const userinfo = ref(defaultUserinfo)
 | 
			
		||||
  const isReady = ref(false)
 | 
			
		||||
 | 
			
		||||
  async function readProfiles() {
 | 
			
		||||
    if (!checkLoggedIn()) {
 | 
			
		||||
      isReady.value = true
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const res = await request("/api/users/me", {
 | 
			
		||||
      headers: { Authorization: `Bearer ${getAtk()}` }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    if (res.status !== 200) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const data = await res.json()
 | 
			
		||||
 | 
			
		||||
    userinfo.value = {
 | 
			
		||||
      isReady: true,
 | 
			
		||||
      isLoggedIn: true,
 | 
			
		||||
      displayName: data["nick"],
 | 
			
		||||
      data: data
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { userinfo, isReady, readProfiles }
 | 
			
		||||
})
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { defineStore } from "pinia"
 | 
			
		||||
import { ref } from "vue"
 | 
			
		||||
 | 
			
		||||
export const useWellKnown = defineStore("well-known", () => {
 | 
			
		||||
  const wellKnown = ref<any>(null)
 | 
			
		||||
 | 
			
		||||
  async function readWellKnown() {
 | 
			
		||||
    const res = await request("/.well-known")
 | 
			
		||||
    wellKnown.value = await res.json()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { wellKnown, readWellKnown }
 | 
			
		||||
})
 | 
			
		||||
@@ -1,63 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-container class="flex max-md:flex-col gap-3 overflow-auto max-h-[calc(100vh-64px)] no-scrollbar">
 | 
			
		||||
    <div class="timeline flex-grow-1 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">
 | 
			
		||||
      <v-card title="Categories">
 | 
			
		||||
        <v-list density="compact">
 | 
			
		||||
          <v-list-item title="All" prepend-icon="mdi-apps" active></v-list-item>
 | 
			
		||||
        </v-list>
 | 
			
		||||
      </v-card>
 | 
			
		||||
    </div>
 | 
			
		||||
  </v-container>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import PostList from "@/components/posts/PostList.vue"
 | 
			
		||||
import { reactive, ref } from "vue"
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
 | 
			
		||||
 | 
			
		||||
const posts = ref<any[]>([])
 | 
			
		||||
 | 
			
		||||
async function readPosts() {
 | 
			
		||||
  const res = await request(
 | 
			
		||||
    `/api/feed?` +
 | 
			
		||||
      new URLSearchParams({
 | 
			
		||||
        take: pagination.pageSize.toString(),
 | 
			
		||||
        offset: ((pagination.page - 1) * pagination.pageSize).toString()
 | 
			
		||||
      })
 | 
			
		||||
  )
 | 
			
		||||
  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")
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
readPosts()
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,102 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-container class="flex max-md:flex-col gap-3 overflow-auto max-h-[calc(100vh-64px)] no-scrollbar">
 | 
			
		||||
    <div class="content flex-grow-1">
 | 
			
		||||
      <v-card :loading="loading">
 | 
			
		||||
        <article>
 | 
			
		||||
          <v-card-text>
 | 
			
		||||
            <div class="flex justify-between px-3">
 | 
			
		||||
              <div>
 | 
			
		||||
                <h1 class="text-lg font-medium">{{ post?.title }}</h1>
 | 
			
		||||
                <p class="text-sm">{{ post?.description }}</p>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div>
 | 
			
		||||
                <post-action :item="post" />
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <v-divider class="my-5 mx-[-16px] border-opacity-50" />
 | 
			
		||||
 | 
			
		||||
            <div class="px-3 text-xs opacity-80 flex gap-1">
 | 
			
		||||
              <span>Written by {{ post?.author?.nick }}</span>
 | 
			
		||||
              <span>·</span>
 | 
			
		||||
              <span>Published at {{ new Date(post?.created_at).toLocaleString() }}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <v-divider class="mt-5 mx-[-16px] border-opacity-50" />
 | 
			
		||||
 | 
			
		||||
            <div class="px-3">
 | 
			
		||||
              <article-content :item="post" content-only />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <v-divider class="my-5 mx-[-16px] border-opacity-50" />
 | 
			
		||||
 | 
			
		||||
            <div class="px-3">
 | 
			
		||||
              <post-reaction
 | 
			
		||||
                model="articles"
 | 
			
		||||
                :item="post"
 | 
			
		||||
                :reactions="post?.reaction_list ?? {}"
 | 
			
		||||
                @update="updateReactions"
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          </v-card-text>
 | 
			
		||||
        </article>
 | 
			
		||||
      </v-card>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="aside md:sticky top-0 w-full h-fit w-full md:max-w-[380px] md:min-w-[360px]">
 | 
			
		||||
      <v-card title="Comments">
 | 
			
		||||
        <div class="px-[1rem] pb-[0.825rem] mt-[-12px]">
 | 
			
		||||
          <comment-list
 | 
			
		||||
            model="article"
 | 
			
		||||
            dataset="articles"
 | 
			
		||||
            :item="post"
 | 
			
		||||
            :alias="route.params.alias"
 | 
			
		||||
            v-model:comments="comments"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </v-card>
 | 
			
		||||
    </div>
 | 
			
		||||
  </v-container>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref } from "vue"
 | 
			
		||||
import { useRoute } from "vue-router"
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import ArticleContent from "@/components/posts/ArticleContent.vue"
 | 
			
		||||
import PostReaction from "@/components/posts/PostReaction.vue"
 | 
			
		||||
import PostAction from "@/components/posts/PostAction.vue"
 | 
			
		||||
import CommentList from "@/components/comments/CommentList.vue"
 | 
			
		||||
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
 | 
			
		||||
const post = ref<any>(null)
 | 
			
		||||
const comments = ref<any[]>([])
 | 
			
		||||
 | 
			
		||||
const route = useRoute()
 | 
			
		||||
 | 
			
		||||
async function readPost() {
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(`/api/p/articles/${route.params.alias}`)
 | 
			
		||||
  if (res.status !== 200) {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  } else {
 | 
			
		||||
    error.value = null
 | 
			
		||||
    post.value = await res.json()
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
readPost()
 | 
			
		||||
 | 
			
		||||
function updateReactions(symbol: string, num: number) {
 | 
			
		||||
  // eslint-disable-next-line
 | 
			
		||||
  if (post.value.reaction_list.hasOwnProperty(symbol)) {
 | 
			
		||||
    post.value.reaction_list[symbol] += num
 | 
			
		||||
  } else {
 | 
			
		||||
    post.value.reaction_list[symbol] = num
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,116 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-container class="flex max-md:flex-col gap-3 overflow-auto max-h-[calc(100vh-64px)] no-scrollbar">
 | 
			
		||||
    <div class="content flex-grow-1">
 | 
			
		||||
      <v-card :loading="loading">
 | 
			
		||||
        <article>
 | 
			
		||||
          <v-card-text>
 | 
			
		||||
            <div class="flex justify-between px-3">
 | 
			
		||||
              <div class="flex gap-1">
 | 
			
		||||
                <v-avatar
 | 
			
		||||
                  color="grey-lighten-2"
 | 
			
		||||
                  icon="mdi-account-circle"
 | 
			
		||||
                  class="rounded-card me-2"
 | 
			
		||||
                  :image="post?.author.avatar"
 | 
			
		||||
                />
 | 
			
		||||
 | 
			
		||||
                <div>
 | 
			
		||||
                  <p class="font-bold">{{ post?.author.nick }}</p>
 | 
			
		||||
                  <p class="opacity-80">
 | 
			
		||||
                    {{ post?.author.description ? post?.author.description : "No description yet." }}
 | 
			
		||||
                  </p>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div>
 | 
			
		||||
                <post-action :item="post" />
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <v-divider class="mb-5 mt-3.5 mx-[-16px] border-opacity-50" />
 | 
			
		||||
 | 
			
		||||
            <div class="px-3">
 | 
			
		||||
              <moment-content :item="post" content-only />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="mt-3 px-2">
 | 
			
		||||
              <post-attachment v-if="post?.attachments" :attachments="post?.attachments" />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <v-divider class="my-5 mx-[-16px] border-opacity-50" />
 | 
			
		||||
 | 
			
		||||
            <div class="px-3">
 | 
			
		||||
              <post-reaction
 | 
			
		||||
                model="moments"
 | 
			
		||||
                :item="post"
 | 
			
		||||
                :reactions="post?.reaction_list ?? {}"
 | 
			
		||||
                @update="updateReactions"
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          </v-card-text>
 | 
			
		||||
        </article>
 | 
			
		||||
      </v-card>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="aside md:sticky top-0 w-full h-fit w-full md:max-w-[380px] md:min-w-[360px]">
 | 
			
		||||
      <v-card title="Comments">
 | 
			
		||||
        <div class="px-[1rem] pb-[0.825rem] mt-[-12px]">
 | 
			
		||||
          <comment-list
 | 
			
		||||
            model="moment"
 | 
			
		||||
            dataset="moments"
 | 
			
		||||
            :item="post"
 | 
			
		||||
            :alias="route.params.alias"
 | 
			
		||||
            v-model:comments="comments"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </v-card>
 | 
			
		||||
    </div>
 | 
			
		||||
  </v-container>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref } from "vue"
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { useRoute } from "vue-router"
 | 
			
		||||
import MomentContent from "@/components/posts/MomentContent.vue"
 | 
			
		||||
import PostReaction from "@/components/posts/PostReaction.vue"
 | 
			
		||||
import CommentList from "@/components/comments/CommentList.vue"
 | 
			
		||||
import PostAttachment from "@/components/posts/PostAttachment.vue"
 | 
			
		||||
import PostAction from "@/components/posts/PostAction.vue"
 | 
			
		||||
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
 | 
			
		||||
const post = ref<any>(null)
 | 
			
		||||
const comments = ref<any[]>([])
 | 
			
		||||
 | 
			
		||||
const route = useRoute()
 | 
			
		||||
 | 
			
		||||
async function readPost() {
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(`/api/p/moments/${route.params.alias}`)
 | 
			
		||||
  if (res.status !== 200) {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  } else {
 | 
			
		||||
    error.value = null
 | 
			
		||||
    post.value = await res.json()
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
readPost()
 | 
			
		||||
 | 
			
		||||
function updateReactions(symbol: string, num: number) {
 | 
			
		||||
  // eslint-disable-next-line
 | 
			
		||||
  if (post.value.reaction_list.hasOwnProperty(symbol)) {
 | 
			
		||||
    post.value.reaction_list[symbol] += num
 | 
			
		||||
  } else {
 | 
			
		||||
    post.value.reaction_list[symbol] = num
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.rounded-card {
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,122 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <v-container class="flex max-md:flex-col gap-3 overflow-auto max-h-[calc(100vh-64px)] no-scrollbar">
 | 
			
		||||
    <div class="timeline flex-grow-1 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">
 | 
			
		||||
      <v-card :loading="loading">
 | 
			
		||||
        <template #title>
 | 
			
		||||
          <div class="flex justify-between">
 | 
			
		||||
            <span>Realm Info</span>
 | 
			
		||||
 | 
			
		||||
            <realm-action :item="metadata" />
 | 
			
		||||
          </div>
 | 
			
		||||
        </template>
 | 
			
		||||
        <template #text>
 | 
			
		||||
          <div>
 | 
			
		||||
            <h2 class="font-medium">Name</h2>
 | 
			
		||||
            <p>{{ metadata?.name }}</p>
 | 
			
		||||
 | 
			
		||||
            <h2 class="font-medium mt-2">Description</h2>
 | 
			
		||||
            <div v-html="parseContent(metadata?.description ?? '')"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </template>
 | 
			
		||||
      </v-card>
 | 
			
		||||
 | 
			
		||||
      <v-card class="mt-3 pb-3" title="Realm Members">
 | 
			
		||||
        <realm-members class="mt-[-8px]" :item="metadata" />
 | 
			
		||||
      </v-card>
 | 
			
		||||
    </div>
 | 
			
		||||
  </v-container>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { reactive, ref, watch } from "vue"
 | 
			
		||||
import { request } from "@/scripts/request"
 | 
			
		||||
import { useRealms } from "@/stores/realms"
 | 
			
		||||
import { useRoute } from "vue-router"
 | 
			
		||||
import { parse } from "marked"
 | 
			
		||||
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"
 | 
			
		||||
 | 
			
		||||
const route = useRoute()
 | 
			
		||||
const realms = useRealms()
 | 
			
		||||
 | 
			
		||||
const loading = ref(false)
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
 | 
			
		||||
 | 
			
		||||
const metadata = ref<any>(null)
 | 
			
		||||
const posts = ref<any[]>([])
 | 
			
		||||
 | 
			
		||||
async function readMetadata() {
 | 
			
		||||
  loading.value = true
 | 
			
		||||
  const res = await request(`/api/realms/${route.params.realmId}`)
 | 
			
		||||
  if (res.status !== 200) {
 | 
			
		||||
    error.value = await res.text()
 | 
			
		||||
  } else {
 | 
			
		||||
    error.value = null
 | 
			
		||||
    metadata.value = await res.json()
 | 
			
		||||
  }
 | 
			
		||||
  loading.value = false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function readPosts() {
 | 
			
		||||
  const res = await request(
 | 
			
		||||
    `/api/feed?` +
 | 
			
		||||
      new URLSearchParams({
 | 
			
		||||
        take: pagination.pageSize.toString(),
 | 
			
		||||
        offset: ((pagination.page - 1) * pagination.pageSize).toString(),
 | 
			
		||||
        realmId: route.params.realmId 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")
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  () => route.params.realmId,
 | 
			
		||||
  () => {
 | 
			
		||||
    posts.value = []
 | 
			
		||||
    readMetadata()
 | 
			
		||||
    readPosts()
 | 
			
		||||
  },
 | 
			
		||||
  { immediate: true }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
watch(realms, (val) => {
 | 
			
		||||
  if (val.done) {
 | 
			
		||||
    readMetadata().then(() => (realms.done = false))
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function parseContent(src: string): string {
 | 
			
		||||
  return dompurify().sanitize(parse(src) as string)
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,16 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "@vue/tsconfig/tsconfig.dom.json",
 | 
			
		||||
  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
 | 
			
		||||
  "exclude": ["src/**/__tests__/*"],
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "composite": true,
 | 
			
		||||
    "allowJs": true,
 | 
			
		||||
    "checkJs": true,
 | 
			
		||||
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
 | 
			
		||||
 | 
			
		||||
    "baseUrl": ".",
 | 
			
		||||
    "paths": {
 | 
			
		||||
      "@/*": ["./src/*"]
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,11 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "files": [],
 | 
			
		||||
  "references": [
 | 
			
		||||
    {
 | 
			
		||||
      "path": "./tsconfig.node.json"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "path": "./tsconfig.app.json"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
@@ -1,13 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "@tsconfig/node20/tsconfig.json",
 | 
			
		||||
  "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"],
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "composite": true,
 | 
			
		||||
    "noEmit": true,
 | 
			
		||||
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
 | 
			
		||||
 | 
			
		||||
    "module": "ESNext",
 | 
			
		||||
    "moduleResolution": "Bundler",
 | 
			
		||||
    "types": ["node"]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
import { defineConfig, presetAttributify, presetTypography, presetUno } from "unocss"
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  presets: [presetAttributify(), presetTypography(), presetUno({ preflight: false })]
 | 
			
		||||
})
 | 
			
		||||
@@ -1,22 +0,0 @@
 | 
			
		||||
import { fileURLToPath, URL } from "node:url"
 | 
			
		||||
 | 
			
		||||
import { defineConfig } from "vite"
 | 
			
		||||
import vue from "@vitejs/plugin-vue"
 | 
			
		||||
import vueJsx from "@vitejs/plugin-vue-jsx"
 | 
			
		||||
import unocss from "unocss/vite"
 | 
			
		||||
 | 
			
		||||
// https://vitejs.dev/config/
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  plugins: [vue(), vueJsx(), unocss()],
 | 
			
		||||
  resolve: {
 | 
			
		||||
    alias: {
 | 
			
		||||
      "@": fileURLToPath(new URL("./src", import.meta.url))
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  server: {
 | 
			
		||||
    proxy: {
 | 
			
		||||
      "/.well-known": "http://localhost:8445",
 | 
			
		||||
      "/api": "http://localhost:8445"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
name = "Solarplaza"
 | 
			
		||||
maintainer = "SmartSheep Studio"
 | 
			
		||||
 | 
			
		||||
frontend = "https://lian.solsynth.dev"
 | 
			
		||||
 | 
			
		||||
bind = "0.0.0.0:8445"
 | 
			
		||||
domain = "feed.smartsheep.studio"
 | 
			
		||||
secret = "LtTjzAGFLshwXhN4ZD4nG5KlMv1MWcsvfv03TSZYnT1VhiAnLIZFTnHUwR0XhGgi"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user