First Commit
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# uwuzu Light Client
|
||||||
|
uwuzuの軽量クライアント(を目標にしている)です。
|
||||||
|
ネットワーク通信量の削減(とメモリ使用量の削減をしたい)をしています。
|
||||||
|
ULCと略します。
|
||||||
|
|
||||||
|
## ULCを始める
|
||||||
|
> **WARNING**
|
||||||
|
> ULCは現在alpha段階(検証・機能の完全性が保たれていないバージョン)です。
|
||||||
|
この時点でULCを使用する場合、今後のアップデートなどがプログラム上使用できなくなる可能性があります。
|
||||||
|
開発者であり、復元方法を理解しているか自己責任で使用することを理解した上でご利用ください。
|
||||||
|
|
||||||
|
### サイトにアクセスする
|
||||||
|
##### 絶対に使う前に下のほうにある「仕組み」を読んでください。
|
||||||
|
読まないと困惑すると思います。
|
||||||
|
開発者もクレーム対応するほど元気じゃないので。
|
||||||
|
|
||||||
|
#### 読んだ!!
|
||||||
|
https://ulc.last2014.com にアクセスするとULCを使用できます。
|
||||||
|
利用規約に同意しないと開発者が恨みます。また、単純にやばいやつです。
|
||||||
|
|
||||||
|
### 開発する
|
||||||
|
> **NOTE**
|
||||||
|
> ULCの開発を手伝ってくれる人がいれば是非お願いします。
|
||||||
|
当Giteaのアカウントを作成し、通常のGit通りにプルリクエストしてください。
|
||||||
|
|
||||||
|
> **TIP**
|
||||||
|
> このセクションは開発者向けです。
|
||||||
|
Scratchとかそういうのじゃなくマジモンのやつです。
|
||||||
|
何もわからない人は飛ばしてください。
|
||||||
|
|
||||||
|
前提要件:
|
||||||
|
- 以下の開発/使用経験がある。
|
||||||
|
- Vue 3.2以上+Composition API
|
||||||
|
- Vue Router 4
|
||||||
|
- TypeScript 5
|
||||||
|
- Node.js
|
||||||
|
- pnpm
|
||||||
|
- Indexed DB
|
||||||
|
- Service Worker
|
||||||
|
- バックエンドとフロントエンドの違いを理解している
|
||||||
|
- uwuzuとuwuzuサーバーの仕組みを理解している
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
IndexedDBにたしか全データ入ってます。
|
||||||
|
やらかしたら削除したら多分復旧します。
|
||||||
|
|
||||||
|
## 仕組み
|
||||||
|
### 前提知識
|
||||||
|
> **TIP**
|
||||||
|
> uwuzuはSNSサービスを作るソフトである。
|
||||||
|
uwuzuを使って作ったサービスはuwuzuサーバーと呼ばれる。
|
||||||
|
uwuzuサーバーにはゆずねっとなどが挙げられる。
|
||||||
|
|
||||||
|
uwuzuには内部とクライアントというものがあります。
|
||||||
|
クライアントとは、内部をいじるためのサイトやアプリです。
|
||||||
|
uwuzuサーバーに登録するとき、あなたは`https://uwuzu.net`や`https://uwuzu.net/home`などのサイトにアクセスしたと思います。
|
||||||
|
これが、クライアントです。`https://uwuzu.net`はゆずねっとですが、URLが、`uwuzu.net`ほぼそのままですよね。
|
||||||
|
このクライアントは、uwuzuサーバーに内蔵されているため、わかりやすいURLになっています。
|
||||||
|
|
||||||
|
ただし、uwuzuサーバー内臓のクライアントは、設計で重くなっている部分が多いです。
|
||||||
|
そこで、ULCはネットワーク通信量(とメモリ使用量も削減したい)を削減しています。
|
||||||
|
ネットワーク通信量を削減することで、ネットに繋がる量が減ります。(当たり前)
|
||||||
|
ネットに繋がる量が減ることで、スマホのギガがなくなったとしても、少しはましに使えます。
|
||||||
|
|
||||||
|
### 具体的にどこ削減してんだよ
|
||||||
|
uwuzu内臓クライアントは、サイトのプログラムを毎回ネットから奪ってきています。
|
||||||
|
ULCでは、プログラムを全てスマホやPCなどの端末内に保存します。
|
||||||
|
こうすることで、プログラムの部分(多分2番目くらいに大きい)の量を削減できました。
|
||||||
|
|
||||||
|
じゃあユーズ、自身のプロフィールはどうするのか。
|
||||||
|
これらの情報は、必要な分だけuwuzuサーバーから奪ってきます。
|
||||||
|
|
||||||
|
### メディア!!
|
||||||
|
uwuzuではユーズの画像/動画やアイコン、メディアがたくさんありますよね。
|
||||||
|
uwuzu内蔵クライアントでは全てのメディアを毎回uwuzuサーバーから奪います。
|
||||||
|
これではよく見るユーザーなど、無駄な通信が多いです。
|
||||||
|
そこで、ULCでは全てのメディアを端末の中に残します。(厳密には残そうとしていますがuwuzuサーバーがダメ!!って言ったりバグとかでできなかったらしません)
|
||||||
|
そのため、よく見るユーザーのアイコンなどの通信量が削減できます。
|
||||||
|
|
||||||
|
### あれ?なんでパスワード打ったりしてないのに情報を奪えるの?
|
||||||
|
uwuzuにはAPIというuwuzu内臓クライアント以外からuwuzuをいじる機能があります。
|
||||||
|
ULCのサインイン時に、許可するかどうかのuwuzu内臓クライアントの画面が出たと思います。
|
||||||
|
あそこの画面とULCがくっついていて、許可を押すことで、パスワードとは違う情報を奪う時の鍵がULCに送られます。
|
||||||
|
その鍵を使って情報を奪っています。
|
||||||
|
|
||||||
|
### APIでできないこと
|
||||||
|
めっちゃあります。
|
||||||
|
uwuzu内臓クライアントは専用の道を使っておりAPIは使っていません。
|
||||||
|
APIのできることはuwuzu内臓クライアントのできることに比べてかなり少なく、まず**ユーザーのタイムラインが見れません。**その上**絵文字を奪えません。**
|
||||||
|
絵文字を奪えないのはかなり致命的だと思うので絵文字がないと嫌な人はULCを使わないほうがいいと思います。
|
||||||
|
ULCはAPIのできることをフル活用しているのでULCでできないことはまだ完成していないかuwuzu側がそもそも無理です。気長に待ちましょう。
|
||||||
|
|
||||||
|
### 仕組み解説終わり!!
|
||||||
|
一般ユーザーの方ここまで読んでくれてありがとうございます。
|
||||||
|
ここで仕組み解説は終わりです。
|
||||||
|
ULCを使ってバグを見つけたりしたら[Last2014](https://about.last2014.com)に報告いくらでもしていいです。遠慮せずに。
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/ulc.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>uwuzu Light Client</title>
|
||||||
|
<meta name="description" content="uwuzu軽量クライアント" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "uwuzu-light-client",
|
||||||
|
"description": "uwuzu軽量クライアント",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0-alpha.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify/vue": "^5.0.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/semver": "^7.7.1",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"better-uwuzu-sdk": "git+https://gitea.last2014.com/last2014/better-uwuzu-sdk.git#1.1.4",
|
||||||
|
"dexie": "^4.2.1",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-stringify": "^10.0.1",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
|
"semver": "^7.7.3",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vue": "^3.5.27"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^25.0.10",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"@vueuse/head": "^2.0.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
|
"vue-tsc": "^3.2.3",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5833
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M14.6 8.075q0-1.075-.712-1.725T12 5.7q-.725 0-1.312.313t-1.013.912q-.4.575-1.088.663T7.4 7.225q-.35-.325-.387-.8t.237-.9q.8-1.2 2.038-1.862T12 3q2.425 0 3.938 1.375t1.512 3.6q0 1.125-.475 2.025t-1.75 2.125q-.925.875-1.25 1.363T13.55 14.6q-.1.6-.513 1t-.987.4t-.987-.387t-.413-.963q0-.975.425-1.787T12.5 11.15q1.275-1.125 1.688-1.737t.412-1.338M12 22q-.825 0-1.412-.587T10 20t.588-1.412T12 18t1.413.588T14 20t-.587 1.413T12 22"/></svg>
|
||||||
|
After Width: | Height: | Size: 662 B |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0-alpha.0",
|
||||||
|
"isPrerelease": true,
|
||||||
|
"update": {
|
||||||
|
"requiredCacheClear": true,
|
||||||
|
"requiredAgainSignin": true,
|
||||||
|
"requiredSettingsClear": true,
|
||||||
|
"canUpdate": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="1024"
|
||||||
|
height="1024"
|
||||||
|
viewBox="0 0 270.93333 270.93333"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
|
||||||
|
sodipodi:docname="ulc.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="0.72426347"
|
||||||
|
inkscape:cx="432.16318"
|
||||||
|
inkscape:cy="570.92483"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1094"
|
||||||
|
inkscape:window-x="-11"
|
||||||
|
inkscape:window-y="-11"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<rect
|
||||||
|
style="fill:#cccccc;fill-rule:evenodd;stroke-width:14.9219"
|
||||||
|
id="rect1"
|
||||||
|
width="270.93332"
|
||||||
|
height="270.93332"
|
||||||
|
x="5.5511151e-17"
|
||||||
|
y="0" />
|
||||||
|
<g
|
||||||
|
id="g3"
|
||||||
|
transform="translate(0,7.7895512)">
|
||||||
|
<ellipse
|
||||||
|
style="fill:#ffff00;fill-rule:evenodd;stroke-width:7.44355"
|
||||||
|
id="path2"
|
||||||
|
cx="135.46666"
|
||||||
|
cy="155.07564"
|
||||||
|
rx="21.918818"
|
||||||
|
ry="86.031364" />
|
||||||
|
<ellipse
|
||||||
|
style="fill:#00ff00;fill-rule:evenodd;stroke-width:13.59"
|
||||||
|
id="path3"
|
||||||
|
cx="135.46666"
|
||||||
|
cy="40.001842"
|
||||||
|
rx="10.228783"
|
||||||
|
ry="25.754614" />
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
style="fill:#ffffff;fill-rule:evenodd;stroke-width:13.59"
|
||||||
|
id="rect3"
|
||||||
|
width="12.785977"
|
||||||
|
height="238.5498"
|
||||||
|
x="100.73574"
|
||||||
|
y="16.328756" />
|
||||||
|
<rect
|
||||||
|
style="fill:#ffffff;fill-rule:evenodd;stroke-width:13.59"
|
||||||
|
id="rect4"
|
||||||
|
width="12.785977"
|
||||||
|
height="238.5498"
|
||||||
|
x="157.29234"
|
||||||
|
y="16.191765" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
+112
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<main :class='[
|
||||||
|
"w-full", "h-full", "large:px-8",
|
||||||
|
"flex", "justify-center", "flex-1",
|
||||||
|
"bg-(--bg-color)", "text-(--text-color)",
|
||||||
|
]'>
|
||||||
|
<ErrorMsg :error="error" />
|
||||||
|
|
||||||
|
<div id="alert-container" class="z-9995" />
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
:class='[
|
||||||
|
"fixed", "inset-0", "z-9999",
|
||||||
|
"left-auto", "top-2", "right-2",
|
||||||
|
]'
|
||||||
|
:size="6"
|
||||||
|
v-if="routerStatus.isLoad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<RouterView
|
||||||
|
:key="route.fullPath"
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
class="max-large:hidden fixed top-auto bottom-1"
|
||||||
|
to="/about/ulc"
|
||||||
|
>
|
||||||
|
uwuzu Light Client {{ release?.version ?? "" }}
|
||||||
|
</RouterLink>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, onBeforeUnmount, ref, Suspense } from "vue";
|
||||||
|
import { RouterView, useRoute } from "vue-router";
|
||||||
|
import Database, { getByIndex } from "@/lib/db";
|
||||||
|
import ErrorMsg from "@/components/Window/ErrorMsg.vue";
|
||||||
|
import { isSignin } from "@/lib/account";
|
||||||
|
import Progress from "@/components/Progress.vue";
|
||||||
|
import routerStatus from "@/lib/router";
|
||||||
|
import { network } from "@/layouts/Layout.vue";
|
||||||
|
import { release } from "@/main";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const error = ref<any>();
|
||||||
|
const customStyle = ref<HTMLStyleElement>();
|
||||||
|
|
||||||
|
function handleError(event: ErrorEvent | PromiseRejectionEvent) {
|
||||||
|
error.value = event instanceof PromiseRejectionEvent
|
||||||
|
? event.reason
|
||||||
|
: event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOnline() {
|
||||||
|
network.isOnline = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOffline() {
|
||||||
|
network.isOnline = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener("error", handleError);
|
||||||
|
window.addEventListener("unhandledrejection", handleError);
|
||||||
|
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
window.addEventListener("offline", handleOffline);
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
const swFile = import.meta.env.MODE === "production"
|
||||||
|
? "/sw.js"
|
||||||
|
: "/dev-sw.js?dev-sw";
|
||||||
|
|
||||||
|
navigator.serviceWorker.register(swFile, {
|
||||||
|
type: import.meta.env.MODE === "production" ? "classic" : "module",
|
||||||
|
scope: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
try {
|
||||||
|
await db.open();
|
||||||
|
await isSignin(db);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err;
|
||||||
|
}
|
||||||
|
const customcssRow = await getByIndex(db.settings, "name", "customcss");
|
||||||
|
|
||||||
|
if (typeof customcssRow?.value === "string") {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "customcss";
|
||||||
|
style.textContent = customcssRow.value;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
customStyle.value = style;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("error", handleError);
|
||||||
|
window.removeEventListener("unhandledrejection", handleError);
|
||||||
|
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
window.removeEventListener("offline", handleOffline);
|
||||||
|
|
||||||
|
if (customStyle.value) {
|
||||||
|
document.head.removeChild(customStyle.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="authState.isSignedIn
|
||||||
|
? Signined
|
||||||
|
: 'div'
|
||||||
|
">
|
||||||
|
<div class="w-full h-full flex flex-col gap-2 justify-center items-center">
|
||||||
|
<h1 class="text-3xl font-extrabold">データなんかねえよ</h1>
|
||||||
|
|
||||||
|
<RouterLink to="/">
|
||||||
|
<Button>
|
||||||
|
ホームに戻る
|
||||||
|
</Button>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="isViewVideo = !isViewVideo"
|
||||||
|
>
|
||||||
|
動画を開閉する
|
||||||
|
</Button>
|
||||||
|
<span>動画の読み込みにあたり6.6MB通信します</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isViewVideo"
|
||||||
|
v-html="video"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import Button from "@/components/Button.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import Signined from "@/layouts/Layout.vue";
|
||||||
|
import { authState } from "@/lib/account";
|
||||||
|
|
||||||
|
const isViewVideo = ref<boolean>(false);
|
||||||
|
const video =
|
||||||
|
`<iframe
|
||||||
|
width="560"
|
||||||
|
height="315"
|
||||||
|
src="https://www.youtube.com/embed/5sqW51YrGoA?si=2Lwv7BvpOxxcLzNr"
|
||||||
|
title="データなんかねえよ"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer;
|
||||||
|
autoplay;
|
||||||
|
clipboard-write;
|
||||||
|
encrypted-media;
|
||||||
|
gyroscope;
|
||||||
|
picture-in-picture;
|
||||||
|
web-share"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allowfullscreen
|
||||||
|
/>`
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<button :class='[
|
||||||
|
"bg-(--accent)", "text-(--accent-in-text)",
|
||||||
|
"rounded-2xl", "cursor-pointer",
|
||||||
|
"w-fit", "px-4", "py-1",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
|
"disabled:bg-(--accent)/50",
|
||||||
|
"disabled:opacity-80",
|
||||||
|
]'>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
class="appearance-none peer sr-only"
|
||||||
|
v-model="isChecked"
|
||||||
|
type="checkbox"
|
||||||
|
:id="id"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
:class="[
|
||||||
|
'bg-neutral-200', 'dark:bg-zinc-800',
|
||||||
|
'rounded', 'p-2', 'text-lg',
|
||||||
|
'hover:outline-none',
|
||||||
|
'focus:outline-none',
|
||||||
|
'block', 'w-4', 'h-4',
|
||||||
|
'cursor-pointer', 'relative',
|
||||||
|
'peer-checked:bg-blue-500',
|
||||||
|
]"
|
||||||
|
:for="id"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:check-rounded"
|
||||||
|
class="absolute inset-0"
|
||||||
|
v-show="isChecked"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
:for="id"
|
||||||
|
class="select-none cursor-pointer"
|
||||||
|
v-if="$slots"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import { useId } from "vue";
|
||||||
|
|
||||||
|
const isChecked = defineModel<boolean>({
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = useId();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<input
|
||||||
|
:class='[
|
||||||
|
"bg-neutral-200", "dark:bg-zinc-800",
|
||||||
|
"rounded", "px-2", "py-1", "text-lg",
|
||||||
|
"hover:outline-0",
|
||||||
|
"focus:outline-0",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
|
"disabled:bg-neutral-500/50",
|
||||||
|
"disabled:dark:bg-zinc-900/30",
|
||||||
|
"disabled:opacity-70",
|
||||||
|
]'
|
||||||
|
v-model="text"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const text = defineModel<string>({
|
||||||
|
default: "",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"fixed","inset-0", "flex", "z-9995", "wrap-break-word",
|
||||||
|
"items-center", "justify-center", "text-center",
|
||||||
|
"bg-black/50", "lg-media",
|
||||||
|
{ hidden: !data.src },
|
||||||
|
]'
|
||||||
|
@click="data.src = ''"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:class='[
|
||||||
|
"shadow-lg", "max-w-[95vw]", "w-auto", "max-h-[80vh]",
|
||||||
|
"m-6", "gap-2", "rounded-lg", "bg-(--text-color)",
|
||||||
|
"object-contain", "cursor-zoom-out",
|
||||||
|
"border", "border-(--border-color)",
|
||||||
|
]'
|
||||||
|
:src="data.src"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { type Reactive } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: Reactive<{
|
||||||
|
src: string;
|
||||||
|
}>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<img
|
||||||
|
:src="props.src"
|
||||||
|
v-if="isPhoto"
|
||||||
|
:class="className"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<video
|
||||||
|
:src="props.src"
|
||||||
|
:class="className"
|
||||||
|
class="cursor-auto!"
|
||||||
|
controls
|
||||||
|
v-else
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
src: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ext = computed(() => props.src.slice(props.src.lastIndexOf(".") + 1).toLowerCase());
|
||||||
|
|
||||||
|
const isPhoto = computed(() => {
|
||||||
|
const videoExts = ["mp4", "mpeg", "mpg", "webm", "avi"];
|
||||||
|
return !videoExts.includes(ext.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const className = "w-full bg-(--text-color) object-cover border border-(--border-color) rounded-md cursor-zoom-in";
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"large:flex!", "flex-col", "max-large:rounded-r-lg",
|
||||||
|
"max-large:hidden", "max-large:fixed", "max-large:z-9996",
|
||||||
|
"bg-(--bg-color)", "shrink-0", "items-center", "justify-center",
|
||||||
|
"max-large:px-4", "left-0", "right-auto",
|
||||||
|
"max-w-40", "max-large:max-w-55", "w-fit", "h-screen", "gap-3",
|
||||||
|
]'
|
||||||
|
id="menu"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
class="flex flex-col items-center gap-1 mb-2"
|
||||||
|
to="/about/server"
|
||||||
|
v-if="authState.isSignedIn"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="w-30 h-20 block rounded-lg object-cover"
|
||||||
|
:src="servericon()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="text-(--text-color)/85">
|
||||||
|
{{ authState.hostname }}
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
:class='[
|
||||||
|
"flex", "px-4", "py-2", "transition--all", "w-full",
|
||||||
|
"rounded-[50px]", "cursor-pointer", "lg-menu-content",
|
||||||
|
]'
|
||||||
|
v-for="content of contents"
|
||||||
|
:to="content.link"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="content.icon"
|
||||||
|
class="shrink-0 mr-1 my-auto w-6 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="shrink-0 text-xl font-bold"
|
||||||
|
>
|
||||||
|
{{ content.title }}
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
class="flex left-5 bottom-15 gap-2 lg-menu-account"
|
||||||
|
:to="`/@${authState.me.userid}`"
|
||||||
|
v-if="authState.isSignedIn"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="w-12 h-12 object-cover rounded-full"
|
||||||
|
:src="authState.me.user_icon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<span class="font-bold">
|
||||||
|
{{ authState.me.username }}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:robot-2-rounded"
|
||||||
|
class="w-4 h-4 shrink-0 m-auto opacity-70"
|
||||||
|
v-if="authState.me.isBot"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:admin-panel-settings-rounded"
|
||||||
|
class="w-4 h-4 shrink-0 m-auto text-(--accent)"
|
||||||
|
v-if="authState.me.isAdmin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-(--text-color)/85">
|
||||||
|
@{{ authState.me.userid }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.router-link-active.lg-menu-content,
|
||||||
|
.lg-menu-content:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--accent-in-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { authState } from "@/lib/account";
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
|
||||||
|
const servericon = () => {
|
||||||
|
if (authState.isSignedIn) {
|
||||||
|
if (authState.serverinfo.server_info.server_icon) {
|
||||||
|
return authState.serverinfo.server_info.server_icon;
|
||||||
|
} else {
|
||||||
|
return `${authState.origin}/img/uwuzucolorlogo.svg`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "/error_image.svg";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = [
|
||||||
|
{
|
||||||
|
title: "ホーム",
|
||||||
|
icon: "material-symbols:home-rounded",
|
||||||
|
link: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "検索",
|
||||||
|
icon: "material-symbols:search-rounded",
|
||||||
|
link: "/search",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "通知",
|
||||||
|
icon: "material-symbols:notifications-rounded",
|
||||||
|
link: "/notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "ブックマーク",
|
||||||
|
icon: "material-symbols:bookmark-rounded",
|
||||||
|
link: "/bookmarks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "設定",
|
||||||
|
icon: "material-symbols:settings-rounded",
|
||||||
|
link: "/settings",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"fixed", "max-large:flex", "large:hidden", "z-9997",
|
||||||
|
"items-center", "justify-center",
|
||||||
|
"top-auto", "bottom-0",
|
||||||
|
"rounded-t-lg", "border-t", "border-t-(--border-color)",
|
||||||
|
"bg-(--bg-color)", "w-full", "h-10",
|
||||||
|
]'
|
||||||
|
>
|
||||||
|
<template v-for="content of contents">
|
||||||
|
<RouterLink
|
||||||
|
:class='[
|
||||||
|
"flex", "w-full", "h-full", "transition--all",
|
||||||
|
"text-(--accent)", "dark:text-(--text-color)",
|
||||||
|
"cursor-pointer", "sp-menu-content",
|
||||||
|
]'
|
||||||
|
:to="content.link"
|
||||||
|
v-if="content.link"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="content.icon"
|
||||||
|
class="m-auto w-8 h-8"
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"flex", "w-full", "h-full", "transition--all",
|
||||||
|
"text-(--accent)", "dark:text-inherit",
|
||||||
|
"cursor-pointer", "sp-menu-content",
|
||||||
|
]'
|
||||||
|
@click="content.onclick"
|
||||||
|
v-else
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="content.icon"
|
||||||
|
class="m-auto w-8 h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sp-menu-content:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--accent-in-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
|
||||||
|
const lgMenu = computed(() => document.getElementById("menu"));
|
||||||
|
|
||||||
|
const contents = [
|
||||||
|
{
|
||||||
|
icon: "material-symbols:menu-rounded",
|
||||||
|
onclick: () => {
|
||||||
|
if (!lgMenu.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lgMenu.value.style.display === "flex") {
|
||||||
|
lgMenu.value.style.display = "none";
|
||||||
|
} else {
|
||||||
|
lgMenu.value.style.display = "flex";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "material-symbols:home-rounded",
|
||||||
|
link: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "material-symbols:notifications-rounded",
|
||||||
|
link: "/notifications",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"rounded-full", "animate-spin",
|
||||||
|
"border-t-blue-500", "border-gray-300",
|
||||||
|
]'
|
||||||
|
:style="{
|
||||||
|
minWidth: elmSize,
|
||||||
|
maxWidth: elmSize,
|
||||||
|
minHeight: elmSize,
|
||||||
|
maxHeight: elmSize,
|
||||||
|
borderWidth: `${size / 3}px`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const props = defineProps<{
|
||||||
|
size?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const size = props.size ?? 12;
|
||||||
|
const elmSize = `${size * 0.25}rem`;
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<p :class='[
|
||||||
|
"text-sm",
|
||||||
|
"text-gray-500", "dark:text-gray-300"
|
||||||
|
]'>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fixed w-full h-full inset-0 z-9994 ueuse-more-menu"
|
||||||
|
v-if="ueuseMoreMenu.visibility && authState.isSignedIn"
|
||||||
|
@click="ueuseMoreMenu.visibility = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"w-fit", "p-2", "gap-2", "max-large:right-2", "large:right-8",
|
||||||
|
"bg-(--bg-color)", "border", "border-(--border-color)",
|
||||||
|
"rounded-lg", "shadow-md", "flex", "flex-col", "absolute",
|
||||||
|
]'
|
||||||
|
:style="{ top: ueuseMoreMenu.position + 'px' }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<template v-for="content in contents">
|
||||||
|
<a
|
||||||
|
:href="content.link"
|
||||||
|
target="_blank"
|
||||||
|
class="flex gap-1 cursor-pointer"
|
||||||
|
v-if="content.type === 'link'"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="content.icon"
|
||||||
|
class="my-auto w-6 h-6"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
{{ content.message }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div
|
||||||
|
@click="content.onClick()"
|
||||||
|
v-if="content.type === 'event'"
|
||||||
|
class="flex gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="content.icon"
|
||||||
|
class="my-auto w-6 h-6"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
{{ content.message }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { authState } from "@/lib/account";
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
|
||||||
|
const contents = computed(() => {
|
||||||
|
if (!authState.isSignedIn)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "event",
|
||||||
|
icon: "material-symbols:content-copy-rounded",
|
||||||
|
message: "クリップボードにコピー",
|
||||||
|
onClick: () => void(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
icon: "material-symbols:open-in-new-rounded",
|
||||||
|
link: `${authState.origin}/!${ueuseMoreMenu.uniqid}`,
|
||||||
|
message: `${authState.serverinfo.server_info.server_name}で開く`,
|
||||||
|
},
|
||||||
|
] as (({
|
||||||
|
type: "link";
|
||||||
|
link: string;
|
||||||
|
} | {
|
||||||
|
type: "event";
|
||||||
|
onClick: () => void | never;
|
||||||
|
}) & {
|
||||||
|
icon: string;
|
||||||
|
message: string;
|
||||||
|
})[];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, reactive } from "vue";
|
||||||
|
|
||||||
|
export const ueuseMoreMenu = reactive<{
|
||||||
|
visibility: boolean;
|
||||||
|
uniqid: string;
|
||||||
|
position: number;
|
||||||
|
}>({
|
||||||
|
visibility: false,
|
||||||
|
uniqid: "",
|
||||||
|
position: 0,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"flex", "items-center",
|
||||||
|
"h-7.5", "gap-1", "py-0.5", "px-2",
|
||||||
|
"rounded-2xl", "transition--all",
|
||||||
|
"cursor-pointer", "select-none",
|
||||||
|
"border", "border-(--border-color)/80",
|
||||||
|
`${(isUse ?? false)
|
||||||
|
? "bg-(--accent) text-white"
|
||||||
|
: "bg-(--bg-color) text-(--accent)"}`,
|
||||||
|
`ueuse-operation-${name}`,
|
||||||
|
]'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
class="w-5 h-5"
|
||||||
|
:icon="isUse
|
||||||
|
? usedIcon
|
||||||
|
: icon"
|
||||||
|
/>
|
||||||
|
<span v-if="data !== undefined">
|
||||||
|
{{ data }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
data?: number;
|
||||||
|
} & ({
|
||||||
|
usedIcon?: never;
|
||||||
|
isUse?: never;
|
||||||
|
} | {
|
||||||
|
usedIcon: string;
|
||||||
|
isUse: boolean;
|
||||||
|
})>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"ueuse",
|
||||||
|
"flex", "w-full", "h-fit",
|
||||||
|
"rounded-lg", "gap-2",
|
||||||
|
]'
|
||||||
|
v-if="authState.isSignedIn"
|
||||||
|
ref="ueuseElement"
|
||||||
|
>
|
||||||
|
<RouterLink class="shrink-0 h-fit rounded-full" :to="`/@${data.account.userid}`">
|
||||||
|
<img
|
||||||
|
:class='[
|
||||||
|
"w-12", "h-12", "rounded-full", "cursor-pointer",
|
||||||
|
"block", "shrink-0", "object-cover", "ueuse-user-icon"
|
||||||
|
]'
|
||||||
|
:src="data.account.user_icon"
|
||||||
|
v-if="data.text !== ''"
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div class="flex flex-col grow gap-1 min-w-0">
|
||||||
|
<div class="flex gap-1 ueuse-detail">
|
||||||
|
<RouterLink
|
||||||
|
:to="`/@${data.account.userid}`"
|
||||||
|
class="flex gap-1 ueuse-detail-account truncate"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:class='[
|
||||||
|
"block", "w-6", "h-6", "rounded-full", "mr-1",
|
||||||
|
"shrink-0", "object-cover", "ueuse-user-icon"
|
||||||
|
]'
|
||||||
|
:src="data.account.user_icon"
|
||||||
|
v-if="isReuse && data.text === ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="font-bold ueuse-user-name truncate">
|
||||||
|
{{ data.account.username }}
|
||||||
|
</span>
|
||||||
|
<span class="ueuse-user-id text-(--text-color)/85 truncate">
|
||||||
|
@{{ data.account.userid }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:robot-2-rounded"
|
||||||
|
class="w-4 h-4 shrink-0 m-auto opacity-70"
|
||||||
|
v-if="data.account.is_bot"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:check-circle-rounded"
|
||||||
|
class="w-4 h-4 shrink-0 m-auto text-(--accent)"
|
||||||
|
v-if="/*Botかどうかを毎回取得してたらきりない*/false"
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:repeat-rounded"
|
||||||
|
class="w-4 h-4 shrink-0 my-auto text-(--accent)"
|
||||||
|
v-if="isReuse && data.text === ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="ml-auto w-fit shrink-0">
|
||||||
|
{{ parsedDate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"flex", "flex-col",
|
||||||
|
"bg-(--accent)", "text-(--accent-in-text)",
|
||||||
|
"rounded-lg", "p-2", "gap-1",
|
||||||
|
"ueuse-nsfw",
|
||||||
|
]'
|
||||||
|
v-if="data.nsfw"
|
||||||
|
>
|
||||||
|
<div class="flex shrink items-center w-full gap-1">
|
||||||
|
<Icon
|
||||||
|
class="my-auto break-all"
|
||||||
|
icon="material-symbols:visibility-off-rounded"
|
||||||
|
/>
|
||||||
|
NSFWユーズ
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="visibility = !visibility"
|
||||||
|
class="px-2! ml-auto text-sm border border-(--text-color)"
|
||||||
|
>
|
||||||
|
{{ visibility
|
||||||
|
? "非表示"
|
||||||
|
: "表示"
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ueuse-nsfw-detail">
|
||||||
|
{{ detail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-1 ueuse-content"
|
||||||
|
v-if="visibility"
|
||||||
|
>
|
||||||
|
<p class="ueuse-text break-all" v-if="parsedText">
|
||||||
|
<template v-for="(node, i) in parsedText" :key="i">
|
||||||
|
<component v-if="typeof node !== 'string'" :is="node" />
|
||||||
|
<span v-else>{{ node }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid gap-1 ueuse-media"
|
||||||
|
:class="mediaGridClass"
|
||||||
|
v-if="medias.length > 0"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(media, i) in medias"
|
||||||
|
:key="media"
|
||||||
|
>
|
||||||
|
<Media
|
||||||
|
v-if="
|
||||||
|
i < 2 ||
|
||||||
|
(medias.length >= 4 &&
|
||||||
|
i < 4)
|
||||||
|
"
|
||||||
|
:src="media"
|
||||||
|
class="col-span-1 aspect-4/3"
|
||||||
|
@click="showLgMedia(media)"
|
||||||
|
/>
|
||||||
|
<Media
|
||||||
|
v-else
|
||||||
|
:src="media"
|
||||||
|
class="col-span-2 aspect-2/1"
|
||||||
|
@click="showLgMedia(media)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"flex", "flex-col", "p-2", "rounded-lg", "gap-1.5",
|
||||||
|
"border", "border-(--border-color)", "bg-(--bg-color)/70", "ueuse-abi",
|
||||||
|
]'
|
||||||
|
v-if="data.abi !== '' && data.abidatetime !== ''"
|
||||||
|
>
|
||||||
|
<span :class='[
|
||||||
|
"bg-(--accent)", "text-(--accent-in-text)",
|
||||||
|
"px-2", "py-1.5", "rounded-md", "font-bold", "flex",
|
||||||
|
]'>
|
||||||
|
<Icon
|
||||||
|
class="my-auto mr-1 w-5 h-5"
|
||||||
|
icon="material-symbols:add-notes-rounded"
|
||||||
|
/>
|
||||||
|
追記
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p class="ueuse-abi-content break-all" v-if="parsedAbiText">
|
||||||
|
<template v-for="(node, i) in parsedAbiText" :key="i">
|
||||||
|
<component v-if="typeof node !== 'string'" :is="node" />
|
||||||
|
<span v-else>{{ node }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<span class="text-sm text-(--text-color)/70">
|
||||||
|
{{ parsedAbiDate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Ueuse
|
||||||
|
:data="reuseData"
|
||||||
|
:lgMedia="lgMedia"
|
||||||
|
:depth="depth + 1"
|
||||||
|
v-if="isReuse && reuseData"
|
||||||
|
:class='[
|
||||||
|
"mt-2",
|
||||||
|
{
|
||||||
|
"border": data.text !== "",
|
||||||
|
"border-(--border-color)": data.text !== "",
|
||||||
|
"p-2": data.text !== "",
|
||||||
|
},
|
||||||
|
]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-2 flex flex-wrap gap-3 ueuse-operation"
|
||||||
|
v-if="data.text !== ''"
|
||||||
|
>
|
||||||
|
<Operation
|
||||||
|
@click="likeUpdate(data.uniqid)"
|
||||||
|
name="like"
|
||||||
|
icon="material-symbols:favorite-outline-rounded"
|
||||||
|
usedIcon="material-symbols:favorite-rounded"
|
||||||
|
:isUse="data.favorite.includes(authState.me.userid)"
|
||||||
|
:data="data.favorite_cnt"
|
||||||
|
/>
|
||||||
|
<Operation
|
||||||
|
name="reuse"
|
||||||
|
icon="material-symbols:repeat-rounded"
|
||||||
|
:data="data.reuse_cnt"
|
||||||
|
/>
|
||||||
|
<RouterLink
|
||||||
|
class="rounded-2xl"
|
||||||
|
:to="`/ueuse/${data.uniqid}`"
|
||||||
|
>
|
||||||
|
<Operation
|
||||||
|
name="reply"
|
||||||
|
icon="material-symbols:reply-rounded"
|
||||||
|
:data="data.reply_cnt"
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
<Operation
|
||||||
|
name="more"
|
||||||
|
icon="material-symbols:more-horiz"
|
||||||
|
class="ml-auto"
|
||||||
|
@click="showMoreMenu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
|
||||||
|
import { DateParse, parseTextToNodes } from "@/lib/parser";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import Button from "@/components/Button.vue";
|
||||||
|
import Media from "@/components/Media/index.vue";
|
||||||
|
import Ueuse from "@/components/Ueuse/index.vue";
|
||||||
|
import Operation from "@/components/Ueuse/Operation.vue";
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import uwuzu from "better-uwuzu-sdk";
|
||||||
|
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
|
||||||
|
import Parser from "better-uwuzu-sdk/1.6.8/parser";
|
||||||
|
import {
|
||||||
|
ref, inject, computed, watch,
|
||||||
|
onMounted, onBeforeUnmount,
|
||||||
|
type Reactive, type Ref, type Component,
|
||||||
|
} from "vue";
|
||||||
|
import { authState } from "@/lib/account";
|
||||||
|
import waitUntil from "@/lib/wait";
|
||||||
|
import { ueuseMoreMenu } from "@/components/Ueuse/More.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: ueuseModule;
|
||||||
|
lgMedia: Reactive<{
|
||||||
|
src: string;
|
||||||
|
}>;
|
||||||
|
depth?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const depth = props.depth ?? 0;
|
||||||
|
const MAX_DEPTH = ref<number>(1);
|
||||||
|
|
||||||
|
const apiClient = ref<uwuzu<ApiMap>>();
|
||||||
|
|
||||||
|
const visibility = ref<boolean>(!props.data.nsfw);
|
||||||
|
const isReuse = props.data.reuseid !== "" && depth < MAX_DEPTH.value;
|
||||||
|
const reuseData = ref<ueuseModule>();
|
||||||
|
|
||||||
|
const now = inject<Ref<Date>>("now")!;
|
||||||
|
|
||||||
|
type ParsedNodes = (string | Component)[];
|
||||||
|
|
||||||
|
const parsedText = ref<ParsedNodes | null>(null);
|
||||||
|
const parsedAbiText = ref<ParsedNodes | null>(null);
|
||||||
|
|
||||||
|
const ueuseElement = ref<HTMLElement | null>(null);
|
||||||
|
const parsedDate = ref("");
|
||||||
|
const parsedAbiDate = ref("");
|
||||||
|
let observer: IntersectionObserver;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!ueuseElement.value) return;
|
||||||
|
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
parsedDate.value ||= DateParse(props.data.datetime, now.value);
|
||||||
|
|
||||||
|
if (props.data.abidatetime) {
|
||||||
|
parsedAbiDate.value ||= DateParse(props.data.abidatetime, now.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
visibility.value &&
|
||||||
|
props.data.text &&
|
||||||
|
!parsedText.value
|
||||||
|
) {
|
||||||
|
parsedText.value = parseTextToNodes(props.data.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
visibility.value &&
|
||||||
|
props.data.abi &&
|
||||||
|
props.data.abidatetime &&
|
||||||
|
!parsedAbiText.value
|
||||||
|
) {
|
||||||
|
parsedAbiText.value = parseTextToNodes(props.data.abi);
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
rootMargin: "100px 0px",
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(ueuseElement.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(visibility, (v) => {
|
||||||
|
if (!v)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (props.data.text !== "") {
|
||||||
|
parsedText.value ||= parseTextToNodes(props.data.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.data.abi !== "") {
|
||||||
|
parsedAbiText.value ||= parseTextToNodes(props.data.abi);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const medias = props.data.media.photo.concat(props.data.media.video);
|
||||||
|
|
||||||
|
const mediaGridClass = computed(() => {
|
||||||
|
switch (medias.length) {
|
||||||
|
case 1:
|
||||||
|
return "grid-cols-1";
|
||||||
|
default:
|
||||||
|
return "grid-cols-2";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail = computed(() => {
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
if (props.data.text.trim() !== "" && props.data.reuseid === "") {
|
||||||
|
result.push(`${props.data.text.trim().length}文字の本文`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (medias.length > 0) {
|
||||||
|
result.push(`${medias.length}枚のメディア`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.data.abi.trim() !== "") {
|
||||||
|
result.push(`${props.data.abi.trim().length}文字の追記`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.data.replyid !== "") {
|
||||||
|
result.push("返信");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.data.reuseid !== "") {
|
||||||
|
if (props.data.text === "") {
|
||||||
|
result.push("リユーズ");
|
||||||
|
} else {
|
||||||
|
result.push(`${props.data.text.trim().length}文字の引用`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join("・");
|
||||||
|
});
|
||||||
|
|
||||||
|
const showLgMedia = (src: string) => {
|
||||||
|
const ext = src.slice(src.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
|
||||||
|
const isPhoto = () => {
|
||||||
|
const videoExts = ["mp4", "mpeg", "mpg", "webm", "avi"];
|
||||||
|
return !videoExts.includes(ext);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isPhoto())
|
||||||
|
return;
|
||||||
|
|
||||||
|
props.lgMedia.src = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
const likeUpdate = async (uniqid: string) => {
|
||||||
|
await waitUntil(() => apiClient.value !== undefined, 100);
|
||||||
|
if (!apiClient.value) return false;
|
||||||
|
if (!authState.isSignedIn) return false;
|
||||||
|
|
||||||
|
MAX_DEPTH.value = authState.reuse_maxdepth;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chg = await apiClient.value.request("favorite/change", {
|
||||||
|
uniqid: uniqid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chg.success) {
|
||||||
|
props.data.favorite = chg.favorite_list;
|
||||||
|
props.data.favorite_cnt = chg.favorite_list.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chg.success;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showMoreMenu = (event: MouseEvent) => {
|
||||||
|
if (!ueuseMoreMenu.visibility) {
|
||||||
|
ueuseMoreMenu.uniqid = props.data.uniqid;
|
||||||
|
ueuseMoreMenu.position = event.pageY;
|
||||||
|
ueuseMoreMenu.visibility = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (!authState.isSignedIn)
|
||||||
|
return;
|
||||||
|
|
||||||
|
apiClient.value = new uwuzu<ApiMap>({
|
||||||
|
origin: authState.origin,
|
||||||
|
parser: Parser,
|
||||||
|
});
|
||||||
|
apiClient.value.token = authState.token;
|
||||||
|
|
||||||
|
if (isReuse) {
|
||||||
|
const ueuse = await apiClient.value.request("ueuse/get", {
|
||||||
|
uniqid: props.data.reuseid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ueuse.success) {
|
||||||
|
throw new Error(ueuse.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
reuseData.value = ueuse.data[0];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center shadow-sm shadow-(color:--accent) rounded-lg gap-2 p-2 max-w-90 w-full min-w-0 mx-auto"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="meData.user_icon"
|
||||||
|
:alt="`${meData.username}さんのアイコン`"
|
||||||
|
class="w-20 h-20 rounded-full shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col min-w-0 w-full text-center">
|
||||||
|
<span class="font-bold wrap-break-words">{{ meData.username }}</span>
|
||||||
|
<span class="break-all shrink">@{{ meData.userid }}@{{ hostname }}</span>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
class="resize-none w-full min-w-0 max-h-15 box-border overflow-hidden"
|
||||||
|
v-html="meData.profile"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{
|
||||||
|
meData: any;
|
||||||
|
hostname: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<Window class="alert">
|
||||||
|
<span class="text-xl font-bold">{{ title }}</span>
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="onClose()"
|
||||||
|
class="m-auto"
|
||||||
|
>
|
||||||
|
{{ closeMsg }}
|
||||||
|
</Button>
|
||||||
|
</Window>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alert {
|
||||||
|
transform: translateZ(v-bind(layer));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Button from "@/components/Button.vue";
|
||||||
|
import Window from "@/components/Window/index.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
layer: number;
|
||||||
|
closeMsg: string;
|
||||||
|
onClose: () => any;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createApp, h, ref } from "vue";
|
||||||
|
import Component from "@/components/Window/Alert/Component.vue";
|
||||||
|
|
||||||
|
export const layer = ref<number>(0);
|
||||||
|
|
||||||
|
export default function Alert(
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
closeMsg?: string,
|
||||||
|
onClose?: () => void | never
|
||||||
|
) {
|
||||||
|
layer.value++
|
||||||
|
const container = document.querySelector("#alert-container")!
|
||||||
|
.appendChild(document.createElement("div"));
|
||||||
|
const currentLayer = layer.value;
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
render() {
|
||||||
|
return h(Component, {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
layer: currentLayer,
|
||||||
|
closeMsg: closeMsg ?? "閉じる",
|
||||||
|
onClose: () => {
|
||||||
|
if (onClose)
|
||||||
|
onClose();
|
||||||
|
app.unmount();
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount(container);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<Window
|
||||||
|
v-if="props.error !== undefined"
|
||||||
|
:layer="9998"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-semibold">問題が発生しました</span>
|
||||||
|
<SmallText v-html='
|
||||||
|
props.error
|
||||||
|
? (props.error instanceof Error
|
||||||
|
? props.error
|
||||||
|
: String(props.error).replaceAll(/\n/g, "<br>")
|
||||||
|
) : "不明なエラー"
|
||||||
|
'/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="reload"
|
||||||
|
:disabled="isReloading"
|
||||||
|
class="m-auto"
|
||||||
|
>
|
||||||
|
<span v-if="isReloading">再読み込み中...</span>
|
||||||
|
<span v-else>再読み込み</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
@click="toHome()"
|
||||||
|
class="m-auto"
|
||||||
|
v-if="$route.path !== '/'"
|
||||||
|
>
|
||||||
|
ホームに戻る
|
||||||
|
</Button>
|
||||||
|
</Window>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Button from "@/components/Button.vue";
|
||||||
|
import SmallText from "@/components/SmallText.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import Window from "@/components/Window/index.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
error?: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isReloading = ref<boolean>(false);
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
isReloading.value = true;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const toHome = () => {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"fixed","inset-0", "flex", "wrap-break-word",
|
||||||
|
"items-center", "justify-center", "text-center",
|
||||||
|
"bg-black/50", "backdrop-blur-sm",
|
||||||
|
]'
|
||||||
|
:style="layer
|
||||||
|
? `z-index: ${layer}`
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div :class='[
|
||||||
|
"bg-white", "text-black",
|
||||||
|
"dark:bg-neutral-600", "dark:text-white",
|
||||||
|
"shadow-lg", "w-80",
|
||||||
|
"p-6", "gap-2", "rounded-lg",
|
||||||
|
"flex", "flex-col",
|
||||||
|
]'>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const props = defineProps<{
|
||||||
|
layer?: number;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
:root {
|
||||||
|
--bg-color: var(--color-neutral-100);
|
||||||
|
--text-color: var(--color-black);
|
||||||
|
--border-color: var(--color-slate-200);
|
||||||
|
|
||||||
|
--timeline-color: var(--color-neutral-150);
|
||||||
|
|
||||||
|
--accent: color-mix(in oklch, var(--color-amber-300), black 5%);
|
||||||
|
--accent-in-text: var(--color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg-color: var(--color-zinc-950);
|
||||||
|
--text-color: var(--color-white);
|
||||||
|
--border-color: var(--color-zinc-800);
|
||||||
|
|
||||||
|
--timeline-color: var(--color-zinc-900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100dvw;
|
||||||
|
height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition--all {
|
||||||
|
transition: all 250ms ease-out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
@source "tailwind-class.txt";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--breakpoint-large: 50rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"flex", "w-full", "h-full", "gap-5",
|
||||||
|
]'
|
||||||
|
>
|
||||||
|
<LargeMenu />
|
||||||
|
<SpMenu />
|
||||||
|
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"large:rounded-lg", "large:border", "border-(--border-color)",
|
||||||
|
"flex", "flex-col", "grow", "max-large:mb-10",
|
||||||
|
"large:w-[80%]", "large:h-[90vh]", "large:my-auto",
|
||||||
|
"bg-(--timeline-color)/70",
|
||||||
|
"max-large:w-screen", "timeline",
|
||||||
|
]'
|
||||||
|
id="timeline"
|
||||||
|
>
|
||||||
|
<template v-if="network.isOnline">
|
||||||
|
<div :class='[
|
||||||
|
"bg-(--bg-color)",
|
||||||
|
"flex", "w-full", "p-4",
|
||||||
|
"large:rounded-t-lg", "top-2",
|
||||||
|
"timeline-header",
|
||||||
|
]'>
|
||||||
|
<span class="text-lg font-bold">
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
icon="material-symbols:refresh-rounded"
|
||||||
|
class="ml-auto w-6 h-6 cursor-pointer"
|
||||||
|
@click="init()"
|
||||||
|
v-if="init"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
v-if="processStatus"
|
||||||
|
class="m-auto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
ref="timeline"
|
||||||
|
:class='[
|
||||||
|
"overflow-auto", "overscroll-none", "rounded-b-lg",
|
||||||
|
"max-large:p-2", "large:p-4",
|
||||||
|
]'
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-2xl font-bold m-auto">
|
||||||
|
オフラインです
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import Progress from "@/components/Progress.vue";
|
||||||
|
import LargeMenu from "@/components/Menu/Large.vue";
|
||||||
|
import SpMenu from "@/components/Menu/Sp.vue";
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
|
||||||
|
const timeline = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
timeline,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
processStatus: boolean;
|
||||||
|
name: string;
|
||||||
|
init?: () => any,
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export const network = reactive({
|
||||||
|
isOnline: navigator.onLine,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { reactive } from "vue";
|
||||||
|
import uwuzu from "better-uwuzu-sdk";
|
||||||
|
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
|
||||||
|
import Parser from "better-uwuzu-sdk/1.6.8/parser";
|
||||||
|
import Database, { getByIndex } from "@/lib/db";
|
||||||
|
import type Role from "better-uwuzu-sdk/types/1.6.8/types/modules/role";
|
||||||
|
import Alert from "@/components/Window/Alert";
|
||||||
|
|
||||||
|
interface ServerInfo {
|
||||||
|
server_info: {
|
||||||
|
/** サーバー名 */
|
||||||
|
server_name: string;
|
||||||
|
/** サーバーアイコン画像URL */
|
||||||
|
server_icon: string;
|
||||||
|
/** サーバー説明文 */
|
||||||
|
server_description: string;
|
||||||
|
/** サーバー管理者 */
|
||||||
|
adminstor: {
|
||||||
|
/** 名前 */
|
||||||
|
name: string;
|
||||||
|
/** メールアドレス */
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
/** 利用規約URL */
|
||||||
|
terms_url: string;
|
||||||
|
/** プライバシーポリシーURL */
|
||||||
|
privacy_policy_url: string;
|
||||||
|
/** 最大文字数 */
|
||||||
|
max_ueuse_length: number;
|
||||||
|
/** 招待制である */
|
||||||
|
invitation_code: boolean;
|
||||||
|
/** アカウント移行が許可されている */
|
||||||
|
account_migration: boolean;
|
||||||
|
/** 統計 */
|
||||||
|
usage: {
|
||||||
|
/** ユーザー数 */
|
||||||
|
users: number;
|
||||||
|
/** ユーズ数 */
|
||||||
|
ueuse: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** ソフトウェア */
|
||||||
|
software: {
|
||||||
|
/**
|
||||||
|
* 名称
|
||||||
|
* 公式uwuzuである場合はuwuzuです。
|
||||||
|
* 改変uwuzuでかつその改変に名称がある場合はuwuzu以外です。
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* バージョン
|
||||||
|
* vは入りません。
|
||||||
|
*/
|
||||||
|
version: string;
|
||||||
|
/**
|
||||||
|
* リポジトリURL
|
||||||
|
* 公式uwuzuである場合はhttps://github.com/Daichimarukana/uwuzuです。
|
||||||
|
* 改変uwuzuでかつその改変内容が公開されている場合は各リポジトリURLです。
|
||||||
|
*/
|
||||||
|
repository: string;
|
||||||
|
};
|
||||||
|
/** お知らせ */
|
||||||
|
server_notice: {
|
||||||
|
/** タイトル */
|
||||||
|
title: string;
|
||||||
|
/** 内容 */
|
||||||
|
note: string;
|
||||||
|
/** 作成者(ユーザーID) */
|
||||||
|
editor: string;
|
||||||
|
/**
|
||||||
|
* 作成時刻
|
||||||
|
* YYYY-MM-DD HH:MM:SS形式です。
|
||||||
|
*/
|
||||||
|
datetime: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponseSuccess {
|
||||||
|
/** ユーザー名(表示名) */
|
||||||
|
username: string;
|
||||||
|
/** ユーザーID */
|
||||||
|
userid: string;
|
||||||
|
/** プロフィール */
|
||||||
|
profile: string;
|
||||||
|
/** アイコン画像URL */
|
||||||
|
user_icon: string;
|
||||||
|
/** ヘッダー画像URL */
|
||||||
|
user_header: string;
|
||||||
|
/**
|
||||||
|
* 登録時刻
|
||||||
|
* YYYY-MM-DD HH:MM:SS形式です。
|
||||||
|
*/
|
||||||
|
registered_date: string;
|
||||||
|
/** フォロー */
|
||||||
|
followee: string[];
|
||||||
|
/** フォロー数 */
|
||||||
|
followee_cnt: number;
|
||||||
|
/** フォロワー */
|
||||||
|
follower: string[];
|
||||||
|
/** フォロワー数 */
|
||||||
|
follower_cnt: number;
|
||||||
|
/** ユーズ数 */
|
||||||
|
ueuse_cnt: number;
|
||||||
|
/** Botである */
|
||||||
|
isBot: boolean;
|
||||||
|
/** 管理者である */
|
||||||
|
isAdmin: boolean;
|
||||||
|
/** ロール */
|
||||||
|
role: Role[];
|
||||||
|
/** オンラインステータス */
|
||||||
|
online_status: "Online" | "Away" | "Offline" | null;
|
||||||
|
/** 言語 */
|
||||||
|
language: "ja-JP";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResolveError = (error: string) => {
|
||||||
|
switch (error) {
|
||||||
|
case "this_account_has_been_frozen":
|
||||||
|
Alert(
|
||||||
|
"アカウント",
|
||||||
|
"ご利用のアカウントは凍結されています。",
|
||||||
|
"再サインイン",
|
||||||
|
() => location.href = "/signout",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "ueuse_not_found":
|
||||||
|
throw new Error("ユーズがありません。");
|
||||||
|
case "critical_error_userdata_not_found":
|
||||||
|
throw new Error("ユーザーが見つかりませんでした。");
|
||||||
|
case "token_invalid":
|
||||||
|
case "not_allow_scope":
|
||||||
|
Alert(
|
||||||
|
"トークン",
|
||||||
|
"トークンが無効です。再サインインが必要です。",
|
||||||
|
"再サインイン",
|
||||||
|
() => location.href = "/signout",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "input_not_found":
|
||||||
|
throw new Error("入力が不足しています。");
|
||||||
|
default:
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authState = reactive<({
|
||||||
|
isSignedIn: true;
|
||||||
|
origin: string;
|
||||||
|
token: string;
|
||||||
|
hostname: string;
|
||||||
|
limit: number;
|
||||||
|
reuse_maxdepth: number;
|
||||||
|
me: UserResponseSuccess;
|
||||||
|
serverinfo: ServerInfo;
|
||||||
|
} | {
|
||||||
|
isSignedIn: false;
|
||||||
|
}) & {
|
||||||
|
lastChecked?: Date;
|
||||||
|
}>({
|
||||||
|
isSignedIn: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function isSignin(db: Database) {
|
||||||
|
await db.open();
|
||||||
|
const origin = await getByIndex(db.server, "name", "origin");
|
||||||
|
const token = await getByIndex(db.server, "name", "token");
|
||||||
|
const limit = await getByIndex(db.settings, "name", "ueuse-limit");
|
||||||
|
const reuse_maxdepth = await getByIndex(db.settings, "name", "reuse-maxdepth");
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof origin?.value !== "string" ||
|
||||||
|
typeof token?.value !== "string" ||
|
||||||
|
typeof limit?.value !== "string" ||
|
||||||
|
typeof reuse_maxdepth?.value !== "string"
|
||||||
|
) {
|
||||||
|
return Object.assign(authState, {
|
||||||
|
isSignedIn: false,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiClient = new uwuzu<ApiMap>({
|
||||||
|
origin: origin.value,
|
||||||
|
parser: Parser,
|
||||||
|
});
|
||||||
|
apiClient.token = token.value;
|
||||||
|
|
||||||
|
const serverinfo = await apiClient.request("serverinfo-api");
|
||||||
|
const me = await apiClient.request("me/");
|
||||||
|
|
||||||
|
if (!me.success) {
|
||||||
|
await db.server.delete(origin.id);
|
||||||
|
await db.server.delete(token.id);
|
||||||
|
|
||||||
|
return Object.assign(authState, {
|
||||||
|
isSignedIn: false,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return Object.assign(authState, {
|
||||||
|
isSignedIn: true,
|
||||||
|
origin: origin.value,
|
||||||
|
token: token.value,
|
||||||
|
hostname: new URL(origin.value).hostname,
|
||||||
|
limit: Number(limit.value),
|
||||||
|
reuse_maxdepth: reuse_maxdepth.value,
|
||||||
|
me: me,
|
||||||
|
serverinfo: serverinfo,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await db.server.delete(origin.id);
|
||||||
|
await db.server.delete(token.id);
|
||||||
|
|
||||||
|
return Object.assign(authState, {
|
||||||
|
isSignedIn: false,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Dexie, { type EntityTable } from "dexie";
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
value: string | Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class extends Dexie {
|
||||||
|
server!: EntityTable<Server, "id">;
|
||||||
|
settings!: EntityTable<Settings, "id">;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("clean-follow-uwuzu");
|
||||||
|
|
||||||
|
this.version(1).stores({
|
||||||
|
server: "++id,&name",
|
||||||
|
settings: "++id,&name",
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await this.open();
|
||||||
|
|
||||||
|
if (await getByIndex(this.settings, "name", "ueuse-limit") === undefined) {
|
||||||
|
await this.settings.bulkAdd([
|
||||||
|
{
|
||||||
|
name: "ueuse-limit",
|
||||||
|
value: "15",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await getByIndex(this.settings, "name", "reuse-maxdepth") === undefined) {
|
||||||
|
await this.settings.bulkAdd([
|
||||||
|
{
|
||||||
|
name: "reuse-maxdepth",
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getByIndex<T>(
|
||||||
|
table: EntityTable<T, any>,
|
||||||
|
index: string,
|
||||||
|
indexValue: any
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
return await table.where(index).equals(indexValue).first();
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import rehypeSanitize from "rehype-sanitize";
|
||||||
|
import rehypeStringify from "rehype-stringify";
|
||||||
|
import remarkParse from "remark-parse";
|
||||||
|
import remarkRehype from "remark-rehype";
|
||||||
|
import { unified } from "unified";
|
||||||
|
import type ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
|
||||||
|
import { h, type VNode } from "vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
|
||||||
|
type Node = string | VNode;
|
||||||
|
|
||||||
|
export const remark = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeSanitize)
|
||||||
|
.use(rehypeStringify);
|
||||||
|
|
||||||
|
export const dupUeuse = (ueuses: ueuseModule[]) => Array.from(
|
||||||
|
new Map(ueuses.map(item => [item.uniqid, item])).values()
|
||||||
|
);
|
||||||
|
|
||||||
|
function escapeHTML(text: string) {
|
||||||
|
return text.replaceAll(/&/g, "&")
|
||||||
|
.replaceAll(/</g, "<")
|
||||||
|
.replaceAll(/>/g, ">")
|
||||||
|
.replaceAll(/"/g, """)
|
||||||
|
.replaceAll(/"/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInline(text: string): Node[] {
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
|
||||||
|
const regex =
|
||||||
|
/(\*\*\*.+?\*\*\*|\*\*.+?\*\*|\*.+?\*|(^|\s)#([^\s#]+))/g;
|
||||||
|
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
nodes.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = match[0];
|
||||||
|
|
||||||
|
if (token.includes("#") && match[3]) { // ハッシュタグ
|
||||||
|
const space = match[2] ?? "";
|
||||||
|
const tag = match[3];
|
||||||
|
|
||||||
|
if (space)
|
||||||
|
nodes.push(space);
|
||||||
|
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
RouterLink,
|
||||||
|
{
|
||||||
|
to: { path: "/search", query: { q: tag } },
|
||||||
|
class: "md-hashtag text-(--accent) hover:underline"
|
||||||
|
},
|
||||||
|
() => `#${tag}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (token.startsWith("***")) { // 太字+斜体
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "md-bold-italic font-bold italic" },
|
||||||
|
token.slice(3, -3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (token.startsWith("**")) { // 太字
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "md-bold font-bold" },
|
||||||
|
token.slice(2, -2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (token.startsWith("*")) { // 斜体
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "md-italic italic" },
|
||||||
|
token.slice(1, -1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + token.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
nodes.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTextToNodes(text: string) {
|
||||||
|
const safe = escapeHTML(text).trim();
|
||||||
|
const lines = safe.split("\n");
|
||||||
|
const nodes: (string | VNode)[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("### ")) {
|
||||||
|
nodes.push(h("span", { class: "md-h3 text-xl font-bold" }, line.slice(4)));
|
||||||
|
} else if (line.startsWith("## ")) {
|
||||||
|
nodes.push(h("span", { class: "md-h2 text-2xl font-bold" }, line.slice(3)));
|
||||||
|
} else if (line.startsWith("# ")) {
|
||||||
|
nodes.push(h("span", { class: "md-h1 text-3xl font-bold" }, line.slice(2)));
|
||||||
|
} else {
|
||||||
|
nodes.push(...parseInline(line));
|
||||||
|
}
|
||||||
|
nodes.push(h("br"));
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.pop();
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateParse(date: string, startDate?: Date) {
|
||||||
|
const diffMs = (startDate ?? new Date()).getTime() - new Date(date).getTime();
|
||||||
|
const diffSec = Math.abs(Math.floor(diffMs / 1000));
|
||||||
|
const diffMin = Math.abs(Math.floor(diffSec / 60));
|
||||||
|
const diffHour = Math.abs(Math.floor(diffMin / 60));
|
||||||
|
const diffDay = Math.abs(Math.floor(diffHour / 24));
|
||||||
|
const diffMonth = Math.abs(Math.floor(diffDay / 30));
|
||||||
|
const diffYear = Math.abs(Math.floor(diffMonth / 12));
|
||||||
|
|
||||||
|
const diffStr = diffMs < 0
|
||||||
|
? "後"
|
||||||
|
: "前";
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case diffSec < 60:
|
||||||
|
return `${diffSec}秒${diffStr}`;
|
||||||
|
case diffMin < 60:
|
||||||
|
return `${diffMin}分${diffStr}`;
|
||||||
|
case diffHour < 24:
|
||||||
|
return `${diffHour}時間${diffStr}`;
|
||||||
|
case diffDay < 30:
|
||||||
|
return `${diffDay}日${diffStr}`;
|
||||||
|
case diffMonth < 12:
|
||||||
|
return `${diffMonth}ヶ月${diffStr}`;
|
||||||
|
default:
|
||||||
|
return `${diffYear}年${diffStr}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { reactive } from "vue";
|
||||||
|
|
||||||
|
const routerStatus = reactive<{
|
||||||
|
isLoad: boolean;
|
||||||
|
}>({
|
||||||
|
isLoad: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default routerStatus;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
const swSelf = globalThis as unknown as ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
swSelf.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(swSelf.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
swSelf.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(swSelf.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
swSelf.addEventListener("fetch", (event) => {
|
||||||
|
const request = event.request;
|
||||||
|
|
||||||
|
const originalCacheHosts: string[] = [
|
||||||
|
swSelf.location.hostname,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.url.indexOf("http") !== 0 ||
|
||||||
|
!(originalCacheHosts.includes(new URL(request.url).hostname))
|
||||||
|
) return;
|
||||||
|
|
||||||
|
if (new URL(request.url).pathname.includes("/release.json"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
event.respondWith((async () => {
|
||||||
|
const coreCache = await caches.open("ulc-core");
|
||||||
|
const assetCache = await caches.open("ulc-asset");
|
||||||
|
const mediaCache = await caches.open("ulc-media");
|
||||||
|
const generalCache = await caches.open("ulc-general");
|
||||||
|
const targetCache =
|
||||||
|
/*await coreCache.match(request) ??*/
|
||||||
|
await assetCache.match(request) ??
|
||||||
|
await mediaCache.match(request) ??
|
||||||
|
await generalCache.match(request);
|
||||||
|
|
||||||
|
if (targetCache) {
|
||||||
|
return targetCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(request);
|
||||||
|
if (res.ok) {
|
||||||
|
let saveTarget;
|
||||||
|
|
||||||
|
switch (request.destination) {
|
||||||
|
case "document":
|
||||||
|
case "script":
|
||||||
|
case "worker":
|
||||||
|
case "sharedworker":
|
||||||
|
case "manifest":
|
||||||
|
saveTarget = coreCache;
|
||||||
|
break;
|
||||||
|
case "style":
|
||||||
|
case "image":
|
||||||
|
case "font":
|
||||||
|
saveTarget = assetCache;
|
||||||
|
break;
|
||||||
|
case "video":
|
||||||
|
case "audio":
|
||||||
|
case "embed":
|
||||||
|
case "frame":
|
||||||
|
case "iframe":
|
||||||
|
saveTarget = mediaCache;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
saveTarget = generalCache;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTarget.put(request, res.clone());
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
return ((await assetCache.match(request)) ??
|
||||||
|
new Response("Network error", {
|
||||||
|
status: 504,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import semver from "semver";
|
||||||
|
|
||||||
|
export function isVersionAvailable({
|
||||||
|
current,
|
||||||
|
min,
|
||||||
|
max
|
||||||
|
}: {
|
||||||
|
current: string;
|
||||||
|
min: string;
|
||||||
|
max: string;
|
||||||
|
}) {
|
||||||
|
const c = semver.coerce(current);
|
||||||
|
const minV = min ? semver.coerce(min) : null;
|
||||||
|
const maxV = max ? semver.coerce(max) : null;
|
||||||
|
|
||||||
|
if (!c) return false;
|
||||||
|
|
||||||
|
if (minV && semver.lt(c, minV)) return false;
|
||||||
|
if (maxV && semver.gt(c, maxV)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export default function waitUntil(conditionCallback: () => boolean, interval: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (conditionCallback()) {
|
||||||
|
clearInterval(timer);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
});
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
import { createApp, ref } from "vue";
|
||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import { createHead } from "@vueuse/head";
|
||||||
|
import routerStatus from "@/lib/router";
|
||||||
|
|
||||||
|
import "@/css/tailwind.css";
|
||||||
|
import "@/css/global.css";
|
||||||
|
import Layout from "@/Layout.vue";
|
||||||
|
|
||||||
|
const app = createApp(Layout);
|
||||||
|
|
||||||
|
app.use(createHead());
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: () => import("@/routes/index.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/about/ulc",
|
||||||
|
component: () => import("@/routes/about/ulc.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/signin",
|
||||||
|
component: () => import("@/routes/signin/index.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/signin/callback",
|
||||||
|
component: () => import("@/routes/signin/callback.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/@:userid",
|
||||||
|
component: () => import("@/routes/user.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/ueuse/:uniqid",
|
||||||
|
component: () => import("@/routes/ueuse.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/:NotFound(.*)*",
|
||||||
|
component: () => import("@/NotFound.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// @ts-ignore 余分な引数の警告
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
routerStatus.isLoad = true;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
router.afterEach(() => {
|
||||||
|
routerStatus.isLoad = false;
|
||||||
|
});
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
export const release = ref<{
|
||||||
|
version: string,
|
||||||
|
isPrerelease: boolean,
|
||||||
|
update: {
|
||||||
|
requiredCacheClear: boolean,
|
||||||
|
requiredAgainSignin: boolean,
|
||||||
|
requiredSettingsClear: boolean,
|
||||||
|
canUpdate: boolean,
|
||||||
|
},
|
||||||
|
} | null>(null);
|
||||||
|
(async () => {
|
||||||
|
const req = await fetch("/release.json", { cache: "no-store" })
|
||||||
|
if (!req.ok)
|
||||||
|
return;
|
||||||
|
|
||||||
|
release.value = await req.json();
|
||||||
|
})();
|
||||||
|
|
||||||
|
app.mount("body");
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<Layout
|
||||||
|
class="w-full"
|
||||||
|
name="uwuzu Light Clientについて"
|
||||||
|
:processStatus="false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="prose dark:prose-invert break-all flex flex-col gap-1"
|
||||||
|
v-html="content"
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose {
|
||||||
|
max-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose *{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.prose code)::after,
|
||||||
|
:deep(.prose code)::before,
|
||||||
|
:deep(.prose blockquote p)::after,
|
||||||
|
:deep(.prose blockquote p)::before {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.prose blockquote p) {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.prose code) {
|
||||||
|
padding: 0.25rem;
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
font-weight: normal;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Layout from "@/layouts/Layout.vue";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { remark } from "@/lib/parser";
|
||||||
|
import { useHead } from "@vueuse/head";
|
||||||
|
|
||||||
|
const content = ref("");
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: `uwuzu Light Clientについて | uwuzu Light Client`,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
content.value = remark.processSync(
|
||||||
|
`## uwuzu Light Clientとは
|
||||||
|
uwuzu Light Clientとは、uwuzuの軽量クライアント(を目標にしている)です。
|
||||||
|
ネットワーク通信量の削減 **(とメモリ使用量の削減をしたい)** をしています。
|
||||||
|
ULCと略します。
|
||||||
|
## 仕組み
|
||||||
|
### 前提知識
|
||||||
|
> **📝常識**
|
||||||
|
> uwuzuはSNSサービスを作るソフトである。
|
||||||
|
uwuzuを使って作ったサービスはuwuzuサーバーと呼ばれる。
|
||||||
|
uwuzuサーバーにはゆずねっとなどが挙げられる。
|
||||||
|
|
||||||
|
uwuzuには内部とクライアントというものがあります。
|
||||||
|
クライアントとは、内部をいじるためのサイトやアプリです。
|
||||||
|
uwuzuサーバーに登録するとき、あなたは\`https://uwuzu.net\`や\`https://uwuzu.net/home\`などのサイトにアクセスしたと思います。
|
||||||
|
これが、クライアントです。\`https://uwuzu.net\`はゆずねっとですが、URLが、\`uwuzu.net\`ほぼそのままですよね。
|
||||||
|
このクライアントは、uwuzuサーバーに内蔵されているため、わかりやすいURLになっています。
|
||||||
|
|
||||||
|
ただし、uwuzuサーバー内臓のクライアントは、設計で重くなっている部分が多いです。
|
||||||
|
そこで、ULCはネットワーク通信量(とメモリ使用量も削減したい)を削減しています。
|
||||||
|
ネットワーク通信量を削減することで、ネットに繋がる量が減ります。(当たり前)
|
||||||
|
ネットに繋がる量が減ることで、スマホのギガがなくなったとしても、少しはましに使えます。
|
||||||
|
|
||||||
|
### 具体的にどこ削減してんだよ
|
||||||
|
uwuzu内臓クライアントは、サイトのプログラムを毎回ネットから奪ってきています。
|
||||||
|
ULCでは、プログラムを全てスマホやPCなどの端末内に保存します。
|
||||||
|
こうすることで、プログラムの部分(多分2番目くらいに大きい)の量を削減できました。
|
||||||
|
|
||||||
|
じゃあユーズ、自身のプロフィールはどうするのか。
|
||||||
|
これらの情報は、必要な分だけuwuzuサーバーから奪ってきます。
|
||||||
|
|
||||||
|
### メディア!!
|
||||||
|
uwuzuではユーズの画像/動画やアイコン、メディアがたくさんありますよね。
|
||||||
|
uwuzu内蔵クライアントでは全てのメディアを毎回uwuzuサーバーから奪います。
|
||||||
|
これではよく見るユーザーなど、無駄な通信が多いです。
|
||||||
|
そこで、ULCでは全てのメディアを端末の中に残します。(厳密には残そうとしていますがuwuzuサーバーがダメ!!って言ったりバグとかでできなかったらしません)
|
||||||
|
そのため、よく見るユーザーのアイコンなどの通信量が削減できます。
|
||||||
|
|
||||||
|
### あれ?なんでパスワード打ったりしてないのに情報を奪えるの?
|
||||||
|
uwuzuにはAPIというuwuzu内臓クライアント以外からuwuzuをいじる機能があります。
|
||||||
|
ULCのサインイン時に、許可するかどうかのuwuzu内臓クライアントの画面が出たと思います。
|
||||||
|
あそこの画面とULCがくっついていて、許可を押すことで、パスワードとは違う情報を奪う時の鍵がULCに送られます。
|
||||||
|
その鍵を使って情報を奪っています。
|
||||||
|
|
||||||
|
### APIでできないこと
|
||||||
|
めっちゃあります。
|
||||||
|
uwuzu内臓クライアントは専用の道を使っておりAPIは使っていません。
|
||||||
|
APIのできることはuwuzu内臓クライアントのできることに比べてかなり少なく、まず**ユーザーのタイムラインが見れません。**その上**絵文字を奪えません。**
|
||||||
|
絵文字を奪えないのはかなり致命的だと思うので絵文字がないと嫌な人はULCを使わないほうがいいと思います。
|
||||||
|
ULCはAPIのできることをフル活用しているのでULCでできないことはまだ完成していないかuwuzu側がそもそも無理です。気長に待ちましょう。
|
||||||
|
|
||||||
|
### 仕組み解説終わり!!
|
||||||
|
一般ユーザーの方ここまで読んでくれてありがとうございます。
|
||||||
|
ここで仕組み解説は終わりです。
|
||||||
|
ULCを使ってバグを見つけたりしたら[Last2014](https://about.last2014.com)に報告いくらでもしていいです。遠慮せずに。`
|
||||||
|
.replaceAll(/^[ \t]+/gm, "").trim()
|
||||||
|
).toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<Layout
|
||||||
|
:processStatus="processStatus"
|
||||||
|
:init="init"
|
||||||
|
name="ローカルタイムライン"
|
||||||
|
ref="layoutRef"
|
||||||
|
><template v-if="authState.isSignedIn">
|
||||||
|
<LgMedia
|
||||||
|
:data="lgMedia"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UeuseMore />
|
||||||
|
|
||||||
|
<template v-for="(ueuse, index) in ueuses" :key="ueuse.uniqid">
|
||||||
|
<Ueuse
|
||||||
|
:data="ueuse"
|
||||||
|
:lgMedia="lgMedia"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr
|
||||||
|
class="w-full mx-auto my-3 border-t border-t-(--accent)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="w-full text-center"
|
||||||
|
v-if="index === ueuses.length - 1 && !processStatus && isEnd"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
これ以上のユーズはありません
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
class="m-auto"
|
||||||
|
v-if="!isEnd"
|
||||||
|
/>
|
||||||
|
</template></Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useHead } from "@vueuse/head";
|
||||||
|
import { computed, onMounted, ref, provide } from "vue";
|
||||||
|
import Database from "@/lib/db";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import uwuzu from "better-uwuzu-sdk";
|
||||||
|
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
|
||||||
|
import type ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
|
||||||
|
import Parser from "better-uwuzu-sdk/1.6.8/parser";
|
||||||
|
import { authState, ResolveError } from "@/lib/account";
|
||||||
|
import waitUntil from "@/lib/wait";
|
||||||
|
import { dupUeuse } from "@/lib/parser";
|
||||||
|
import Layout from "@/layouts/Layout.vue";
|
||||||
|
import LgMedia from "@/components/Media/LgMedia.vue";
|
||||||
|
import UeuseMore from "@/components/Ueuse/More.vue";
|
||||||
|
import Progress from "@/components/Progress.vue";
|
||||||
|
import Ueuse from "@/components/Ueuse/index.vue";
|
||||||
|
import { useInfiniteScroll } from "@vueuse/core";
|
||||||
|
import { reactive } from "vue";
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "ホーム | uwuzu Light Client",
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
const router = useRouter();
|
||||||
|
const apiClient = ref<uwuzu<ApiMap>>();
|
||||||
|
|
||||||
|
const lgMedia = reactive<{
|
||||||
|
src: string,
|
||||||
|
}>({
|
||||||
|
src: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ueuses = ref<ueuseModule[]>([]);
|
||||||
|
const processStatus = ref<boolean>(false);
|
||||||
|
|
||||||
|
const now = ref(new Date());
|
||||||
|
onMounted(() => {
|
||||||
|
setInterval(() => { now.value = new Date(); }, 1000);
|
||||||
|
});
|
||||||
|
provide("now", now);
|
||||||
|
|
||||||
|
const layoutRef = ref<{
|
||||||
|
timeline: HTMLElement | null;
|
||||||
|
} | null>(null);
|
||||||
|
const timeline = computed(() =>
|
||||||
|
layoutRef.value?.timeline ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadedPage = ref<number>(0);
|
||||||
|
const isEnd = ref<boolean>(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useInfiniteScroll(timeline, async () => {
|
||||||
|
if (isEnd.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (processStatus.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await waitUntil(() => apiClient.value !== undefined, 200);
|
||||||
|
if (!apiClient.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!authState.isSignedIn)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const ueuseRes = await apiClient.value.request("ueuse/", {
|
||||||
|
page: loadedPage.value + 1,
|
||||||
|
limit: authState.limit
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ueuseRes.success) {
|
||||||
|
return ResolveError(ueuseRes.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ueuseRes.data.length < authState.limit)
|
||||||
|
isEnd.value = true;
|
||||||
|
|
||||||
|
ueuses.value = dupUeuse(ueuses.value.concat(ueuseRes.data));
|
||||||
|
loadedPage.value++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
processStatus.value = true;
|
||||||
|
|
||||||
|
await db.open();
|
||||||
|
await waitUntil(() => authState.lastChecked !== undefined, 200);
|
||||||
|
if (!authState.isSignedIn) {
|
||||||
|
router.replace("/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.value = new uwuzu<ApiMap>({
|
||||||
|
origin: authState.origin,
|
||||||
|
parser: Parser,
|
||||||
|
});
|
||||||
|
apiClient.value.token = authState.token;
|
||||||
|
|
||||||
|
const ueuseRes = await apiClient.value.request("ueuse/", {
|
||||||
|
page: 1,
|
||||||
|
limit: authState.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ueuseRes.success) {
|
||||||
|
return ResolveError(ueuseRes.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ueuseRes.data.length < authState.limit)
|
||||||
|
isEnd.value = true;
|
||||||
|
|
||||||
|
ueuses.value = dupUeuse(ueuses.value.concat(ueuseRes.data));
|
||||||
|
loadedPage.value++;
|
||||||
|
|
||||||
|
processStatus.value = false;
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isProcessing"
|
||||||
|
:class='[
|
||||||
|
"fixed","inset-0", "flex", "flex-col", "gap-2", "z-49",
|
||||||
|
"wrap-break-word", "items-center", "justify-center", "text-center",
|
||||||
|
]'
|
||||||
|
>
|
||||||
|
<Progress />
|
||||||
|
<p>{{ stageDetails[processStage] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if='processStage === "check"'
|
||||||
|
:class='[
|
||||||
|
"fixed","inset-0", "m-auto", "p-5", "gap-3",
|
||||||
|
"flex", "flex-col", "justify-center", "items-center",
|
||||||
|
"w-screen", "h-screen", "rounded-2xl",
|
||||||
|
]'
|
||||||
|
>
|
||||||
|
<h1 class="text-3xl font-bold">確認</h1>
|
||||||
|
<User
|
||||||
|
:meData="meData"
|
||||||
|
:hostname="hostname"
|
||||||
|
/>
|
||||||
|
<p>あなたは{{ meData.username }}ですか?</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<Button class="m-auto" @click="checked">
|
||||||
|
はい
|
||||||
|
</Button>
|
||||||
|
<Button class="m-auto" @click="restart">
|
||||||
|
いいえ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if='processStage === "done"'
|
||||||
|
:class='[
|
||||||
|
"fixed","inset-0", "m-auto",
|
||||||
|
"flex", "flex-col", "justify-center", "items-center",
|
||||||
|
"bg-white", "text-black", "p-5", "gap-3",
|
||||||
|
"dark:bg-neutral-600", "dark:text-white",
|
||||||
|
"w-fit", "h-fit", "rounded-2xl", "text-center"
|
||||||
|
]'
|
||||||
|
>
|
||||||
|
<h1 class="text-3xl font-bold">成功しました</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
おめでとうございます!@{{ meData.userid }}@{{ hostname }}としてサインインできました!<br />
|
||||||
|
uwuzu Light Clientをお楽しみください!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button class="m-auto" @click="redirect_home">
|
||||||
|
ホームに移動
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useHead } from "@vueuse/head";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { z } from "zod/v3";
|
||||||
|
import { authState, isSignin } from "@/lib/account";
|
||||||
|
import Database from "@/lib/db";
|
||||||
|
import Progress from "@/components/Progress.vue";
|
||||||
|
import Button from "@/components/Button.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import User from "@/components/User.vue";
|
||||||
|
import uwuzu from "better-uwuzu-sdk";
|
||||||
|
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
|
||||||
|
import Parser from "better-uwuzu-sdk/1.6.8/parser";
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
const query = useRoute().query;
|
||||||
|
const router = useRouter();
|
||||||
|
let isProcessing = true;
|
||||||
|
let processStage = ref<"redirect" |
|
||||||
|
"init" | "valid" | "get" |"check" |
|
||||||
|
"save" | "done">("init");
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "サインイン処理 | uwuzu Light Client",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authState.isSignedIn)
|
||||||
|
useRouter().replace("/");
|
||||||
|
|
||||||
|
const stageDetails = {
|
||||||
|
redirect: "リダイレクトしています",
|
||||||
|
init: "初期化しています",
|
||||||
|
valid: "入力を確認しています",
|
||||||
|
get: "トークンを取得しています",
|
||||||
|
check: "ユーザーを確認しています",
|
||||||
|
save: "データを保存しています",
|
||||||
|
done: "完了しました",
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isProcessing = true;
|
||||||
|
processStage.value = "redirect";
|
||||||
|
|
||||||
|
await isSignin(db);
|
||||||
|
router.replace("/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect_home = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isProcessing = true;
|
||||||
|
processStage.value = "redirect";
|
||||||
|
|
||||||
|
const authState = await isSignin(db);
|
||||||
|
if (authState.isSignedIn) {
|
||||||
|
router.push("/");
|
||||||
|
} else {
|
||||||
|
router.replace("/signin");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
processStage.value = "valid";
|
||||||
|
const result = z.object({
|
||||||
|
type: z.union([
|
||||||
|
z.literal("auth"),
|
||||||
|
z.literal("token"),
|
||||||
|
], {
|
||||||
|
required_error: "種別が入力されていません",
|
||||||
|
invalid_type_error: "種別が不正です",
|
||||||
|
}),
|
||||||
|
origin: z.string({
|
||||||
|
required_error: "オリジンが入力されていません",
|
||||||
|
invalid_type_error: "オリジンが文字列ではありません",
|
||||||
|
})
|
||||||
|
.refine(value => {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.origin === value;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
message: "オリジンが有効ではありません",
|
||||||
|
}),
|
||||||
|
session: z.string({
|
||||||
|
required_error: "セッション/トークンが入力されていません",
|
||||||
|
invalid_type_error: "セッション/トークンが文字列ではありません",
|
||||||
|
}),
|
||||||
|
}).safeParse({
|
||||||
|
type: query["type"] ?? query["amp;type"],
|
||||||
|
origin: query["origin"] ?? query["amp;origin"],
|
||||||
|
session: query["session"] ?? query["amp;session"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw result.error.errors.map(err => err.message).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
processStage.value = "get";
|
||||||
|
const apiClient = new uwuzu<ApiMap>({
|
||||||
|
origin: result.data.origin,
|
||||||
|
parser: Parser,
|
||||||
|
});
|
||||||
|
const tokenData = await apiClient.request("token/get", {
|
||||||
|
session: result.data.session,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenData.success) {
|
||||||
|
throw new Error(tokenData.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.token = tokenData.token;
|
||||||
|
const meData = await apiClient.request("me/");
|
||||||
|
|
||||||
|
if (!meData.success) {
|
||||||
|
throw new Error(meData.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = new URL(result.data.origin).hostname;
|
||||||
|
isProcessing = false;
|
||||||
|
processStage.value = "check";
|
||||||
|
|
||||||
|
const checked = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isProcessing = true;
|
||||||
|
processStage.value = "save";
|
||||||
|
|
||||||
|
await db.open();
|
||||||
|
db.server.put({
|
||||||
|
name: "origin",
|
||||||
|
value: result.data.origin,
|
||||||
|
});
|
||||||
|
db.server.put({
|
||||||
|
name: "token",
|
||||||
|
value: tokenData.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
processStage.value = "done";
|
||||||
|
isProcessing = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<template>
|
||||||
|
<Layout
|
||||||
|
:processStatus="false"
|
||||||
|
name="サインイン"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class='[
|
||||||
|
"flex", "flex-col", "mx-auto",
|
||||||
|
"max-w-100", "w-fit", "items-center",
|
||||||
|
]'
|
||||||
|
>
|
||||||
|
<h1 class="text-2xl font-bold">サインイン</h1>
|
||||||
|
<SmallText>
|
||||||
|
uwuzu {{ sprtVerTxt }}に対応しています。
|
||||||
|
</SmallText>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-2 mt-4" @submit="onSubmit">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="origin">オリジン(必須)</label>
|
||||||
|
<InputText
|
||||||
|
type="url"
|
||||||
|
placeholder="https://uwuzu.net"
|
||||||
|
id="origin"
|
||||||
|
v-model="origin"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
<SmallText>
|
||||||
|
サインインするサーバーのオリジンを入力してください。
|
||||||
|
</SmallText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="w-[90%] m-auto" />
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary class="select-none cursor-pointer">その他のオプション</summary>
|
||||||
|
<div class="flex flex-col mt-1">
|
||||||
|
<label for="token">APIトークン(任意)</label>
|
||||||
|
<InputText
|
||||||
|
type="password"
|
||||||
|
placeholder="ABCD123456789"
|
||||||
|
id="token"
|
||||||
|
v-model="token"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
<div class="text-yellow-200">
|
||||||
|
通常は必要ありません。また、APIトークンの手動入力(この機能)はそもそも未完成なので使えません。
|
||||||
|
</div>
|
||||||
|
<SmallText>
|
||||||
|
サインインするアカウントのAPIトークンを入力してください。権限の確認は行いません。<br />
|
||||||
|
以下の権限が必要です。
|
||||||
|
</SmallText>
|
||||||
|
<ul>
|
||||||
|
<li class="list-disc list-inside" v-for="scope in requiredScopes">
|
||||||
|
{{ scope }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<hr class="w-[90%] m-auto" />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
uwuzu Light Clientを利用するにあたり、以下の全てに当てはまる必要があります。
|
||||||
|
全ての項目を入力しサインインをクリック/タップした時点でユーザーは全ての項目に当てはまったとみなします。
|
||||||
|
</span>
|
||||||
|
<Checkbox>
|
||||||
|
私はuwuzuの仕組みを理解しています
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox>
|
||||||
|
私はuwuzuとuwuzuサーバーの違いを理解しています
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox>
|
||||||
|
私はuwuzu Light Clientが試験段階であることに同意します
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox>
|
||||||
|
私は
|
||||||
|
<RouterLink
|
||||||
|
to="/about/ulc"
|
||||||
|
class="text-(--accent) underline"
|
||||||
|
>
|
||||||
|
uwuzu Light Clientについて
|
||||||
|
</RouterLink>
|
||||||
|
をよく読み、理解しました
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="m-auto"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
<span v-if="isSubmitting">サインイン中...</span>
|
||||||
|
<span v-else>サインイン</span>
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Layout from "@/layouts/Layout.vue";
|
||||||
|
import { useHead } from "@vueuse/head";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { z } from "zod/v3";
|
||||||
|
import { v4 as UUID } from "uuid";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { authState } from "@/lib/account";
|
||||||
|
import { isVersionAvailable } from "@/lib/version";
|
||||||
|
import SmallText from "@/components/SmallText.vue";
|
||||||
|
import InputText from "@/components/InputText.vue";
|
||||||
|
import Button from "@/components/Button.vue";
|
||||||
|
import uwuzu from "better-uwuzu-sdk";
|
||||||
|
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
|
||||||
|
import Parser from "better-uwuzu-sdk/1.6.8/parser";
|
||||||
|
import Alert from "@/components/Window/Alert";
|
||||||
|
import Checkbox from "@/components/Checkbox.vue";
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "サインイン | uwuzu Light Client",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authState.isSignedIn)
|
||||||
|
useRouter().replace("/");
|
||||||
|
|
||||||
|
const sprtVer = __CONFIG.uwuzu.supportedVersion;
|
||||||
|
const sprtVerTxt = `v${sprtVer.min}${
|
||||||
|
sprtVer.min !== sprtVer.max
|
||||||
|
? `からv${sprtVer.max}`
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
const requiredScopes = __CONFIG.uwuzu.requiredScopes;
|
||||||
|
const origin = ref<string>("");
|
||||||
|
const token = ref<string>("");
|
||||||
|
const isSubmitting = ref<boolean>(false);
|
||||||
|
|
||||||
|
const onSubmit = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isSubmitting.value = true;
|
||||||
|
|
||||||
|
const result = z.object({
|
||||||
|
origin: z.string({
|
||||||
|
invalid_type_error: "オリジンが文字列ではありません",
|
||||||
|
})
|
||||||
|
.min(1, "オリジンが入力されていません")
|
||||||
|
.refine(value => {
|
||||||
|
if (value.length === 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.origin === value;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, "オリジンが有効ではありません"),
|
||||||
|
token: z.string({
|
||||||
|
invalid_type_error: "APIトークンが文字列ではありません",
|
||||||
|
})
|
||||||
|
.min(1, "APIトークンが入力されていません")
|
||||||
|
.length(64, "APIトークンは64文字です")
|
||||||
|
.or(z.literal("")),
|
||||||
|
}).safeParse({
|
||||||
|
origin: origin.value,
|
||||||
|
token: token.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
Alert("入力エラー", result.error.errors.map(err => err.message).join("\n"));
|
||||||
|
isSubmitting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiClient = new uwuzu<ApiMap>({
|
||||||
|
origin: result.data.origin,
|
||||||
|
parser: Parser,
|
||||||
|
});
|
||||||
|
const serverinfo = await apiClient.request("serverinfo-api");
|
||||||
|
|
||||||
|
if (serverinfo.software.name !== "uwuzu") {
|
||||||
|
Alert("エラー", "サーバーがuwuzuではありません。");
|
||||||
|
isSubmitting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVersionAvailable({
|
||||||
|
current: serverinfo.software.version,
|
||||||
|
min: sprtVer.min,
|
||||||
|
max: sprtVer.max,
|
||||||
|
})) {
|
||||||
|
Alert("エラー", "サーバーのバージョンが対象外です。");
|
||||||
|
isSubmitting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.token.length === 64) {
|
||||||
|
const callback = new URL("/signin/callback", window.location.origin);
|
||||||
|
callback.searchParams.set("type", "token");
|
||||||
|
callback.searchParams.set("origin", result.data.origin);
|
||||||
|
callback.searchParams.set("session", result.data.token);
|
||||||
|
window.location.href = callback.toString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = UUID();
|
||||||
|
const callback = new URL("/signin/callback", window.location.origin);
|
||||||
|
callback.searchParams.set("type", "auth");
|
||||||
|
callback.searchParams.set("origin", result.data.origin);
|
||||||
|
callback.searchParams.set("session", session);
|
||||||
|
const authURL = new URL("/api/auth", result.data.origin);
|
||||||
|
authURL.searchParams.set("session", session);
|
||||||
|
authURL.searchParams.set("scope", requiredScopes.join(","));
|
||||||
|
authURL.searchParams.set("callback", callback.toString());
|
||||||
|
authURL.searchParams.set("client", "uwuzu Light Client");
|
||||||
|
authURL.searchParams.set("about", "uwuzu軽量クライアント");
|
||||||
|
authURL.searchParams.set("icon", new URL("/ulc.svg", window.location.origin).toString());
|
||||||
|
window.location.href = authURL.toString();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<Layout
|
||||||
|
:processStatus="processStatus"
|
||||||
|
:init="init"
|
||||||
|
:name="title"
|
||||||
|
ref="layoutRef"
|
||||||
|
><template v-if="authState.isSignedIn">
|
||||||
|
<LgMedia
|
||||||
|
:data="lgMedia"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UeuseMore />
|
||||||
|
|
||||||
|
<template v-for="(ueuse, index) in ueuses" :key="ueuse.uniqid">
|
||||||
|
<Ueuse
|
||||||
|
:data="ueuse"
|
||||||
|
:lgMedia="lgMedia"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr
|
||||||
|
class="w-full mx-auto my-3 border-t border-t-(--accent)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="w-full text-center"
|
||||||
|
v-if="index === ueuses.length - 1 && !processStatus && isEnd"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
これ以上のユーズはありません
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
class="m-auto"
|
||||||
|
v-if="!isEnd"
|
||||||
|
/>
|
||||||
|
</template></Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useHead } from "@vueuse/head";
|
||||||
|
import { onMounted, reactive, ref, provide, computed, watch } from "vue";
|
||||||
|
import Database from "@/lib/db";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import uwuzu from "better-uwuzu-sdk";
|
||||||
|
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
|
||||||
|
import type ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
|
||||||
|
import Parser from "better-uwuzu-sdk/1.6.8/parser";
|
||||||
|
import { authState, ResolveError } from "@/lib/account";
|
||||||
|
import waitUntil from "@/lib/wait";
|
||||||
|
import Layout from "@/layouts/Layout.vue";
|
||||||
|
import LgMedia from "@/components/Media/LgMedia.vue";
|
||||||
|
import UeuseMore from "@/components/Ueuse/More.vue";
|
||||||
|
import Progress from "@/components/Progress.vue";
|
||||||
|
import Ueuse from "@/components/Ueuse/index.vue";
|
||||||
|
import { useInfiniteScroll } from "@vueuse/core";
|
||||||
|
import { dupUeuse } from "@/lib/parser";
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const apiClient = ref<uwuzu<ApiMap>>();
|
||||||
|
|
||||||
|
const lgMedia = reactive<{
|
||||||
|
src: string,
|
||||||
|
}>({
|
||||||
|
src: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ueuses = ref<ueuseModule[]>([]);
|
||||||
|
const processStatus = ref<boolean>(false);
|
||||||
|
|
||||||
|
const now = ref(new Date());
|
||||||
|
onMounted(() => {
|
||||||
|
setInterval(() => { now.value = new Date(); }, 1000);
|
||||||
|
});
|
||||||
|
provide("now", now);
|
||||||
|
|
||||||
|
const layoutRef = ref<{
|
||||||
|
timeline: HTMLElement | null
|
||||||
|
} | null>(null);
|
||||||
|
const timeline = computed(() =>
|
||||||
|
layoutRef.value?.timeline ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadedPage = ref<number>(0);
|
||||||
|
const isEnd = ref<boolean>(false);
|
||||||
|
const itUsername = ref<string>();
|
||||||
|
const title = computed(() =>
|
||||||
|
itUsername.value ? `${itUsername.value}さんのユーズ` : "ユーズ"
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(title, (newTitle) => {
|
||||||
|
useHead({
|
||||||
|
title: `${newTitle} | uwuzu Light Client`,
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
immediate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useInfiniteScroll(timeline, async () => {
|
||||||
|
if (isEnd.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (processStatus.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
processStatus.value = true;
|
||||||
|
|
||||||
|
await waitUntil(() => apiClient.value !== undefined, 200);
|
||||||
|
if (!apiClient.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!authState.isSignedIn)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (typeof route.params.uniqid !== "string")
|
||||||
|
throw new Error("Params is not string.");
|
||||||
|
|
||||||
|
const ueuseRes = await apiClient.value.request("ueuse/replies", {
|
||||||
|
uniqid: route.params.uniqid,
|
||||||
|
page: loadedPage.value + 1,
|
||||||
|
limit: authState.limit
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ueuseRes.success) {
|
||||||
|
return ResolveError(ueuseRes.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ueuseRes.data.length < authState.limit)
|
||||||
|
isEnd.value = true;
|
||||||
|
|
||||||
|
ueuses.value = dupUeuse(ueuses.value.concat(ueuseRes.data));
|
||||||
|
loadedPage.value++;
|
||||||
|
|
||||||
|
processStatus.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
processStatus.value = true;
|
||||||
|
|
||||||
|
if (typeof route.params.uniqid !== "string")
|
||||||
|
throw new Error("パラメータが文字列ではありません。");
|
||||||
|
|
||||||
|
await db.open();
|
||||||
|
await waitUntil(() => authState.lastChecked !== undefined, 200);
|
||||||
|
if (!authState.isSignedIn) {
|
||||||
|
router.replace("/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.value = new uwuzu<ApiMap>({
|
||||||
|
origin: authState.origin,
|
||||||
|
parser: Parser,
|
||||||
|
});
|
||||||
|
apiClient.value.token = authState.token;
|
||||||
|
|
||||||
|
const ueuseRes = await apiClient.value.request("ueuse/replies", {
|
||||||
|
uniqid: route.params.uniqid,
|
||||||
|
page: 1,
|
||||||
|
limit: authState.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ueuseRes.success) {
|
||||||
|
return ResolveError(ueuseRes.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itUeuse = ueuseRes.data.filter((ueuse) => ueuse.uniqid === route.params.uniqid)[0];
|
||||||
|
itUsername.value = itUeuse.account.username;
|
||||||
|
if (itUeuse.replyid !== "") {
|
||||||
|
const toReplyRes = await apiClient.value.request("ueuse/get", {
|
||||||
|
uniqid: itUeuse.replyid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!toReplyRes.success) {
|
||||||
|
return ResolveError(itUeuse.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
ueuses.value = ueuses.value.concat(toReplyRes.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ueuseRes.data.length < authState.limit)
|
||||||
|
isEnd.value = true;
|
||||||
|
|
||||||
|
ueuses.value = dupUeuse(ueuses.value.concat(ueuseRes.data));
|
||||||
|
loadedPage.value++;
|
||||||
|
|
||||||
|
processStatus.value = false;
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<Layout
|
||||||
|
:processStatus="processStatus"
|
||||||
|
:init="init"
|
||||||
|
name="ユーザー"
|
||||||
|
><template v-if="authState && userData">
|
||||||
|
<div>
|
||||||
|
<h1>{{ userData.username }}</h1>
|
||||||
|
</div>
|
||||||
|
</template></Layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import Layout from "@/layouts/Layout.vue";
|
||||||
|
import { authState, ResolveError, type UserResponseSuccess } from "@/lib/account";
|
||||||
|
import Database from "@/lib/db";
|
||||||
|
import waitUntil from "@/lib/wait";
|
||||||
|
import uwuzu from "better-uwuzu-sdk";
|
||||||
|
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
|
||||||
|
import Parser from "better-uwuzu-sdk/1.6.8/parser";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const processStatus = ref<boolean>(false);
|
||||||
|
const db = new Database();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const userData = ref<UserResponseSuccess>();
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
processStatus.value = true;
|
||||||
|
|
||||||
|
if (typeof route.params.userid !== "string")
|
||||||
|
throw new Error("パラメータが文字列ではありません。");
|
||||||
|
|
||||||
|
await db.open();
|
||||||
|
await waitUntil(() => authState.lastChecked !== undefined, 200);
|
||||||
|
console.log(authState);
|
||||||
|
if (!authState.isSignedIn) {
|
||||||
|
router.replace("/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiClient = new uwuzu<ApiMap>({
|
||||||
|
origin: authState.origin,
|
||||||
|
parser: Parser,
|
||||||
|
});
|
||||||
|
apiClient.token = authState.token;
|
||||||
|
|
||||||
|
const user = await apiClient.request("users/", {
|
||||||
|
userid: route.params.userid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.success) {
|
||||||
|
return ResolveError(user.error_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
userData.value = user;
|
||||||
|
|
||||||
|
processStatus.value = false;
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
type scope =
|
||||||
|
"read:me" | "write:me" |
|
||||||
|
"read:ueuse" | "write:ueuse" |
|
||||||
|
"read:users" |
|
||||||
|
"write:follow" |
|
||||||
|
"write:favorite" |
|
||||||
|
"read:notifications" | "write:notifications" |
|
||||||
|
"read:bookmark" | "write:bookmark";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const __CONFIG: {
|
||||||
|
uwuzu: {
|
||||||
|
supportedVersion: {
|
||||||
|
min: string;
|
||||||
|
max: string;
|
||||||
|
};
|
||||||
|
requiredScopes: scope[]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"typeRoots": ["./node_modules/"],
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023", "DOM", "WebWorker", "WebWorker.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
/* Alias */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
strategies: "injectManifest",
|
||||||
|
srcDir: "./src/lib",
|
||||||
|
filename: "sw.ts",
|
||||||
|
injectRegister: false,
|
||||||
|
manifest: false,
|
||||||
|
injectManifest: {
|
||||||
|
injectionPoint: undefined,
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
type: "module",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
hmr: {
|
||||||
|
host: "localhost",
|
||||||
|
clientPort: 5173,
|
||||||
|
protocol: "ws",
|
||||||
|
}/*false*/,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": `${import.meta.dirname}/src`,
|
||||||
|
"better-uwuzu-sdk/1.6.8/parser": `${import.meta.dirname}/node_modules/better-uwuzu-sdk/dist/1.6.8/parser.js`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__CONFIG: JSON.stringify({
|
||||||
|
uwuzu: {
|
||||||
|
supportedVersion: {
|
||||||
|
min: "1.6.8",
|
||||||
|
max: "1.6.11",
|
||||||
|
},
|
||||||
|
requiredScopes: [
|
||||||
|
"read:me", "read:users",
|
||||||
|
"read:ueuse", "read:bookmark",
|
||||||
|
"read:notifications",
|
||||||
|
"write:me", "write:follow",
|
||||||
|
"write:ueuse", "write:favorite",
|
||||||
|
"write:notifications", "write:bookmark",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user