diff options
Diffstat (limited to 'app/src/components')
-rw-r--r-- | app/src/components/bookmark-fetcher.tsx | 143 | ||||
-rw-r--r-- | app/src/components/bookmark-list.tsx | 109 | ||||
-rw-r--r-- | app/src/components/cat/Entry.tsx | 214 | ||||
-rw-r--r-- | app/src/components/cat/Form.tsx | 5 | ||||
-rw-r--r-- | app/src/components/counter.tsx | 21 | ||||
-rw-r--r-- | app/src/components/footer.tsx | 18 | ||||
-rw-r--r-- | app/src/components/header.tsx | 9 |
7 files changed, 519 insertions, 0 deletions
diff --git a/app/src/components/bookmark-fetcher.tsx b/app/src/components/bookmark-fetcher.tsx new file mode 100644 index 0000000..6522310 --- /dev/null +++ b/app/src/components/bookmark-fetcher.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { TwitterBookmark } from '../lib/bookmark-models'; +import { BookmarkStorageService } from '../lib/bookmark-storage'; +import { BookmarkList } from './bookmark-list'; + +export function BookmarkFetcher() { + const [bookmarks, setBookmarks] = useState<TwitterBookmark[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [username, setUsername] = useState(''); + const [authToken, setAuthToken] = useState(''); + + // Load existing bookmarks on mount + useEffect(() => { + const existingBookmarks = BookmarkStorageService.getBookmarks(); + setBookmarks(existingBookmarks); + }, []); + + const handleFetchBookmarks = async () => { + if (!username || !authToken) { + setError('Please enter your Twitter username and auth token'); + return; + } + + setLoading(true); + setError(null); + + try { + // Simple fetch to our API endpoint + const response = await fetch('/api/sync-bookmarks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, authToken }), + }); + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch bookmarks'); + } + + // Load the newly saved bookmarks + const updatedBookmarks = BookmarkStorageService.getBookmarks(); + setBookmarks(updatedBookmarks); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch bookmarks'); + } finally { + setLoading(false); + } + }; + + const handleClearBookmarks = () => { + BookmarkStorageService.clearAll(); + setBookmarks([]); + }; + + return ( + <div className="space-y-6"> + {/* Input fields */} + <div className="bg-white border border-gray-200 rounded-lg p-6"> + <h2 className="text-xl font-semibold mb-4">Twitter Credentials</h2> + + <div className="space-y-4"> + <div> + <label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1"> + Twitter Username + </label> + <input + id="username" + type="text" + value={username} + onChange={(e) => setUsername(e.target.value)} + placeholder="your_twitter_username" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + </div> + + <div> + <label htmlFor="authToken" className="block text-sm font-medium text-gray-700 mb-1"> + Auth Token + </label> + <input + id="authToken" + type="password" + value={authToken} + onChange={(e) => setAuthToken(e.target.value)} + placeholder="your_auth_token" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + </div> + </div> + </div> + + {/* Action buttons */} + <div className="flex gap-4"> + <button + onClick={handleFetchBookmarks} + disabled={loading} + className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed font-medium" + > + {loading ? ( + <> + <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> + <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> + <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> + </svg> + Fetching... + </> + ) : ( + 'Fetch Twitter Bookmarks' + )} + </button> + + {bookmarks.length > 0 && ( + <button + onClick={handleClearBookmarks} + className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium" + > + Clear All + </button> + )} + </div> + + {/* Error message */} + {error && ( + <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg"> + {error} + </div> + )} + + {/* Bookmark list */} + <div className="bg-white border border-gray-200 rounded-lg p-6"> + <h2 className="text-xl font-semibold mb-4">Your Bookmarks</h2> + <BookmarkList bookmarks={bookmarks} /> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/app/src/components/bookmark-list.tsx b/app/src/components/bookmark-list.tsx new file mode 100644 index 0000000..6138e31 --- /dev/null +++ b/app/src/components/bookmark-list.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState } from "react"; + +import { BookmarkStorageService } from "../lib/bookmark-storage"; +import { TwitterBookmark } from "../lib/twitter-api"; + +interface BookmarkListProps { + bookmarks: TwitterBookmark[]; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function truncateText(text: string, maxLength: number = 200): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + "..."; +} + +export function BookmarkList({ bookmarks }: BookmarkListProps) { + if (bookmarks.length === 0) { + return ( + <div className="text-center py-8 text-gray-500"> + No bookmarks found. Click "Fetch Twitter Bookmarks" to load your + bookmarks. + </div> + ); + } + + return ( + <div className="space-y-4"> + <div className="text-sm text-gray-600 mb-4"> + Found {bookmarks.length} bookmark{bookmarks.length !== 1 ? "s" : ""} + </div> + + {bookmarks.map((bookmark) => ( + <BookmarkEntry bookmark={bookmark} key={bookmark.id} /> + ))} + </div> + ); +} + +function BookmarkEntry({ bookmark }: { bookmark: TwitterBookmark }) { + console.log({ bookmark }); + return ( + <div className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"> + <div className="flex items-start justify-between mb-2"> + <div className="flex-1"> + <div className="flex items-center gap-2 mb-1"> + <span className="font-semibold text-gray-900"> + @{bookmark.author.username} + </span> + <span className="text-sm text-gray-500">•</span> + <span className="text-sm text-gray-500"> + {formatDate(bookmark.createdAt)} + </span> + </div> + <div className="text-sm text-gray-700 mb-2"> + {truncateText(bookmark.text)} + </div> + <div className="flex flex-wrap"> + {bookmark.media.pics.map((p) => ( + <img width="400" src={p} /> + ))} + </div> + {bookmark.media.video.url && ( + <video src={bookmark.media.video.url} controls /> + )} + {bookmark.urls.length > 0 && ( + <div className="space-y-1"> + {bookmark.urls.map((url, index) => ( + <div key={index} className="text-xs"> + <a + href={url.expandedUrl} + target="_blank" + rel="noopener noreferrer" + className="text-blue-600 hover:underline" + > + {url.displayUrl} + </a> + </div> + ))} + </div> + )} + {bookmark.hashtags.length > 0 && ( + <div className="flex flex-wrap gap-1 mt-2"> + {bookmark.hashtags.map((tag, index) => ( + <span + key={index} + className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded" + > + #{tag} + </span> + ))} + </div> + )} + </div> + </div> + </div> + ); +} diff --git a/app/src/components/cat/Entry.tsx b/app/src/components/cat/Entry.tsx new file mode 100644 index 0000000..1ded145 --- /dev/null +++ b/app/src/components/cat/Entry.tsx @@ -0,0 +1,214 @@ +import { + CategorizationResponse, + userCategories, +} from "../../lib/categorization"; +import { TwitterApiService, TwitterBookmark } from "../../lib/twitter-api"; + +export default function ({ + bookmark, + categorization, + currentIndex, + totalCount, +}: { + bookmark: TwitterBookmark; + categorization: CategorizationResponse; + currentIndex: number; + totalCount: number; +}) { + async function processEntry() { + "use server"; + const cookie = Bun.env.TWATTER_COKI; + const api = new TwitterApiService(cookie!); + await api.removeBookmark(bookmark.id); + } + + return ( + <div className="bg-white rounded-lg shadow-lg overflow-hidden"> + {/* Bookmark Content */} + <div className="p-6 border-b"> + <div className="flex items-start space-x-3 mb-4"> + <img + src={bookmark.author.avatar} + alt={bookmark.author.name} + className="w-12 h-12 rounded-full" + /> + <div> + <h3 className="font-semibold text-gray-900"> + {bookmark.author.name} + </h3> + <p className="text-gray-600">@{bookmark.author.username}</p> + <p className="text-sm text-gray-500"> + {new Date(bookmark.createdAt).toLocaleDateString()} + </p> + </div> + </div> + + <div className="prose max-w-none mb-4"> + <p className="whitespace-pre-wrap">{bookmark.text}</p> + </div> + + {bookmark.media.pics.length > 0 && ( + <div className="mb-4"> + <h4 className="text-sm font-semibold text-gray-700 mb-2">Images</h4> + <div className="grid grid-cols-3 gap-2"> + {bookmark.media.pics.map((url, index) => ( + <img + key={index} + src={url} + alt={`Bookmark image ${index + 1}`} + className="rounded-lg border" + /> + ))} + </div> + </div> + )} + + <div className="flex flex-wrap gap-2"> + {bookmark.hashtags.map((tag) => ( + <span + key={tag} + className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-sm" + > + #{tag} + </span> + ))} + </div> + </div> + + {/* LLM Analysis */} + <div className="p-6 border-b bg-gray-50"> + <h3 className="text-lg font-semibold mb-3">AI Analysis</h3> + <p className="text-sm text-gray-600 mb-3">{categorization.summary}</p> + + <div className="mb-4"> + <h4 className="text-sm font-semibold mb-2">Key Topics</h4> + <div className="flex flex-wrap gap-2"> + {categorization.keyTopics.map((topic) => ( + <span + key={topic} + className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm" + > + {topic} + </span> + ))} + </div> + </div> + + <div> + <h4 className="text-sm font-semibold mb-2">Suggested Categories</h4> + {categorization.suggestedCategories.map((suggestion, index) => ( + <div key={index} className="mb-2 p-2 bg-white rounded border"> + <div className="flex items-center justify-between mb-1"> + <span className="font-medium"> + {suggestion.categories.join(", ")} + </span> + <span className="text-sm text-gray-600"> + {(suggestion.confidence * 100).toFixed(0)}% + </span> + </div> + <p className="text-sm text-gray-600">{suggestion.reasoning}</p> + </div> + ))} + </div> + </div> + + {/* Category Selection */} + <form method="POST" action="/api/save-categorization"> + <div className="p-6"> + <h3 className="text-lg font-semibold mb-3">Select Categories</h3> + + <div className="mb-4"> + <h4 className="text-sm font-semibold mb-2">User Categories</h4> + <div className="flex flex-wrap gap-2"> + {userCategories.map((category) => ( + <label + key={category.name} + className="flex items-center cursor-pointer" + > + <input + type="checkbox" + name="categories" + value={category.name} + defaultChecked={categorization.suggestedCategories[0]?.categories.includes( + category.name, + )} + className="mr-2" + /> + <span className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-sm"> + {category.name} + </span> + </label> + ))} + </div> + </div> + + {categorization.newCategories.length > 0 && ( + <div className="mb-4"> + <h4 className="text-sm font-semibold mb-2"> + New Category Suggestions + </h4> + <div className="flex flex-wrap gap-2"> + {categorization.newCategories.map((category) => ( + <label + key={category} + className="flex items-center cursor-pointer" + > + <input + type="checkbox" + name="categories" + value={category} + className="mr-2" + /> + <span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm"> + {category} + </span> + </label> + ))} + </div> + </div> + )} + + <div className="mb-4"> + <label className="block text-sm font-semibold mb-2"> + Custom Categories + </label> + <input + type="text" + name="customCategories" + placeholder="Add custom categories, separated by commas" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + </div> + + <input type="hidden" name="bookmarkId" value={bookmark.id} /> + <input type="hidden" name="nextIndex" value={currentIndex + 1} /> + + <div className="flex space-x-4"> + {currentIndex > 0 && ( + <a + href={`/categorize?idx=${currentIndex - 1}`} + className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50" + > + Previous + </a> + )} + <button + type="submit" + className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700" + > + Save & Next + </button> + {currentIndex + 1 < totalCount && ( + <a + href={`/categorize?idx=${currentIndex + 1}`} + className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50" + > + Skip + </a> + )} + </div> + </div> + </form> + </div> + ); +} diff --git a/app/src/components/cat/Form.tsx b/app/src/components/cat/Form.tsx new file mode 100644 index 0000000..7e58be4 --- /dev/null +++ b/app/src/components/cat/Form.tsx @@ -0,0 +1,5 @@ +"use client"; + +export default function ProcessEntry() { + return <form method="POST" action="/api/save-categorization"></form>; +} diff --git a/app/src/components/counter.tsx b/app/src/components/counter.tsx new file mode 100644 index 0000000..0e540b8 --- /dev/null +++ b/app/src/components/counter.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useState } from 'react'; + +export const Counter = () => { + const [count, setCount] = useState(0); + + const handleIncrement = () => setCount((c) => c + 1); + + return ( + <section className="border-blue-400 -mx-4 mt-4 rounded-sm border border-dashed p-4"> + <div>Count: {count}</div> + <button + onClick={handleIncrement} + className="rounded-xs bg-black px-2 py-0.5 text-sm text-white" + > + Increment + </button> + </section> + ); +}; diff --git a/app/src/components/footer.tsx b/app/src/components/footer.tsx new file mode 100644 index 0000000..d9d2511 --- /dev/null +++ b/app/src/components/footer.tsx @@ -0,0 +1,18 @@ +export const Footer = () => { + return ( + <footer className="p-6 lg:fixed lg:bottom-0 lg:left-0"> + <div> + visit{" "} + <a + href="https://sortug.com/" + target="_blank" + rel="noreferrer" + className="mt-4 inline-block underline" + > + sortug.com + </a>{" "} + to learn more + </div> + </footer> + ); +}; diff --git a/app/src/components/header.tsx b/app/src/components/header.tsx new file mode 100644 index 0000000..390043b --- /dev/null +++ b/app/src/components/header.tsx @@ -0,0 +1,9 @@ +import { Link } from "waku"; + +export const Header = () => { + return ( + <header className="flex items-center gap-4 p-6 lg:fixed lg:left-0 lg:top-0"> + <h2 className="text-lg font-bold tracking-tight"></h2> + </header> + ); +}; |