This commit is contained in:
2026-03-30 20:17:55 +09:00
commit e63dc09213
32 changed files with 2469 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"]
}
+4
View File
@@ -0,0 +1,4 @@
# Clean Follow uwuzu
uwuzu用フォロセアプリ
CFUって呼んでもらっても構いません。
v1.6.5-v1.6.10で多分動きます。
+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="/cfu.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clean Follow uwuzu</title>
<meta name="description" content="uwuzu向けのフォロー整理アプリです。" />
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{
"name": "clean-follow-uwuzu",
"description": "uwuzu向けのフォロー整理アプリです。",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"dexie": "^4.2.1",
"semver": "^7.7.3",
"uuid": "^13.0.0",
"vue": "^3.5.24"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"@vueuse/head": "^2.0.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-router": "^4.6.4",
"vue-tsc": "^3.1.4",
"zod": "^4.2.1"
}
}
+1403
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 512 378" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,-0.01803)">
<g transform="matrix(1.695364,0,0,1.605685,-81.377483,-325.954128)">
<g transform="matrix(0.995876,0,0,1.01204,1.119771,-3.082353)">
<path d="M350.325,265.846L350.325,425.989C350.325,431.423 346.079,435.835 340.849,435.835L56.551,435.835C51.321,435.835 47.074,431.423 47.074,425.989L47.074,265.846C47.074,260.412 51.321,256 56.551,256L340.849,256C346.079,256 350.325,260.412 350.325,265.846Z" style="fill:rgb(241,184,81);"/>
</g>
<g transform="matrix(0.995876,0,0,0.111213,1.119771,195.529412)">
<path d="M350.325,323.199C350.325,286.111 347.14,256 343.218,256L47.074,256L47.074,368.635C47.074,405.724 50.259,435.835 54.182,435.835L343.218,435.835C347.14,435.835 350.325,405.724 350.325,368.635L350.325,323.199Z" style="fill:rgb(241,184,81);"/>
</g>
<g transform="matrix(0.336355,-0,0,-0.122335,32.16628,256.317647)">
<path d="M350.325,256L47.074,256L47.074,374.744C47.074,408.461 56.504,435.835 68.118,435.835L329.281,435.835C340.896,435.835 350.325,408.461 350.325,374.744L350.325,256Z" style="fill:rgb(241,184,81);"/>
</g>
</g>
<g transform="matrix(1.291413,0,0,1.223102,-74.601675,-124.445966)">
<g transform="matrix(0.572804,0,0,0.687365,124.146552,61.817966)">
<ellipse cx="230.189" cy="267.94" rx="61.103" ry="50.919" style="fill:rgb(110,110,110);"/>
</g>
<g transform="matrix(1.49502,0,0,1.060679,-38.787484,-11.433873)">
<rect x="155.04" y="290.789" width="84.28" height="65.995" style="fill:rgb(0,255,32);fill-opacity:0;"/>
<clipPath id="_clip1">
<rect x="155.04" y="290.789" width="84.28" height="65.995"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(1.081081,0,0,1.244586,-102.924861,-118.295436)">
<ellipse cx="277.597" cy="381.717" rx="38.979" ry="53.026" style="fill:rgb(110,110,110);"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+43
View File
@@ -0,0 +1,43 @@
<template>
<Header />
<main :class='[
"w-full", "h-full", "px-3",
"flex", "justify-center", "flex-1",
"bg-neutral-100", "dark:bg-zinc-800",
"text-black", "dark:text-white",
]'>
<ErrorMsg :error="error" />
<Suspense>
<RouterView @failed="handleFailed" />
</Suspense>
</main>
</template>
<script lang="ts" setup>
import Header from "@/components/Header.vue";
import { onMounted, onBeforeUnmount, ref, Suspense } from "vue";
import { RouterView } from "vue-router";
import ErrorMsg from "@/components/ErrorMsg.vue";
const error = ref<any>();
function handleError(event: ErrorEvent | PromiseRejectionEvent) {
error.value = event instanceof PromiseRejectionEvent
? event.reason
: event;
}
function handleFailed(event: any) {
error.value = event;
}
onMounted(() => {
window.addEventListener("error", handleError);
window.addEventListener("unhandledrejection", handleError);
});
onBeforeUnmount(() => {
window.removeEventListener("error", handleError);
window.removeEventListener("unhandledrejection", handleError);
});
</script>
+1
View File
@@ -0,0 +1 @@
<template></template>
+14
View File
@@ -0,0 +1,14 @@
<template>
<button :class='[
"bg-neutral-200", "dark:bg-zinc-800",
"rounded-2xl",
"w-fit", "px-4", "py-1",
"cursor-pointer",
"disabled:cursor-not-allowed",
"disabled:bg-neutral-500/50",
"disabled:dark:bg-zinc-900/30",
"disabled:opacity-70",
]'>
<slot />
</button>
</template>
+53
View File
@@ -0,0 +1,53 @@
<template>
<div
v-if="props.error !== undefined"
:class='[
"fixed","inset-0", "flex", "z-50", "wrap-break-word",
"items-center", "justify-center", "text-center",
"bg-black/50",
]'
>
<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",
]'>
<span class="text-lg font-semibold">問題が発生しました</span>
<SmallText v-html='
props.error
? (props.error instanceof Error
? props.error.message
: 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>
</div>
</div>
</template>
<script lang="ts" setup>
import Button from "@/components/Button.vue";
import SmallText from "@/components/SmallText.vue";
import { ref } from "vue";
const props = defineProps<{
error?: any;
}>();
const isReloading = ref<boolean>(false);
const reload = () => {
isReloading.value = true;
window.location.reload();
}
</script>
+21
View File
@@ -0,0 +1,21 @@
<template>
<header :class='[
"bg-white", "dark:bg-neutral-600",
"text-black","dark:text-white",
"sticky", "top-0", "select-none",
"flex", "justify-center", "items-center",
"w-full", "h-20", "p-2", "gap-2",
]'>
<img
src="/cfu.svg"
:class='["h-10"]'
/>
<span :class='["text-2xl", "font-bold", `font-["M_PLUS_2"]`]'>
Clean Follow uwuzu
</span>
</header>
</template>
<style>
@import url("https://fonts.googleapis.com/css2?family=M+PLUS+2:wght@700&display=swap");
</style>
+26
View File
@@ -0,0 +1,26 @@
<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",
]'
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
<script lang="ts" setup>
defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
</script>
+7
View File
@@ -0,0 +1,7 @@
<template>
<div :class='[
"w-12", "h-12", "animate-spin",
"border-4", "rounded-full",
"border-t-blue-500", "border-gray-300",
]' />
</template>
+8
View File
@@ -0,0 +1,8 @@
<template>
<p :class='[
"text-sm",
"text-gray-500", "dark:text-gray-300"
]'>
<slot />
</p>
</template>
+26
View File
@@ -0,0 +1,26 @@
<template>
<div class="flex w-fit border rounded-lg p-2">
<img
:src="meData.user_icon"
:alt="`${meData.username}さんのアイコン`"
class="w-30 h-30 rounded-full"
/>
<div class="flex flex-col ml-2">
<span class="text-2xl font-bold">{{ meData.username }}</span>
<span>@{{ meData.userid }}@{{ hostname }}</span>
<textarea
class="resize-none h-full"
v-html="meData.profile"
disabled
/>
</div>
</div>
</template>
<script lang="ts" setup>
defineProps<{
meData: any;
hostname: string;
}>();
</script>
+15
View File
@@ -0,0 +1,15 @@
html, body {
width: 100%;
height: 100%;
max-height: fit-content;
margin: 0;
}
html {
font-size: 18px;
}
body {
display: flex;
flex-direction: column;
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+35
View File
@@ -0,0 +1,35 @@
import Database, { getByIndex } from "@/lib/db";
export async function isSignin(db: Database) {
const origin = await getByIndex(db.server, "name", "origin");
const token = await getByIndex(db.server, "name", "token");
if (!origin || !token) {
return false;
}
try {
const req = await fetch(new URL("/api/me/", origin.value), {
method: "POST",
body: JSON.stringify({
token: token.value,
}),
});
const res = await req.json();
if (!res.success) {
await db.server.delete(origin.id);
await db.server.delete(token.id);
return false;
} else {
return true;
}
} catch (err) {
await db.server.delete(origin.id);
await db.server.delete(token.id);
throw err;
}
}
+19
View File
@@ -0,0 +1,19 @@
export default async (origin: string, endpoint: string, options?: RequestInit) => {
try {
const req = await fetch(new URL("/api" + endpoint, origin), options);
if (!req.ok) {
throw new Error(`Network Error: HTTP${req.status} ${req.statusText}`);
}
const res = await req.json();
if (res.error_code !== undefined) {
throw new Error(`uwuzu Error: ${res.error_code}`);
}
return res;
} catch (err) {
throw err;
}
}
+35
View File
@@ -0,0 +1,35 @@
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",
});
};
}
export async function getByIndex<T>(
table: EntityTable<T, any>,
index: string,
indexValue: any
): Promise<T | undefined> {
return await table.where(index).equals(indexValue).first();
}
+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;
}
+33
View File
@@ -0,0 +1,33 @@
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { createHead } from "@vueuse/head";
import "@/css/tailwind.css";
import "@/css/global.css";
import Layout from "@/Layout.vue";
const app = createApp(Layout);
app.use(createHead())
app.use(createRouter({
history: createWebHistory(),
routes: [
{
path: "/",
component: () => import("@/routes/index.vue"),
},
{
path: "/signin",
component: () => import("@/routes/signin/index.vue"),
},
{
path: "/signin/callback",
component: () => import("@/routes/signin/callback.vue"),
},
{
path: "/:NotFound(.*)*",
component: () => import("@/NotFound.vue"),
},
],
}));
app.mount("body");
+101
View File
@@ -0,0 +1,101 @@
<template>
<div
v-if="processStatus === true"
:class='[
"fixed","inset-0", "flex", "flex-col", "z-49",
"wrap-break-word", "items-center", "justify-center", "text-center",
]'
>
<Progress />
</div>
<div
:class='[
"flex", "flex-col", "items-center",
"w-full", "h-full", "mt-2", "gap-3",
]'
v-else
>
<h1 class="text-2xl font-bold">こんにちは{{ me.username }}さん</h1>
<div
class="flex flex-wrap gap-3"
id="followers"
>
<User
v-for="follower in followers"
:key="follower.id"
:meData="follower"
:hostname="hostname"
class="grow"
/>
</div>
<Progress v-if='processStatus === "async"' />
</div>
</template>
<script lang="ts" setup>
import { useHead } from "@vueuse/head";
import Database, { getByIndex } from "@/lib/db";
import useAPI from "@/lib/api";
import { isSignin } from "@/lib/account";
import { useRouter } from "vue-router";
import { ref } from "vue";
import Progress from "@/components/Progress.vue";
import User from "@/components/User.vue";
useHead({
title: "ホーム | Clean Follow uwuzu",
});
const db = new Database();
const router = useRouter();
const me = ref<any>();
const followers = ref<any[]>([]);
const processStatus = ref<boolean | "async">(false);
const hostname = ref<string>("");
(async () => {
processStatus.value = true;
if (!await isSignin(db)) {
router.replace("/signin");
return;
}
const origin = await getByIndex(db.server, "name", "origin");
const token = await getByIndex(db.server, "name", "token");
if (!origin || !token) {
router.replace("/signin");
return;
}
hostname.value = new window.URL(origin.value).hostname;
me.value = await useAPI(origin.value, "/me/", {
method: "POST",
cache: "no-store",
body: JSON.stringify({
token: token.value,
}),
});
processStatus.value = "async";
for (const follower of me.value.follower) {
const user = await useAPI(origin.value, "/users/", {
method: "POST",
cache: "no-store",
body: JSON.stringify({
token: token.value,
userid: follower,
}),
});
followers.value.push(user);
}
processStatus.value = false;
}) ();
</script>
+198
View File
@@ -0,0 +1,198 @@
<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",
"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",
]'
>
<h1 class="text-3xl font-bold">確認</h1>
<User
:meData="meData"
:hostname="hostname"
/>
<p>あなたは{{ meData.username }}ですか</p>
<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 />
Clean Follow uwuzuをお楽しみください
</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 { isSignin } from "@/lib/account";
import useAPI from "@/lib/api";
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";
const db = new Database();
const query = useRoute().query;
const router = useRouter();
const emit = defineEmits(["failed"]);
let isProcessing = true;
let processStage = ref<"redirect" |
"init" | "valid" | "get" |"check" |
"save" | "done">("init");
useHead({
title: "サインイン処理 | Clean Follow uwuzu",
});
if (await isSignin(db))
useRouter().replace("/");
const stageDetails = {
redirect: "リダイレクトしています",
init: "初期化しています",
valid: "入力を確認しています",
get: "トークンを取得しています",
check: "ユーザーを確認しています",
save: "データを保存しています",
done: "完了しました",
};
const restart = (e: Event) => {
e.preventDefault();
isProcessing = true;
processStage.value = "redirect";
router.replace("/signin");
}
const redirect_home = (e: Event) => {
e.preventDefault();
isProcessing = true;
processStage.value = "redirect";
router.push("/");
}
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 tokenData = await useAPI(result.data.origin, "/token/get", {
method: "POST",
cache: "no-store",
body: JSON.stringify({
session: result.data.session,
}),
}) as {
success: true;
username: string;
userid: string;
token: string;
};
const meData = await useAPI(result.data.origin, "/me/", {
method: "POST",
cache: "no-store",
body: JSON.stringify({
token: tokenData.token,
}),
});
const hostname = new URL(result.data.origin).hostname;
isProcessing = false;
processStage.value = "check";
const checked = (e: Event) => {
e.preventDefault();
isProcessing = true;
processStage.value = "save";
db.server.put({
name: "origin",
value: result.data.origin,
});
db.server.put({
name: "token",
value: tokenData.token,
});
processStage.value = "done";
isProcessing = false;
}
</script>
+188
View File
@@ -0,0 +1,188 @@
<template>
<div
:class='[
"w-full", "h-full", "pt-10",
"flex", "justify-center",
]'
>
<div :class='[
"w-130", "h-fit", "flex", "flex-col", "p-6",
"bg-white", "text-black",
"dark:bg-neutral-600", "dark:text-white",
"shadow-sm", "rounded-2xl",
]'>
<h1 class="font-bold">サインイン</h1>
<SmallText v-html="
`uwuzu ${sprtVerTxt}に対応しています。<br />
ただし最新版でない場合一部の機能が制限される可能性があります`
"/>
<hr class="my-3" />
<form class="flex flex-col gap-2" @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">
通常は必要ありません
</div>
<SmallText>
サインインするアカウントのAPIトークンを入力してください権限の確認は行いません<br />
以下の権限が必要です
</SmallText>
<ul>
<li class="list-disc list-inside" v-for="scope in requiredScopes">
{{ scope }}
</li>
</ul>
</div>
</details>
<Button
type="submit"
class="m-auto"
:disabled="isSubmitting"
>
<span v-if="isSubmitting">サインイン中...</span>
<span v-else>サインイン</span>
</Button>
</form>
</div>
</div>
</template>
<script lang="ts" setup>
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 Database from "@/lib/db";
import { isSignin } from "@/lib/account";
import useAPI from "@/lib/api";
import { isVersionAvailable } from "@/lib/version";
import SmallText from "@/components/SmallText.vue";
import InputText from "@/components/InputText.vue";
import Button from "@/components/Button.vue";
useHead({
title: "サインイン | Clean Follow uwuzu",
});
if (await isSignin(new Database()))
useRouter().replace("/");
const emit = defineEmits(["failed"]);
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) {
emit("failed", result.error.errors.map(err => err.message).join("\n"));
return;
}
const serverinfo = await useAPI(result.data.origin, "/serverinfo-api", {
method: "POST",
cache: "no-store",
});
if (serverinfo.software.name !== "uwuzu") {
throw new Error("サーバーがuwuzuではありません。");
}
if (!isVersionAvailable({
current: serverinfo.software.version,
min: sprtVer.min,
max: sprtVer.max,
})) {
throw "サーバーのバージョンが対象外です。";
}
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", "Clean Follow uwuzu");
authURL.searchParams.set("about", "uwuzu向けのフォロー整理アプリです。");
authURL.searchParams.set("icon", new URL("/cfu.svg", window.location.origin).toString());
window.location.href = authURL.toString();
return;
};
</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[]
};
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"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"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": `${import.meta.dirname}/src`,
},
},
define: {
__CONFIG: JSON.stringify({
uwuzu: {
supportedVersion: {
min: "1.6.5",
max: "1.6.10",
},
requiredScopes: ["read:me", "read:users"]
},
}),
},
});