Merge branch 'main' into enhance-list-features

This commit is contained in:
ryo 2024-11-28 12:38:31 +09:00 committed by GitHub
commit 3d1d291ffa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 2989 additions and 15571 deletions

2
.gitignore vendored
View File

@ -46,3 +46,5 @@ publish_keys.json
docs/.vitepress/cache docs/.vitepress/cache
project.zip project.zip
*storybook.log

View File

@ -3,17 +3,29 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [ addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding", "@storybook/addon-onboarding",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions", "@storybook/addon-interactions",
"storybook-dark-mode",
], ],
framework: { framework: {
name: "@storybook/react-vite", name: "@storybook/react-vite",
options: {}, options: {},
}, },
docs: { viteFinal: (config) => {
autodocs: "tag", config.define = {
...config.define,
"process.env": process.env,
esbuild: {
jsx: "automatic",
},
};
return config;
},
typescript: {
reactDocgen: "react-docgen-typescript",
}, },
}; };
export default config; export default config;

View File

@ -1,9 +1,8 @@
import type { Preview } from "@storybook/react"; import type { Preview } from "@storybook/react";
import '../src/style.content.css'; import "../src/style.content.css";
const preview: Preview = { const preview: Preview = {
parameters: { parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,
@ -11,6 +10,8 @@ const preview: Preview = {
}, },
}, },
}, },
tags: ["autodocs"],
}; };
export default preview; export default preview;

View File

@ -47,7 +47,7 @@ https://github.com/kawamataryo/sky-follower-bridge/assets/11070996/67bdd228-dc67
## 🚨 Limitations ## 🚨 Limitations
- User search may fail due to late limit in Bluesky's API. In this case, please wait for 2 to 3 minutes and execute the search again. - User search may fail due to rate limit in Bluesky's API. In this case, please wait for 2 to 3 minutes and execute the search again.
## Development ## Development

View File

@ -26,7 +26,7 @@ export default defineConfig({
description: "Sky Follower Bridge is a Chrome extension that allows you to follow users on Bluesky from your own account.", description: "Sky Follower Bridge is a Chrome extension that allows you to follow users on Bluesky from your own account.",
themeConfig: { themeConfig: {
logo: { logo: {
src: "/images/logo.png", src: "/images/logo.webp",
alt: "Sky Follower Bridge Logo", alt: "Sky Follower Bridge Logo",
}, },
@ -49,6 +49,10 @@ export default defineConfig({
icon: "x", icon: "x",
link: "https://x.com/KawamataRyo", link: "https://x.com/KawamataRyo",
}, },
{
icon: "kofi",
link: "https://ko-fi.com/kawamataryo",
},
], ],
outline: { outline: {
@ -138,6 +142,18 @@ export default defineConfig({
{ text: "Guia de Solução de Problemas", link: "/pt/troubleshooting" }, { text: "Guia de Solução de Problemas", link: "/pt/troubleshooting" },
], ],
} }
},
es: {
label: "Español",
lang: "es",
link: "/es/",
themeConfig: {
nav: [
{ text: "Inicio", link: "/" },
{ text: "Comenzando", link: "/es/get-started" },
{ text: "Guía de solución de problemas", link: "/es/troubleshooting" },
],
}
} }
} }
}); });

View File

@ -11,8 +11,15 @@
} }
.VPImage.image-src { .VPImage.image-src {
max-width: 180px; max-width: 130px;
max-height: 180px; max-height: 130px;
}
@media (min-width: 640px) {
.VPImage.image-src {
max-width: 170px;
max-height: 170px;
}
} }
@media (min-width: 640px) { @media (min-width: 640px) {

87
docs/es/get-started.md Normal file
View File

@ -0,0 +1,87 @@
# Comenzando
Sky Follower Bridge te ayuda a encontrar y seguir tus conexiones de 𝕏 (Twitter) en Bluesky.
<iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="Reproductor de video de YouTube" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
## Instalación
Sky Follower Bridge está disponible en:
- [Chrome Web Store](https://chrome.google.com/webstore/detail/sky-follower-bridge/behhbpbpmailcnfbjagknjngnfdojpko) (Recomendado)
- [Complementos de Firefox](https://addons.mozilla.org/en-US/firefox/addon/sky-follower-bridge/)
- [Complementos de Microsoft Edge](https://microsoftedge.microsoft.com/addons/detail/sky-follower-bridge/dpeolmdblhfolkhlhbhlofkkpaojnnbb)
::: tip
Recomendamos usar la versión de Chrome Web Store ya que siempre está actualizada. Las versiones de otras tiendas pueden retrasarse en las actualizaciones.
:::
::: warning
Sky Follower Bridge solo está disponible para navegadores de escritorio. Los navegadores móviles no son compatibles.
:::
## Uso
### 1. Navega a 𝕏 (Twitter)
Visita cualquiera de estas páginas en X:
- Tu página de Seguidos: [x.com/following](https://x.com/following)
- Tu página de Usuarios bloqueados: [x.com/settings/blocked/all](https://x.com/settings/blocked/all)
- La página de Miembros de una Lista pública: `x.com/i/lists/<list_id>/members`
![following-page](/images/following-page.png)
### 2. Inicia Sky Follower Bridge
Presiona `Alt + B` o haz clic en el ícono de la extensión en la barra de herramientas de tu navegador.
::: tip
Para los usuarios de Firefox, presionar `Alt + B` puede no funcionar. En ese caso, haz clic en el ícono de la extensión en la barra de herramientas del navegador.
https://support.mozilla.org/en-US/kb/extensions-button
:::
![Open Extension](/images/open-extension.png)
### 3. Inicia sesión en Bluesky
Ingresa tu identificador de Bluesky (o correo electrónico) y [Contraseña de la aplicación](https://bsky.app/settings/app-passwords).
::: tip
Si encuentras errores de inicio de sesión, consulta la [Guía de solución de problemas](/troubleshooting).
:::
![enter-credentials](/images/enter-credentials.png)
### 4. Inicia la búsqueda
Haz clic en "Buscar usuarios de Bluesky" para comenzar a escanear. La extensión buscará perfiles de Bluesky coincidentes verificando la API de Bluesky.
![find-bluesky-users](/images/scan-users.png)
### 5. Revisa los resultados
Haz clic en "Ver resultados" para ver las posibles coincidencias encontradas en Bluesky.
![view-results-button](/images/click-results.png)
Esto abrirá la página de opciones mostrando todos los usuarios de Bluesky detectados.
![options](/images/options.png)
### 6. Sigue a los usuarios
Haz clic en el botón "Seguir" junto a cualquier usuario con el que desees conectarte.
![follow](/images/click-follow-btn.png)
o usa el botón "Seguir a todos" para seguir a todos los usuarios detectados de una vez.
![follow-all](/images/follow-all-btn.png)
::: warning
Ten en cuenta que el proceso de coincidencia no es perfecto y puede sugerir coincidencias incorrectas ocasionalmente. Siempre verifica el perfil antes de seguir.
:::
¡Eso es todo! Disfruta conectándote con tu comunidad en Bluesky 🎉

32
docs/es/index.md Normal file
View File

@ -0,0 +1,32 @@
---
layout: home
hero:
name: "Sky Follower Bridge"
text: "Conecta tus redes sociales"
tagline: Migra sin problemas tus conexiones sociales de 𝕏 a Bluesky
actions:
- theme: brand
text: Comenzar
link: /es/get-started
- theme: alt
text: Solución de problemas
link: /es/troubleshooting
image:
src: /images/logo.webp
alt: Imagen de portada de Sky Follower Bridge
features:
- icon: 🔍
title: Detección automática de perfiles
details: Detecta automáticamente usuarios de Bluesky similares a tus seguidos en 𝕏.
- icon: 🚀
title: Función de seguimiento masivo
details: Ahorra tiempo siguiendo a múltiples usuarios a la vez con nuestro botón "Seguir a todos".
- icon: 📋
title: Soporte para múltiples listas
details: Funciona con listas de Seguidos, Seguidores, Usuarios bloqueados e incluso listas públicas de 𝕏.
- icon: 🌐
title: Soporte multiplataforma
details: Disponible en Chrome, Firefox y Microsoft Edge para tu conveniencia.
---

128
docs/es/troubleshooting.md Normal file
View File

@ -0,0 +1,128 @@
# Guía de solución de problemas
## Errores de autenticación
### Problemas de inicio de sesión
**Mensaje de error:**
<span class="error-message">Error: Invalid identifier or password</span>
**Lista de verificación:**
1. Entrada de nombre de usuario y contraseña
- Verifica si hay espacios accidentales
- Si copias y pegas, asegúrate de que no se incluyan caracteres adicionales
2. Formato del nombre de usuario
- Formato correcto: `tu-usuario.bsky.social`
- Error común: `tu-usuario` (falta .bsky.social)
3. Información de la contraseña
- Recomendamos encarecidamente usar una [Contraseña de la aplicación](https://bsky.app/settings/app-passwords) en lugar de tu contraseña regular
- Formato de la contraseña de la aplicación: `xxxx-xxxx-xxxx-xxxx` (19 caracteres)
::: tip Consejos útiles
No confundas la Contraseña de la aplicación con el "nombre de la contraseña" que se muestra en la configuración.
Cómo crear una nueva Contraseña de la aplicación:
2. [Navega a la sección de Contraseñas de la aplicación](https://bsky.app/settings/app-passwords)
3. Haz clic en "Agregar Contraseña de la aplicación"
4. Haz clic en "Crear Contraseña de la aplicación"
4. Copia la contraseña generada de 19 caracteres
:::
---
### Se requiere autenticación de dos factores
**Mensaje de error:**
<span class="error-message">Error: Two-factor authentication required</span>
**Solución:**
1. Revisa tu correo electrónico para obtener el código de autenticación
2. Ingresa el código en el campo de entrada de 2FA
3. Intenta iniciar sesión nuevamente
## Errores de límite de tasa
**Mensaje de error:**
<span class="error-message">Error: Rate limit error</span>
**Solución:**
1. La API de Bluesky tiene los siguientes límites ([documentación oficial](https://docs.bsky.app/docs/advanced-guides/rate-limits)):
- Hasta 5,000 puntos por hora (aproximadamente 1,666 acciones nuevas)
- Hasta 35,000 puntos por día
- Puntos por acción:
- Crear: 3 puntos
- Actualizar: 2 puntos
- Eliminar: 1 punto
2. Si alcanzas el límite, espera hasta que se restablezca
3. Haz clic en el botón "Reiniciar" para intentarlo de nuevo
::: warning
La versión publicada en Firefox frecuentemente encuentra errores de límite de tasa. Si encuentras un error, intenta en Chrome.
:::
::: tip
La mayoría de los usuarios no alcanzarán estos límites durante el uso normal. Sin embargo, ten cuidado al realizar acciones masivas como seguir a muchos usuarios o dar me gusta a muchas publicaciones en un corto período.
:::
## Errores de página
### Página inválida
**Mensaje de error:**
<span class="error-message">Error: Invalid page. please open the 𝕏 following or blocking or list page.</span>
**Solución:**
Usa la extensión solo en estas páginas de 𝕏 (Twitter):
- Página de seguidos ([x.com/following](https://x.com/following))
- Página de bloqueados ([x.com/settings/blocked/all](https://x.com/settings/blocked/all))
- Página de miembros de lista (`x.com/i/lists/<list_id>/members`)
o verifica los permisos de tu extensión en la página de extensiones.
Los permisos del sitio deben ser como se muestra a continuación:
<img src="/images/site_permissions.png" alt="permisos del sitio" width="500"/>
## Problemas de escaneo
### El botón View Detected Users no funciona
Por alguna razón, el botón View Detected Users puede no funcionar.
**Solución:**
1. Haz clic derecho en el ícono de la extensión y selecciona "Opciones"
2. Se mostrará la página de resultados
<img src="/images/click-option.png" alt="hacer clic en opción" width="500"/>
### El escaneo se detiene temprano
El escaneo se detiene antes de llegar al final de la página
**Solución:**
1. Haz clic en "Reanudar escaneo" para continuar
2. El escaneo se detendrá automáticamente cuando llegue al final de la página
3. Puedes hacer clic en "Detener escaneo y ver resultados" en cualquier momento
### No se encontraron usuarios
No se detectaron usuarios de Bluesky después del escaneo
**Solución:**
1. Asegúrate de haber iniciado sesión correctamente
2. Intenta escanear de nuevo - algunos usuarios pueden no ser detectados en el primer intento
3. Verifica si los usuarios de 𝕏 han vinculado sus cuentas de Bluesky en sus perfiles
## Otros problemas
Si encuentras errores inesperados:
1. Recarga la página
2. Intenta la operación nuevamente
3. Si el problema persiste, puedes:
- [Crear un problema](https://github.com/kawamataryo/sky-follower-bridge/issues) con:
- El mensaje de error exacto
- Lo que estabas intentando hacer
- Tu tipo y versión de navegador
- Cualquier captura de pantalla relevante
- O mencionar a [@kawamataryo.bsky.social](https://bsky.app/profile/kawamataryo.bsky.social) en Bluesky

View File

@ -4,7 +4,6 @@ Sky Follower Bridge vous aide à trouver et suivre vos connexions 𝕏 (Twitter)
<iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> <iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<p style="color: gray; font-size: 0.8em; line-height: 1.4;">Cette vidéo démontre la version Edge. La dernière version de Chrome et les versions récentes de Firefox peuvent se comporter différemment. Un tutoriel vidéo pour Chrome arrive bientôt.</p>
## Installation ## Installation
@ -18,6 +17,10 @@ Sky Follower Bridge est disponible sur :
Nous recommandons d'utiliser la version du Chrome Web Store car elle est toujours à jour. Les versions des autres magasins peuvent être en retard dans les mises à jour. Nous recommandons d'utiliser la version du Chrome Web Store car elle est toujours à jour. Les versions des autres magasins peuvent être en retard dans les mises à jour.
::: :::
::: warning
Sky Follower Bridge n'est disponible que pour les navigateurs de bureau. Les navigateurs mobiles ne sont pas supportés.
:::
## Utilisation ## Utilisation
### 1. Naviguez vers 𝕏 (Twitter) ### 1. Naviguez vers 𝕏 (Twitter)

View File

@ -13,7 +13,7 @@ hero:
text: Troubleshooting text: Troubleshooting
link: /fr/troubleshooting link: /fr/troubleshooting
image: image:
src: /images/logo.png src: /images/logo.webp
alt: Image de couverture de Sky Follower Bridge alt: Image de couverture de Sky Follower Bridge
features: features:

View File

@ -85,6 +85,16 @@ Les permissions du site devraient être comme ci-dessous :
## Problèmes de scan ## Problèmes de scan
### Le bouton View Detected Users ne fonctionne pas
Pour une raison quelconque, le bouton View Detected Users peut ne pas fonctionner.
**Solution :**
1. Faites un clic droit sur l'icône de l'extension et sélectionnez "Options"
2. La page des résultats sera affichée
<img src="/images/click-option.png" alt="cliquer sur option" width="500"/>
### Le scan s'arrête trop tôt ### Le scan s'arrête trop tôt
Le scan s'arrête avant d'atteindre le bas de la page Le scan s'arrête avant d'atteindre le bas de la page

View File

@ -4,7 +4,6 @@ Sky Follower Bridge helps you find and follow your 𝕏 (Twitter) connections on
<iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> <iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<p style="color: gray; font-size: 0.8em; line-height: 1.4;">This video demonstrates the Edge version. The latest Chrome version and recent Firefox versions may behave differently. A video tutorial for Chrome is coming soon.</p>
## Installation ## Installation
@ -19,6 +18,10 @@ Sky Follower Bridge is available on:
We recommend using the Chrome Web Store version as it's always up to date. Other store versions may lag behind in updates. We recommend using the Chrome Web Store version as it's always up to date. Other store versions may lag behind in updates.
::: :::
::: warning
Sky Follower Bridge is only available on desktop browsers. Mobile browsers are not supported.
:::
## Usage ## Usage
### 1. Navigate to 𝕏 (Twitter) ### 1. Navigate to 𝕏 (Twitter)

View File

@ -14,7 +14,7 @@ hero:
text: Troubleshooting text: Troubleshooting
link: /troubleshooting link: /troubleshooting
image: image:
src: /images/logo.png src: /images/logo.webp
alt: Sky Follower Bridge Cover Image alt: Sky Follower Bridge Cover Image
features: features:

View File

@ -4,7 +4,6 @@ Sky Follower Bridge ti aiuta a trovare e seguire le tue connessioni su 𝕏 (Twi
<iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> <iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<p style="color: gray; font-size: 0.8em; line-height: 1.4;">Questo video dimostra la versione Edge. La versione più recente di Chrome e le versioni recenti di Firefox potrebbero comportarsi diversamente. Un tutorial video per Chrome è in arrivo.</p>
## Installazione ## Installazione
@ -18,6 +17,10 @@ Sky Follower Bridge è disponibile su:
Consigliamo di utilizzare la versione del Chrome Web Store poiché è sempre aggiornata. Le versioni di altri store potrebbero essere in ritardo con gli aggiornamenti. Consigliamo di utilizzare la versione del Chrome Web Store poiché è sempre aggiornata. Le versioni di altri store potrebbero essere in ritardo con gli aggiornamenti.
::: :::
::: warning
Sky Follower Bridge è disponibile solo sui browser desktop. I browser mobili non sono supportati.
:::
## Utilizzo ## Utilizzo
### 1. Naviga su 𝕏 (Twitter) ### 1. Naviga su 𝕏 (Twitter)

View File

@ -13,7 +13,7 @@ hero:
text: Risoluzione dei problemi text: Risoluzione dei problemi
link: /it/troubleshooting link: /it/troubleshooting
image: image:
src: /images/logo.png src: /images/logo.webp
alt: Immagine di copertina di Sky Follower Bridge alt: Immagine di copertina di Sky Follower Bridge
features: features:

View File

@ -85,6 +85,16 @@ I permessi del sito dovrebbero essere come di seguito:
## Problemi di scansione ## Problemi di scansione
### Il pulsante View Detected Users non funziona
Per qualche motivo, il pulsante View Detected Users potrebbe non funzionare.
**Soluzione:**
1. Fai clic con il tasto destro sull'icona dell'estensione e seleziona "Opzioni"
2. Verrà visualizzata la pagina dei risultati
<img src="/images/click-option.png" alt="clicca su opzione" width="500"/>
### La scansione si interrompe presto ### La scansione si interrompe presto
La scansione si interrompe prima di raggiungere il fondo della pagina La scansione si interrompe prima di raggiungere il fondo della pagina

View File

@ -4,7 +4,6 @@ Sky Follower Bridgeは、𝕏Twitterのあなたのフォローしてい
<iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> <iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<p style="color: gray; font-size: 0.8em; line-height: 1.4;">このビデオはEdgeバージョンを示しています。最新のChromeバージョンと最近のFirefoxバージョンでは動作が異なる場合があります。Chromeのビデオチュートリアルは近日公開予定です。</p>
## インストール ## インストール
@ -18,6 +17,10 @@ Sky Follower Bridgeは以下で利用可能です
Chrome Web Storeバージョンを使用することをお勧めします。常に最新の状態です。他のストアバージョンは更新が遅れる場合があります。 Chrome Web Storeバージョンを使用することをお勧めします。常に最新の状態です。他のストアバージョンは更新が遅れる場合があります。
::: :::
::: warning
Sky Follower Bridgeはデスクトップブラウザのみで利用可能です。モバイルブラウザはサポートされていません。
:::
## 使用方法 ## 使用方法
### 1. 𝕏Twitterに移動 ### 1. 𝕏Twitterに移動

View File

@ -13,7 +13,7 @@ hero:
text: トラブルシューティング text: トラブルシューティング
link: /ja/troubleshooting link: /ja/troubleshooting
image: image:
src: /images/logo.png src: /images/logo.webp
alt: Sky Follow Bridge cover image alt: Sky Follow Bridge cover image
features: features:

View File

@ -89,6 +89,16 @@ Firefoxで公開されているバージョンは、レート制限エラーに
## スキャンの問題 ## スキャンの問題
### View Detected Users ボタンが機能しない
何らかの理由で、View Detected Users ボタンが機能しない場合があります。
**解決策:**
1. 拡張機能のアイコンを右クリックし、「オプション」を選択
2. 結果ページが表示されます
<img src="/images/click-option.png" alt="オプションをクリック" width="500"/>
### スキャンが途中で停止 ### スキャンが途中で停止
スキャンがページの下部に到達する前に停止する スキャンがページの下部に到達する前に停止する

View File

@ -4,7 +4,6 @@ O Sky Follower Bridge ajuda você a encontrar e seguir suas conexões do 𝕏 (T
<iframe width="100%" height="315" src="https://www.youtube.com/embed/pVqoDv-1uac?si=jKDFFcKQXh61jBdL" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> <iframe width="100%" height="315" src="https://www.youtube.com/embed/pVqoDv-1uac?si=jKDFFcKQXh61jBdL" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<p style="color: gray; font-size: 0.8em; line-height: 1.4;">Este vídeo demonstra a versão Edge. A versão mais recente do Chrome e as versões recentes do Firefox podem se comportar de maneira diferente. Um tutorial em vídeo para o Chrome está chegando em breve.</p>
## Instalação ## Instalação
@ -18,6 +17,10 @@ O Sky Follower Bridge está disponível em:
Recomendamos usar a versão do Chrome Web Store, pois está sempre atualizada. As versões de outras lojas podem estar atrasadas nas atualizações. Recomendamos usar a versão do Chrome Web Store, pois está sempre atualizada. As versões de outras lojas podem estar atrasadas nas atualizações.
::: :::
::: warning
Sky Follower Bridge é apenas compatível com navegadores de desktop. Navegadores móveis não são suportados.
:::
## Uso ## Uso
### 1. Navegue para 𝕏 (Twitter) ### 1. Navegue para 𝕏 (Twitter)

View File

@ -13,7 +13,7 @@ hero:
text: Troubleshooting text: Troubleshooting
link: /pt/troubleshooting link: /pt/troubleshooting
image: image:
src: /images/logo.png src: /images/logo.webp
alt: Imagem de Capa do Sky Follower Bridge alt: Imagem de Capa do Sky Follower Bridge
features: features:

View File

@ -85,6 +85,16 @@ As permissões do site devem ser como abaixo:
## Problemas de Varredura ## Problemas de Varredura
### O botão View Detected Users não funciona
Por algum motivo, o botão View Detected Users pode não funcionar.
**Solução:**
1. Clique com o botão direito no ícone da extensão e selecione "Opções"
2. A página de resultados será exibida
<img src="/images/click-option.png" alt="clicar em opção" width="500"/>
### Varredura Para Cedo ### Varredura Para Cedo
A varredura para antes de chegar ao final da página A varredura para antes de chegar ao final da página

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -88,6 +88,16 @@ Site permissions should be like below:
## Scanning Issues ## Scanning Issues
### View Detected Users button does not work
Due to some reason, the View Detected Users button may not work.
**Solution:**
1. Right-click the extension icon and select "Options"
2. The results page will be displayed
<img src="/images/click-option.png" alt="click option" width="500"/>
### Scan Stops Early ### Scan Stops Early
Scanning stops before reaching the bottom of the page Scanning stops before reaching the bottom of the page

View File

@ -4,7 +4,6 @@ Sky Follower Bridge 帮助您在 Bluesky 上找到并关注您的 𝕏 (Twitter)
<iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> <iframe width="100%" height="315" src="https://www.youtube.com/embed/dfMK07PJeL4?si=SDC7P8basmoOOdjw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
<p style="color: gray; font-size: 0.8em; line-height: 1.4;">此视频演示了 Edge 版本。最新的 Chrome 版本和最近的 Firefox 版本可能会有所不同。Chrome 的视频教程即将推出。</p>
## 安装 ## 安装
@ -18,6 +17,10 @@ Sky Follower Bridge 可在以下平台获取:
我们推荐使用 Chrome 网上应用店版本因为它<E4B8BA><E5AE83><EFBFBD>是最新的。其他商店版本可能会延迟更新。 我们推荐使用 Chrome 网上应用店版本因为它<E4B8BA><E5AE83><EFBFBD>是最新的。其他商店版本可能会延迟更新。
::: :::
::: warning
Sky Follower Bridge 仅适用于桌面浏览器。移动浏览器不支持。
:::
## 使用方法 ## 使用方法
### 1. 访问 𝕏 (Twitter) ### 1. 访问 𝕏 (Twitter)

View File

@ -13,7 +13,7 @@ hero:
text: Troubleshooting text: Troubleshooting
link: /zh/troubleshooting link: /zh/troubleshooting
image: image:
src: /images/logo.png src: /images/logo.webp
alt: Sky Follower Bridge 封面图片 alt: Sky Follower Bridge 封面图片
features: features:

7
lefthook.yml Normal file
View File

@ -0,0 +1,7 @@
pre-commit:
parallel: true
commands:
check:
glob: "src/*.{ts,tsx}"
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
staged_fixed: true

17309
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "sky-follower-bridge", "name": "sky-follower-bridge",
"displayName": "Sky Follower Bridge", "displayName": "Sky Follower Bridge",
"version": "1.1.0", "version": "1.3.0",
"description": "Instantly find and follow the same users from your Twitter follows on Bluesky.", "description": "Instantly find and follow the same users from your Twitter follows on Bluesky.",
"author": "kawamataryou", "author": "kawamataryou",
"scripts": { "scripts": {
@ -26,34 +26,40 @@
"@changesets/cli": "^2.27.1", "@changesets/cli": "^2.27.1",
"@plasmohq/messaging": "^0.6.2", "@plasmohq/messaging": "^0.6.2",
"@plasmohq/storage": "^1.12.0", "@plasmohq/storage": "^1.12.0",
"@vitejs/plugin-react": "^4.3.3",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.11",
"plasmo": "^0.84.2", "plasmo": "^0.84.2",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-toastify": "^10.0.6",
"ts-pattern": "^5.0.6", "ts-pattern": "^5.0.6",
"vanjs-core": "^1.2.8", "vanjs-core": "^1.2.8",
"vitepress": "^1.5.0" "vitepress": "^1.5.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.3", "@biomejs/biome": "1.9.3",
"@chromatic-com/storybook": "^3.2.2",
"@plasmohq/prettier-plugin-sort-imports": "4.0.1", "@plasmohq/prettier-plugin-sort-imports": "4.0.1",
"@storybook/addon-essentials": "^7.6.12", "@storybook/addon-essentials": "^8.4.5",
"@storybook/addon-interactions": "^7.6.12", "@storybook/addon-interactions": "^8.4.5",
"@storybook/addon-links": "^7.6.12", "@storybook/addon-links": "^8.4.5",
"@storybook/addon-onboarding": "^1.0.11", "@storybook/addon-onboarding": "^8.4.5",
"@storybook/blocks": "^7.6.12", "@storybook/blocks": "^8.4.5",
"@storybook/react": "^7.6.12", "@storybook/react": "^8.4.5",
"@storybook/react-vite": "^7.6.12", "@storybook/react-vite": "^8.4.5",
"@storybook/test": "^7.6.12", "@storybook/test": "^8.4.5",
"@types/chrome": "0.0.260", "@types/chrome": "0.0.260",
"@types/node": "20.11.16", "@types/node": "20.11.16",
"@types/react": "18.2.51", "@types/react": "18.2.51",
"@types/react-dom": "18.2.18", "@types/react-dom": "18.2.18",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"daisyui": "^4.6.1", "daisyui": "^4.12.14",
"lefthook": "^1.8.4",
"postcss": "^8.4.33", "postcss": "^8.4.33",
"postcss-nesting": "^13.0.1",
"prettier": "3.2.4", "prettier": "3.2.4",
"storybook": "^7.6.12", "storybook": "^8.4.5",
"storybook-dark-mode": "^4.0.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "5.3.3" "typescript": "5.3.3"
}, },
@ -77,8 +83,5 @@
} }
}, },
"web_accessible_resources": [] "web_accessible_resources": []
},
"volta": {
"node": "16.20.0"
} }
} }

View File

@ -3,7 +3,8 @@
*/ */
module.exports = { module.exports = {
plugins: { plugins: {
"tailwindcss/nesting": "postcss-nesting",
tailwindcss: {}, tailwindcss: {},
autoprefixer: {} autoprefixer: {},
} },
} }

View File

@ -23,7 +23,6 @@ export const getStyle = () => {
const App = () => { const App = () => {
const { const {
initialize, initialize,
modalRef,
users, users,
loading, loading,
stopRetrieveLoop, stopRetrieveLoop,
@ -33,6 +32,12 @@ const App = () => {
listName, listName,
} = useRetrieveBskyUsers(); } = useRetrieveBskyUsers();
const [isModalOpen, setIsModalOpen] = React.useState(false);
const closeModal = () => {
setIsModalOpen(false);
stopRetrieveLoop();
};
React.useEffect(() => { React.useEffect(() => {
const messageHandler = ( const messageHandler = (
message: { message: {
@ -44,6 +49,7 @@ const App = () => {
if (Object.values(MESSAGE_NAMES).includes(message.name)) { if (Object.values(MESSAGE_NAMES).includes(message.name)) {
initialize() initialize()
.then(() => { .then(() => {
setIsModalOpen(true);
sendResponse({ hasError: false }); sendResponse({ hasError: false });
}) })
.catch((e) => { .catch((e) => {
@ -76,7 +82,7 @@ const App = () => {
return ( return (
<> <>
<Modal anchorRef={modalRef} onClose={stopRetrieveLoop}> <Modal open={isModalOpen} onClose={closeModal}>
<div className="flex flex-col gap-2 items-center"> <div className="flex flex-col gap-2 items-center">
{loading && ( {loading && (
<p className="text-lg font-bold"> <p className="text-lg font-bold">

View File

@ -34,7 +34,7 @@ export const isSimilarUser = (
) { ) {
return { return {
isSimilar: true, isSimilar: true,
type: BSKY_USER_MATCH_TYPE.HANDLE, type: BSKY_USER_MATCH_TYPE.DESCRIPTION,
}; };
} }
} }
@ -75,22 +75,6 @@ export const isSimilarUser = (
}; };
} }
if (
bskyProfile.description
?.toLocaleLowerCase()
.includes(`@${lowerCaseNames.accountName}`) &&
!["pfp ", "pfp: ", "pfp by "].some((t) =>
bskyProfile.description
.toLocaleLowerCase()
.includes(`${t}@${lowerCaseNames.accountName}`),
)
) {
return {
isSimilar: true,
type: BSKY_USER_MATCH_TYPE.DESCRIPTION,
};
}
return { return {
isSimilar: false, isSimilar: false,
type: BSKY_USER_MATCH_TYPE.NONE, type: BSKY_USER_MATCH_TYPE.NONE,

View File

@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import AlertError from "./AlertError"; import AlertError from "./AlertError";
const meta: Meta<typeof AlertError> = { const meta: Meta<typeof AlertError> = {
title: "CSUI/AlertError", title: "Components/AlertError",
component: AlertError, component: AlertError,
}; };
export default meta; export default meta;

View File

@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import AlertSuccess from "./AlertSuccess"; import AlertSuccess from "./AlertSuccess";
const meta: Meta<typeof AlertSuccess> = { const meta: Meta<typeof AlertSuccess> = {
title: "CSUI/AlertSuccess", title: "Components/AlertSuccess",
component: AlertSuccess, component: AlertSuccess,
}; };
export default meta; export default meta;

View File

@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import AsyncButton from "./AsyncButton";
const meta = {
title: "Components/AsyncButton",
component: AsyncButton,
} as Meta<typeof AsyncButton>;
export default meta;
type Story = StoryObj<typeof AsyncButton>;
export const Default: Story = {
args: {
label: "Click Me",
onClick: async () => {
return new Promise((resolve) => setTimeout(resolve, 2000));
},
},
};

View File

@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react";
import useConfirm, { ConfirmationDialog } from "./ConfirmDialog";
const meta = {
title: "Components/ConfirmDialog",
component: ConfirmationDialog,
} satisfies Meta<typeof ConfirmationDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
message: "Are you sure you want to proceed?",
open: false,
handleConfirm: () => {},
handleCancel: () => {},
},
render: (args) => {
const { ConfirmationDialog, confirm } = useConfirm({
message: args.message,
});
const handleClick = async () => {
const result = await confirm();
alert(`Confirmed: ${result}`);
};
return (
<div>
<button type="button" onClick={handleClick} className="btn btn-primary">
Open Confirm Dialog
</button>
<ConfirmationDialog />
</div>
);
},
};

View File

@ -0,0 +1,97 @@
import { useState } from "react";
export const ConfirmationDialog = ({
title,
message,
open,
handleConfirm,
handleCancel,
cancelText = "Cancel",
okText = "OK",
}: {
title?: string;
message: string;
open: boolean;
cancelText?: string;
okText?: string;
handleConfirm: () => void;
handleCancel: () => void;
}) => (
<dialog id="my_modal_1" className="modal" open={open}>
<div className="modal-box">
{title && <h3 className="font-bold text-xl mb-2">{title}</h3>}
<p className="text-sm">{message}</p>
<div className="modal-action">
<form method="dialog">
<div className="flex gap-2">
<button
className="btn btn-neutral btn-sm min-w-24"
type="button"
onClick={handleCancel}
>
{cancelText}
</button>
<button
className="btn btn-primary btn-sm min-w-24"
type="button"
onClick={handleConfirm}
>
{okText}
</button>
</div>
</form>
</div>
</div>
</dialog>
);
const useConfirm = ({
title = "Confirm",
message = "Are you sure you want to proceed?",
cancelText = "Cancel",
okText = "OK",
}: {
title?: string;
message?: string;
cancelText?: string;
okText?: string;
}) => {
const [promise, setPromise] = useState(null);
const confirm = () => {
return new Promise((resolve, reject) => {
setPromise({ resolve });
});
};
const handleClose = () => {
setPromise(null);
};
const handleConfirm = () => {
promise?.resolve(true);
handleClose();
};
const handleCancel = () => {
promise?.resolve(false);
handleClose();
};
return {
ConfirmationDialog: () => (
<ConfirmationDialog
title={title}
message={message}
open={promise !== null}
handleConfirm={handleConfirm}
handleCancel={handleCancel}
cancelText={cancelText}
okText={okText}
/>
),
confirm,
};
};
export default useConfirm;

View File

@ -1,49 +0,0 @@
const Header = () => {
return (
<div className="navbar bg-base-100 border-b border-base-200">
<div className="flex-1 flex items-center gap-2">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
>
<g
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="4"
>
<path
strokeLinecap="round"
d="M36 8H13c-3 0-9 2-9 8s6 8 9 8h22c3 0 9 2 9 8s-6 8-9 8H12"
/>
<path d="M40 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8ZM8 44a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z" />
</g>
</svg>
<span className="text-2xl font-bold">Sky Follower Bridge</span>
</div>
<div className="flex-none">
<a
href="https://github.com/kawamataryo/sky-follower-bridge"
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className="fill-current"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</div>
);
};
export default Header;

View File

@ -1,23 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BSKY_USER_MATCH_TYPE } from "../constants";
import MatchTypeFilter from "./MatchTypeFilter";
const meta: Meta<typeof MatchTypeFilter> = {
title: "CSUI/MatchTypeFilter",
component: MatchTypeFilter,
};
export default meta;
type Story = StoryObj<typeof MatchTypeFilter>;
export const Default: Story = {
args: {
value: {
[BSKY_USER_MATCH_TYPE.HANDLE]: true,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: false,
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: true,
},
onChange: () => {},
},
};

View File

@ -1,42 +0,0 @@
import React from "react";
import type { MatchType } from "../../types";
import { BSKY_USER_MATCH_TYPE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
export type MatchTypeFilterValue = {
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: boolean;
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: boolean;
[BSKY_USER_MATCH_TYPE.HANDLE]: boolean;
};
export type props = {
value: MatchTypeFilterValue;
onChange: (key: MatchType) => void;
};
const MatchTypeFilter = ({ value, onChange }: props) => {
return (
<div className="flex gap-2 items-center">
{Object.keys(value).map((key: MatchType) => (
<div className="form-control" key={key}>
<label
htmlFor={key}
className={`badge badge-${
MATCH_TYPE_LABEL_AND_COLOR[key].color
} gap-1 cursor-pointer py-3 ${value[key] ? "" : "badge-outline"}`}
>
<input
type="checkbox"
id={key}
checked={value[key]}
onChange={() => onChange(key)}
className="checkbox checkbox-xs"
/>
<span className="">{MATCH_TYPE_LABEL_AND_COLOR[key].label}</span>
</label>
</div>
))}
</div>
);
};
export default MatchTypeFilter;

View File

@ -1,32 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { useRef } from "react";
import BlueskyIconSvg from "./Icons/BlueskyIconSvg";
import Modal from "./Modal"; import Modal from "./Modal";
import UserCard, { type Props as UserCardProps } from "./UserCard";
const meta: Meta<typeof UserCard> = { const meta: Meta<typeof Modal> = {
title: "CSUI/Modal", title: "Components/Modal",
component: UserCard, component: Modal,
}; };
export default meta; export default meta;
type Story = StoryObj<{ items: UserCardProps["user"][] }>; type Story = StoryObj<typeof Modal>;
const DefaultTemplate: Story = { const DefaultTemplate: Story = {
render: () => { render: () => {
const modalRef = useRef<HTMLDialogElement>(null); const [isModalOpen, setIsModalOpen] = useState(false);
return ( return (
<> <>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={() => modalRef.current?.showModal()} onClick={() => setIsModalOpen(true)}
> >
open open
</button> </button>
<Modal anchorRef={modalRef}> <Modal open={isModalOpen} onClose={() => setIsModalOpen(false)}>
<p>Modal content</p> <p>Modal content</p>
</Modal> </Modal>
</> </>
@ -34,42 +31,6 @@ const DefaultTemplate: Story = {
}, },
}; };
const ShowModalTemplate: Story = {
render: () => {
const modalRef = useRef<HTMLDialogElement>(null);
return (
<>
<button
type="button"
className="btn btn-primary"
onClick={() => modalRef.current?.showModal()}
>
open
</button>
<Modal anchorRef={modalRef} open>
<div className="flex justify-between">
<h1 className="text-xl font-bold">🔎 Find Bluesky Users</h1>
<div className="text-xl">34 / 160</div>
</div>
<div className="flex gap-1 items-center mt-3">
<p className="">Match type: </p>
<div className="badge badge-info">Same handle name</div>
<div className="badge badge-warning">Same display name</div>
<div className="badge badge-secondary">
Included handle name in description
</div>
</div>
</Modal>
</>
);
},
};
export const Default = { export const Default = {
...DefaultTemplate, ...DefaultTemplate,
}; };
export const ShowModal = {
...ShowModalTemplate,
};

View File

@ -1,14 +1,15 @@
import type React from "react"; import type React from "react";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
export type Props = { export type Props = {
children: React.ReactNode; children: React.ReactNode;
anchorRef: React.RefObject<HTMLDialogElement>; open: boolean;
open?: boolean;
onClose?: () => void; onClose?: () => void;
}; };
const Modal = ({ children, anchorRef, open = false, onClose }: Props) => { const Modal = ({ children, open = false, onClose }: Props) => {
const anchorRef = useRef<HTMLDialogElement>(null);
useEffect(() => { useEffect(() => {
if (anchorRef.current) { if (anchorRef.current) {
anchorRef.current.addEventListener("close", onClose); anchorRef.current.addEventListener("close", onClose);
@ -19,7 +20,7 @@ const Modal = ({ children, anchorRef, open = false, onClose }: Props) => {
anchorRef.current.removeEventListener("close", onClose); anchorRef.current.removeEventListener("close", onClose);
} }
}; };
}, [anchorRef, onClose]); }, [onClose]);
return ( return (
<> <>

View File

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { BSKY_USER_MATCH_TYPE } from "../constants"; import { ACTION_MODE, BSKY_USER_MATCH_TYPE } from "../constants";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
const meta = { const meta = {
@ -15,7 +15,7 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {
args: { args: {
detectedCount: 42, detectedCount: 40,
filterValue: { filterValue: {
[BSKY_USER_MATCH_TYPE.HANDLE]: true, [BSKY_USER_MATCH_TYPE.HANDLE]: true,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: false, [BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: false,
@ -25,6 +25,16 @@ export const Default: Story = {
onChangeFilter: (key) => { onChangeFilter: (key) => {
console.log(`Filter changed: ${key}`); console.log(`Filter changed: ${key}`);
}, },
matchTypeStats: {
[BSKY_USER_MATCH_TYPE.HANDLE]: 10,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: 10,
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: 10,
[BSKY_USER_MATCH_TYPE.FOLLOWING]: 10,
},
actionAll: async () => {
console.log("actionAll");
},
actionMode: ACTION_MODE.FOLLOW,
}, },
}; };
@ -40,5 +50,15 @@ export const NoDetections: Story = {
onChangeFilter: (key) => { onChangeFilter: (key) => {
console.log(`Filter changed: ${key}`); console.log(`Filter changed: ${key}`);
}, },
matchTypeStats: {
[BSKY_USER_MATCH_TYPE.HANDLE]: 0,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: 0,
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: 0,
[BSKY_USER_MATCH_TYPE.FOLLOWING]: 0,
},
actionAll: async () => {
console.log("actionAll");
},
actionMode: ACTION_MODE.FOLLOW,
}, },
}; };

View File

@ -14,7 +14,7 @@ type Props = {
onChangeFilter: (key: MatchType) => void; onChangeFilter: (key: MatchType) => void;
actionAll: () => Promise<void>; actionAll: () => Promise<void>;
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE]; actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
matchTypeStats: Record<MatchType, number>; matchTypeStats: Record<Exclude<MatchType, "none">, number>;
}; };
const Sidebar = ({ const Sidebar = ({
@ -179,10 +179,9 @@ const Sidebar = ({
className="w-full" className="w-full"
> >
<img <img
height={36}
src="https://storage.ko-fi.com/cdn/kofi1.png?v=6" src="https://storage.ko-fi.com/cdn/kofi1.png?v=6"
alt="Buy Me a Coffee at ko-fi.com" alt="Buy Me a Coffee at ko-fi.com"
className="w-[110px] h-auto m-auto" className="w-[120px] h-auto m-auto"
/> />
</a> </a>
<div className="divider" /> <div className="divider" />

View File

@ -1,15 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import Header from "./Header"; import SocialLinks from "./SocialLinks";
const meta = { const meta = {
title: "Header", title: "Components/SocialLinks",
component: Header, component: SocialLinks,
parameters: { } as Meta<typeof SocialLinks>;
layout: "fullscreen",
},
} satisfies Meta<typeof Header>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Default: Story = {}; export const Default: Story = {};

View File

@ -5,12 +5,12 @@ const SocialLinks = () => {
href="https://github.com/kawamataryo/sky-follower-bridge" href="https://github.com/kawamataryo/sky-follower-bridge"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8" className="bg-base-100 p-2 rounded-full hover:opacity-80"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="18"
height="24" height="18"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
> >
@ -21,12 +21,12 @@ const SocialLinks = () => {
href="https://bsky.app/profile/kawamataryo.bsky.social" href="https://bsky.app/profile/kawamataryo.bsky.social"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8" className="bg-base-100 p-2 rounded-full hover:opacity-80"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="18"
height="24" height="18"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
> >
@ -37,12 +37,12 @@ const SocialLinks = () => {
href="https://twitter.com/KawamataRyo" href="https://twitter.com/KawamataRyo"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8" className="bg-base-100 p-2 rounded-full hover:opacity-80"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="18"
height="24" height="18"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
> >

View File

@ -4,7 +4,7 @@ import { ACTION_MODE, BSKY_USER_MATCH_TYPE } from "../constants";
import UserCard, { type Props } from "./UserCard"; import UserCard, { type Props } from "./UserCard";
const meta: Meta<typeof UserCard> = { const meta: Meta<typeof UserCard> = {
title: "CSUI/UserCard", title: "Components/UserCard",
component: UserCard, component: UserCard,
}; };
export default meta; export default meta;
@ -27,13 +27,16 @@ const demoUser: Props["user"] = {
Twitter: twitter.com/KawamataRyo Twitter: twitter.com/KawamataRyo
GitHub: github.com/kawamataryo GitHub: github.com/kawamataryo
Zenn: zenn.dev/ryo_kawamata`, Zenn: zenn.dev/ryo_kawamata`,
avatar: avatar: "https://i.pravatar.cc/150?u=123",
"https://cdn.bsky.app/img/avatar/plain/did:plc:hcp53er6pefwijpdceo5x4bp/bafkreibm42fe6ionzntt2oryzv2coulgiwh5ejman4vf53bpkdtotszpp4@jpeg",
matchType: BSKY_USER_MATCH_TYPE.HANDLE, matchType: BSKY_USER_MATCH_TYPE.HANDLE,
isFollowing: false, isFollowing: false,
followingUri: "", followingUri: "",
isBlocking: false, isBlocking: false,
blockingUri: "", blockingUri: "",
originalAvatar: "https://i.pravatar.cc/150?u=123",
originalHandle: "kawamataryo",
originalDisplayName: "KawamataRyo",
originalProfileLink: "https://x.com/kawamataryo",
}; };
const mockAction: Props["clickAction"] = async () => { const mockAction: Props["clickAction"] = async () => {

View File

@ -4,6 +4,74 @@ import type { BskyUser } from "~types";
import { ACTION_MODE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants"; import { ACTION_MODE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
import AvatarFallbackSvg from "./Icons/AvatarFallbackSvg"; import AvatarFallbackSvg from "./Icons/AvatarFallbackSvg";
type UserProfileProps = {
avatar: string;
url: string;
};
const UserProfile = ({ avatar, url }: UserProfileProps) => (
<div className="avatar">
<div className="w-10 h-10 rounded-full border border-white">
<a href={url} target="_blank" rel="noreferrer">
{avatar ? <img src={avatar} alt="" /> : <AvatarFallbackSvg />}
</a>
</div>
</div>
);
type UserInfoProps = {
handle: string;
displayName: string;
url: string;
};
const UserInfo = ({ handle, displayName, url }: UserInfoProps) => (
<div>
<h2 className="card-title break-all text-[1.1rem] font-bold">
<a href={url} target="_blank" rel="noreferrer">
{displayName}
</a>
</h2>
<p className="w-fit break-all text-gray-500 dark:text-gray-400 text-sm">
<a href={url} target="_blank" rel="noreferrer" className="break-all">
@{handle}
</a>
</p>
</div>
);
type ActionButtonProps = {
loading: boolean;
actionBtnLabelAndClass: { label: string; class: string };
handleActionButtonClick: () => void;
setIsBtnHovered: (value: boolean) => void;
setIsJustClicked: (value: boolean) => void;
};
const ActionButton = ({
loading,
actionBtnLabelAndClass,
handleActionButtonClick,
setIsBtnHovered,
setIsJustClicked,
}: ActionButtonProps) => (
<button
type="button"
className={`btn btn-sm rounded-3xl ${
loading ? "" : actionBtnLabelAndClass.class
}`}
onClick={handleActionButtonClick}
onMouseEnter={() => setIsBtnHovered(true)}
onMouseLeave={() => {
setIsBtnHovered(false);
setIsJustClicked(false);
}}
disabled={loading}
>
{loading ? "Processing..." : actionBtnLabelAndClass.label}
</button>
);
export type Props = { export type Props = {
user: BskyUser; user: BskyUser;
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE]; actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
@ -82,67 +150,64 @@ const UserCard = ({ user, actionMode, clickAction }: Props) => {
}; };
return ( return (
<div className="bg-base-100 w-full relative"> <div className="bg-base-100 w-full relative grid grid-cols-[22%_1fr] gap-5">
<div <div className="flex flex-row gap-2 bg-slate-100 dark:bg-slate-800 justify-between pr-2">
className={`border-l-8 border-${ <div
MATCH_TYPE_LABEL_AND_COLOR[user.matchType].color className={`border-l-8 border-${
} card-body relative py-3 px-4 rounded-sm grid grid-cols-[70px_1fr]`} MATCH_TYPE_LABEL_AND_COLOR[user.matchType].color
> } relative py-3 pl-4 pr-1 grid grid-cols-[50px_1fr]`}
<div> >
<div className="avatar"> <UserProfile
<div className="w-14 rounded-full border border-white "> avatar={user.originalAvatar}
<a url={user.originalProfileLink}
href={`https://bsky.app/profile/${user.handle}`} />
target="_blank" <div className="flex flex-col gap-2">
rel="noreferrer" <div className="flex justify-between items-center gap-2">
> <UserInfo
{user.avatar ? ( handle={user.originalHandle}
<img src={user.avatar} alt="" /> displayName={user.originalDisplayName}
) : ( url={user.originalProfileLink}
<AvatarFallbackSvg /> />
)}
</a>
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-7 w-7"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</div>
</div>
<div className="relative py-3 pl-0 pr-2 grid grid-cols-[50px_1fr]">
<UserProfile
avatar={user.avatar}
url={`https://bsky.app/profile/${user.handle}`}
/>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2"> <div className="flex justify-between items-center gap-2">
<div> <UserInfo
<h2 className="card-title break-all"> handle={user.handle}
<a displayName={user.displayName}
href={`https://bsky.app/profile/${user.handle}`} url={`https://bsky.app/profile/${user.handle}`}
target="_blank" />
rel="noreferrer"
>
{user.displayName}
</a>
</h2>
<p className="whitespace-nowrap w-fit break-all text-gray-500 dark:text-gray-400 text-sm">
<a
href={`https://bsky.app/profile/${user.handle}`}
target="_blank"
rel="noreferrer"
>
@{user.handle}
</a>
</p>
</div>
<div className="card-actions"> <div className="card-actions">
<button <ActionButton
type="button" loading={loading}
className={`btn btn-sm rounded-3xl ${ actionBtnLabelAndClass={actionBtnLabelAndClass}
loading ? "" : actionBtnLabelAndClass.class handleActionButtonClick={handleActionButtonClick}
}`} setIsBtnHovered={setIsBtnHovered}
onClick={handleActionButtonClick} setIsJustClicked={setIsJustClicked}
onMouseEnter={() => setIsBtnHovered(true)} />
onMouseLeave={() => {
setIsBtnHovered(false);
setIsJustClicked(false);
}}
disabled={loading}
>
{loading ? "Processing..." : actionBtnLabelAndClass.label}
</button>
</div> </div>
</div> </div>
<p className="text-sm break-all">{user.description}</p> <p className="text-sm break-all">{user.description}</p>

View File

@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import UserCardSkeleton from "./UserCardSkeleton"; import UserCardSkeleton from "./UserCardSkeleton";
const meta: Meta<typeof UserCardSkeleton> = { const meta: Meta<typeof UserCardSkeleton> = {
title: "CSUI/UserCardSkeleton", title: "Components/UserCardSkeleton",
component: UserCardSkeleton, component: UserCardSkeleton,
}; };
export default meta; export default meta;

View File

@ -2,11 +2,28 @@ import type { AtpSessionData } from "@atproto/api";
import { Storage } from "@plasmohq/storage"; import { Storage } from "@plasmohq/storage";
import { useStorage } from "@plasmohq/storage/hook"; import { useStorage } from "@plasmohq/storage/hook";
import React from "react"; import React from "react";
import { P, match } from "ts-pattern";
import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient"; import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
import { type MESSAGE_NAMES, STORAGE_KEYS } from "~lib/constants"; import { MESSAGE_NAMES, STORAGE_KEYS } from "~lib/constants";
import { searchBskyUser } from "~lib/searchBskyUsers"; import { searchBskyUser } from "~lib/searchBskyUsers";
import { XService } from "~lib/services/x"; import type { AbstractService } from "~lib/services/abstractService";
import type { BskyUser, CrawledUserInfo } from "~types"; import { XService } from "~lib/services/xService";
import type { BskyUser, CrawledUserInfo, MessageName } from "~types";
const getService = (messageName: string): AbstractService => {
return match(messageName)
.with(
P.when((name) =>
[
MESSAGE_NAMES.SEARCH_BSKY_USER_ON_FOLLOW_PAGE,
MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE,
MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE,
].includes(name as MessageName),
),
() => new XService(messageName),
)
.otherwise(() => new XService(messageName));
};
const scrapeListNameFromPage = (): string => { const scrapeListNameFromPage = (): string => {
const listNameElement = document.querySelector( const listNameElement = document.querySelector(
@ -39,11 +56,6 @@ export const useRetrieveBskyUsers = () => {
}>(null); }>(null);
const [listName, setListName] = React.useState<string>(""); const [listName, setListName] = React.useState<string>("");
const modalRef = React.useRef<HTMLDialogElement>(null);
const showModal = () => {
modalRef.current?.showModal();
};
const retrieveBskyUsers = React.useCallback( const retrieveBskyUsers = React.useCallback(
async (usersData: CrawledUserInfo[]) => { async (usersData: CrawledUserInfo[]) => {
for (const userData of usersData) { for (const userData of usersData) {
@ -69,6 +81,10 @@ export const useRetrieveBskyUsers = () => {
followingUri: searchResult.bskyProfile.viewer?.following, followingUri: searchResult.bskyProfile.viewer?.following,
isBlocking: !!searchResult.bskyProfile.viewer?.blocking, isBlocking: !!searchResult.bskyProfile.viewer?.blocking,
blockingUri: searchResult.bskyProfile.viewer?.blocking, blockingUri: searchResult.bskyProfile.viewer?.blocking,
originalAvatar: userData.originalAvatar,
originalHandle: userData.accountName,
originalDisplayName: userData.displayName,
originalProfileLink: userData.originalProfileLink,
}, },
]; ];
}); });
@ -86,7 +102,7 @@ export const useRetrieveBskyUsers = () => {
let index = 0; let index = 0;
const xService = new XService(messageName); const service = getService(messageName);
// loop until we get to the bottom // loop until we get to the bottom
while (!isBottomReached) { while (!isBottomReached) {
@ -94,10 +110,10 @@ export const useRetrieveBskyUsers = () => {
break; break;
} }
const data = xService.getCrawledUsers(); const data = service.getCrawledUsers();
await retrieveBskyUsers(data); await retrieveBskyUsers(data);
const isEnd = await xService.performScrollAndCheckEnd(); const isEnd = await service.performScrollAndCheckEnd();
if (isEnd) { if (isEnd) {
setIsBottomReached(true); setIsBottomReached(true);
@ -155,7 +171,6 @@ export const useRetrieveBskyUsers = () => {
}); });
setLoading(true); setLoading(true);
await setUsers([]); await setUsers([]);
showModal();
}, []); }, []);
const restart = React.useCallback(() => { const restart = React.useCallback(() => {
@ -177,8 +192,6 @@ export const useRetrieveBskyUsers = () => {
); );
return { return {
modalRef,
showModal,
initialize, initialize,
users, users,
listName, listName,

View File

@ -0,0 +1,36 @@
import { MESSAGE_NAME_TO_QUERY_PARAM_MAP } from "~lib/constants";
import type { CrawledUserInfo, MessageName } from "~types";
export abstract class AbstractService {
messageName: MessageName;
crawledUsers: Set<string>;
constructor(messageName: string) {
this.messageName = messageName as MessageName;
this.crawledUsers = new Set();
}
abstract extractUserData(userCell: Element): CrawledUserInfo;
getCrawledUsers(): CrawledUserInfo[] {
const userCells = Array.from(
document.querySelectorAll(
MESSAGE_NAME_TO_QUERY_PARAM_MAP[this.messageName],
),
);
const users = Array.from(userCells)
.map((userCell) => this.extractUserData(userCell))
.filter((user) => {
const isNewUser = !this.crawledUsers.has(user.accountName);
if (isNewUser) {
this.crawledUsers.add(user.accountName);
}
return isNewUser;
});
return users;
}
abstract performScrollAndCheckEnd(): Promise<boolean>;
}

View File

@ -1,20 +1,11 @@
import { MESSAGE_NAMES } from "~lib/constants"; import { MESSAGE_NAMES } from "~lib/constants";
import { BSKY_DOMAIN, MESSAGE_NAME_TO_QUERY_PARAM_MAP } from "~lib/constants"; import { BSKY_DOMAIN, MESSAGE_NAME_TO_QUERY_PARAM_MAP } from "~lib/constants";
import { wait } from "~lib/utils"; import { wait } from "~lib/utils";
import type { CrawledUserInfo, MessageName } from "~types"; import type { CrawledUserInfo } from "~types";
import { AbstractService } from "./abstractService";
export class XService { export class XService extends AbstractService {
// 対象のdomを取得する処理 extractUserData(userCell: Element): CrawledUserInfo {
messageName: MessageName;
crawledUsers: Set<string>;
constructor(messageName: string) {
// TODO: add type check
this.messageName = messageName as MessageName;
this.crawledUsers = new Set();
}
private extractUserData(userCell: Element): CrawledUserInfo {
const anchors = Array.from(userCell.querySelectorAll("a")); const anchors = Array.from(userCell.querySelectorAll("a"));
const [avatarEl, displayNameEl] = anchors; const [avatarEl, displayNameEl] = anchors;
const accountName = avatarEl?.getAttribute("href")?.replace("/", ""); const accountName = avatarEl?.getAttribute("href")?.replace("/", "");
@ -29,6 +20,10 @@ export class XService {
?.match(/bsky\.app\/profile\/([^/\s]+)…?/)?.[1] ?.match(/bsky\.app\/profile\/([^/\s]+)…?/)?.[1]
?.replace("…", "") ?? ?.replace("…", "") ??
""; "";
const originalAvatar = userCell
.querySelector('[data-testid^="UserAvatar-Container"]')
?.querySelector("img")
?.getAttribute("src");
return { return {
accountName, accountName,
@ -36,28 +31,11 @@ export class XService {
accountNameRemoveUnderscore, accountNameRemoveUnderscore,
accountNameReplaceUnderscore, accountNameReplaceUnderscore,
bskyHandle, bskyHandle,
originalAvatar,
originalProfileLink: `https://x.com/${accountName}`,
}; };
} }
getCrawledUsers(): CrawledUserInfo[] {
const userCells = Array.from(
document.querySelectorAll(
MESSAGE_NAME_TO_QUERY_PARAM_MAP[this.messageName],
),
);
const users = userCells
.map((userCell) => this.extractUserData(userCell))
.filter((user) => !this.crawledUsers.has(user.accountName));
this.crawledUsers = new Set([
...this.crawledUsers,
...users.map((user) => user.accountName),
]);
return users;
}
async performScrollAndCheckEnd(): Promise<boolean> { async performScrollAndCheckEnd(): Promise<boolean> {
const isListMembersPage = const isListMembersPage =
this.messageName === MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE; this.messageName === MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE;

View File

@ -1,7 +1,10 @@
import UserCard from "~lib/components/UserCard"; import UserCard from "~lib/components/UserCard";
import { useBskyUserManager } from "~lib/hooks/useBskyUserManager"; import { useBskyUserManager } from "~lib/hooks/useBskyUserManager";
import "./style.css"; import "./style.css";
import { ToastContainer, toast } from "react-toastify";
import useConfirm from "~lib/components/ConfirmDialog";
import Sidebar from "~lib/components/Sidebar"; import Sidebar from "~lib/components/Sidebar";
import "react-toastify/dist/ReactToastify.css";
const Option = () => { const Option = () => {
const { const {
@ -16,17 +19,23 @@ const Option = () => {
matchTypeStats, matchTypeStats,
} = useBskyUserManager(); } = useBskyUserManager();
const { confirm, ConfirmationDialog } = useConfirm({
title: "Proceed with Execution?",
message:
"User detection is not perfect and may include false positives. Do you still want to proceed?",
cancelText: "Cancel",
okText: "OK",
});
const handleActionAll = async () => { const handleActionAll = async () => {
if ( if (!(await confirm())) {
!window.confirm(
"User detection is not perfect and may include false positives. Do you still want to proceed?",
)
) {
return; return;
} }
await actionAll(); const result = await actionAll();
toast.success(`Followed ${result} users`);
}; };
return ( return (
<> <>
<div className="flex h-screen"> <div className="flex h-screen">
@ -40,22 +49,30 @@ const Option = () => {
matchTypeStats={matchTypeStats} matchTypeStats={matchTypeStats}
/> />
</div> </div>
<div className="flex-1 ml-80 p-6 overflow-y-auto"> <div className="flex-1 ml-80 p-6 pt-0 overflow-y-auto">
<div className="flex flex-col gap-6"> <div className="grid grid-cols-[22%_1fr] sticky top-0 z-10 bg-base-100 border-b-[1px] border-gray-500">
<div className="flex flex-col gap-4"> <h2 className="text-lg font-bold text-center py-2">Source</h2>
<div className="divide-y divide-gray-500"> <h2 className="text-lg font-bold text-center py-2">Detected</h2>
{filteredUsers.map((user) => ( </div>
<UserCard <div className="flex flex-col gap-4">
key={user.handle} <div className="divide-y divide-gray-500">
user={user} {filteredUsers.map((user) => (
clickAction={handleClickAction} <UserCard
actionMode={actionMode} key={user.handle}
/> user={user}
))} clickAction={handleClickAction}
</div> actionMode={actionMode}
/>
))}
</div> </div>
</div> </div>
</div> </div>
<ToastContainer
position="top-right"
autoClose={5000}
className="text-sm"
/>
<ConfirmationDialog />
</div> </div>
</> </>
); );

View File

@ -1,5 +1,6 @@
import { type FormEvent, useCallback, useEffect, useState } from "react"; import { type FormEvent, useCallback, useEffect, useState } from "react";
import { P, match } from "ts-pattern"; import { P, match } from "ts-pattern";
import packageJson from "../package.json";
import "./style.css"; import "./style.css";
@ -213,7 +214,8 @@ function IndexPopup() {
<path d="M40 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8ZM8 44a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z" /> <path d="M40 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8ZM8 44a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z" />
</g> </g>
</svg> </svg>
Sky Follower Bridge Sky Follower Bridge{" "}
<span className="text-sm self-end">v{packageJson.version}</span>
</h1> </h1>
<form onSubmit={searchBskyUser} className="mt-5"> <form onSubmit={searchBskyUser} className="mt-5">
<label className="w-full block" htmlFor="identifier"> <label className="w-full block" htmlFor="identifier">

View File

@ -1,3 +1,7 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.modal {
background-color: rgba(0, 0, 0, 0.5);
}

View File

@ -1,3 +1,7 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.modal {
background-color: rgba(0, 0, 0, 0.5);
}

View File

@ -16,6 +16,10 @@ export type BskyUser = {
followingUri: string | null; followingUri: string | null;
isBlocking: boolean; isBlocking: boolean;
blockingUri: string | null; blockingUri: string | null;
originalAvatar: string;
originalHandle: string;
originalDisplayName: string;
originalProfileLink: string;
}; };
export type MatchTypeFilterValue = { export type MatchTypeFilterValue = {
@ -31,4 +35,6 @@ export type CrawledUserInfo = {
accountNameRemoveUnderscore: string; accountNameRemoveUnderscore: string;
accountNameReplaceUnderscore: string; accountNameReplaceUnderscore: string;
bskyHandle: string; bskyHandle: string;
originalAvatar: string;
originalProfileLink: string;
}; };

6
vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});