Save
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Clean Follow uwuzu
|
||||||
|
uwuzu用フォロセアプリ
|
||||||
|
CFUって呼んでもらっても構いません。
|
||||||
|
v1.6.5-v1.6.10で多分動きます。
|
||||||
+13
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1403
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<template></template>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<p :class='[
|
||||||
|
"text-sm",
|
||||||
|
"text-gray-500", "dark:text-gray-300"
|
||||||
|
]'>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
type scope =
|
||||||
|
"read:me" | "write:me" |
|
||||||
|
"read:ueuse" | "write:ueuse" |
|
||||||
|
"read:users" |
|
||||||
|
"write:follow" |
|
||||||
|
"write:favorite" |
|
||||||
|
"read:notifications" | "write:notifications" |
|
||||||
|
"read:bookmark" | "write:bookmark";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const __CONFIG: {
|
||||||
|
uwuzu: {
|
||||||
|
supportedVersion: {
|
||||||
|
min: string;
|
||||||
|
max: string;
|
||||||
|
};
|
||||||
|
requiredScopes: scope[]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"]
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user