├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── README.md
├── components.json
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── public
├── favicon512.png
├── icon.png
└── manifest.json
├── src
├── App.tsx
├── background.tsx
├── components
│ ├── DateRangePicker.tsx
│ ├── FileFoundCard.tsx
│ ├── Post.tsx
│ ├── ThemeToggle.tsx
│ ├── UploadedPost.tsx
│ ├── pages
│ │ ├── Home
│ │ │ ├── home.tsx
│ │ │ ├── render1
│ │ │ │ ├── fileUpload.tsx
│ │ │ │ ├── render1.tsx
│ │ │ │ └── sync.tsx
│ │ │ ├── render2
│ │ │ │ └── render2.tsx
│ │ │ ├── render3.tsx
│ │ │ └── steps.tsx
│ │ └── login
│ │ │ ├── Login.tsx
│ │ │ └── loginForm.tsx
│ ├── ui
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── popover.tsx
│ │ └── switch.tsx
│ └── utils.tsx
├── hooks
│ ├── LogInContext.tsx
│ ├── useAnalysis.ts
│ ├── useFileUpload.ts
│ └── useUpload.ts
├── index.css
├── lib
│ ├── auth
│ │ ├── login.ts
│ │ └── validateUser.ts
│ ├── constant.ts
│ ├── parse
│ │ ├── __test__
│ │ │ ├── minify.test.ts
│ │ │ └── parse.test.ts
│ │ ├── analyze.tsx
│ │ ├── minify.ts
│ │ ├── parse.tsx
│ │ └── processTweets.ts
│ ├── rateLimit
│ │ └── RateLimitedAgent.ts
│ └── utils.ts
├── main.tsx
├── popup.css
└── types
│ ├── login.type.ts
│ ├── render.d.ts
│ └── tweets.d.ts
├── tailwind.config.cjs
├── tsconfig.json
└── vite.config.js
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release Chrome Extension
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build-and-release:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v4
15 |
16 | - name: Set up Node.js
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: '20'
20 | cache: 'npm'
21 |
22 | - name: Install dependencies
23 | run: npm ci
24 |
25 | - name: Extract Extension Version
26 | id: package-version
27 | run: |
28 | VERSION=$(grep '"version":' public/manifest.json | cut -d'"' -f4)
29 | echo "version=$VERSION" >> $GITHUB_OUTPUT
30 |
31 | - name: Build extension
32 | run: npm run build
33 |
34 | - name: Create ZIP package
35 | run: |
36 | cd dist
37 | zip -r porto-extension-v${{ steps.package-version.outputs.version }}.zip .
38 |
39 | - name: Create GitHub Release
40 | uses: softprops/action-gh-release@v2
41 | with:
42 | files: dist/porto-extension-v${{ steps.package-version.outputs.version }}.zip
43 | tag_name: v${{ steps.package-version.outputs.version }}
44 | env:
45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 | dist.zip
4 | .DS_Store
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [Porto](https://chromewebstore.google.com/detail/porto-import-your-tweets/ckilhjdflnaakopknngigiggfpnjaaop) is an Open Source [donation](https://ko-fi.com/nesterdev) based tool that lets you import your Tweets (X posts) into Bluesky in few clicks through a Chrome Extension. It's build by [Ankit Bhandari](https://bsky.app/profile/anku.bsky.social), [Yogesh Aryal](https://bsky.app/profile/aryog.bsky.social) & [Adarsh Kunwar](https://bsky.app/profile/helloalex.bsky.social).
2 |
3 | ---
4 |
5 | How to Use Porto? 🤔
6 | Step 1: Download your archive data from Twitter/X.
7 | Step 2: Extract the zip file into one standalone folder of archived data.
8 | Step 3: Upload the extracted root folder into the extension.
9 | Step 4: Choose the date range & customize what you want to import.
10 | Step 5: Proceed, minimize the window, and let the magic happen!
11 |
12 | ---
13 |
14 | Porto has been featured on [Lifehacker.com](https://lifehacker.com/tech/use-porto-to-upload-all-your-old-tweets-to-bluesky), [Popular Science](https://www.popsci.com/diy/how-to-leave-twitter-for-bluesky/), [Mashable](https://mashable.com/article/bluesky-importing-tweets-x-posts) & more. Lifehacker has best walk through guide on how to use Porto, so we recommend reading their article.
15 |
16 | ---
17 | Feel free to create any PR & issues that needs to be fixed to this tool. Also, thank you to [Divyaswor Makai](https://bsky.app/profile/divyaswor.bsky.social) for valuable contribution to Porto!
18 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "gray",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import globals from "globals";
3 | import reactHooks from "eslint-plugin-react-hooks";
4 | import reactRefresh from "eslint-plugin-react-refresh";
5 | import tseslint from "typescript-eslint";
6 |
7 | export default tseslint.config(
8 | { ignores: ["dist"] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ["**/*.{ts,tsx}"],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | "react-hooks": reactHooks,
18 | "react-refresh": reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | "react-refresh/only-export-components": [
23 | "warn",
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | );
29 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Porto - Import Tweets to Bluesky
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "porto",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "watch": "vite build --watch",
10 | "preview": "vite preview",
11 | "format": "prettier --write .",
12 | "lint": "eslint . --fix",
13 | "test": "vitest"
14 | },
15 | "devDependencies": {
16 | "@eslint/js": "^9.15.0",
17 | "@types/chrome": "^0.0.278",
18 | "@types/follow-redirects": "^1.14.4",
19 | "@types/he": "^1.2.3",
20 | "@types/node": "^22.7.7",
21 | "@types/react": "^18.3.11",
22 | "@types/react-dom": "^18.3.1",
23 | "@types/urijs": "^1.19.25",
24 | "@vitejs/plugin-react": "^4.3.2",
25 | "autoprefixer": "^10.4.20",
26 | "eslint": "^9.15.0",
27 | "eslint-plugin-react": "^7.37.2",
28 | "eslint-plugin-react-hooks": "^5.0.0",
29 | "eslint-plugin-react-refresh": "^0.4.14",
30 | "globals": "^15.12.0",
31 | "postcss": "^8.4.47",
32 | "tailwindcss": "^3.4.14",
33 | "typescript": "^5.6.3",
34 | "typescript-eslint": "^8.15.0",
35 | "vite": "^5.4.9",
36 | "vite-plugin-chrome-extension": "^0.0.7",
37 | "vite-plugin-html": "^3.2.2",
38 | "vitest": "^2.1.5"
39 | },
40 | "dependencies": {
41 | "@atproto/api": "^0.13.12",
42 | "@radix-ui/react-dialog": "^1.1.14",
43 | "@radix-ui/react-icons": "^1.3.0",
44 | "@radix-ui/react-label": "^2.1.0",
45 | "@radix-ui/react-popover": "^1.1.2",
46 | "@radix-ui/react-slot": "^1.2.3",
47 | "@radix-ui/react-switch": "^1.2.5",
48 | "browser-image-compression": "^2.0.2",
49 | "class-variance-authority": "^0.7.0",
50 | "clsx": "^2.1.1",
51 | "dotenv": "^16.4.5",
52 | "follow-redirects": "^1.15.9",
53 | "he": "^1.2.0",
54 | "lucide-react": "^0.453.0",
55 | "node-html-parser": "^6.1.13",
56 | "prettier": "^3.3.3",
57 | "process": "^0.11.10",
58 | "react": "^18.3.1",
59 | "react-datepicker": "^7.5.0",
60 | "react-day-picker": "^9.7.0",
61 | "react-dom": "^18.3.1",
62 | "react-icons": "^5.3.0",
63 | "react-router-dom": "^6.27.0",
64 | "tailwind-merge": "^2.5.4",
65 | "tailwindcss-animate": "^1.0.7",
66 | "urijs": "^1.19.11"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/favicon512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nester-xyz/Porto/4cf47b14c5f7fc77e5c6225cb6945c399df42823/public/favicon512.png
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nester-xyz/Porto/4cf47b14c5f7fc77e5c6225cb6945c399df42823/public/icon.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Porto - Import Tweets to Bluesky",
4 | "version": "1.2.7",
5 | "description": "Easily import Tweets to Bluesky with few clicks",
6 | "action": {
7 | "default_icon": "icon.png",
8 | "default_title": "Porto - Import Tweets to Bluesky"
9 | },
10 | "background": {
11 | "service_worker": "background.js"
12 | },
13 | "permissions": ["windows"],
14 | "icons": {
15 | "128": "icon.png"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Login from "./components/pages/login/Login";
2 | import { LogInProvider } from "./hooks/LogInContext";
3 | import ThemeToggle from "./components/ThemeToggle";
4 |
5 | const App: React.FC = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 | );
14 | };
15 |
16 | export default App;
17 |
--------------------------------------------------------------------------------
/src/background.tsx:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | let windowId: number | null = null;
4 |
5 | chrome.action.onClicked.addListener(async () => {
6 | if (windowId !== null) {
7 | const window = await chrome.windows.get(windowId);
8 | if (window) {
9 | chrome.windows.update(windowId, { focused: true });
10 | return;
11 | }
12 | }
13 |
14 | const window = await chrome.windows.create({
15 | url: "index.html",
16 | type: "popup",
17 | width: 500,
18 | height: 800,
19 | focused: true,
20 | });
21 |
22 | // Store the window ID
23 | windowId = window.id || null;
24 |
25 | // Listen for window close
26 | chrome.windows.onRemoved.addListener((removedWindowId) => {
27 | if (removedWindowId === windowId) {
28 | windowId = null;
29 | }
30 | });
31 | });
32 |
33 | // Handle messages
34 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
35 | if (message.action === "sayHello") {
36 | sendResponse({ response: "Hello from background!" });
37 | }
38 | });
39 |
--------------------------------------------------------------------------------
/src/components/DateRangePicker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { CalendarIcon } from "lucide-react";
5 |
6 | import { Button } from "@/components/ui/button";
7 | import { Calendar } from "@/components/ui/calendar";
8 | import { Input } from "@/components/ui/input";
9 | import { Label } from "@/components/ui/label";
10 | import {
11 | Popover,
12 | PopoverContent,
13 | PopoverTrigger,
14 | } from "@/components/ui/popover";
15 | import { TDateRange } from "@/types/render";
16 |
17 | function formatDate(date: Date | undefined) {
18 | if (!date) {
19 | return "";
20 | }
21 |
22 | return date.toLocaleDateString("en-US", {
23 | day: "2-digit",
24 | month: "long",
25 | year: "numeric",
26 | });
27 | }
28 |
29 | function isValidDate(date: Date | undefined) {
30 | if (!date) {
31 | return false;
32 | }
33 | return !isNaN(date.getTime());
34 | }
35 |
36 | function SingleDatePicker({
37 | label,
38 | date: initialDate,
39 | onDateChange,
40 | }: {
41 | label: string;
42 | date: Date;
43 | onDateChange: (date: Date) => void;
44 | }) {
45 | const [open, setOpen] = React.useState(false);
46 | const [date, setDate] = React.useState(initialDate);
47 | const [month, setMonth] = React.useState(initialDate);
48 | const [value, setValue] = React.useState(formatDate(initialDate));
49 |
50 | React.useEffect(() => {
51 | setDate(initialDate);
52 | setMonth(initialDate);
53 | setValue(formatDate(initialDate));
54 | }, [initialDate]);
55 |
56 | return (
57 |
58 |
61 |
62 |
{
68 | const date = new Date(e.target.value);
69 | setValue(e.target.value);
70 | if (isValidDate(date)) {
71 | setDate(date);
72 | setMonth(date);
73 | onDateChange(date);
74 | }
75 | }}
76 | onKeyDown={(e) => {
77 | if (e.key === "ArrowDown") {
78 | e.preventDefault();
79 | setOpen(true);
80 | }
81 | }}
82 | />
83 |
84 |
85 |
93 |
94 |
100 | {
107 | if (!date) return;
108 | setDate(date);
109 | setValue(formatDate(date));
110 | onDateChange(date);
111 | setOpen(false);
112 | }}
113 | />
114 |
115 |
116 |
117 |
118 | );
119 | }
120 |
121 | export function Calendar28({
122 | onDateChange,
123 | initialDate,
124 | }: {
125 | onDateChange: (range: TDateRange) => void;
126 | initialDate: TDateRange;
127 | }) {
128 | const handleStartDateChange = (date: Date) => {
129 | onDateChange({ ...initialDate, min_date: date });
130 | };
131 |
132 | const handleEndDateChange = (date: Date) => {
133 | onDateChange({ ...initialDate, max_date: date });
134 | };
135 |
136 | return (
137 |
138 |
143 |
148 |
149 | );
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/FileFoundCard.tsx:
--------------------------------------------------------------------------------
1 | const FileFoundCard = ({
2 | cardName,
3 | found,
4 | }: {
5 | cardName: string;
6 | found: boolean;
7 | }) => {
8 | return (
9 |
16 |
17 |
{cardName}
18 |
19 | {found ? "was successfully loaded" : "could not be found"}
20 |
21 |
22 |
25 | {found ? "✔" : "✖"}
26 |
27 |
28 | );
29 | };
30 | export default FileFoundCard;
31 |
--------------------------------------------------------------------------------
/src/components/Post.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { Upload } from "lucide-react";
3 |
4 | export default function Post() {
5 | const [files, setFiles] = React.useState(null);
6 | useEffect(() => {}, [files]);
7 | return (
8 |
9 |
10 |
11 | Port Twitter posts to Bluesky
12 |
13 |
14 |
34 |
35 |
36 | Choose the folder containing your Twitter posts to import them into
37 | Bluesky.
38 |
39 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Switch } from "./ui/switch";
3 |
4 | const ThemeToggle = () => {
5 | const [enabled, setEnabled] = useState(false);
6 |
7 | useEffect(() => {
8 | const stored = localStorage.getItem("theme");
9 | const prefersDark = window.matchMedia(
10 | "(prefers-color-scheme: dark)"
11 | ).matches;
12 | const isDark = stored === "dark" || (!stored && prefersDark);
13 | setEnabled(isDark);
14 | if (isDark) {
15 | document.documentElement.classList.add("dark");
16 | } else {
17 | document.documentElement.classList.remove("dark");
18 | }
19 | }, []);
20 |
21 | const toggle = () => {
22 | const newTheme = enabled ? "light" : "dark";
23 | setEnabled(!enabled);
24 | if (newTheme === "dark") {
25 | document.documentElement.classList.add("dark");
26 | } else {
27 | document.documentElement.classList.remove("dark");
28 | }
29 | localStorage.setItem("theme", newTheme);
30 | };
31 |
32 | return (
33 |
34 | Dark Mode
35 |
36 |
37 | );
38 | };
39 |
40 | export default ThemeToggle;
41 |
--------------------------------------------------------------------------------
/src/components/UploadedPost.tsx:
--------------------------------------------------------------------------------
1 | const UploadedPost = ({
2 | postName,
3 | link,
4 | }: {
5 | postName: string;
6 | link: string;
7 | }) => {
8 | return (
9 |
24 | );
25 | };
26 |
27 | export default UploadedPost;
28 |
--------------------------------------------------------------------------------
/src/components/pages/Home/home.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { initialShareableData } from "@/lib/constant";
3 | import RenderStep1 from "./render1/render1";
4 | import { shareableData } from "@/types/render";
5 | import RenderStep2 from "./render2/render2";
6 | import RenderStep3 from "./render3";
7 | import Steps from "./steps";
8 |
9 | const Home = () => {
10 | const [currentStep, setCurrentStep] = useState(1);
11 | const [shareableData, setShareableData] =
12 | useState(initialShareableData);
13 |
14 | const updateShareableData = (data: shareableData) => {
15 | setShareableData(data);
16 | };
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 | {currentStep === 1 ? (
26 |
30 | ) : currentStep === 2 ? (
31 |
36 | ) : (
37 |
41 | )}
42 |
43 |
44 | );
45 | };
46 |
47 | export default Home;
48 |
--------------------------------------------------------------------------------
/src/components/pages/Home/render1/fileUpload.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Upload,
3 | X,
4 | Folder as FolderIcon,
5 | CheckCircle,
6 | XCircle,
7 | } from "lucide-react";
8 | import React, { useState, useCallback, useEffect } from "react";
9 |
10 | type FileUploadProps = {
11 | onFilesChange: (files: File[]) => void;
12 | onTargetFileFound: (found: boolean) => void;
13 | targetFileName: string;
14 | };
15 |
16 | const FileUpload: React.FC = ({
17 | onFilesChange,
18 | onTargetFileFound,
19 | targetFileName,
20 | }) => {
21 | const [files, setFiles] = useState([]);
22 | const [isDragging, setIsDragging] = useState(false);
23 | const [folderName, setFolderName] = useState("");
24 | const [targetFileFound, setTargetFileFound] = useState(null);
25 |
26 | useEffect(() => {
27 | if (files.length > 0) {
28 | const found = files.some((file) => file.name === targetFileName);
29 | setTargetFileFound(found);
30 | onTargetFileFound(found);
31 | } else {
32 | setTargetFileFound(null);
33 | onTargetFileFound(false);
34 | }
35 | }, [files, targetFileName, onTargetFileFound]);
36 |
37 | const handleFiles = useCallback(
38 | (newFiles: FileList | null) => {
39 | if (newFiles && newFiles.length > 0) {
40 | const newFilesArray = Array.from(newFiles);
41 | const firstFile = newFilesArray[0];
42 |
43 | if ((firstFile as any).webkitRelativePath) {
44 | setFolderName((firstFile as any).webkitRelativePath.split("/")[0]);
45 | } else if (newFilesArray.length === 1) {
46 | setFolderName(newFilesArray[0].name);
47 | } else {
48 | setFolderName(`${newFilesArray.length} files selected`);
49 | }
50 |
51 | setFiles(newFilesArray);
52 | onFilesChange(newFilesArray);
53 | }
54 | },
55 | [onFilesChange]
56 | );
57 |
58 | const clearFiles = useCallback(() => {
59 | setFiles([]);
60 | setFolderName("");
61 | onFilesChange([]);
62 | }, [onFilesChange]);
63 |
64 | const handleDragEnter = (e: React.DragEvent) => {
65 | e.preventDefault();
66 | e.stopPropagation();
67 | setIsDragging(true);
68 | };
69 |
70 | const handleDragLeave = (e: React.DragEvent) => {
71 | e.preventDefault();
72 | e.stopPropagation();
73 | setIsDragging(false);
74 | };
75 |
76 | const handleDragOver = (e: React.DragEvent) => {
77 | e.preventDefault();
78 | e.stopPropagation();
79 | };
80 |
81 | const handleDrop = (e: React.DragEvent) => {
82 | e.preventDefault();
83 | e.stopPropagation();
84 | setIsDragging(false);
85 | handleFiles(e.dataTransfer.files);
86 | };
87 |
88 | return (
89 |
90 |
0 ? "py-6" : "py-10"}
97 | ${
98 | isDragging
99 | ? "border-blue-500 bg-blue-50 dark:bg-gray-700"
100 | : files.length > 0
101 | ? "border-blue-500 bg-blue-50 dark:bg-gray-800"
102 | : "border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500"
103 | }
104 | `}
105 | >
106 | {files.length === 0 ? (
107 |
108 |
133 |
134 | ) : (
135 |
136 |
137 |
138 |
142 |
143 |
144 | {folderName}
145 |
146 |
147 | {files.length} files
148 |
149 | {targetFileFound !== null && (
150 |
151 | {targetFileFound ? (
152 |
153 | ) : (
154 |
155 | )}
156 |
157 | {targetFileName}{" "}
158 | {targetFileFound ? "found" : "not found"}
159 |
160 |
161 | )}
162 |
163 |
164 |
165 |
172 |
173 |
174 |
175 | )}
176 |
177 |
178 | );
179 | };
180 |
181 | export default FileUpload;
182 |
--------------------------------------------------------------------------------
/src/components/pages/Home/render1/render1.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "@/components/ui/button";
3 | import { Calendar28 } from "@/components/DateRangePicker";
4 | import { Render1Props, TDateRange } from "@/types/render";
5 | import { initialFileState, intialDate, TWEETS_FILENAME } from "@/lib/constant";
6 | import FileUpload from "./fileUpload";
7 | import { useFileUpload } from "@/hooks/useFileUpload";
8 | import { useAnalysis } from "@/hooks/useAnalysis";
9 |
10 | const RenderStep1: React.FC = ({
11 | onAnalysisComplete,
12 | setCurrentStep,
13 | }) => {
14 | const [dateRange, setDateRange] = useState(intialDate);
15 |
16 | const { fileState, onFilesChange, targetFileFound, setTargetFileFound } =
17 | useFileUpload(initialFileState);
18 |
19 | const { analysisProgress, tweets, validTweets } = useAnalysis(
20 | fileState,
21 | dateRange
22 | );
23 |
24 | const analyzeTweets = async () => {
25 | try {
26 | const analysisResults = {
27 | fileMap: fileState.fileMap,
28 | dateRange: dateRange,
29 | totalTweets: tweets?.length ?? 0,
30 | validTweets: validTweets?.length ?? 0,
31 | tweetsLocation: fileState.tweetsLocation!,
32 | mediaLocation: fileState.mediaLocation!,
33 | validTweetsData: validTweets ?? [],
34 | selectedTweetIds: validTweets ? validTweets.map((t) => t.tweet.id) : [],
35 | };
36 |
37 | setCurrentStep(2);
38 | onAnalysisComplete(analysisResults);
39 | } catch (error) {
40 | console.error("Error analyzing tweets:", error);
41 | }
42 | };
43 |
44 | return (
45 |
46 |
47 |
52 |
53 |
54 | setDateRange(newDateRange)}
57 | />
58 |
59 |
60 |
67 |
68 |
69 | );
70 | };
71 |
72 | export default RenderStep1;
73 |
--------------------------------------------------------------------------------
/src/components/pages/Home/render1/sync.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from "@/components/ui/label";
2 | import { Switch } from "@/components/ui/switch";
3 |
4 | type SycnProps = {
5 | checked: boolean;
6 | updateChecked: (checked: boolean) => void;
7 | };
8 |
9 | const Sync = ({ checked, updateChecked }: SycnProps) => {
10 | return (
11 |
12 |
13 |
18 |
19 | );
20 | };
21 |
22 | export default Sync;
23 |
--------------------------------------------------------------------------------
/src/components/pages/Home/render2/render2.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Card } from "@/components/ui/card";
3 | import { Render2Props } from "@/types/render";
4 | import { useEffect, useState } from "react";
5 | import { useUpload } from "@/hooks/useUpload";
6 | import { Tweet } from "@/types/tweets";
7 | import {
8 | Dialog,
9 | DialogContent,
10 | DialogHeader,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from "@/components/ui/dialog";
14 | import { Eye } from "lucide-react";
15 |
16 | const RenderStep2: React.FC = ({
17 | setCurrentStep,
18 | shareableData,
19 | setShareableData,
20 | }) => {
21 | const { totalTweets, validTweets, validTweetsData, selectedTweetIds } =
22 | shareableData;
23 | const [selectedIds, setSelectedIds] = useState([]);
24 | const [query, setQuery] = useState("");
25 | const [visibleCount, setVisibleCount] = useState(50);
26 | const [isEmailConfirmed, setIsEmailConfirmed] = useState(true);
27 | const [tweetsWithVideos, setTweetsWithVideos] = useState([]);
28 |
29 | const { isProcessing, progress, tweet_to_bsky, skippedVideos } = useUpload({
30 | shareableData,
31 | });
32 |
33 | useEffect(() => {
34 | const emailConfirmed = localStorage.getItem("emailConfirmed") === "true";
35 | setIsEmailConfirmed(emailConfirmed);
36 | }, []);
37 |
38 | useEffect(() => {
39 | if (validTweetsData) {
40 | const videoTweets = validTweetsData.filter(
41 | (t) => t.tweet.extended_entities?.media?.[0]?.type === "video"
42 | );
43 | setTweetsWithVideos(videoTweets);
44 | }
45 | }, [validTweetsData]);
46 |
47 | useEffect(() => {
48 | if (selectedTweetIds && selectedTweetIds.length > 0) {
49 | setSelectedIds(selectedTweetIds);
50 | } else if (validTweetsData) {
51 | setSelectedIds(validTweetsData.map((t) => t.tweet.id));
52 | }
53 | }, [validTweetsData, selectedTweetIds]);
54 |
55 | useEffect(() => {
56 | if (!isProcessing && progress === 100 && selectedIds.length > 0) {
57 | setShareableData({ ...shareableData, selectedTweetIds: selectedIds });
58 | setCurrentStep(3);
59 | }
60 | }, [isProcessing, progress]);
61 |
62 | const toggleId = (id: string) => {
63 | setSelectedIds((prev) =>
64 | prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
65 | );
66 | };
67 |
68 | const filteredTweets =
69 | validTweetsData?.filter((t) =>
70 | t.tweet.full_text.toLowerCase().includes(query.toLowerCase())
71 | ) || [];
72 |
73 | const displayTweets = filteredTweets.slice(0, visibleCount);
74 |
75 | return (
76 |
77 |
78 | Tweet Analysis
79 |
80 |
81 | Total tweets found: {totalTweets}
82 |
83 |
84 | Valid tweets to import: {validTweets}
85 |
86 |
87 | Excluded: {totalTweets - validTweets} (quotes, retweets, replies, or
88 | outside date range)
89 |
90 | {!isEmailConfirmed && (
91 |
92 |
93 | Your email isn't verified. Videos won't be uploaded.
94 |
95 | {tweetsWithVideos.length > 0 && (
96 |
115 | )}
116 |
117 | )}
118 |
119 |
120 |
121 |
122 | {
125 | setQuery(e.target.value);
126 | setVisibleCount(50);
127 | }}
128 | placeholder="Search tweets..."
129 | className="flex-1 dark:bg-gray-800 px-2 py-1 border rounded-md"
130 | />
131 |
143 |
144 |
145 | {validTweetsData && (
146 |
147 |
148 | Select Tweets ({selectedIds.length})
149 |
150 |
151 | {displayTweets.map((t) => (
152 |
161 | ))}
162 |
163 | {filteredTweets.length > visibleCount && (
164 |
165 |
171 |
172 | )}
173 |
174 | )}
175 |
176 | {isProcessing && (
177 |
183 | )}
184 |
185 |
186 |
194 |
208 |
209 |
210 | );
211 | };
212 |
213 | export default RenderStep2;
214 |
--------------------------------------------------------------------------------
/src/components/pages/Home/render3.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Card } from "@/components/ui/card";
3 | import { BLUESKY_USERNAME } from "@/lib/constant";
4 | import { Render3Props } from "@/types/render";
5 | import { CheckCircle } from "lucide-react";
6 | import { Tweet } from "@/types/tweets";
7 |
8 | const RenderStep3: React.FC = ({
9 | shareableData,
10 | setCurrentStep,
11 | }) => {
12 | const { totalTweets, validTweetsData, selectedTweetIds, skippedVideos } =
13 | shareableData;
14 | const importedCount = selectedTweetIds.length
15 | ? selectedTweetIds.length
16 | : (validTweetsData?.length ?? 0);
17 | const skippedCount = totalTweets - importedCount;
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | Import Complete!
28 |
29 |
30 | Your tweets have been successfully imported to Bluesky
31 |
32 |
33 |
34 |
35 | Import Summary
36 |
37 |
38 | Total tweets found: {totalTweets}
39 |
40 |
41 | Successfully imported: {importedCount}
42 |
43 |
44 | Skipped: {skippedCount} (unselected or invalid)
45 |
46 |
47 |
48 |
49 | {skippedVideos && skippedVideos.length > 0 && (
50 |
51 | Tweets with Skipped Videos
52 |
53 | The following tweets had videos that were not uploaded because
54 | your email is not confirmed on Bluesky.
55 |
56 |
57 | {skippedVideos.map((tweet: Tweet["tweet"]) => (
58 |
59 |
{tweet.full_text}
60 |
61 | ))}
62 |
63 |
64 | )}
65 |
66 |
67 |
68 |
Support Our Work
69 |
70 | If you found this tool helpful, consider supporting us to keep it
71 | free and maintained
72 |
73 |
81 |
82 |
83 |
84 |
85 |
92 |
103 |
104 |
105 |
106 | );
107 | };
108 |
109 | export default RenderStep3;
110 |
--------------------------------------------------------------------------------
/src/components/pages/Home/steps.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { UploadCloud, Check, Settings, PartyPopper } from "lucide-react";
3 |
4 | type StepProps = {
5 | step: number;
6 | currentStep: number;
7 | title: string;
8 | icon: React.ReactNode;
9 | };
10 |
11 | const Step = ({ step, currentStep, title, icon }: StepProps) => {
12 | const isActive = currentStep === step;
13 | const isCompleted = currentStep > step;
14 |
15 | return (
16 |
17 |
26 | {isCompleted ? : icon}
27 |
28 |
29 |
36 | {title}
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | const Steps = ({ currentStep }: { currentStep: number }) => {
44 | const steps = [
45 | {
46 | step: 1,
47 | title: "Upload",
48 | icon: ,
49 | },
50 | {
51 | step: 2,
52 | title: "Customize",
53 | icon: ,
54 | },
55 | { step: 3, title: "Finish", icon: },
56 | ];
57 |
58 | return (
59 |
60 | {steps.map((s, index) => (
61 |
62 |
68 | {index < steps.length - 1 && (
69 | s.step
72 | ? "bg-green-500"
73 | : "bg-gray-200 dark:bg-gray-700"
74 | }`}
75 | />
76 | )}
77 |
78 | ))}
79 |
80 | );
81 | };
82 |
83 | export default Steps;
84 |
--------------------------------------------------------------------------------
/src/components/pages/login/Login.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import LoginForm from "./loginForm";
3 | import { useLogInContext } from "@/hooks/LogInContext";
4 | import Home from "../Home/home";
5 |
6 | const Login = () => {
7 | const [error, setError] = useState("");
8 | const [isLoading, setIsLoading] = useState(false);
9 | const [showTwoFactor, setShowTwoFactor] = useState(false);
10 | const [verificationCode, setVerificationCode] = useState("");
11 | const { agent, loggedIn, setLoggedIn } = useLogInContext();
12 |
13 | const handleLogin = async (userName: string, password: string) => {
14 | if (!agent) throw new Error("No agent found");
15 | setIsLoading(true);
16 | setError("");
17 |
18 | try {
19 | const user = await agent.login({
20 | identifier: userName,
21 | password: password,
22 | authFactorToken: verificationCode,
23 | });
24 |
25 | if (user.success) {
26 | setLoggedIn(true);
27 | localStorage.setItem(
28 | "emailConfirmed",
29 | String(user.data.emailConfirmed)
30 | );
31 | console.info("User logged in successfully");
32 | }
33 | } catch (error: any) {
34 | console.error("Login error:", error);
35 |
36 | // Check for the specific error message from Bluesky API
37 | if (
38 | error?.message?.includes("A sign in code has been sent") ||
39 | error?.cause?.message?.includes("A sign in code has been sent")
40 | ) {
41 | setShowTwoFactor(true);
42 | setError(
43 | "A sign-in code has been sent to your email. Please enter it below."
44 | );
45 | } else {
46 | setError("Invalid username or password");
47 | }
48 | } finally {
49 | setIsLoading(false);
50 | }
51 | };
52 |
53 | if (loggedIn) return ;
54 |
55 | return (
56 |
64 | );
65 | };
66 |
67 | export default Login;
68 |
--------------------------------------------------------------------------------
/src/components/pages/login/loginForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3 | import { Input } from "@/components/ui/input";
4 | import { Label } from "@/components/ui/label";
5 | import { CgDanger } from "react-icons/cg";
6 | import { BsFillInfoCircleFill } from "react-icons/bs";
7 | import { HiEye, HiEyeSlash } from "react-icons/hi2";
8 | import { Button } from "@/components/ui/button";
9 |
10 | interface LoginFormProps {
11 | onLogin: (userName: string, password: string) => void;
12 | error: string;
13 | isLoading: boolean;
14 | showTwoFactor: boolean;
15 | verificationCode: string;
16 | onVerificationCodeChange: (code: string) => void;
17 | }
18 |
19 | const LoginForm = ({
20 | onLogin,
21 | error,
22 | isLoading,
23 | showTwoFactor,
24 | verificationCode,
25 | onVerificationCodeChange,
26 | }: LoginFormProps) => {
27 | const [userName, setUserName] = useState("");
28 | const [password, setPassword] = useState("");
29 | const [showToolTip, setShowToolTip] = useState(false);
30 | const [showPassword, setShowPassword] = useState(false);
31 |
32 | const handleSubmit = () => {
33 | if (userName === "" || password === "") {
34 | alert("Please fill in both the username and password.");
35 | } else {
36 | onLogin(userName, password);
37 | }
38 | };
39 |
40 | const appPasswordRoute = () => {
41 | window.open(
42 | "https://github.com/bluesky-social/atproto-ecosystem/blob/main/app-passwords.md",
43 | "_blank"
44 | );
45 | };
46 |
47 | return (
48 |
49 |
50 |
51 | Login
52 |
53 |
54 |
157 |
158 |
159 |
160 | );
161 | };
162 |
163 | export default LoginForm;
164 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | }
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 | ChevronDownIcon,
4 | ChevronLeftIcon,
5 | ChevronRightIcon,
6 | } from "lucide-react"
7 | import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
8 |
9 | import { cn } from "@/lib/utils"
10 | import { Button, buttonVariants } from "@/components/ui/button"
11 |
12 | function Calendar({
13 | className,
14 | classNames,
15 | showOutsideDays = true,
16 | captionLayout = "label",
17 | buttonVariant = "ghost",
18 | formatters,
19 | components,
20 | ...props
21 | }: React.ComponentProps & {
22 | buttonVariant?: React.ComponentProps["variant"]
23 | }) {
24 | const defaultClassNames = getDefaultClassNames()
25 |
26 | return (
27 | svg]:rotate-180`,
32 | String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
33 | className
34 | )}
35 | captionLayout={captionLayout}
36 | formatters={{
37 | formatMonthDropdown: (date) =>
38 | date.toLocaleString("default", { month: "short" }),
39 | ...formatters,
40 | }}
41 | classNames={{
42 | root: cn("w-fit", defaultClassNames.root),
43 | months: cn(
44 | "relative flex flex-col gap-4 md:flex-row",
45 | defaultClassNames.months
46 | ),
47 | month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
48 | nav: cn(
49 | "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
50 | defaultClassNames.nav
51 | ),
52 | button_previous: cn(
53 | buttonVariants({ variant: buttonVariant }),
54 | "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
55 | defaultClassNames.button_previous
56 | ),
57 | button_next: cn(
58 | buttonVariants({ variant: buttonVariant }),
59 | "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
60 | defaultClassNames.button_next
61 | ),
62 | month_caption: cn(
63 | "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
64 | defaultClassNames.month_caption
65 | ),
66 | dropdowns: cn(
67 | "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
68 | defaultClassNames.dropdowns
69 | ),
70 | dropdown_root: cn(
71 | "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
72 | defaultClassNames.dropdown_root
73 | ),
74 | dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
75 | caption_label: cn(
76 | "select-none font-medium",
77 | captionLayout === "label"
78 | ? "text-sm"
79 | : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
80 | defaultClassNames.caption_label
81 | ),
82 | table: "w-full border-collapse",
83 | weekdays: cn("flex", defaultClassNames.weekdays),
84 | weekday: cn(
85 | "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
86 | defaultClassNames.weekday
87 | ),
88 | week: cn("mt-2 flex w-full", defaultClassNames.week),
89 | week_number_header: cn(
90 | "w-[--cell-size] select-none",
91 | defaultClassNames.week_number_header
92 | ),
93 | week_number: cn(
94 | "text-muted-foreground select-none text-[0.8rem]",
95 | defaultClassNames.week_number
96 | ),
97 | day: cn(
98 | "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
99 | defaultClassNames.day
100 | ),
101 | range_start: cn(
102 | "bg-accent rounded-l-md",
103 | defaultClassNames.range_start
104 | ),
105 | range_middle: cn("rounded-none", defaultClassNames.range_middle),
106 | range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
107 | today: cn(
108 | "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
109 | defaultClassNames.today
110 | ),
111 | outside: cn(
112 | "text-muted-foreground aria-selected:text-muted-foreground",
113 | defaultClassNames.outside
114 | ),
115 | disabled: cn(
116 | "text-muted-foreground opacity-50",
117 | defaultClassNames.disabled
118 | ),
119 | hidden: cn("invisible", defaultClassNames.hidden),
120 | ...classNames,
121 | }}
122 | components={{
123 | Root: ({ className, rootRef, ...props }) => {
124 | return (
125 |
131 | )
132 | },
133 | Chevron: ({ className, orientation, ...props }) => {
134 | if (orientation === "left") {
135 | return (
136 |
137 | )
138 | }
139 |
140 | if (orientation === "right") {
141 | return (
142 |
146 | )
147 | }
148 |
149 | return (
150 |
151 | )
152 | },
153 | DayButton: CalendarDayButton,
154 | WeekNumber: ({ children, ...props }) => {
155 | return (
156 |
157 |
158 | {children}
159 |
160 | |
161 | )
162 | },
163 | ...components,
164 | }}
165 | {...props}
166 | />
167 | )
168 | }
169 |
170 | function CalendarDayButton({
171 | className,
172 | day,
173 | modifiers,
174 | ...props
175 | }: React.ComponentProps) {
176 | const defaultClassNames = getDefaultClassNames()
177 |
178 | const ref = React.useRef(null)
179 | React.useEffect(() => {
180 | if (modifiers.focused) ref.current?.focus()
181 | }, [modifiers.focused])
182 |
183 | return (
184 |