diff --git a/backend/src/__tests__/mocks/twitter-service.mock.ts b/backend/src/__tests__/mocks/twitter-service.mock.ts index fa69dfe..5bf3544 100644 --- a/backend/src/__tests__/mocks/twitter-service.mock.ts +++ b/backend/src/__tests__/mocks/twitter-service.mock.ts @@ -1,12 +1,52 @@ import { Tweet } from "agent-twitter-client"; +import { TwitterService } from "../../services/twitter/client"; +import { logger } from "../../utils/logger"; -export class MockTwitterService { +export class MockTwitterService extends TwitterService { private mockTweets: Tweet[] = []; private mockUserIds: Map = new Map(); - private lastCheckedTweetId: string | null = null; + private tweetIdCounter: bigint = BigInt(Date.now()); - public addMockTweet(tweet: Tweet) { - this.mockTweets.push(tweet); + constructor() { + // Pass empty config since we're mocking + super({ + username: "mock_user", + password: "mock_pass", + email: "mock@example.com", + }); + // Override the client with a basic mock + (this as any).client = { + isLoggedIn: async () => true, + login: async () => {}, + logout: async () => {}, + getCookies: async () => [], + setCookies: async () => {}, + }; + } + + private getNextTweetId(): string { + this.tweetIdCounter = this.tweetIdCounter + BigInt(1); + return this.tweetIdCounter.toString(); + } + + public addMockTweet(tweet: Partial & { inReplyToStatusId?: string }) { + const fullTweet: Tweet = { + id: this.getNextTweetId(), + text: tweet.text || "", + username: tweet.username || "test_user", + userId: tweet.userId || `mock-user-id-${tweet.username || "test_user"}`, + timeParsed: tweet.timeParsed || new Date(), + hashtags: tweet.hashtags || [], + mentions: tweet.mentions || [], + photos: tweet.photos || [], + urls: tweet.urls || [], + videos: tweet.videos || [], + thread: [], + inReplyToStatusId: tweet.inReplyToStatusId, + }; + this.mockTweets.push(fullTweet); + logger.info(`Mock: Added tweet "${fullTweet.text}" from @${fullTweet.username}${tweet.inReplyToStatusId ? ` as reply to ${tweet.inReplyToStatusId}` : ''}`); + return fullTweet; } public addMockUserId(username: string, userId: string) { @@ -15,14 +55,15 @@ export class MockTwitterService { public clearMockTweets() { this.mockTweets = []; + logger.info("Mock: Cleared all tweets"); } async initialize(): Promise { - // No-op for mock + logger.info("Mock Twitter service initialized"); } async stop(): Promise { - // No-op for mock + logger.info("Mock Twitter service stopped"); } async getUserIdByScreenName(screenName: string): Promise { @@ -30,7 +71,29 @@ export class MockTwitterService { } async fetchAllNewMentions(): Promise { - return this.mockTweets; + // Get the last tweet ID we processed + const lastCheckedId = this.getLastCheckedTweetId(); + + // If we have tweets and no last checked ID, set it to the newest tweet + if (this.mockTweets.length > 0 && !lastCheckedId) { + const newestTweet = this.mockTweets[this.mockTweets.length - 1]; + await this.setLastCheckedTweetId(newestTweet.id); + return [newestTweet]; + } + + // Filter tweets newer than last checked ID + const newTweets = this.mockTweets.filter(tweet => { + if (!lastCheckedId) return true; + return BigInt(tweet.id) > BigInt(lastCheckedId); + }); + + // Update last checked ID if we found new tweets + if (newTweets.length > 0) { + const newestTweet = newTweets[newTweets.length - 1]; + await this.setLastCheckedTweetId(newestTweet.id); + } + + return newTweets; } async getTweet(tweetId: string): Promise { @@ -38,18 +101,54 @@ export class MockTwitterService { } async replyToTweet(tweetId: string, message: string): Promise { - return `mock-reply-${Date.now()}`; + const replyTweet = await this.addMockTweet({ + text: message, + username: "curatedotfun", + inReplyToStatusId: tweetId, + }); + logger.info(`Mock: Replied to tweet ${tweetId} with "${message}"`); + return replyTweet.id; } - async setLastCheckedTweetId(tweetId: string): Promise { - this.lastCheckedTweetId = tweetId; + async likeTweet(tweetId: string): Promise { + logger.info(`Mock: Liked tweet ${tweetId}`); } - async likeTweet(tweetId: string): Promise { - return; + // Helper methods for test scenarios + async simulateSubmission(contentUrl: string) { + // First create the content tweet + const contentTweet = await this.addMockTweet({ + text: "Original content", + username: "content_creator", + }); + + // Then create the curator's submission as a reply + return this.addMockTweet({ + text: `@curatedotfun !submit ${contentUrl}`, + username: "curator", + userId: "mock-user-id-curator", + timeParsed: new Date(), + inReplyToStatusId: contentTweet.id, + }); + } + + async simulateApprove(submissionTweetId: string, projectId: string) { + return this.addMockTweet({ + text: `@curatedotfun !approve ${projectId}`, + username: "moderator", + userId: "mock-user-id-moderator", + timeParsed: new Date(), + inReplyToStatusId: submissionTweetId, + }); } - getLastCheckedTweetId(): string | null { - return this.lastCheckedTweetId; + async simulateReject(submissionTweetId: string, projectId: string, reason: string) { + return this.addMockTweet({ + text: `@curatedotfun !reject ${projectId} ${reason}`, + username: "moderator", + userId: "mock-user-id-moderator", + timeParsed: new Date(), + inReplyToStatusId: submissionTweetId, + }); } } diff --git a/backend/src/index.ts b/backend/src/index.ts index e9c9ab4..0fc5ac6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,7 @@ import { db } from "./services/db"; import { DistributionService } from "./services/distribution/distribution.service"; import { SubmissionService } from "./services/submissions/submission.service"; import { TwitterService } from "./services/twitter/client"; +import { testRoutes, mockTwitterService } from "./routes/test"; import { cleanup, failSpinner, @@ -50,19 +51,25 @@ export async function main() { await distributionService.initialize(config.plugins); succeedSpinner("distribution-init", "distribution service initialized"); - // Try to initialize Twitter service, but continue if it fails + // Use mock service in development, real service in production try { startSpinner("twitter-init", "Initializing Twitter service..."); - twitterService = new TwitterService({ - username: process.env.TWITTER_USERNAME!, - password: process.env.TWITTER_PASSWORD!, - email: process.env.TWITTER_EMAIL!, - twoFactorSecret: process.env.TWITTER_2FA_SECRET, - }); - await twitterService.initialize(); + if (process.env.NODE_ENV === "development") { + logger.info("Using mock Twitter service"); + twitterService = mockTwitterService; + await twitterService.initialize(); + } else { + twitterService = new TwitterService({ + username: process.env.TWITTER_USERNAME!, + password: process.env.TWITTER_PASSWORD!, + email: process.env.TWITTER_EMAIL!, + twoFactorSecret: process.env.TWITTER_2FA_SECRET, + }); + await twitterService.initialize(); + } succeedSpinner("twitter-init", "Twitter service initialized"); - // Only initialize submission service if Twitter is available + // Initialize submission service startSpinner("submission-init", "Initializing submission service..."); submissionService = new SubmissionService( twitterService, @@ -86,12 +93,12 @@ export async function main() { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - imgSrc: ["'self'", "data:", "https:"], // Allow images from HTTPS sources - fontSrc: ["'self'", "data:", "https:"], // Allow fonts + imgSrc: ["'self'", "data:", "https:"], + fontSrc: ["'self'", "data:", "https:"], }, }, - crossOriginEmbedderPolicy: false, // Required for some static assets - crossOriginResourcePolicy: { policy: "cross-origin" }, // Allow resources to be shared + crossOriginEmbedderPolicy: false, + crossOriginResourcePolicy: { policy: "cross-origin" }, xFrameOptions: { action: "sameorigin" }, }), ) @@ -102,6 +109,8 @@ export async function main() { }), ) .use(swagger()) + // Include test routes in development + .use(process.env.NODE_ENV === "development" ? testRoutes : new Elysia()) .get("/health", () => new Response("OK", { status: 200 })) // API Routes .get("/api/twitter/last-tweet-id", () => { @@ -180,23 +189,6 @@ export async function main() { const rawConfig = await configService.getRawConfig(); return rawConfig.feeds; }) - // .post("/api/twitter/cookies", async ({ body }: { body: TwitterCookie[] }) => { - // if (!twitterService) { - // throw new Error("Twitter service not available"); - // } - // if (!Array.isArray(body)) { - // throw new Error("Expected array of cookies"); - // } - // await twitterService.setCookies(body); - // return { success: true }; - // }) - // .get("/api/twitter/cookies", () => { - // if (!twitterService) { - // throw new Error("Twitter service not available"); - // } - // const cookies = twitterService.getCookies(); - // return cookies || []; - // }) .get( "/api/config/:feedId", ({ params: { feedId } }: { params: { feedId: string } }) => { diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts new file mode 100644 index 0000000..518b384 --- /dev/null +++ b/backend/src/routes/test.ts @@ -0,0 +1,59 @@ +import { Elysia } from "elysia"; +import { MockTwitterService } from "../__tests__/mocks/twitter-service.mock"; +import { Tweet } from "agent-twitter-client"; + +// Create a single mock instance to maintain state +const mockTwitterService = new MockTwitterService(); + +// Helper to create a tweet object +const createTweet = (text: string, username: string): Tweet => ({ + id: Date.now().toString(), + text, + username, + userId: `mock-user-id-${username}`, + timeParsed: new Date(), + hashtags: [], + mentions: [], + photos: [], + urls: [], + videos: [], + thread: [], +}); + +export const testRoutes = new Elysia({ prefix: "/api/test" }) + .guard({ + beforeHandle: ({ request }) => { + // Only allow in development and test environments + if (process.env.NODE_ENV === "production") { + return new Response("Not found", { status: 404 }); + } + }, + }) + .get("/tweets", () => { + return mockTwitterService.fetchAllNewMentions(); + }) + .post("/tweets", async ({ body }) => { + const { text, username } = body as { text: string; username: string }; + const tweet = createTweet(text, username); + mockTwitterService.addMockTweet(tweet); + return tweet; + }) + .post("/reset", () => { + mockTwitterService.clearMockTweets(); + return { success: true }; + }) + .post("/scenario/approve", async ({ body }) => { + const { projectId } = body as { projectId: string }; + const tweet = createTweet(`@curatedotfun approve ${projectId}`, "curator"); + mockTwitterService.addMockTweet(tweet); + return { success: true, tweet }; + }) + .post("/scenario/reject", async ({ body }) => { + const { projectId, reason } = body as { projectId: string; reason: string }; + const tweet = createTweet(`@curatedotfun reject ${projectId} ${reason}`, "curator"); + mockTwitterService.addMockTweet(tweet); + return { success: true, tweet }; + }); + +// Export for use in tests and for replacing the real service +export { mockTwitterService }; diff --git a/backend/src/services/config/config.service.ts b/backend/src/services/config/config.service.ts index f999f07..ea34e6a 100644 --- a/backend/src/services/config/config.service.ts +++ b/backend/src/services/config/config.service.ts @@ -2,6 +2,7 @@ import fs from "fs/promises"; import path from "path"; import { AppConfig } from "../../types/config"; import { hydrateConfigValues } from "../../utils/config"; +import { logger } from "../../utils/logger"; export class ConfigService { private static instance: ConfigService; @@ -9,8 +10,14 @@ export class ConfigService { private configPath: string; private constructor() { - // Always look for config relative to the distribution directory - this.configPath = path.resolve(__dirname, "../../../../curate.config.json"); + // Use test config in development mode + if (process.env.NODE_ENV === "development") { + this.configPath = path.resolve(__dirname, "../../../../curate.config.test.json"); + logger.info("Using test configuration"); + } else { + this.configPath = path.resolve(__dirname, "../../../../curate.config.json"); + logger.info("Using production configuration"); + } } public static getInstance(): ConfigService { diff --git a/backend/src/services/twitter/client.ts b/backend/src/services/twitter/client.ts index a31b4ff..0243dd4 100644 --- a/backend/src/services/twitter/client.ts +++ b/backend/src/services/twitter/client.ts @@ -59,11 +59,11 @@ export class TwitterService { if (await this.client.isLoggedIn()) { // Cache the new cookies const cookies = await this.client.getCookies(); - const formattedCookies = cookies.map((cookie) => ({ + const formattedCookies: TwitterCookie[] = cookies.map((cookie) => ({ name: cookie.key, value: cookie.value, - domain: cookie.domain, - path: cookie.path, + domain: cookie.domain || ".twitter.com", // Provide default if null + path: cookie.path || "/", // Provide default if null secure: cookie.secure, httpOnly: cookie.httpOnly, sameSite: cookie.sameSite as "Strict" | "Lax" | "None" | undefined, diff --git a/bun.lockb b/bun.lockb index 6333c54..3bd9461 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/curate.config.json b/curate.config.json index 4a37072..2852cfb 100644 --- a/curate.config.json +++ b/curate.config.json @@ -19,6 +19,10 @@ "@curatedotfun/gpt-transform": { "type": "transformer", "url": "./external/gpt-transform" + }, + "@curatedotfun/supabase": { + "type": "distributor", + "url": "https://unpkg.com/@curatedotfun/supabase@latest/dist/index.js" } }, "feeds": [ diff --git a/curate.config.test.json b/curate.config.test.json new file mode 100644 index 0000000..81b7d58 --- /dev/null +++ b/curate.config.test.json @@ -0,0 +1,84 @@ +{ + "global": { + "botId": "curatedotfun_test", + "defaultStatus": "pending", + "maxSubmissionsPerUser": 100, + "blacklist": { + "twitter": [] + } + }, + "plugins": { + "@curatedotfun/supabase": { + "type": "distributor", + "url": "@curatedotfun/supabase" + } + }, + "feeds": [ + { + "id": "test-feed", + "name": "Test Feed", + "description": "Main feed for testing basic functionality", + "moderation": { + "approvers": { + "twitter": ["test_curator", "test_admin"] + } + }, + "outputs": { + "stream": { + "enabled": true, + "distribute": [ + { + "plugin": "@curatedotfun/supabase", + "config": { + + } + } + ] + } + } + }, + { + "id": "multi-approver", + "name": "Multi-Approver Test", + "description": "Testing multiple approver scenarios", + "moderation": { + "approvers": { + "twitter": ["curator1", "curator2", "curator3"] + } + }, + "outputs": { + "stream": { + "enabled": true + } + } + }, + { + "id": "edge-cases", + "name": "Edge Cases", + "description": "Testing edge cases and error scenarios", + "moderation": { + "approvers": { + "twitter": ["edge_curator"] + } + }, + "outputs": { + "stream": { + "enabled": true + }, + "recap": { + "enabled": true, + "schedule": "*/5 * * * *", + "distribute": [ + { + "plugin": "@curatedotfun/rss", + "config": { + "title": "Edge Cases Recap", + "path": "./public/edge-cases.xml" + } + } + ] + } + } + } + ] +} diff --git a/frontend/package.json b/frontend/package.json index f4dbc7a..427c81d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@eslint/js": "^9.15.0", + "@mswjs/data": "^0.16.2", "@rsbuild/core": "1.1.13", "@rsbuild/plugin-react": "1.1.0", "@tanstack/router-plugin": "^1.97.0", @@ -35,6 +36,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.12.0", + "msw": "^2.7.0", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0" } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index dc00964..a50ac82 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -10,104 +10,123 @@ // Import Routes -import { Route as rootRoute } from "./routes/__root"; -import { Route as SettingsImport } from "./routes/settings"; -import { Route as IndexImport } from "./routes/index"; -import { Route as FeedFeedIdImport } from "./routes/feed.$feedId"; +import { Route as rootRoute } from './routes/__root' +import { Route as TestImport } from './routes/test' +import { Route as SettingsImport } from './routes/settings' +import { Route as IndexImport } from './routes/index' +import { Route as FeedFeedIdImport } from './routes/feed.$feedId' // Create/Update Routes +const TestRoute = TestImport.update({ + id: '/test', + path: '/test', + getParentRoute: () => rootRoute, +} as any) + const SettingsRoute = SettingsImport.update({ - id: "/settings", - path: "/settings", + id: '/settings', + path: '/settings', getParentRoute: () => rootRoute, -} as any); +} as any) const IndexRoute = IndexImport.update({ - id: "/", - path: "/", + id: '/', + path: '/', getParentRoute: () => rootRoute, -} as any); +} as any) const FeedFeedIdRoute = FeedFeedIdImport.update({ - id: "/feed/$feedId", - path: "/feed/$feedId", + id: '/feed/$feedId', + path: '/feed/$feedId', getParentRoute: () => rootRoute, -} as any); +} as any) // Populate the FileRoutesByPath interface -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexImport; - parentRoute: typeof rootRoute; - }; - "/settings": { - id: "/settings"; - path: "/settings"; - fullPath: "/settings"; - preLoaderRoute: typeof SettingsImport; - parentRoute: typeof rootRoute; - }; - "/feed/$feedId": { - id: "/feed/$feedId"; - path: "/feed/$feedId"; - fullPath: "/feed/$feedId"; - preLoaderRoute: typeof FeedFeedIdImport; - parentRoute: typeof rootRoute; - }; + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexImport + parentRoute: typeof rootRoute + } + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsImport + parentRoute: typeof rootRoute + } + '/test': { + id: '/test' + path: '/test' + fullPath: '/test' + preLoaderRoute: typeof TestImport + parentRoute: typeof rootRoute + } + '/feed/$feedId': { + id: '/feed/$feedId' + path: '/feed/$feedId' + fullPath: '/feed/$feedId' + preLoaderRoute: typeof FeedFeedIdImport + parentRoute: typeof rootRoute + } } } // Create and export the route tree export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/settings": typeof SettingsRoute; - "/feed/$feedId": typeof FeedFeedIdRoute; + '/': typeof IndexRoute + '/settings': typeof SettingsRoute + '/test': typeof TestRoute + '/feed/$feedId': typeof FeedFeedIdRoute } export interface FileRoutesByTo { - "/": typeof IndexRoute; - "/settings": typeof SettingsRoute; - "/feed/$feedId": typeof FeedFeedIdRoute; + '/': typeof IndexRoute + '/settings': typeof SettingsRoute + '/test': typeof TestRoute + '/feed/$feedId': typeof FeedFeedIdRoute } export interface FileRoutesById { - __root__: typeof rootRoute; - "/": typeof IndexRoute; - "/settings": typeof SettingsRoute; - "/feed/$feedId": typeof FeedFeedIdRoute; + __root__: typeof rootRoute + '/': typeof IndexRoute + '/settings': typeof SettingsRoute + '/test': typeof TestRoute + '/feed/$feedId': typeof FeedFeedIdRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: "/" | "/settings" | "/feed/$feedId"; - fileRoutesByTo: FileRoutesByTo; - to: "/" | "/settings" | "/feed/$feedId"; - id: "__root__" | "/" | "/settings" | "/feed/$feedId"; - fileRoutesById: FileRoutesById; + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/settings' | '/test' | '/feed/$feedId' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/settings' | '/test' | '/feed/$feedId' + id: '__root__' | '/' | '/settings' | '/test' | '/feed/$feedId' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - SettingsRoute: typeof SettingsRoute; - FeedFeedIdRoute: typeof FeedFeedIdRoute; + IndexRoute: typeof IndexRoute + SettingsRoute: typeof SettingsRoute + TestRoute: typeof TestRoute + FeedFeedIdRoute: typeof FeedFeedIdRoute } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SettingsRoute: SettingsRoute, + TestRoute: TestRoute, FeedFeedIdRoute: FeedFeedIdRoute, -}; +} export const routeTree = rootRoute ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileTypes() /* ROUTE_MANIFEST_START { @@ -117,6 +136,7 @@ export const routeTree = rootRoute "children": [ "/", "/settings", + "/test", "/feed/$feedId" ] }, @@ -126,6 +146,9 @@ export const routeTree = rootRoute "/settings": { "filePath": "settings.tsx" }, + "/test": { + "filePath": "test.tsx" + }, "/feed/$feedId": { "filePath": "feed.$feedId.tsx" } diff --git a/frontend/src/routes/test.tsx b/frontend/src/routes/test.tsx new file mode 100644 index 0000000..03a7a8e --- /dev/null +++ b/frontend/src/routes/test.tsx @@ -0,0 +1,194 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import Layout from "../components/Layout"; + +interface Tweet { + id: string; + text: string; + username: string; + userId: string; + timeParsed: Date; + inReplyToStatusId?: string; +} + +export const Route = createFileRoute("/test")({ + component: TestPage, +}); + +function TestPage() { + const [tweets, setTweets] = useState([]); + const [contentUrl, setContentUrl] = useState("https://example.com/content"); + const [selectedFeed, setSelectedFeed] = useState("test-feed"); + const [submissionTweetId, setSubmissionTweetId] = useState(null); + + const fetchTweets = async () => { + const response = await fetch("/api/test/tweets"); + const data = await response.json(); + setTweets(data); + }; + + useEffect(() => { + if (process.env.NODE_ENV === "development") { + fetchTweets(); + } + }, []); + + const handleSubmit = async () => { + await fetch("/api/test/tweets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "Original content", + username: "content_creator", + }), + }); + + const submissionResponse = await fetch("/api/test/tweets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `@curatedotfun !submit ${contentUrl} #${selectedFeed}`, + username: "curator", + inReplyToStatusId: tweets[tweets.length - 1]?.id, + }), + }); + + const submissionTweet = await submissionResponse.json(); + setSubmissionTweetId(submissionTweet.id); + fetchTweets(); + }; + + const handleApprove = async () => { + if (!submissionTweetId) { + alert("Please submit content first"); + return; + } + + await fetch("/api/test/tweets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `@curatedotfun !approve ${selectedFeed}`, + username: "moderator", + inReplyToStatusId: submissionTweetId, + }), + }); + fetchTweets(); + }; + + const handleReject = async () => { + if (!submissionTweetId) { + alert("Please submit content first"); + return; + } + + await fetch("/api/test/tweets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `@curatedotfun !reject ${selectedFeed} spam`, + username: "moderator", + inReplyToStatusId: submissionTweetId, + }), + }); + fetchTweets(); + }; + + const handleReset = async () => { + await fetch("/api/test/reset", { method: "POST" }); + setSubmissionTweetId(null); + fetchTweets(); + }; + + if (process.env.NODE_ENV === "production") { + return null; + } + + return ( + +
+

Test Control Panel

+ + {/* Feed Selection */} +
+

Test Feed Selection

+ +
+ + {/* Content URL */} +
+

Content URL

+ setContentUrl(e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + placeholder="https://example.com/content" + /> +
+ + {/* Quick Actions */} +
+

Quick Actions

+
+ + + + +
+
+ + {/* Current Tweets */} +
+

Current Tweets

+
+ {tweets.map((tweet) => ( +
+
@{tweet.username}
+
{tweet.text}
+
+ {new Date(tweet.timeParsed).toLocaleString()} + {tweet.inReplyToStatusId && ( + + (Reply to: {tweet.inReplyToStatusId}) + + )} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/package.json b/package.json index a2f1242..205a494 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "prettier": "^3.3.3" }, "dependencies": { + "@curatedotfun/supabase": "^0.0.5", "elysia-rate-limit": "^4.1.0" } }