(feat) シェア中継機能 (#47)

* (feat) シェア中継

* バリデーションを強化

* fix design

* fix some path issues
This commit is contained in:
かっこかり 2023-12-04 20:41:55 +09:00 committed by GitHub
parent ddceae3126
commit 7a7b07c2bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 253 additions and 1 deletions

View File

@ -22,6 +22,7 @@ $primary: #86b300;
@import "bootstrap/scss/forms/form-select";
@import "bootstrap/scss/forms/form-range";
@import "bootstrap/scss/forms/form-check";
@import "bootstrap/scss/forms/form-text";
@import "bootstrap/scss/forms/input-group";
// button

10
assets/data/forks.ts Normal file
View File

@ -0,0 +1,10 @@
export const forkedSoftwares = [
'firefish',
'calckey',
'iceshrimp',
'meisskey',
'ebisskey',
'foundkey',
'sharkey',
'cherrypick'
];

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path d="M8.75 19.25a3.25 3.25 0 1 1 6.5 0 3.25 3.25 0 0 1-6.5 0ZM15 4.75a3.25 3.25 0 1 1 6.5 0 3.25 3.25 0 0 1-6.5 0Zm-12.5 0a3.25 3.25 0 1 1 6.5 0 3.25 3.25 0 0 1-6.5 0ZM5.75 6.5a1.75 1.75 0 1 0-.001-3.501A1.75 1.75 0 0 0 5.75 6.5ZM12 21a1.75 1.75 0 1 0-.001-3.501A1.75 1.75 0 0 0 12 21Zm6.25-14.5a1.75 1.75 0 1 0-.001-3.501A1.75 1.75 0 0 0 18.25 6.5Z"></path><path d="M6.5 7.75v1A2.25 2.25 0 0 0 8.75 11h6.5a2.25 2.25 0 0 0 2.25-2.25v-1H19v1a3.75 3.75 0 0 1-3.75 3.75h-6.5A3.75 3.75 0 0 1 5 8.75v-1Z"></path><path d="M11.25 16.25v-5h1.5v5h-1.5Z"></path></svg>

After

Width:  |  Height:  |  Size: 645 B

5
layouts/blank.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<div>
<slot></slot>
</div>
</template>

View File

@ -231,6 +231,13 @@ _i18n:
description: "このドキュメントはコミュニティによる翻訳です。ドキュメントの翻訳は{link}から行えます。ご協力をお願いします🙏"
linkLabel: "Crowdin"
_share:
title: "Misskeyへート"
chooseServer: "ノートするサーバーを選択してください"
addServer: "サーバーを追加"
domain: "サーバーのドメイン"
compatibleWith: "Misskeyと、一部のMisskeyフォークに対応しています。"
_api:
_permissions:
title: "権限"

226
pages/share.vue Normal file
View File

@ -0,0 +1,226 @@
<template>
<div class="min-h-screen">
<div class="sm:py-20 sm:max-w-xl mx-auto">
<div class="p-6 bg-white dark:bg-gray-800 sm:rounded-lg min-h-screen sm:min-h-[230px]">
<div v-if="loading" class="mx-auto py-12">
<MkLoading />
</div>
<div v-else class="space-y-6">
<div class="w-12 h-12 mx-auto bg-accent-600/20 text-accent-600 rounded-full p-3">
<ShareIco class="w-6 h-6" />
</div>
<h1 class="font-bold text-center text-lg md:text-xl">{{ $t('_share.chooseServer') }}</h1>
<ul class="rounded-lg border divide-y border-gray-300 dark:border-gray-600 divide-gray-300 dark:divide-gray-600">
<li v-for="instance in displayInstances" :key="instance.url" class="group">
<GNuxtLink
:to="`https://${instance.url}/share?${stringifyQuery(query)}`"
class="group-first:rounded-t-lg group-last:rounded-b-lg p-4 w-full flex items-center hover:bg-gray-100 dark:hover:bg-gray-700"
>
<div
class="h-9 w-9 flex-shrink-0 overflow-hidden rounded bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 mr-3"
:class="instance.isUserDefined && 'transition-[border-color] hover:border-red-600 dark:hover:border-red-600'"
>
<template v-if="instance.isUserDefined">
<button class="relative w-full h-full group/delete" @click.stop.prevent="deleteInstance(instance.url)">
<img v-if="instance.icon && instance.meta?.iconUrl" :src="getInstanceImage(instance)" class="w-full h-full" />
<div v-else-if="['forked', 'notDetermined'].includes(getPlaceholderImage(instance))" class="w-full h-full bg-accent-600/20 p-2 text-accent-600">
<ForkedIco v-if="getPlaceholderImage(instance) === 'forked'" class="bi h-5 w-5 stroke-1 stroke-current" />
<QuestionIco v-else class="h-5 w-5" />
</div>
<img v-else :src="getPlaceholderImage(instance)" class="w-full h-full" />
<div class="pointer-events-none absolute top-0 left-0 w-full h-full bg-white dark:bg-gray-800 transition-opacity opacity-0 group-hover/delete:opacity-100 p-2 text-red-600">
<DeleteIco class="h-5 w-5" />
</div>
</button>
</template>
<img v-else-if="instance.icon && instance.meta?.iconUrl" :src="instance.meta?.iconUrl" class="w-full h-full" />
</div>
<div class="min-w-0 mr-3">
<h2 class="font-bold truncate">{{ instance.name }}</h2>
<p class="text-xs truncate">{{ instance.url }}</p>
</div>
<ArrowRightIco class="block ml-auto flex-shrink-0 h-4 w-4" />
</GNuxtLink>
</li>
<li class="group">
<details class="group-first:rounded-t-lg group-last:rounded-b-lg group/details">
<summary class="p-4 w-full flex items-center hover:bg-gray-100 dark:hover:bg-gray-700 group-first:rounded-t-lg group-last:rounded-b-lg group-last:group-open/details:rounded-b-none group-open/details:border-b cursor-pointer border-gray-300 dark:border-gray-600">
<div class="h-9 w-9 mr-2">
<div class="w-full h-full rounded bg-accent-600/20 p-2 text-accent-600">
<PlusIco class="h-5 w-5 stroke-1 stroke-current" />
</div>
</div>
<h2 class="font-bold">{{ $t('_share.addServer') }}</h2>
<ArrowRightIco class="block ml-auto flex-shrink-0 h-4 w-4 transition-transform group-open/details:rotate-90" />
</summary>
<div class="p-4">
<form @submit.prevent="getAndSetInstanceInfo">
<label class="form-label inline-block" for="userDefinedInstanceInput">{{ $t('_share.domain') }}</label>
<div class="input-group">
<input id="userDefinedInstanceInput" class="form-control" autocomplete="off" placeholder="misskey.example.com" v-model="userDefinedInstanceInput" :disabled="iFetching" />
<button type="submit" class="btn btn-primary !text-white hover:!text-white focus-visible:!text-white" :disabled="iFetching"><PlusIco class="h-4 w-4 stroke-1 stroke-current" /></button>
</div>
<div class="form-text">{{ $t('_share.compatibleWith') }}</div>
</form>
</div>
</details>
</li>
</ul>
<div class="text-sm text-center">
&copy; 2023 Misskey, syuilo, and other contributors<br>
<GNuxtLink :to="localePath('/')" target="_blank" class="hover:underline underline-offset-1">Misskey Hub</GNuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ShareIco from 'bi/share-fill.svg';
import ArrowRightIco from 'bi/chevron-right.svg';
import PlusIco from 'bi/plus-lg.svg';
import DeleteIco from 'bi/trash.svg';
import QuestionIco from 'bi/question-lg.svg';
import ForkedIco from '@/assets/svg/repo-forked.svg';
import { isLocalPath, resolveObjPath } from '@/assets/js/misc';
import { stringifyQuery, parseURL, joinURL } from 'ufo';
import { api as misskeyApi } from 'misskey-js';
import { forkedSoftwares } from '~/assets/data/forks';
import type { InstanceInfo, InstanceItem } from '@/types/instances-info';
definePageMeta({
layout: 'blank',
});
useHead({
meta: [
{ name: 'robots', content: 'noindex' },
],
});
const { query, meta } = useRoute();
const { t } = useI18n();
const localePath = useLocalePath();
meta.title = t('_share.title');
type ExtendedInstanceItem = InstanceItem & {
isUserDefined?: boolean;
};
const loading = ref(true);
const iFetching = ref(false);
const featuredInstances = ref<ExtendedInstanceItem[]>([]);
const userDefinedInstances = ref<ExtendedInstanceItem[]>([]);
const displayInstances = computed(() => [
...userDefinedInstances.value,
...featuredInstances.value,
]);
const userDefinedInstanceInput = ref<string>('');
async function getAndSetInstanceInfo() {
if (!process.client || !userDefinedInstanceInput.value || !userDefinedInstanceInput.value.includes('.')) return;
iFetching.value = true;
nextTick(async () => {
const realHost = parseURL(userDefinedInstanceInput.value.startsWith('https://') ? userDefinedInstanceInput.value : 'https://' + userDefinedInstanceInput.value);
if (!realHost.host) {
alert(t('_servers._system.fetchError'));
return;
}
const miApi = new misskeyApi.APIClient({
origin: `https://${realHost.host}`,
});
try {
const res = await miApi.request('meta', { detail: true });
userDefinedInstances.value.push({
background: !(!res.backgroundImageUrl),
banner: !(!res.bannerUrl),
description: res.description,
icon: !(!res.iconUrl),
isAlive: true,
langs: res.langs,
meta: res,
name: res.name ?? '',
nodeinfo: null,
stats: {},
url: realHost.host ?? '',
value: 0,
isUserDefined: true,
});
userDefinedInstanceInput.value = '';
} catch (err) {
alert(t('_servers._system.fetchError'));
console.error(err);
} finally {
iFetching.value = false;
}
});
}
function deleteInstance(host: string) {
const i = userDefinedInstances.value.findIndex((v) => v.url === host);
userDefinedInstances.value.splice(i, 1);
}
function getInstanceImage(instance: ExtendedInstanceItem | InstanceItem) {
if (!instance.meta?.iconUrl) return;
if (isLocalPath(instance.meta.iconUrl)) {
return joinURL(`https://${instance.url}`, instance.meta.iconUrl);
}
return instance.meta.iconUrl;
}
function getPlaceholderImage(instance: ExtendedInstanceItem | InstanceItem) {
if (instance.meta?.repositoryUrl) {
if (forkedSoftwares.some((v) => instance.meta?.repositoryUrl.toLowerCase().includes(v))) {
return 'forked';
}
if (instance.meta.repositoryUrl.includes('misskey')) {
return '/img/icons/f/mi.png';
}
}
return 'notDetermined';
}
onMounted(async () => {
if (process.client) {
const fetchedInfo = await window.fetch('https://instanceapp.misskey.page/instances.json');
if (![200, 304].includes(fetchedInfo.status)) {
alert(t('_servers._system.fetchError'));
return;
}
const fetchedInfoJson = await fetchedInfo.json() as InstanceInfo;
featuredInstances.value = fetchedInfoJson.instancesInfos.sort((a, b) => {
return resolveObjPath(a, 'stats.originalUsersCount') > resolveObjPath(b, 'stats.originalUsersCount') ? -1 : 1;
}).slice(0, 5);
const ls = localStorage.getItem('miHub_share_instances');
if (ls) {
const lsJ = JSON.parse(ls) as ExtendedInstanceItem[];
userDefinedInstances.value = [...lsJ];
}
}
loading.value = false;
});
watch(userDefinedInstances, (to) => {
if (process.client) {
localStorage.setItem('miHub_share_instances', JSON.stringify(to));
}
}, {
deep: true,
});
</script>
<style scoped>
</style>

BIN
public/img/icons/f/mi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,3 +1,5 @@
import type * as Misskey from 'misskey-js';
/** 各インスタンスの情報 */
export type InstanceItem = {
/** Hostname (e.g. `misskey.io`) */
@ -21,7 +23,7 @@ export type InstanceItem = {
/** nodeinfo */
nodeinfo: Record<string, any> | null,
/** result of api/meta */
meta: Record<string, any> | null,
meta: Misskey.entities.InstanceMetadata | null,
stats?: Record<string, any>, // deprecated (result of api/stats)
};