First Commit

This commit is contained in:
2026-03-30 20:15:26 +09:00
commit 5db6f08060
52 changed files with 9292 additions and 0 deletions
+24
View File
@@ -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?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+99
View File
@@ -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
View File
@@ -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>
+42
View File
@@ -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"
}
}
+5833
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild
+1
View File
@@ -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

+10
View File
@@ -0,0 +1,10 @@
{
"version": "1.0.0-alpha.0",
"isPrerelease": true,
"update": {
"requiredCacheClear": true,
"requiredAgainSignin": true,
"requiredSettingsClear": true,
"canUpdate": true
}
}
+81
View File
@@ -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
View File
@@ -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>
+55
View File
@@ -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>
+12
View File
@@ -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>
+47
View File
@@ -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>
+21
View File
@@ -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>
+37
View File
@@ -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>
+32
View File
@@ -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>
+133
View File
@@ -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>
+87
View File
@@ -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>
+24
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
<template>
<p :class='[
"text-sm",
"text-gray-500", "dark:text-gray-300"
]'>
<slot />
</p>
</template>
+100
View File
@@ -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>
+41
View File
@@ -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>
+438
View File
@@ -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>
+29
View File
@@ -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>
+32
View File
@@ -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>
+35
View File
@@ -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);
}
+54
View File
@@ -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>
+29
View File
@@ -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>
+41
View File
@@ -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;
}
View File
+7
View File
@@ -0,0 +1,7 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "tailwind-class.txt";
@theme {
--breakpoint-large: 50rem;
}
+88
View File
@@ -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>
+222
View File
@@ -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(),
});
}
}
+57
View File
@@ -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();
}
+148
View File
@@ -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, "&amp;")
.replaceAll(/</g, "&lt;")
.replaceAll(/>/g, "&gt;")
.replaceAll(/"/g, "&quot;")
.replaceAll(/"/g, "&#39;");
}
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}`;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { reactive } from "vue";
const routerStatus = reactive<{
isLoad: boolean;
}>({
isLoad: false,
});
export default routerStatus;
+82
View File
@@ -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,
}
));
}
})());
});
+22
View File
@@ -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;
}
+10
View File
@@ -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
View File
@@ -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");
+114
View File
@@ -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>
+160
View File
@@ -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>
+204
View File
@@ -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>
+218
View File
@@ -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>
+194
View File
@@ -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>
+63
View File
@@ -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>
+22
View File
@@ -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[]
};
}
}
+26
View File
@@ -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"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
},
},
}
+26
View File
@@ -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"]
}
+57
View File
@@ -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",
]
},
}),
},
});