summaryrefslogtreecommitdiff
path: root/app/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/components')
-rw-r--r--app/src/components/bookmark-fetcher.tsx143
-rw-r--r--app/src/components/bookmark-list.tsx109
-rw-r--r--app/src/components/cat/Entry.tsx214
-rw-r--r--app/src/components/cat/Form.tsx5
-rw-r--r--app/src/components/counter.tsx21
-rw-r--r--app/src/components/footer.tsx18
-rw-r--r--app/src/components/header.tsx9
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>
+ );
+};