ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

remove redundant file upload section

+24 -276
-55
src/components/FileUploadZone.tsx
··· 1 - import { Upload } from "lucide-react"; 2 - import { RefObject } from "react"; 3 - 4 - interface FileUploadZoneProps { 5 - onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void; 6 - fileInputRef?: RefObject<HTMLInputElement | null>; 7 - } 8 - 9 - export default function FileUploadZone({ onFileChange, fileInputRef }: FileUploadZoneProps) { 10 - return ( 11 - <div> 12 - <div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-6 text-center hover:border-blue-400 dark:hover:border-blue-500 focus-within:border-blue-400 dark:focus-within:border-blue-500 transition-colors"> 13 - <Upload className="w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-3" aria-hidden="true" /> 14 - <p className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-1">Choose File</p> 15 - <p className="text-sm text-gray-500 dark:text-gray-300 mb-3">TikTok Following.txt, Instagram HTML/JSON, or ZIP export</p> 16 - 17 - <input 18 - id="file-upload" 19 - ref={fileInputRef} 20 - type="file" 21 - accept=".txt,.json,.html,.zip" 22 - onChange={onFileChange} 23 - className="sr-only" 24 - aria-label="Upload following data file" 25 - /> 26 - 27 - <label 28 - htmlFor="file-upload" 29 - className="inline-block bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-lg font-medium transition-colors cursor-pointer focus-within:ring-2 focus-within:ring-blue-400 focus-within:ring-offset-2" 30 - tabIndex={0} 31 - onKeyDown={(e) => { 32 - if (e.key === 'Enter' || e.key === ' ') { 33 - e.preventDefault(); 34 - document.getElementById('file-upload')?.click(); 35 - } 36 - }} 37 - > 38 - Browse Files 39 - </label> 40 - </div> 41 - 42 - <div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl"> 43 - <p className="text-sm text-blue-900 dark:text-blue-300"> 44 - 💡 <strong>How to get your data:</strong> 45 - </p> 46 - <p className="text-sm text-blue-900 dark:text-blue-300 mt-2"> 47 - <strong>TikTok:</strong> Profile → Settings → Account → Download your data → Upload Following.txt 48 - </p> 49 - <p className="text-sm text-blue-900 dark:text-blue-300 mt-1"> 50 - <strong>Instagram:</strong> Profile → Settings → Accounts Center → Your information and permissions → Download your information → Upload following.html 51 - </p> 52 - </div> 53 - </div> 54 - ); 55 - }
-216
src/lib/fileParser.ts
··· 1 - import JSZip from "jszip"; 2 - import type { TikTokUser } from '../types'; 3 - 4 - export class FileParseError extends Error { 5 - constructor(message: string) { 6 - super(message); 7 - this.name = 'FileParseError'; 8 - } 9 - } 10 - 11 - export const fileParser = { 12 - async parseJsonFile(jsonText: string): Promise<TikTokUser[]> { 13 - const users: TikTokUser[] = []; 14 - const jsonData = JSON.parse(jsonText); 15 - 16 - const followingArray = jsonData?.["Your Activity"]?.["Following"]?.["Following"]; 17 - 18 - if (!followingArray || !Array.isArray(followingArray)) { 19 - throw new FileParseError( 20 - "Could not find following data in JSON. Expected path: Your Activity > Following > Following" 21 - ); 22 - } 23 - 24 - for (const entry of followingArray) { 25 - users.push({ 26 - username: entry.UserName, 27 - date: entry.Date || "", 28 - }); 29 - } 30 - 31 - return users; 32 - }, 33 - 34 - parseTxtFile(text: string): TikTokUser[] { 35 - const users: TikTokUser[] = []; 36 - const entries = text.split("\n\n").map((b) => b.trim()).filter(Boolean); 37 - 38 - for (const entry of entries) { 39 - const userMatch = entry.match(/Username:\s*(.+)/); 40 - if (userMatch) { 41 - users.push({ username: userMatch[1].trim(), date: "" }); 42 - } 43 - } 44 - 45 - return users; 46 - }, 47 - 48 - async parseInstagramHtmlFile(htmlText: string): Promise<TikTokUser[]> { 49 - const users: TikTokUser[] = []; 50 - 51 - // Parse the HTML 52 - const parser = new DOMParser(); 53 - const doc = parser.parseFromString(htmlText, 'text/html'); 54 - 55 - // Instagram following data is in divs with class "_a6-g uiBoxWhite noborder" 56 - // The username is in an h2 with class "_a6-h _a6-i" 57 - const userDivs = doc.querySelectorAll('div.pam._3-95._2ph-._a6-g.uiBoxWhite.noborder'); 58 - 59 - userDivs.forEach((div) => { 60 - const h2 = div.querySelector('h2._3-95._2pim._a6-h._a6-i'); 61 - const dateDiv = div.querySelector('div._a6-p > div > div:nth-child(2)'); 62 - 63 - if (h2) { 64 - const username = h2.textContent?.trim(); 65 - const date = dateDiv?.textContent?.trim() || ''; 66 - 67 - if (username) { 68 - users.push({ 69 - username: username, 70 - date: date 71 - }); 72 - } 73 - } 74 - }); 75 - 76 - return users; 77 - }, 78 - 79 - async parseInstagramJsonFile(jsonText: string): Promise<TikTokUser[]> { 80 - const users: TikTokUser[] = []; 81 - const jsonData = JSON.parse(jsonText); 82 - 83 - // Instagram JSON exports can have different structures 84 - // Try the most common structure first 85 - let followingArray = jsonData?.relationships_following; 86 - 87 - if (!followingArray && jsonData?.following) { 88 - followingArray = jsonData.following; 89 - } 90 - 91 - if (!Array.isArray(followingArray)) { 92 - throw new FileParseError( 93 - "Could not find following data in Instagram JSON file" 94 - ); 95 - } 96 - 97 - for (const entry of followingArray) { 98 - const username = entry.string_list_data?.[0]?.value || entry.username || entry.handle; 99 - const timestamp = entry.string_list_data?.[0]?.timestamp || entry.timestamp; 100 - 101 - if (username) { 102 - users.push({ 103 - username: username, 104 - date: timestamp ? new Date(timestamp * 1000).toISOString() : '' 105 - }); 106 - } 107 - } 108 - 109 - return users; 110 - }, 111 - 112 - async parseZipFile(file: File): Promise<TikTokUser[]> { 113 - const zip = await JSZip.loadAsync(file); 114 - 115 - // Try TikTok first 116 - const followingFile = 117 - zip.file("TikTok/Profile and Settings/Following.txt") || 118 - zip.file("Profile and Settings/Following.txt") || 119 - zip.files[ 120 - Object.keys(zip.files).find( 121 - (path) => path.endsWith("Following.txt") && path.includes("Profile") 122 - ) || "" 123 - ]; 124 - 125 - if (followingFile) { 126 - const followingText = await followingFile.async("string"); 127 - return this.parseTxtFile(followingText); 128 - } 129 - 130 - // Try Instagram HTML 131 - const instagramFollowingHtml = Object.values(zip.files).find( 132 - (f) => f.name.includes("following") && f.name.endsWith(".html") && !f.dir 133 - ); 134 - 135 - if (instagramFollowingHtml) { 136 - const htmlText = await instagramFollowingHtml.async("string"); 137 - return this.parseInstagramHtmlFile(htmlText); 138 - } 139 - 140 - // Try Instagram JSON 141 - const instagramJsonFile = Object.values(zip.files).find( 142 - (f) => (f.name.includes("following") || f.name.includes("connections")) && 143 - f.name.endsWith(".json") && !f.dir 144 - ); 145 - 146 - if (instagramJsonFile) { 147 - const jsonText = await instagramJsonFile.async("string"); 148 - return this.parseInstagramJsonFile(jsonText); 149 - } 150 - 151 - // If no specific file found, look for any JSON at the top level 152 - const jsonFileEntry = Object.values(zip.files).find( 153 - (f) => f.name.endsWith(".json") && !f.dir 154 - ); 155 - 156 - if (!jsonFileEntry) { 157 - throw new FileParseError( 158 - "Could not find following data in the ZIP archive. Please ensure it contains Instagram's following.html or connections.json file." 159 - ); 160 - } 161 - 162 - const jsonText = await jsonFileEntry.async("string"); 163 - 164 - // Try Instagram JSON format first 165 - try { 166 - return this.parseInstagramJsonFile(jsonText); 167 - } catch { 168 - // Fall back to TikTok JSON format 169 - return this.parseJsonFile(jsonText); 170 - } 171 - }, 172 - 173 - async parseFile(file: File): Promise<TikTokUser[]> { 174 - let users: TikTokUser[]; 175 - let sourceFile = file.name; 176 - 177 - if (file.name.endsWith(".json")) { 178 - users = await this.parseJsonFile(await file.text()); 179 - } else if (file.name.endsWith(".txt")) { 180 - users = await this.parseTxtFile(await file.text()); 181 - } else if (file.name.endsWith(".zip")) { 182 - users = await this.parseZipFile(file); 183 - // Determine which file was actually used from the ZIP 184 - const zip = await JSZip.loadAsync(file); 185 - const followingFile = zip.file("TikTok/Profile and Settings/Following.txt") || 186 - zip.file("Profile and Settings/Following.txt") || 187 - zip.files[Object.keys(zip.files).find(path => path.endsWith("Following.txt") && path.includes("Profile")) || ""]; 188 - 189 - if (followingFile) { 190 - sourceFile = `${file.name} (TikTok Following.txt)`; 191 - } else { 192 - const instagramFollowingHtml = Object.values(zip.files).find( 193 - (f) => f.name.includes("following") && f.name.endsWith(".html") && !f.dir 194 - ); 195 - if (instagramFollowingHtml) { 196 - sourceFile = `${file.name} (Instagram ${instagramFollowingHtml.name})`; 197 - } else { 198 - const instagramJsonFile = Object.values(zip.files).find( 199 - (f) => (f.name.includes("following") || f.name.includes("connections")) && 200 - f.name.endsWith(".json") && !f.dir 201 - ); 202 - if (instagramJsonFile) { 203 - sourceFile = `${file.name} (Instagram ${instagramJsonFile.name})`; 204 - } 205 - } 206 - } 207 - } else if (file.name.endsWith(".html")) { 208 - users = await this.parseInstagramHtmlFile(await file.text()); 209 - } else { 210 - throw new FileParseError("Please upload a .txt, .json, .html, or .zip file"); 211 - } 212 - 213 - console.log(`Parsed ${users.length} users from: ${sourceFile}`); 214 - return users; 215 - } 216 - };
+24 -5
src/pages/Home.tsx
··· 2 2 import { useState, useEffect, useRef } from "react"; 3 3 import AppHeader from "../components/AppHeader"; 4 4 import PlatformSelector from "../components/PlatformSelector"; 5 - import FileUploadZone from "../components/FileUploadZone"; 6 5 import { apiClient } from "../lib/apiClient"; 7 6 import type { Upload as UploadType } from "../types"; 8 7 ··· 94 93 </h2> 95 94 </div> 96 95 <p className="text-gray-600 dark:text-gray-400 mb-6"> 97 - Upload your exported data from any platform to find matches on the ATmosphere 96 + Click a platform below to upload your exported data and find matches on the ATmosphere 98 97 </p> 99 98 100 99 <PlatformSelector onPlatformSelect={handlePlatformSelect} /> 101 - <FileUploadZone 102 - onFileChange={(e) => onFileUpload(e, selectedPlatform || 'tiktok')} 103 - fileInputRef={fileInputRef} /> 100 + 101 + {/* Hidden file input */} 102 + <input 103 + id="file-upload" 104 + ref={fileInputRef} 105 + type="file" 106 + accept=".txt,.json,.html,.zip" 107 + onChange={(e) => onFileUpload(e, selectedPlatform || 'tiktok')} 108 + className="sr-only" 109 + aria-label="Upload following data file" 110 + /> 111 + 112 + <div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl"> 113 + <p className="text-sm text-blue-900 dark:text-blue-300"> 114 + 💡 <strong>How to get your data:</strong> 115 + </p> 116 + <p className="text-sm text-blue-900 dark:text-blue-300 mt-2"> 117 + <strong>TikTok:</strong> Profile → Settings → Account → Download your data → Upload Following.txt 118 + </p> 119 + <p className="text-sm text-blue-900 dark:text-blue-300 mt-1"> 120 + <strong>Instagram:</strong> Profile → Settings → Accounts Center → Your information and permissions → Download your information → Upload following.html 121 + </p> 122 + </div> 104 123 </div> 105 124 106 125 {/* Upload History Section */}