This repository has no description
1import React, { useState, useEffect, useRef } from 'react';
2import { useNavigate } from 'react-router-dom';
3import './AltTextRatingTool.css';
4
5const PUBLIC_API_URL = "https://public.api.bsky.app";
6
7const AltTextRatingTool = () => {
8 const [username, setUsername] = useState('');
9 const [analysis, setAnalysis] = useState(null);
10 const [allRecords, setAllRecords] = useState([]);
11 const [actorDID, setActorDID] = useState('');
12 const [loading, setLoading] = useState(false);
13 const [excludeReplies, setExcludeReplies] = useState(false);
14 const [shareButtonVisible, setShareButtonVisible] = useState(false);
15 const [textResults, setTextResults] = useState(null);
16 const [suggestions, setSuggestions] = useState([]);
17 const [autocompleteActive, setAutocompleteActive] = useState(false);
18 const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
19 const [selectedSuggestion, setSelectedSuggestion] = useState('');
20 const [showResults, setShowResults] = useState(false);
21
22 const needleGroupRef = useRef(null);
23 const animFrameIdRef = useRef(null);
24 const lastTimestampRef = useRef(null);
25 const currentOscillationRef = useRef(0);
26 const oscillationDirectionRef = useRef(1);
27
28 const navigate = useNavigate();
29
30 const updateGauge = (percentage) => {
31 const angleDeg = (percentage / 100) * 180;
32 if (needleGroupRef.current) {
33 needleGroupRef.current.setAttribute("transform", `rotate(${angleDeg},200,300)`);
34 }
35 };
36
37 async function resolveHandleToDID(handle) {
38 const cleanedHandle = handle.replace(/[\u200E\u200F\u202A-\u202E]/g, '');
39 const res = await fetch(`${PUBLIC_API_URL}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(cleanedHandle)}`);
40 const data = await res.json();
41 if (data.did) return data.did;
42 throw new Error("Invalid username. Please try again without including the '@' symbol before the domain.");
43 }
44
45 async function fetchServiceEndpoint(did) {
46 let url;
47 if (did.startsWith("did:web:")) {
48 const domain = did.slice("did:web:".length);
49 url = `https://${domain}/.well-known/did.json`;
50 } else if (did.startsWith("did:plc:")) {
51 url = `https://plc.directory/${did}`;
52 } else {
53 throw new Error(`Unsupported DID method for DID: ${did}`);
54 }
55 const res = await fetch(url);
56 const data = await res.json();
57 if (data.service && data.service.length > 0) {
58 return data.service[0].serviceEndpoint;
59 }
60 throw new Error(`Service endpoint not found for DID: ${did}`);
61 }
62
63 async function fetchRecordsForCollection(serviceEndpoint, did, collectionName) {
64 // Calculate cutoff time for 90 days
65 const cutoffTime = Date.now() - 90 * 24 * 60 * 60 * 1000;
66
67 const urlBase = `${serviceEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collectionName)}&limit=100`;
68 let records = [];
69 let cursor = null;
70
71 while (true) {
72 const url = cursor ? `${urlBase}&cursor=${encodeURIComponent(cursor)}` : urlBase;
73 const res = await fetch(url);
74 const data = await res.json();
75
76 if (!data || !Array.isArray(data.records) || data.records.length === 0) {
77 break;
78 }
79
80 let minCreatedAt = Infinity;
81 const pageRecords = [];
82
83 for (const rec of data.records) {
84 const createdAt = rec.value?.createdAt;
85 if (!createdAt) continue;
86
87 const recordTime = new Date(createdAt).getTime();
88 minCreatedAt = Math.min(minCreatedAt, recordTime);
89
90 if (recordTime >= cutoffTime) {
91 pageRecords.push(rec);
92 }
93 }
94
95 records.push(...pageRecords);
96
97 if (minCreatedAt < cutoffTime) {
98 break;
99 }
100
101 if (!data.cursor) {
102 break;
103 }
104
105 cursor = data.cursor;
106 }
107
108 return records;
109 }
110
111 function analyzePosts(records, excludeReplies, actor) {
112 let totalPosts = 0;
113 let postsWithImages = 0;
114 let repliesWithImages = 0;
115 let postsWithAltText = 0;
116
117 records.forEach(rec => {
118 if (!rec.value.createdAt) return;
119
120 const isReply = !!rec.value.reply;
121 let isReplyToSelfFlag = false;
122
123 if (isReply && rec.value.reply?.parent?.author) {
124 isReplyToSelfFlag = rec.value.reply.parent.author.did === actor;
125 }
126
127 if (isReply) {
128 if (isReplyToSelfFlag) {
129 totalPosts += 1;
130 if (rec.value.embed?.["$type"] === "app.bsky.embed.images") {
131 postsWithImages += 1;
132 const hasAltText = rec.value.embed.images.some(img => img.alt?.trim());
133 if (hasAltText) postsWithAltText += 1;
134 }
135 } else if (!excludeReplies) {
136 totalPosts += 1;
137 if (rec.value.embed?.["$type"] === "app.bsky.embed.images") {
138 postsWithImages += 1;
139 repliesWithImages += 1;
140 const hasAltText = rec.value.embed.images.some(img => img.alt?.trim());
141 if (hasAltText) postsWithAltText += 1;
142 }
143 }
144 } else {
145 totalPosts += 1;
146 if (rec.value.embed?.["$type"] === "app.bsky.embed.images") {
147 postsWithImages += 1;
148 const hasAltText = rec.value.embed.images.some(img => img.alt?.trim());
149 if (hasAltText) postsWithAltText += 1;
150 }
151 }
152 });
153
154 const altTextPercentage = (postsWithAltText / postsWithImages) * 100 || 0;
155 const emojis = ["☹️", "😐", "🙂", "☺️"];
156 let emoji = emojis[0];
157 if (altTextPercentage >= 75) emoji = emojis[3];
158 else if (altTextPercentage >= 50) emoji = emojis[2];
159 else if (altTextPercentage >= 25) emoji = emojis[1];
160
161 return {
162 totalPosts,
163 postsWithImages,
164 repliesWithImages,
165 postsWithAltText,
166 altTextPercentage,
167 emoji,
168 };
169 }
170
171 // Animation code remains the same
172 const baseSpeed = 0.10;
173 const oscillationMin = 0;
174 const oscillationMax = 100;
175 const bounceRange = 10;
176
177 const animateNeedle = (timestamp) => {
178 if (!lastTimestampRef.current) {
179 lastTimestampRef.current = timestamp;
180 }
181 const deltaTime = timestamp - lastTimestampRef.current;
182 lastTimestampRef.current = timestamp;
183
184 const randomFactor = 0.8 + Math.random() * 0.4;
185 const increment = baseSpeed * deltaTime * randomFactor;
186 currentOscillationRef.current += oscillationDirectionRef.current * increment;
187
188 if (currentOscillationRef.current >= oscillationMax) {
189 currentOscillationRef.current = oscillationMax - Math.random() * bounceRange;
190 oscillationDirectionRef.current = -1;
191 } else if (currentOscillationRef.current <= oscillationMin) {
192 currentOscillationRef.current = oscillationMin + Math.random() * bounceRange;
193 oscillationDirectionRef.current = 1;
194 }
195
196 updateGauge(currentOscillationRef.current);
197 animFrameIdRef.current = requestAnimationFrame(animateNeedle);
198 };
199
200 const startNeedleAnimation = () => {
201 currentOscillationRef.current = oscillationMin;
202 oscillationDirectionRef.current = 1;
203 lastTimestampRef.current = null;
204 if (animFrameIdRef.current) {
205 cancelAnimationFrame(animFrameIdRef.current);
206 }
207 animFrameIdRef.current = requestAnimationFrame(animateNeedle);
208 };
209
210 const stopNeedleAnimation = () => {
211 if (animFrameIdRef.current) {
212 cancelAnimationFrame(animFrameIdRef.current);
213 animFrameIdRef.current = null;
214 }
215 };
216
217 // Autocomplete functions remain the same
218 const debounce = (func, delay) => {
219 let timer;
220 const debounced = (...args) => {
221 clearTimeout(timer);
222 timer = setTimeout(() => func(...args), delay);
223 };
224 debounced.cancel = () => {
225 clearTimeout(timer);
226 };
227 return debounced;
228 };
229
230 const fetchSuggestions = async (query) => {
231 if (!query) {
232 setSuggestions([]);
233 return;
234 }
235 try {
236 const res = await fetch(
237 `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`
238 );
239 if (!res.ok) throw new Error("Failed to fetch suggestions");
240 const data = await res.json();
241 setSuggestions(data.actors || []);
242 setAutocompleteActive(true);
243 } catch (error) {
244 console.error("Error fetching suggestions:", error);
245 setSuggestions([]);
246 }
247 };
248
249 const debouncedFetchSuggestions = useRef(debounce(fetchSuggestions, 300)).current;
250
251 useEffect(() => {
252 if (!selectedSuggestion) {
253 debouncedFetchSuggestions(username);
254 }
255 }, [username, debouncedFetchSuggestions, selectedSuggestion]);
256
257 const renderTextResults = (analysisResult) => (
258 <div>
259 <p><strong>{analysisResult.totalPosts}</strong> posts analyzed</p>
260 <p><strong>{analysisResult.postsWithImages}</strong> contain images</p>
261 <p><strong>{analysisResult.repliesWithImages}</strong> are replies</p>
262 <p><strong>{analysisResult.postsWithAltText}</strong> posts have alt text</p>
263 {analysisResult.postsWithImages > 0 ? (
264 <h2>Score: {analysisResult.altTextPercentage.toFixed(2)}% {analysisResult.emoji}</h2>
265 ) : (
266 <h2>No images found!</h2>
267 )}
268 </div>
269 );
270
271 const handleSubmit = async (e) => {
272 e.preventDefault();
273 setShowResults(true);
274 setLoading(true);
275 setShareButtonVisible(false);
276 setAnalysis(null);
277 setTextResults(null);
278 stopNeedleAnimation();
279 startNeedleAnimation();
280
281 try {
282 const did = await resolveHandleToDID(username);
283 setActorDID(did);
284 const serviceEndpoint = await fetchServiceEndpoint(did);
285 const records = await fetchRecordsForCollection(serviceEndpoint, did, "app.bsky.feed.post");
286 setAllRecords(records);
287
288 const analysisResult = analyzePosts(records, excludeReplies, did);
289 setAnalysis(analysisResult);
290 setTextResults(renderTextResults(analysisResult));
291 updateGauge(analysisResult.altTextPercentage);
292 setShareButtonVisible(true);
293 stopNeedleAnimation();
294 } catch (error) {
295 setTextResults(<p style={{ color: 'red' }}>Error: {error.message}</p>);
296 updateGauge(0);
297 stopNeedleAnimation();
298 }
299 setLoading(false);
300 };
301
302 useEffect(() => {
303 if (allRecords.length > 0 && actorDID) {
304 const newAnalysis = analyzePosts(allRecords, excludeReplies, actorDID);
305 setAnalysis(newAnalysis);
306 setTextResults(renderTextResults(newAnalysis));
307 updateGauge(newAnalysis.altTextPercentage);
308 }
309 }, [excludeReplies, allRecords, actorDID]);
310
311 const handleInputChange = (e) => {
312 setUsername(e.target.value);
313 if (selectedSuggestion && e.target.value !== selectedSuggestion) {
314 setSelectedSuggestion('');
315 }
316 };
317
318 return (
319 <div className="alt-text-rating-tool">
320 <div id="alt-text-rating-form" className="alt-card">
321 <h1>Bluesky Alt Text Rating</h1>
322 <p>How consistently do you use alt text?</p>
323 <form className="search-bar" onSubmit={handleSubmit} autoComplete="off">
324 <div style={{ position: 'relative' }}>
325 <input
326 type="text"
327 value={username}
328 onChange={handleInputChange}
329 placeholder="(e.g., user.bsky.social)"
330 required
331 />
332 {autocompleteActive && suggestions.length > 0 && (
333 <div className="autocomplete-items">
334 {suggestions.map((actor, index) => (
335 <div
336 key={actor.handle}
337 className={`autocomplete-item ${index === activeSuggestionIndex ? 'active' : ''}`}
338 onClick={() => {
339 setUsername(actor.handle);
340 setSelectedSuggestion(actor.handle);
341 setSuggestions([]);
342 setAutocompleteActive(false);
343 debouncedFetchSuggestions.cancel();
344 }}
345 >
346 <img src={actor.avatar} alt={`${actor.handle}'s avatar`} />
347 <span>{actor.handle}</span>
348 </div>
349 ))}
350 </div>
351 )}
352 </div>
353 <div className="action-row">
354 <button className="analyze-button" type="submit">Analyze</button>
355 <button
356 className="share-button"
357 type="button"
358 onClick={() => window.open(
359 `https://bsky.app/intent/compose?text=${encodeURIComponent(
360 `My alt text rating score is ${analysis?.altTextPercentage?.toFixed(2)}% ${analysis?.emoji}\n\n${analysis?.totalPosts} posts analyzed,\n${analysis?.postsWithImages} contain images,\n${analysis?.postsWithAltText} have alt text...\n\nGet your Bluesky alt text rating here: https://cred.blue/alt-text`
361 )}`, '_blank'
362 )}
363 style={{ display: shareButtonVisible ? 'inline-block' : 'none' }}
364 >
365 Share Results
366 </button>
367 </div>
368 </form>
369 <div className="results" style={{ display: showResults ? 'block' : 'none' }}>
370 <div id="textResults">{textResults}</div>
371 <div className="gauge-container">
372 <svg className="gauge-svg" viewBox="0 0 400 300">
373 <path d="M50,300 A150,150 0 0,1 93.93,193.93 L200,300 Z" fill="#ff0000" />
374 <path d="M93.93,193.93 A150,150 0 0,1 200,150 L200,300 Z" fill="#ff9900" />
375 <path d="M200,150 A150,150 0 0,1 306.07,193.93 L200,300 Z" fill="#ffff66" />
376 <path d="M306.07,193.93 A150,150 0 0,1 350,300 L200,300 Z" fill="#00cc00" />
377 <circle cx="200" cy="300" r="10" fill="#000" />
378 <g ref={needleGroupRef} className="needle-group" transform="rotate(0,200,300)">
379 <line x1="200" y1="300" x2="50" y2="300" stroke="#000" strokeWidth="7" />
380 </g>
381 </svg>
382 </div>
383 <div className="filters-container">
384 <label className="checkbox-container">
385 <input
386 type="checkbox"
387 checked={excludeReplies}
388 onChange={(e) => setExcludeReplies(e.target.checked)}
389 />
390 <span className="checkbox-indicator"></span>
391 Exclude Replies
392 </label>
393 </div>
394 <button
395 className="full-analysis-button"
396 type="button"
397 onClick={() => navigate(`/${username}`)}
398 >
399 View Full Analysis
400 </button>
401 <p>
402 <a href="https://bsky.app/settings/accessibility" target="_blank" rel="noreferrer">
403 Change your Bluesky alt text settings
404 </a>
405 </p>
406 <p>
407 <a href="https://bsky.app/profile/cred.blue" target="_blank" rel="noreferrer">
408 Discover more tools: @cred.blue
409 </a>
410 </p>
411 </div>
412 </div>
413 <div id="extra-info" className="alt-card">
414 <div className="resources">
415 <h3>Learn more about alt text:</h3>
416 <ul>
417 <li>
418 <a href="https://www.section508.gov/create/alternative-text/" target="_blank" rel="noreferrer">
419 Authoring Meaningful Alternative Text
420 </a>
421 </li>
422 <li>
423 <a href="https://accessibility.huit.harvard.edu/describe-content-images" target="_blank" rel="noreferrer">
424 Write helpful Alt Text to describe images
425 </a>
426 </li>
427 </ul>
428 </div>
429 </div>
430 </div>
431 );
432};
433
434export default AltTextRatingTool;