diff --git a/apps/api/package.json b/apps/api/package.json index 34a9311a..64e57f11 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -35,12 +35,13 @@ "zod": "^3.25.62" }, "dependencies": { + "@crosspost/scheduler-sdk": "^0.1.1", "@crosspost/sdk": "^0.3.0", "@crosspost/types": "^0.3.0", - "@crosspost/scheduler-sdk": "^0.1.1", "@curatedotfun/shared-db": "workspace:*", "@curatedotfun/types": "workspace:*", "@curatedotfun/utils": "workspace:*", + "@fastnear/utils": "^0.9.7", "@hono/node-server": "^1.8.2", "@hono/zod-openapi": "^0.9.5", "@hono/zod-validator": "^0.5.0", @@ -60,7 +61,7 @@ "lodash": "^4.17.21", "mustache": "^4.2.0", "near-api-js": "^5.1.1", - "near-sign-verify": "^0.3.6", + "near-sign-verify": "^0.4.1", "ora": "^8.1.1", "pg": "^8.15.6", "pinata-web3": "^0.5.4", diff --git a/apps/api/src/middlewares/auth.middleware.ts b/apps/api/src/middlewares/auth.middleware.ts index 831f4efc..394b8a36 100644 --- a/apps/api/src/middlewares/auth.middleware.ts +++ b/apps/api/src/middlewares/auth.middleware.ts @@ -1,54 +1,31 @@ import { Context, MiddlewareHandler, Next } from "hono"; -import { verify } from "near-sign-verify"; +import { verify } from "hono/jwt"; +import { getCookie } from "hono/cookie"; export function createAuthMiddleware(): MiddlewareHandler { return async (c: Context, next: Next) => { - const method = c.req.method; + const token = getCookie(c, "token"); let accountId: string | null = null; - if (method === "GET") { - const nearAccountHeader = c.req.header("X-Near-Account"); - if ( - nearAccountHeader && - nearAccountHeader.toLowerCase() !== "anonymous" - ) { - accountId = nearAccountHeader; + if (token) { + const secret = process.env.JWT_SECRET; + if (!secret) { + console.error("JWT_SECRET is not set."); + c.status(500); + return c.json({ error: "Internal Server Error" }); + } + try { + const decodedPayload = await verify(token, secret); + if (decodedPayload && typeof decodedPayload.sub === "string") { + accountId = decodedPayload.sub; + } + } catch (error) { + // Invalid token, proceed as anonymous + console.warn("JWT verification failed:", error); } - // If header is missing or "anonymous", accountId remains null - c.set("accountId", accountId); - await next(); - return; - } - - // For non-GET requests (POST, PUT, DELETE, PATCH, etc.) - const authHeader = c.req.header("Authorization"); - if (!authHeader || !authHeader.startsWith("Bearer ")) { - c.status(401); - return c.json({ - error: "Unauthorized", - details: "Missing or malformed Authorization header.", - }); } - const token = authHeader.substring(7); // Remove "Bearer " - - try { - const verificationResult = await verify(token, { - expectedRecipient: "curatefun.near", - requireFullAccessKey: false, - nonceMaxAge: 300000, // 5 mins - }); - - accountId = verificationResult.accountId; - c.set("accountId", accountId); - await next(); - } catch (error) { - console.error("Token verification error:", error); - c.status(401); - return c.json({ - error: "Unauthorized", - details: "Invalid token signature or recipient.", - }); - } + c.set("accountId", accountId); + await next(); }; } diff --git a/apps/api/src/routes/api/auth.ts b/apps/api/src/routes/api/auth.ts new file mode 100644 index 00000000..6e714e08 --- /dev/null +++ b/apps/api/src/routes/api/auth.ts @@ -0,0 +1,67 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { AuthService } from "../../services/auth.service"; +import { Env } from "../../types/app"; +import { setCookie } from "hono/cookie"; + +export const authRoutes = new Hono(); + +const CreateAuthRequestSchema = z.object({ + accountId: z.string(), +}); + +const VerifyAuthRequestSchema = z.object({ + token: z.string(), + accountId: z.string(), +}); + +authRoutes.post( + "/initiate-login", + zValidator("json", CreateAuthRequestSchema), + async (c) => { + const payload = c.req.valid("json"); + const sp = c.var.sp; + const authService = sp.getService("authService"); + const result = await authService.createAuthRequest(payload); + return c.json(result); + }, +); + +authRoutes.post( + "/verify-login", + zValidator("json", VerifyAuthRequestSchema), + async (c) => { + const payload = c.req.valid("json"); + const sp = c.var.sp; + const authService = sp.getService("authService"); + try { + const { jwt } = await authService.verifyAuthRequest(payload); + setCookie(c, "token", jwt, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "Strict", + path: "/", + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + return c.json({ success: true }); + } catch (error: unknown) { + c.status(401); + return c.json({ + success: false, + error: error instanceof Error ? error.message : "Authentication failed", + }); + } + }, +); + +authRoutes.post("/logout", async (c) => { + setCookie(c, "token", "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "Strict", + path: "/", + maxAge: 0, + }); + return c.json({ success: true }); +}); diff --git a/apps/api/src/routes/api/feeds.ts b/apps/api/src/routes/api/feeds.ts index 56e071e4..72765ed0 100644 --- a/apps/api/src/routes/api/feeds.ts +++ b/apps/api/src/routes/api/feeds.ts @@ -1,266 +1,308 @@ +import { + ApiErrorResponseSchema, + CanModerateResponseSchema, + CreateFeedRequestSchema, + FeedsWrappedResponseSchema, + FeedWrappedResponseSchema, + UpdateFeedRequestSchema, +} from "@curatedotfun/types"; +import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; +import { z } from "zod"; import { Env } from "../../types/app"; -import { badRequest } from "../../utils/error"; +import { ForbiddenError, NotFoundError } from "../../types/errors"; import { logger } from "../../utils/logger"; -import { insertFeedSchema, updateFeedSchema } from "@curatedotfun/shared-db"; -import { z } from "zod"; // Added import for z -import { zValidator } from "@hono/zod-validator"; // Added import for zValidator -import { ModerationService } from "../../services/moderation.service"; // Added import for ModerationService +import { ServiceProvider } from "../../utils/service-provider"; const feedsRoutes = new Hono(); -/** - * Get all feeds - */ +const feedIdParamSchema = z.object({ + feedId: z.string().min(1, "Feed ID is required"), +}); + +// GET /api/feeds - Get all feeds feedsRoutes.get("/", async (c) => { - const sp = c.get("sp"); - const feedService = sp.getFeedService(); try { + const feedService = ServiceProvider.getInstance().getFeedService(); const feeds = await feedService.getAllFeeds(); - return c.json(feeds); - } catch (error) { - logger.error("Error fetching all feeds:", error); - return c.json({ error: "Failed to fetch feeds" }, 500); - } -}); - -/** - * Create a new feed - */ -feedsRoutes.post("/", async (c) => { - const accountId = c.get("accountId"); - if (!accountId) { return c.json( - { error: "Unauthorized. User must be logged in to create a feed." }, - 401, - ); - } - - const body = await c.req.json(); - const partialValidationResult = insertFeedSchema - .omit({ created_by: true }) - .safeParse(body); - - if (!partialValidationResult.success) { - return badRequest( - c, - "Invalid feed data", - partialValidationResult.error.flatten(), - ); - } - - const feedDataWithCreator = { - ...partialValidationResult.data, - created_by: accountId, - }; - - const finalValidationResult = insertFeedSchema.safeParse(feedDataWithCreator); - if (!finalValidationResult.success) { - logger.error( - "Error in final validation after adding created_by", - finalValidationResult.error, + FeedsWrappedResponseSchema.parse({ + statusCode: 200, + success: true, + data: feeds.map((feed) => ({ + ...feed, + config: feed.config, + })), + }), ); - return badRequest( - c, - "Internal validation error", - finalValidationResult.error.flatten(), - ); - } - - const sp = c.get("sp"); - const feedService = sp.getFeedService(); - try { - const newFeed = await feedService.createFeed(finalValidationResult.data); - return c.json(newFeed, 201); - } catch (error) { - logger.error("Error creating feed:", error); - return c.json({ error: "Failed to create feed" }, 500); - } -}); - -/** - * Get a specific feed by its ID - */ -feedsRoutes.get("/:feedId", async (c) => { - const feedId = c.req.param("feedId"); - const sp = c.get("sp"); - const feedService = sp.getFeedService(); - try { - const feed = await feedService.getFeedById(feedId); - if (!feed) { - return c.notFound(); - } - return c.json(feed); } catch (error) { - logger.error(`Error fetching feed ${feedId}:`, error); - return c.json({ error: "Failed to fetch feed" }, 500); - } -}); - -/** - * Update an existing feed - */ -feedsRoutes.put("/:feedId", async (c) => { - const accountId = c.get("accountId"); - if (!accountId) { - return c.json( - { error: "Unauthorized. User must be logged in to update a feed." }, - 401, - ); - } - - const feedId = c.req.param("feedId"); - const sp = c.get("sp"); - const feedService = sp.getFeedService(); - - const canUpdate = await feedService.hasPermission( - accountId, - feedId, - "update", - ); - if (!canUpdate) { + logger.error({ error }, "Error fetching all feeds"); return c.json( - { error: "Forbidden. You do not have permission to update this feed." }, - 403, + ApiErrorResponseSchema.parse({ + statusCode: 500, + success: false, + error: { message: "Failed to fetch feeds" }, + }), + 500, ); } +}); - const body = await c.req.json(); - const validationResult = updateFeedSchema.safeParse(body); - - if (!validationResult.success) { - return badRequest(c, "Invalid feed data", validationResult.error.flatten()); - } - - try { - const updatedFeed = await feedService.updateFeed( - feedId, - validationResult.data, - ); - if (!updatedFeed) { - return c.notFound(); +// POST /api/feeds - Create a new feed +feedsRoutes.post( + "/", + zValidator("json", CreateFeedRequestSchema), + async (c) => { + const accountId = c.get("accountId"); + if (!accountId) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 401, + success: false, + error: { message: "Unauthorized. User must be logged in." }, + }), + 401, + ); } - return c.json(updatedFeed); - } catch (error) { - logger.error(`Error updating feed ${feedId}:`, error); - return c.json({ error: "Failed to update feed" }, 500); - } -}); -/** - * Process approved submissions for a feed - * Optional query parameter: distributors - comma-separated list of distributor plugins to use - * Example: /api/feeds/solana/process?distributors=@curatedotfun/rss - */ -feedsRoutes.post("/:feedId/process", async (c) => { - const accountId = c.get("accountId"); - if (!accountId) { - return c.json( - { error: "Unauthorized. User must be logged in to process a feed." }, - 401, - ); - } + try { + const feedConfig = c.req.valid("json"); + const feedService = ServiceProvider.getInstance().getFeedService(); + const newFeed = await feedService.createFeed(feedConfig, accountId); - const sp = c.get("sp"); - const feedService = sp.getFeedService(); + return c.json( + FeedWrappedResponseSchema.parse({ + statusCode: 201, + success: true, + data: { + ...newFeed, + config: newFeed.config, + }, + }), + 201, + ); + } catch (error) { + logger.error({ error, accountId }, "Error creating feed"); + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 500, + success: false, + error: { message: "Failed to create feed" }, + }), + 500, + ); + } + }, +); - const feedId = c.req.param("feedId"); - const distributorsParam = c.req.query("distributors"); +// GET /api/feeds/:feedId - Get a specific feed +feedsRoutes.get( + "/:feedId", + zValidator("param", feedIdParamSchema), + async (c) => { + try { + const { feedId } = c.req.valid("param"); + const feedService = ServiceProvider.getInstance().getFeedService(); + const feed = await feedService.getFeedById(feedId); - try { - const result = await feedService.processFeed(feedId, distributorsParam); - return c.json(result); - } catch (error: any) { - logger.error(`Error processing feed ${feedId}:`, error); - // FeedService.processFeed might throw specific errors (e.g., NotFoundError) - // For now, a generic 500, but could be more specific based on error type - if (error.message && error.message.startsWith("Feed not found")) { - return c.json({ error: error.message }, 404); + return c.json( + FeedWrappedResponseSchema.parse({ + statusCode: 200, + success: true, + data: { + ...feed, + config: feed.config, + }, + }), + ); + } catch (error) { + logger.error({ error }, `Error fetching feed`); + if (error instanceof NotFoundError) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 404, + success: false, + error: { message: error.message }, + }), + 404, + ); + } + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 500, + success: false, + error: { message: "Failed to fetch feed" }, + }), + 500, + ); } - if ( - error.message && - error.message.startsWith("Feed configuration not found") - ) { - return c.json({ error: error.message }, 404); // Or 500 if it's an internal config issue + }, +); + +// PUT /api/feeds/:feedId - Update an existing feed +feedsRoutes.put( + "/:feedId", + zValidator("param", feedIdParamSchema), + zValidator("json", UpdateFeedRequestSchema), + async (c) => { + const accountId = c.get("accountId"); + if (!accountId) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 401, + success: false, + error: { message: "Unauthorized. User must be logged in." }, + }), + 401, + ); } - return c.json({ error: "Failed to process feed" }, 500); - } -}); -/** - * Delete a specific feed by its ID - */ -feedsRoutes.delete("/:feedId", async (c) => { - const accountId = c.get("accountId"); - if (!accountId) { - return c.json( - { error: "Unauthorized. User must be logged in to delete a feed." }, - 401, - ); - } + try { + const { feedId } = c.req.valid("param"); + const feedConfig = c.req.valid("json"); + const feedService = ServiceProvider.getInstance().getFeedService(); - const feedId = c.req.param("feedId"); - const sp = c.get("sp"); - const feedService = sp.getFeedService(); + const updatedFeed = await feedService.updateFeed( + feedId, + feedConfig, + accountId, + ); - const canDelete = await feedService.hasPermission( - accountId, - feedId, - "delete", - ); - if (!canDelete) { - return c.json( - { error: "Forbidden. You do not have permission to delete this feed." }, - 403, - ); - } + return c.json( + FeedWrappedResponseSchema.parse({ + statusCode: 200, + success: true, + data: { + ...updatedFeed, + config: updatedFeed.config, + }, + }), + ); + } catch (error) { + logger.error({ error, accountId }, "Error updating feed"); + if (error instanceof NotFoundError) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 404, + success: false, + error: { message: error.message }, + }), + 404, + ); + } + if (error instanceof ForbiddenError) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 403, + success: false, + error: { message: error.message }, + }), + 403, + ); + } + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 500, + success: false, + error: { message: "Failed to update feed" }, + }), + 500, + ); + } + }, +); - try { - const result = await feedService.deleteFeed(feedId); - if (!result) { - return c.notFound(); +// DELETE /api/feeds/:feedId - Delete a feed +feedsRoutes.delete( + "/:feedId", + zValidator("param", feedIdParamSchema), + async (c) => { + const accountId = c.get("accountId"); + if (!accountId) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 401, + success: false, + error: { message: "Unauthorized. User must be logged in." }, + }), + 401, + ); } - return c.json({ message: "Feed deleted successfully" }, 200); - } catch (error) { - logger.error(`Error deleting feed ${feedId}:`, error); - return c.json({ error: "Failed to delete feed" }, 500); - } -}); -const feedParamSchemaCanModerate = z.object({ - // Renamed to avoid conflict if other schemas exist - feedId: z.string().min(1, "Feed ID is required"), -}); + try { + const { feedId } = c.req.valid("param"); + const feedService = ServiceProvider.getInstance().getFeedService(); + await feedService.deleteFeed(feedId, accountId); + return c.body(null, 204); + } catch (error: unknown) { + logger.error({ error, accountId }, "Error deleting feed"); + if (error instanceof NotFoundError) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 404, + success: false, + error: { message: error.message }, + }), + 404, + ); + } + if (error instanceof ForbiddenError) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 403, + success: false, + error: { message: error.message }, + }), + 403, + ); + } + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 500, + success: false, + error: { message: "Failed to delete feed" }, + }), + 500, + ); + } + }, +); +// GET /api/feeds/:feedId/can-moderate - Check moderation permission feedsRoutes.get( "/:feedId/can-moderate", - zValidator("param", feedParamSchemaCanModerate), + zValidator("param", feedIdParamSchema), async (c) => { - const { feedId } = c.req.valid("param"); - const sp = c.get("sp"); - const moderationService = - sp.getService("moderationService"); - const actingAccountId = c.get("accountId"); - if (!actingAccountId) { - return c.json({ canModerate: false, reason: "User not authenticated" }); + return c.json( + CanModerateResponseSchema.parse({ + canModerate: false, + reason: "User not authenticated", + }), + ); } try { + const { feedId } = c.req.valid("param"); + const moderationService = + ServiceProvider.getInstance().getModerationService(); const canModerate = await moderationService.checkUserFeedModerationPermission( feedId, actingAccountId, ); - return c.json({ canModerate }); - } catch (error: any) { + return c.json(CanModerateResponseSchema.parse({ canModerate })); + } catch (error: unknown) { logger.error( - `Error in /:feedId/can-moderate for feed ${feedId}, user ${actingAccountId}:`, - error, + { error, actingAccountId }, + "Error in /:feedId/can-moderate", ); return c.json( - { canModerate: false, error: "Failed to check moderation permission" }, + CanModerateResponseSchema.parse({ + canModerate: false, + error: "Failed to check moderation permission", + }), 500, ); } diff --git a/apps/api/src/routes/api/index.ts b/apps/api/src/routes/api/index.ts index 6fde926d..436b81d4 100644 --- a/apps/api/src/routes/api/index.ts +++ b/apps/api/src/routes/api/index.ts @@ -14,6 +14,7 @@ import { activityRoutes } from "./activity"; import { uploadRoutes } from "./upload"; import { pluginsRoutes } from "./plugins"; import { moderationRoutes } from "./moderation"; +import { authRoutes } from "./auth"; // Create main API router export const apiRoutes = new Hono(); @@ -36,3 +37,4 @@ apiRoutes.route("/users", usersRoutes); apiRoutes.route("/activity", activityRoutes); apiRoutes.route("/upload", uploadRoutes); apiRoutes.route("/moderate", moderationRoutes); +apiRoutes.route("/auth", authRoutes); diff --git a/apps/api/src/routes/api/moderation.ts b/apps/api/src/routes/api/moderation.ts index 337c6423..f783cdd6 100644 --- a/apps/api/src/routes/api/moderation.ts +++ b/apps/api/src/routes/api/moderation.ts @@ -37,7 +37,7 @@ moderationRoutes.post( }), 201, ); - } catch (error: any) { + } catch (error: unknown) { console.error("Error in moderationRoutes.post('/'):", error); if (error instanceof NotFoundError || error instanceof ServiceError) { @@ -91,7 +91,7 @@ moderationRoutes.get( data: moderation, }), ); - } catch (error: any) { + } catch (error: unknown) { console.error("Error in moderationRoutes.get('/:id'):", error); if (error instanceof NotFoundError || error instanceof ServiceError) { @@ -136,7 +136,7 @@ moderationRoutes.get( data: moderations, }), ); - } catch (error: any) { + } catch (error: unknown) { console.error( "Error in moderationRoutes.get('/submission/:submissionId'):", error, @@ -187,7 +187,7 @@ moderationRoutes.get( data: moderations, }), ); - } catch (error: any) { + } catch (error: unknown) { console.error( "Error in moderationRoutes.get('/submission/:submissionId/feed/:feedId'):", error, diff --git a/apps/api/src/routes/api/plugins.ts b/apps/api/src/routes/api/plugins.ts index fdd5b4f5..9e03cc1a 100644 --- a/apps/api/src/routes/api/plugins.ts +++ b/apps/api/src/routes/api/plugins.ts @@ -28,7 +28,7 @@ pluginsRoutes.post( try { const newPlugin = await pluginRepository.createPlugin(pluginData); return c.json(newPlugin, 201); - } catch (error: any) { + } catch (error: unknown) { console.error("Error registering plugin:", { error, pluginData }); if (error.code === "PLUGIN_ALREADY_EXISTS") { throw new HTTPException(409, { message: error.message }); diff --git a/apps/api/src/routes/api/users.ts b/apps/api/src/routes/api/users.ts index d88c4273..96468191 100644 --- a/apps/api/src/routes/api/users.ts +++ b/apps/api/src/routes/api/users.ts @@ -92,7 +92,7 @@ usersRoutes.post( }), 201, ); - } catch (error: any) { + } catch (error: unknown) { logger.error({ error }, "Error in usersRoutes.post('/')"); if ( @@ -228,7 +228,7 @@ usersRoutes.delete("/me", async (c) => { 500, ); } - } catch (error: any) { + } catch (error: unknown) { logger.error({ error }, "Error in usersRoutes.delete('/me')"); if ( diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts new file mode 100644 index 00000000..568da260 --- /dev/null +++ b/apps/api/src/services/auth.service.ts @@ -0,0 +1,103 @@ +import { + AuthRequestRepository, + InsertAuthRequest, +} from "@curatedotfun/shared-db"; +import { toHex } from "@fastnear/utils"; +import { randomBytes } from "crypto"; +import { sign } from "hono/jwt"; +import { verify } from "near-sign-verify"; +import { z } from "zod"; +import { UserService } from "./users.service"; + +const AUTH_REQUEST_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes +const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 7; // 7 days + +const CreateAuthRequestSchema = z.object({ + accountId: z.string(), +}); + +const VerifyAuthRequestSchema = z.object({ + token: z.string(), + accountId: z.string(), +}); + +export class AuthService { + private userService: UserService; + private authRequestRepository: AuthRequestRepository; + + constructor( + authRequestRepository: AuthRequestRepository, + userService: UserService, + ) { + this.authRequestRepository = authRequestRepository; + this.userService = userService; + } + + async createAuthRequest(payload: z.infer) { + const { accountId } = payload; + await this.userService.ensureUserProfile(accountId); + + const nonce = randomBytes(32).toString("hex"); + + const expiresAt = new Date(Date.now() + AUTH_REQUEST_EXPIRY_MS); + + const newAuthRequest: InsertAuthRequest = { + nonce, + accountId, + expiresAt, + }; + + await this.authRequestRepository.create(newAuthRequest); + + return { + nonce, + recipient: "curatefun.near", + }; + } + + async verifyAuthRequest(payload: z.infer) { + const { token, accountId } = payload; + + const latestRequest = + await this.authRequestRepository.findLatestByAccountId(accountId); + + if (!latestRequest) { + throw new Error("No recent auth request found for this account."); + } + + if (latestRequest.expiresAt < new Date()) { + await this.authRequestRepository.deleteById(latestRequest.id); + throw new Error("Auth request has expired."); + } + + const message = `Authorize Curate.fun`; + + const verificationResult = await verify(token, { + expectedRecipient: "curatefun.near", + expectedMessage: message, + validateNonce: (nonceFromToken) => { + const receivedNonceHex = toHex(nonceFromToken); + return receivedNonceHex === latestRequest.nonce; + }, + }); + + if (verificationResult.accountId !== accountId) { + throw new Error("Account ID mismatch."); + } + + await this.authRequestRepository.deleteById(latestRequest.id); + + const jwtPayload = { + sub: accountId, + exp: Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS, + }; + + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is not set."); + } + + const jwt = await sign(jwtPayload, secret); + return { jwt }; + } +} diff --git a/apps/api/src/services/feed.service.ts b/apps/api/src/services/feed.service.ts index 43e37474..c5cd25cd 100644 --- a/apps/api/src/services/feed.service.ts +++ b/apps/api/src/services/feed.service.ts @@ -1,22 +1,24 @@ import { - DistributorConfig, FeedRepository, InsertFeed, RichSubmission, - StreamConfig, submissionStatusZodEnum, UpdateFeed, type DB, } from "@curatedotfun/shared-db"; +import { + DistributorConfig, + FeedConfig, + StreamConfig, +} from "@curatedotfun/types"; import { Logger } from "pino"; +import { ForbiddenError, NotFoundError } from "../types/errors"; +import { isSuperAdmin } from "../utils/auth.utils"; import { IBaseService } from "./interfaces/base-service.interface"; import { ProcessorService } from "./processor.service"; -import { isSuperAdmin } from "../utils/auth.utils"; +import { merge } from "lodash"; -export type FeedAction = - | "update" // For general updates to feed config, name, description - | "delete" - | "manage_admins"; // For adding/removing users from the feed's admin list +export type FeedAction = "update" | "delete" | "manage_admins"; export class FeedService implements IBaseService { public readonly logger: Logger; @@ -50,7 +52,7 @@ export class FeedService implements IBaseService { return true; } - const feed = await this.feedRepository.getFeedById(feedId); + const feed = await this.feedRepository.findFeedById(feedId); if (!feed) { this.logger.warn( { accountId, feedId, action }, @@ -83,33 +85,80 @@ export class FeedService implements IBaseService { return this.feedRepository.getAllFeeds(); } - async createFeed(data: InsertFeed) { + async createFeed(feedConfig: FeedConfig, accountId: string) { + const dbData: InsertFeed = { + id: feedConfig.id, + name: feedConfig.name, + description: feedConfig.description, + created_by: accountId, + config: feedConfig, + }; + return this.db.transaction(async (tx) => { - return this.feedRepository.createFeed(data, tx); + return this.feedRepository.createFeed(dbData, tx); }); } async getFeedById(feedId: string) { - return this.feedRepository.getFeedById(feedId); + const feed = await this.feedRepository.findFeedById(feedId); + if (!feed) { + throw new NotFoundError("Feed", feedId); + } + return feed; } - async updateFeed(feedId: string, data: UpdateFeed) { + async updateFeed( + feedId: string, + data: Partial, + accountId: string, + ) { + const hasPermission = await this.hasPermission(accountId, feedId, "update"); + if (!hasPermission) { + throw new ForbiddenError( + "You do not have permission to update this feed.", + ); + } + + // Fetch the existing feed to merge the config + const existingFeed = await this.getFeedById(feedId); + const existingConfig = existingFeed.config as FeedConfig; + + const newConfig: FeedConfig = merge({}, existingConfig, data); + + const dbData: UpdateFeed = { + name: newConfig.name, + description: newConfig.description, + config: newConfig, + admins: newConfig.admins as string[], + }; + return this.db.transaction(async (tx) => { - return this.feedRepository.updateFeed(feedId, data, tx); + const updatedFeed = await this.feedRepository.updateFeed( + feedId, + dbData, + tx, + ); + if (!updatedFeed) { + throw new NotFoundError("Feed", feedId); + } + return updatedFeed; }); } - async deleteFeed(feedId: string) { + async deleteFeed(feedId: string, accountId: string) { + const hasPermission = await this.hasPermission(accountId, feedId, "delete"); + if (!hasPermission) { + throw new ForbiddenError( + "You do not have permission to delete this feed.", + ); + } + return this.db.transaction(async (tx) => { - const result = await this.feedRepository.deleteFeed(feedId, tx); - if (result === 0) { - this.logger.warn( - { feedId }, - "FeedService: deleteFeed - Feed not found or not deleted", - ); - return 0; + const deletedFeed = await this.feedRepository.deleteFeed(feedId, tx); + if (!deletedFeed) { + throw new NotFoundError("Feed", feedId); } - return result; + return deletedFeed; }); } @@ -117,16 +166,16 @@ export class FeedService implements IBaseService { // In order to process a feed, you must be the feed owner // this will be called by trigger/ async processFeed(feedId: string, distributorsParam?: string) { - const feed = await this.feedRepository.getFeedById(feedId); + const feed = await this.feedRepository.findFeedById(feedId); if (!feed) { this.logger.error( { feedId }, "FeedService: processFeed - Feed not found", ); - throw new Error(`Feed not found: ${feedId}`); // Or a custom NotFoundError + throw new NotFoundError("Feed", feedId); } - const feedConfig = await this.feedRepository.getFeedConfig(feedId); // Get config from DB + const feedConfig = await this.feedRepository.getFeedConfig(feedId); if (!feedConfig) { this.logger.error( { feedId }, @@ -176,7 +225,8 @@ export class FeedService implements IBaseService { .split(",") .map((d) => d.trim()); const availableDistributors = - streamConfig.distribute?.map((d) => d.plugin) || []; + streamConfig.distribute?.map((d: DistributorConfig) => d.plugin) || + []; const validDistributors = requestedDistributors.filter((d) => availableDistributors.includes(d), ); @@ -187,7 +237,11 @@ export class FeedService implements IBaseService { if (invalidDistributors.length > 0) { this.logger.warn( { feedId, invalidDistributors, availableDistributors }, - `Invalid distributor(s) specified for feed ${feedId}: ${invalidDistributors.join(", ")}. Available: ${availableDistributors.join(", ")}`, + `Invalid distributor(s) specified for feed ${ + feedId + }: ${invalidDistributors.join( + ", ", + )}. Available: ${availableDistributors.join(", ")}`, ); } @@ -204,7 +258,11 @@ export class FeedService implements IBaseService { validDistributors.forEach((d) => usedDistributors.add(d)); this.logger.info( { submissionId: submission.tweetId, feedId, validDistributors }, - `Processing submission ${submission.tweetId} for feed ${feedId} with selected distributors: ${validDistributors.join(", ")}`, + `Processing submission ${ + submission.tweetId + } for feed ${feedId} with selected distributors: ${validDistributors.join( + ", ", + )}`, ); } } diff --git a/apps/api/src/services/moderation.service.ts b/apps/api/src/services/moderation.service.ts index b49aba31..62feea26 100644 --- a/apps/api/src/services/moderation.service.ts +++ b/apps/api/src/services/moderation.service.ts @@ -259,7 +259,7 @@ export class ModerationService implements IBaseService { }, "Moderation action processed successfully via API.", ); - } catch (error: any) { + } catch (error: unknown) { if ( error instanceof NotFoundError || error instanceof AuthorizationError || @@ -333,7 +333,7 @@ export class ModerationService implements IBaseService { feedConfig.outputs.stream, ); } - } catch (error: any) { + } catch (error: unknown) { if ( error instanceof NotFoundError || error instanceof ModerationServiceError diff --git a/apps/api/src/services/users.service.ts b/apps/api/src/services/users.service.ts index 2bc0ae72..af54e869 100644 --- a/apps/api/src/services/users.service.ts +++ b/apps/api/src/services/users.service.ts @@ -46,6 +46,16 @@ export class UserService implements IBaseService { return UserProfileSchema.parse(parsedUser); } + async ensureUserProfile(nearAccountId: string): Promise { + const existingUser = await this.findUserByNearAccountId(nearAccountId); + if (existingUser) { + return existingUser; + } + + const newUser = await this.createUser({ nearAccountId }); + return newUser; + } + /** * Find a user by NEAR account ID and return as API UserProfile */ @@ -77,7 +87,7 @@ export class UserService implements IBaseService { const parsedUser = selectUserSchema.parse(newUser); return UserProfileSchema.parse(parsedUser); - } catch (error: any) { + } catch (error: unknown) { // If the error is already a UserServiceError or NearAccountError, rethrow it if ( error instanceof UserServiceError || @@ -115,7 +125,7 @@ export class UserService implements IBaseService { } const parsedUser = selectUserSchema.parse(updatedUser); return UserProfileSchema.parse(parsedUser); - } catch (error: any) { + } catch (error: unknown) { if (error instanceof UserServiceError || error instanceof NotFoundError) { throw error; } @@ -149,7 +159,7 @@ export class UserService implements IBaseService { const parsedUser = selectUserSchema.parse(updatedUser); return UserProfileSchema.parse(parsedUser); - } catch (error: any) { + } catch (error: unknown) { if (error instanceof UserServiceError || error instanceof NotFoundError) { throw error; } @@ -213,7 +223,7 @@ export class UserService implements IBaseService { }); logger.info(`Successfully deleted NEAR account: ${nearAccountId}`); - } catch (nearError: any) { + } catch (nearerror: unknown) { logger.error( { error: nearError }, `Error deleting NEAR account ${nearAccountId}`, @@ -252,7 +262,7 @@ export class UserService implements IBaseService { ); } return true; - } catch (error: any) { + } catch (error: unknown) { if ( error instanceof UserServiceError || error instanceof NotFoundError || @@ -318,7 +328,7 @@ export class UserService implements IBaseService { }); logger.info(`Successfully deleted NEAR account: ${nearAccountId}`); - } catch (nearError: any) { + } catch (nearerror: unknown) { logger.error( { error: nearError }, `Error deleting NEAR account ${nearAccountId}`, @@ -354,7 +364,7 @@ export class UserService implements IBaseService { ); } return true; - } catch (error: any) { + } catch (error: unknown) { if ( error instanceof UserServiceError || error instanceof NotFoundError || diff --git a/apps/api/src/types/errors.ts b/apps/api/src/types/errors.ts index 039edb2f..f8ce905e 100644 --- a/apps/api/src/types/errors.ts +++ b/apps/api/src/types/errors.ts @@ -1,240 +1,69 @@ -/** - * Base application error class - * All application errors should extend this class - */ -export class AppError extends Error { +import { z } from "zod"; + +export class ApiError extends Error { constructor( message: string, - public statusCode: number = 500, - public cause?: Error, + public statusCode: number, + public details?: any, ) { super(message); this.name = this.constructor.name; - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } - - /** - * Serializes the error for API responses - */ - toJSON() { - return { - error: this.name, - message: this.message, - statusCode: this.statusCode, - }; } } -/** - * Authentication related errors - */ -export class AuthError extends AppError { - constructor(message: string, statusCode: number = 401, cause?: Error) { - super(message, statusCode, cause); +export class NotFoundError extends ApiError { + constructor(resource: string, id: string | number) { + super(`${resource} with ID ${id} not found`, 404); } } -/** - * Validation related errors - */ -export class ValidationError extends AppError { - constructor( - message: string, - public details?: Record, - cause?: Error, - ) { - super(message, 400, cause); - } - - toJSON() { - return { - ...super.toJSON(), - details: this.details, - }; +export class BadRequestError extends ApiError { + constructor(message: string, details?: any) { + super(message, 400, details); } } -/** - * Service layer errors - */ -export class ServiceError extends AppError { - constructor(message: string, statusCode: number = 500, cause?: Error) { - super(message, statusCode, cause); - } -} - -/** - * Database related errors - */ -export class DatabaseError extends AppError { - constructor( - message: string, - public code?: string, - cause?: Error, - ) { - super(message, 500, cause); - } - - toJSON() { - return { - ...super.toJSON(), - code: this.code, - }; - } -} - -/** - * Authorization errors (e.g., insufficient permissions) - */ -export class AuthorizationError extends AppError { - constructor(message: string, statusCode: number = 403, cause?: Error) { - super(message, statusCode, cause); - } -} - -/** - * Not found errors - */ -export class NotFoundError extends AppError { - constructor(resource: string, identifier?: string | number) { - const message = identifier - ? `${resource} with identifier ${identifier} not found` - : `${resource} not found`; - super(message, 404); - } -} - -/** - * Conflict errors (e.g., duplicate resources) - */ -export class ConflictError extends AppError { - constructor(message: string, cause?: Error) { - super(message, 409, cause); - } -} - -/** - * User service specific errors - */ -export class UserServiceError extends ServiceError { - constructor(message: string, statusCode: number = 500, cause?: Error) { - super(message, statusCode, cause); - } -} - -/** - * Moderation service specific errors - */ -export class ModerationServiceError extends ServiceError { - constructor(message: string, statusCode: number = 500, cause?: Error) { - super(message, statusCode, cause); - } -} - -/** - * Activity service specific errors - */ -export class ActivityServiceError extends ServiceError { - constructor( - message: string, - options?: { statusCode?: number; cause?: Error }, - ) { - super(message, options?.statusCode || 500, options?.cause); - } -} - -/** - * NEAR account related errors - */ -export class NearAccountError extends ServiceError { - constructor(message: string, statusCode: number = 400, cause?: Error) { - super(message, statusCode, cause); - } -} - -export class PluginError extends AppError { - constructor( - message: string, - public cause?: Error, - ) { - super(message, 500, cause); - this.name = "PluginError"; - } -} - -export class PluginLoadError extends PluginError { - constructor(name: string, url: string, cause?: Error) { - super(`Failed to load plugin ${name} from ${url}`, cause); - this.name = "PluginLoadError"; - } -} - -export class PluginInitError extends PluginError { - constructor(name: string, cause?: Error) { - super(`Failed to initialize plugin ${name}`, cause); - this.name = "PluginInitError"; +export class UnauthorizedError extends ApiError { + constructor(message = "Unauthorized") { + super(message, 401); } } -export class PluginExecutionError extends PluginError { - constructor(name: string, operation: string, cause?: Error) { - super(`Plugin ${name} failed during ${operation}`, cause); - this.name = "PluginExecutionError"; +export class ForbiddenError extends ApiError { + constructor(message = "Forbidden") { + super(message, 403); } } -export type TransformStage = "global" | "distributor" | "batch"; - -export class TransformError extends AppError { - constructor( - public readonly plugin: string, - public readonly stage: TransformStage, - public readonly index: number, - message: string, - public readonly cause?: Error, - ) { - super( - `Transform error in ${stage} transform #${index + 1} (${plugin}): ${message}`, - 500, - cause, - ); - this.name = "TransformError"; +export class InternalServerError extends ApiError { + constructor(message = "Internal Server Error", details?: any) { + super(message, 500, details); } } -export class ProcessorError extends AppError { - constructor( - public readonly feedId: string, - message: string, - public readonly cause?: Error, - ) { - super(`Processing error for feed ${feedId}: ${message}`, 500, cause); - this.name = "ProcessorError"; +export class NearAccountError extends ApiError { + constructor(message: string, statusCode = 500, details?: any) { + super(message, statusCode, details); } } -/** - * JWT token related errors - */ -export class JwtTokenInvalid extends AuthError { - constructor(message = "Invalid JWT token") { - super(message, 401); - this.name = "JwtTokenInvalid"; +export class UserServiceError extends ApiError { + constructor(message: string, statusCode = 500, details?: any) { + super(message, statusCode, details); } } -export class JwtTokenExpired extends AuthError { - constructor(message = "JWT token has expired") { - super(message, 401); - this.name = "JwtTokenExpired"; +export class ActivityServiceError extends ApiError { + constructor(message: string, statusCode = 500, details?: any) { + super(message, statusCode, details); } } -export class JwtTokenSignatureMismatched extends AuthError { - constructor(message = "JWT token signature mismatch") { - super(message, 401); - this.name = "JwtTokenSignatureMismatched"; - } -} +export const ApiErrorResponseSchema = z.object({ + statusCode: z.number(), + success: z.literal(false), + error: z.object({ + message: z.string(), + details: z.any().optional(), + }), +}); diff --git a/apps/api/src/utils/plugin.ts b/apps/api/src/utils/plugin.ts index 7dda2c17..7a7abdd2 100644 --- a/apps/api/src/utils/plugin.ts +++ b/apps/api/src/utils/plugin.ts @@ -235,7 +235,7 @@ export async function fetchPackageJsonFromRepo( ); } return null; - } catch (error: any) { + } catch (error: unknown) { logger.error( `Error processing repo URL ${repoUrl} (last attempted URL: ${packageJsonUrlAttempted}): ${error.message}`, { error }, diff --git a/apps/api/src/utils/service-provider.ts b/apps/api/src/utils/service-provider.ts index 325ba39c..1587c91c 100644 --- a/apps/api/src/utils/service-provider.ts +++ b/apps/api/src/utils/service-provider.ts @@ -1,5 +1,6 @@ import { ActivityRepository, + AuthRequestRepository, FeedRepository, LeaderboardRepository, ModerationRepository, @@ -11,6 +12,7 @@ import { SubmissionService } from "services/submission.service"; import { MockTwitterService } from "../__test__/mocks/twitter-service.mock"; import { db } from "../db"; import { ActivityService } from "../services/activity.service"; +import { AuthService } from "../services/auth.service"; import { ConfigService, isProduction } from "../services/config.service"; import { DistributionService } from "../services/distribution.service"; import { FeedService } from "../services/feed.service"; @@ -86,6 +88,10 @@ export class ServiceProvider { ); this.services.set("userService", userService); + const authRequestRepository = new AuthRequestRepository(db); + const authService = new AuthService(authRequestRepository, userService); + this.services.set("authService", authService); + const feedService = new FeedService( feedRepository, processorService, @@ -207,6 +213,14 @@ export class ServiceProvider { return this.getService("userService"); } + /** + * Get the auth service + * @returns The auth service + */ + public getAuthService(): AuthService { + return this.getService("authService"); + } + /** * Get the activity service * @returns The activity service @@ -239,6 +253,10 @@ export class ServiceProvider { return this.getService("feedService"); } + public getModerationService(): ModerationService { + return this.getService("moderationService"); + } + /** * Get all services that implement IBackgroundTaskService * @returns An array of background task services diff --git a/apps/app/package.json b/apps/app/package.json index f2cd386e..f19ae52f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -14,14 +14,15 @@ }, "dependencies": { "@crosspost/sdk": "^0.3.0", + "@fastnear/utils": "^0.9.7", "@hookform/resolvers": "^5.0.1", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", - "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.6", - "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.4", @@ -29,16 +30,21 @@ "@radix-ui/react-toast": "^1.2.11", "@radix-ui/react-tooltip": "^1.2.4", "@tailwindcss/typography": "^0.5.15", + "@tanstack/react-form": "^1.12.3", "@tanstack/react-query": "^5.64.1", - "@tanstack/react-router": "1.97.0", + "@tanstack/react-router": "^1.121.27", + "@tanstack/zod-form-adapter": "^0.42.1", + "@tanstack/react-table": "^8.21.3", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "fastintear": "latest", + "immer": "^10.1.1", + "lodash": "^4.17.21", "lucide-react": "^0.483.0", - "near-sign-verify": "^0.3.6", + "near-sign-verify": "^0.4.1", "pinata-web3": "^0.5.4", "postcss": "^8.4.49", "react": "^18.3.1", @@ -59,7 +65,8 @@ "@rsbuild/core": "1.1.13", "@rsbuild/plugin-react": "1.1.0", "@tanstack/router-devtools": "1.97.23", - "@tanstack/router-plugin": "1.69.1", + "@tanstack/router-plugin": "^1.121.29", + "@types/lodash": "^4.17.16", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/apps/app/rsbuild.config.ts b/apps/app/rsbuild.config.ts index ef8a3c43..7e502bb8 100644 --- a/apps/app/rsbuild.config.ts +++ b/apps/app/rsbuild.config.ts @@ -1,6 +1,6 @@ import { defineConfig, rspack } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; -import TanStackRouterRspack from "@tanstack/router-plugin/rspack"; +import { tanstackRouter } from "@tanstack/router-plugin/rspack"; import "dotenv/config"; import path from "path"; @@ -24,6 +24,7 @@ export default defineConfig({ }, source: { alias: { + "@": path.resolve(__dirname, "./src"), "@fonts": path.resolve(__dirname, "public/fonts"), }, define: { @@ -45,9 +46,9 @@ export default defineConfig({ tools: { rspack: { plugins: [ - TanStackRouterRspack({ + tanstackRouter({ routesDirectory: "./src/routes", - enableRouteGeneration: false, + enableRouteGeneration: true, }), ...(isProduction || isStaging ? [] diff --git a/apps/app/src/components/BasicInformationForm.tsx b/apps/app/src/components/BasicInformationForm.tsx index 90010f21..ad80e808 100644 --- a/apps/app/src/components/BasicInformationForm.tsx +++ b/apps/app/src/components/BasicInformationForm.tsx @@ -30,7 +30,7 @@ export default function BasicInformationForm() { const { profileImage: storedProfileImage, - feedName, + name, description, hashtags, setBasicInfo, diff --git a/apps/app/src/components/ContentApprovers.tsx b/apps/app/src/components/ContentApprovers.tsx index 7f41624f..4b0fb1e8 100644 --- a/apps/app/src/components/ContentApprovers.tsx +++ b/apps/app/src/components/ContentApprovers.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useFieldArray, useFormContext } from "react-hook-form"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Plus, X } from "lucide-react"; -import { useFeedCreationStore } from "../store/feed-creation-store"; import { Select, SelectContent, @@ -10,16 +10,24 @@ import { SelectTrigger, SelectValue, } from "./ui/select"; +import { useFeedCreationStore, Approver } from "../store/feed-creation-store"; export default function ContentApprovers() { - const { approvers, setApprovers } = useFeedCreationStore(); + const { control, watch } = useFormContext(); + const { setApprovers } = useFeedCreationStore(); + const { fields, append, remove } = useFieldArray({ + control, + name: "approvers", + }); const [showForm, setShowForm] = useState(false); const [handle, setHandle] = useState(""); const [platform, setPlatform] = useState("Twitter"); - const handleDeleteApprover = (id: number) => { - setApprovers(approvers.filter((approver) => approver.id !== id)); - }; + const approvers = watch("approvers"); + + useEffect(() => { + setApprovers(approvers); + }, [approvers, setApprovers]); const handleAddApprover = () => { setShowForm(true); @@ -27,12 +35,7 @@ export default function ContentApprovers() { const handleSaveApprover = () => { if (handle.trim()) { - const newId = - approvers.length > 0 ? Math.max(...approvers.map((a) => a.id)) + 1 : 1; - setApprovers([ - ...approvers, - { id: newId, name: "", handle: handle.trim() }, - ]); + append({ handle: handle.trim(), platform }); setHandle(""); setShowForm(false); } @@ -124,59 +127,61 @@ export default function ContentApprovers() { )}
- {approvers.map((approver) => ( -
-
- - {approver.name} - - {" "} - @{approver.handle} + {fields.map((field, index) => { + const typedField = field as Approver & { id: string }; + return ( +
+
+ + + {" "} + @{typedField.handle} + - -
+
+ + + + {typedField.platform} +
+
+
+
- -
- ))} + ); + })}
); diff --git a/apps/app/src/components/CurationFormSteps.tsx b/apps/app/src/components/CurationFormSteps.tsx deleted file mode 100644 index b8d6c9da..00000000 --- a/apps/app/src/components/CurationFormSteps.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; -import { toast } from "../hooks/use-toast"; -import { useCreateFeed } from "../lib/api"; -import { useFeedCreationStore } from "../store/feed-creation-store"; -import BasicInformationForm from "./BasicInformationForm"; -import CurationSettingsForm from "./CurationSettingsForm"; -import FeedReviewForm from "./FeedReviewForm"; -import { Button } from "./ui/button"; -import { Progress } from "./ui/progress"; - -// Define step content types -type Step = { - title: string; - description: string; - component: React.ReactNode; -}; - -// Types for feedConfig outputs -interface OutputTransformConfig { - mappings?: { [key: string]: string }; - apiKey?: string; - prompt?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - schema?: { [key: string]: any }; // Consider making this more specific if possible - template?: string; - botToken?: string; - channelId?: string; - threadId?: string; - // Add other potential config fields here - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; // Add index signature to allow any string keys -} - -interface OutputPluginAction { - config: OutputTransformConfig; - plugin: string; - transform?: OutputPluginAction[]; -} - -interface OutputsStream { - enabled: boolean; - transform: OutputPluginAction[]; - distribute: OutputPluginAction[]; -} - -export default function CurationFormSteps() { - const [currentStep, setCurrentStep] = useState(0); - const [isSubmitting, setIsSubmitting] = useState(false); - const feedData = useFeedCreationStore(); - const navigate = useNavigate(); - const createFeedMutation = useCreateFeed(); - - // Define the steps - const steps: Step[] = [ - { - title: "Basic Information", - description: "Enter the basic details for your feed", - component: , - }, - { - title: "Curation Settings", - description: "Configure how content is curated", - component: , - }, - { - title: "Feed Review", - description: "Review your feed before publishing", - component: , - }, - ]; - - // Calculate progress percentage - const progressValue = ((currentStep + 1) / steps.length) * 100; - - // Navigation handlers - const handleNext = () => { - if (currentStep < steps.length - 1) { - // If moving from BasicInformationForm to the next step, set createdAt and show toast - if (currentStep === 0) { - feedData.setBasicInfo({ createdAt: new Date() }); - toast({ - title: "Information Saved", - description: "Your feed information has been saved.", - variant: "default", - }); - } - setCurrentStep(currentStep + 1); - } - }; - - const handlePrevious = () => { - if (currentStep > 0) { - setCurrentStep(currentStep - 1); - } - }; - - return ( -
-
-
- Step {currentStep + 1} of {steps.length} -
- -
- {steps.map((step, index) => ( -
- - {step.title} - -
- ))} -
-
-
- {currentStep === 0 && ( -
-

- {steps[currentStep].title} -

-

- {steps[currentStep].description} -

-
- )} - - {/* Step form content */} -
{steps[currentStep].component}
-
- - - {currentStep === steps.length - 1 ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/apps/app/src/components/CurationSettingsForm.tsx b/apps/app/src/components/CurationSettingsForm.tsx deleted file mode 100644 index a7129d85..00000000 --- a/apps/app/src/components/CurationSettingsForm.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import ContentApprovers from "./ContentApprovers"; -// import ContentProgress from "./ContentProgress"; -import PublishingIntegrations from "./PublishIntegrations"; - -const StepHeader = ({ - number, - title, - description, -}: { - number: number; - title: string; - description: string; -}) => ( -
-
-
- {number} -
-

- {title} -

-
-

- {description} -

-
-); - -type Step = { - title: string; - id: number; - description: string; - component: React.ReactNode; -}; - -const steps: Step[] = [ - // { - // title: "Content Progress Configuration", - // description: "Define how content is processed before being published", - // id: 1, - // component: , - // }, - { - title: "Publishing Integrations", - description: "Define how content is processed before being published", - - id: 2, - component: , - }, - { - title: "Content Approvers", - description: "Define how content is processed before being published", - - id: 3, - component: , - }, - // { - // title: "Submission Rules", - // description: "Set requirements for content submissions to your feed", - - // id: 4, - // component: , - // }, -]; - -// Fixed function name (was CurationFormSteps) -export default function CurationSettingsForm() { - return ( -
- {steps.map((step) => ( -
- -
{step.component}
-
- ))} -
- ); -} diff --git a/apps/app/src/components/DistributorBadges.tsx b/apps/app/src/components/DistributorBadges.tsx index c0217366..322df716 100644 --- a/apps/app/src/components/DistributorBadges.tsx +++ b/apps/app/src/components/DistributorBadges.tsx @@ -6,19 +6,18 @@ export function DistributorBadges({ distribute: { plugin: string }[]; }) { return ( - <> -

Posting to:

- {distribute.map((distributor) => { +
+ {distribute.map((distributor, index) => { const pluginName = distributor.plugin.replace("@curatedotfun/", ""); return ( {pluginName} ); })} - +
); } diff --git a/apps/app/src/components/FeedItem.tsx b/apps/app/src/components/FeedItem.tsx index 5a6f25fc..aed61a19 100644 --- a/apps/app/src/components/FeedItem.tsx +++ b/apps/app/src/components/FeedItem.tsx @@ -4,7 +4,7 @@ import { useApproveSubmission, useRejectSubmission, } from "../lib/api/moderation"; -import { useCanModerateFeed } from "../lib/api/feed"; +import { useCanModerateFeed } from "../lib/api/feeds"; import { getTweetUrl } from "../lib/twitter"; import { formatDate } from "../utils/datetime"; import { Badge } from "./ui/badge"; diff --git a/apps/app/src/components/FeedList.tsx b/apps/app/src/components/FeedList.tsx index 04084929..e9da9a0e 100644 --- a/apps/app/src/components/FeedList.tsx +++ b/apps/app/src/components/FeedList.tsx @@ -4,6 +4,8 @@ import { useAllFeeds } from "../lib/api"; const FeedList = () => { const { data: feeds = [] } = useAllFeeds(); + console.log("feeds", feeds); + return (
diff --git a/apps/app/src/components/Leaderboard.tsx b/apps/app/src/components/Leaderboard.tsx index 8006469b..48099bff 100644 --- a/apps/app/src/components/Leaderboard.tsx +++ b/apps/app/src/components/Leaderboard.tsx @@ -1,17 +1,17 @@ -import { Link } from "@tanstack/react-router"; -import { ChevronDown, ChevronUp, Search } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { LeaderboardEntry, useAllFeeds } from "../lib/api"; +import React from "react"; +import { LeaderboardEntry } from "../lib/api"; import { Container } from "./Container"; -import { UserLink } from "./FeedItem"; import { Hero } from "./Hero"; +import { useLeaderboard } from "../hooks/useLeaderboard"; +import { LeaderboardFilters } from "./leaderboard/LeaderboardFilters"; +import { LeaderboardTable } from "./leaderboard/LeaderboardTable"; interface LeaderboardSearch { feed: string; timeframe: string; } -export default function Leaderboard({ +export default React.memo(function Leaderboard({ search, leaderboard, isLoading, @@ -22,382 +22,54 @@ export default function Leaderboard({ isLoading: boolean; error: Error | null; }) { - const [expandedRows, setExpandedRows] = useState([]); - const [searchQuery, setSearchQuery] = useState(null); - const [showFeedDropdown, setShowFeedDropdown] = useState(false); - const [showTimeDropdown, setShowTimeDropdown] = useState(false); - const { data: allFeeds = [] } = useAllFeeds(); - const feedDropdownRef = useRef(null); - const timeDropdownRef = useRef(null); - - const timeOptions = [ - { label: "All Time", value: "all" }, - { label: "This Month", value: "month" }, - { label: "This Week", value: "week" }, - { label: "Today", value: "today" }, - ]; - - const feeds = useMemo(() => { - return [ - { - label: "All Feeds", - value: "all feeds", - }, - ...( - allFeeds.map((feed) => ({ - label: feed.name, - value: feed.id, - })) || [] - ).filter((feed) => feed.value !== "all"), - ]; - }, [allFeeds]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - feedDropdownRef.current && - !feedDropdownRef.current.contains(event.target as Node) - ) { - setShowFeedDropdown(false); - } - if ( - timeDropdownRef.current && - !timeDropdownRef.current.contains(event.target as Node) - ) { - setShowTimeDropdown(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - const toggleRow = (index: number) => { - setExpandedRows((prev) => - prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], - ); - }; - - const handleSearch = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - }; - - const filteredLeaderboard = leaderboard?.filter((item) => { - const searchTerm = searchQuery?.toLowerCase(); - const feedFilter = - search.feed === "all feeds" - ? true - : item.feedSubmissions?.some((feed) => feed.feedId === search.feed); - - const matchesSearch = - !searchTerm || - item.curatorUsername?.toLowerCase().includes(searchTerm) || - item.feedSubmissions?.some((feed) => - feed.feedId?.toLowerCase().includes(searchTerm), - ); - - return feedFilter && matchesSearch; - }); - - // Map the filtered items to include their original index - const filteredLeaderboardWithRanks = filteredLeaderboard?.map((item) => { - const originalIndex = leaderboard?.findIndex( - (entry) => entry.curatorId === item.curatorId, - ); - return { - ...item, - originalRank: originalIndex !== undefined ? originalIndex + 1 : 0, - }; - }); + const { + searchQuery, + showFeedDropdown, + showTimeDropdown, + feeds, + timeOptions, + handleSearch, + handleFeedDropdownToggle, + handleTimeDropdownToggle, + handleFeedDropdownClose, + handleTimeDropdownClose, + feedDropdownRef, + timeDropdownRef, + table, + hasData, + } = useLeaderboard(leaderboard, search); return ( -
+
-
-
- - -
-
-
- - {showFeedDropdown && ( -
- {feeds.map((feed, index) => ( - { - setShowFeedDropdown(false); - }} - className={`w-full px-4 py-2 text-left hover:bg-neutral-100 text-sm ${ - search.feed === feed.value ? "bg-neutral-100" : "" - }`} - > - {feed.label} - - ))} -
- )} -
-
- - {showTimeDropdown && ( -
- {timeOptions.map((time) => ( - { - setShowTimeDropdown(false); - }} - className={`w-full px-4 py-2 text-left hover:bg-neutral-100 text-sm ${ - search.timeframe === time.label ? "bg-neutral-100" : "" - }`} - > - {time.label} - - ))} -
- )} -
-
-
- -
-
- - - - - {/* */} - - - - - - - - {isLoading && ( - - - - )} - - {error && ( - - - - )} - - {leaderboard && leaderboard.length === 0 && ( - - - - )} - {filteredLeaderboardWithRanks?.map( - ( - item: LeaderboardEntry & { originalRank: number }, - index, - ) => ( - - - {/* */} - - - - - - ), - )} - -
- Rank - - Curator - - Username - - Approval Rate - - Submissions - - Top Feeds -
-

Loading leaderboard data...

-
-

- Error loading leaderboard: {(error as Error).message} -

-
-

No curator data available.

-
-
- {item.originalRank === 1 && ( - Gold star - 1st place - )} - {item.originalRank === 2 && ( - Silver star - 2nd place - )} - {item.originalRank === 3 && ( - Bronze star - 3rd place - )} -
- - {item.originalRank} - -
-
-
-
- - - {removeSpecialChars(item.curatorUsername)} - -
-
-
- -
-
-
- {item.submissionCount > 0 - ? `${Math.round((item.approvalCount / item.submissionCount) * 100)}%` - : "0%"} -
-
-
- {item.submissionCount} -
-
-
-
- {item.feedSubmissions && - item.feedSubmissions.length > 0 && ( -
- - #{item.feedSubmissions[0].feedId} - - - {item.feedSubmissions[0].count}/ - {item.feedSubmissions[0].totalInFeed} - -
- )} - - {item.feedSubmissions && - item.feedSubmissions.length > 1 && ( - - )} -
- - {item.feedSubmissions && - expandedRows.includes(index) && ( -
- {item.feedSubmissions - .slice(1) - .map((feed, feedIndex) => ( -
-
- - #{feed.feedId} - - - {feed.count}/{feed.totalInFeed} - -
-
- ))} -
- )} -
-
-
-
+ + +
); -} +}); diff --git a/apps/app/src/components/PublishIntegrations.tsx b/apps/app/src/components/PublishIntegrations.tsx index 50672602..43ed370c 100644 --- a/apps/app/src/components/PublishIntegrations.tsx +++ b/apps/app/src/components/PublishIntegrations.tsx @@ -1,14 +1,11 @@ +import { useFormContext } from "react-hook-form"; +import { FormControl, FormField, FormItem, FormLabel } from "./ui/form"; +import { Input } from "./ui/input"; import { Switch } from "./ui/switch"; -import { Button } from "./ui/button"; -import { useFeedCreationStore } from "../store/feed-creation-store"; export default function PublishingIntegrations() { - const { - telegramEnabled, - telegramChannelId, - telegramThreadId, - setTelegramConfig, - } = useFeedCreationStore(); + const { control, watch } = useFormContext(); + const telegramEnabled = watch("telegramEnabled"); return (
@@ -53,116 +50,61 @@ export default function PublishingIntegrations() {

- {/*
-
- - - - - - - - - - -
- 150 $CURATE -
*/}
- - setTelegramConfig({ telegramEnabled: checked }) - } + ( + + + + + + )} />
{telegramEnabled && (
- {/*
- - -

- Your Telegram bot token from @BotFather -

-
*/} + ( + + Channel ID + + + +

+ Username or ID of your Channel/group +

+
+ )} + /> -
- - - setTelegramConfig({ telegramChannelId: e.target.value }) - } - /> -

- Username or ID of your Channel/group -

-
- -
- - - setTelegramConfig({ telegramThreadId: e.target.value }) - } - /> -
- -
- - -
+ ( + + Thread ID (optional) + + + + + )} + />
)}
diff --git a/apps/app/src/components/TopCurators.tsx b/apps/app/src/components/TopCurators.tsx index 53442a30..29fbd343 100644 --- a/apps/app/src/components/TopCurators.tsx +++ b/apps/app/src/components/TopCurators.tsx @@ -113,22 +113,22 @@ const TopCurators = ({ feedId, limit = 10 }: TopCuratorsProps) => { d="M7.4659 3.32994C8.12994 3.32994 8.76678 3.59373 9.23633 4.06327C9.70587 4.53282 9.96966 5.16966 9.96966 5.8337V8.75476H8.30049V5.8337C8.30049 5.61235 8.21256 5.40007 8.05604 5.24356C7.89952 5.08704 7.68724 4.99911 7.4659 4.99911C7.24455 4.99911 7.03227 5.08704 6.87575 5.24356C6.71924 5.40007 6.63131 5.61235 6.63131 5.8337V8.75476H4.96213V5.8337C4.96213 5.16966 5.22592 4.53282 5.69547 4.06327C6.16501 3.59373 6.80186 3.32994 7.4659 3.32994Z" stroke="#0F172A" stroke-width="0.678103" - stroke-linecap="round" - stroke-linejoin="round" + strokeLinecap="round" + strokeLinejoin="round" /> diff --git a/apps/app/src/components/UserMenu.tsx b/apps/app/src/components/UserMenu.tsx index 91a71c47..73141082 100644 --- a/apps/app/src/components/UserMenu.tsx +++ b/apps/app/src/components/UserMenu.tsx @@ -21,8 +21,14 @@ interface UserMenuProps { export default function UserMenu({ className }: UserMenuProps) { const [dropdownOpen, setDropdownOpen] = useState(false); const navigate = useNavigate(); - const { currentAccountId, handleSignIn, isSignedIn, handleSignOut } = - useAuth(); + const { + currentAccountId, + handleSignIn, + isSignedIn, + handleSignOut, + isAuthorized, + handleAuthorize, + } = useAuth(); const { data: userProfile } = useNearSocialProfile(currentAccountId || ""); const ProfileImage = ({ size = "small" }: { size?: "small" | "medium" }) => { @@ -41,71 +47,79 @@ export default function UserMenu({ className }: UserMenuProps) { return "User"; }; + if (!isSignedIn) { + return ( + + ); + } + + if (!isAuthorized) { + return ( + + ); + } + return ( - <> - {isSignedIn ? ( - - - - - - -
- {currentAccountId ? ( - - ) : ( - - )} -
-

- {currentAccountId} -

-
-
-
- - { - navigate({ to: "/profile" }); - }} - > - - Profile - - - - Disconnect - -
-
- ) : ( - - )} - + + + +
+ {currentAccountId ? ( + + ) : ( + + )} +
+

+ {currentAccountId} +

+
+
+
+ + { + navigate({ to: "/profile" }); + }} + > + + Profile + + + + Disconnect + +
+ ); } diff --git a/apps/app/src/components/leaderboard/LeaderboardColumns.tsx b/apps/app/src/components/leaderboard/LeaderboardColumns.tsx new file mode 100644 index 00000000..f2c10534 --- /dev/null +++ b/apps/app/src/components/leaderboard/LeaderboardColumns.tsx @@ -0,0 +1,134 @@ +import { ChevronUp } from "lucide-react"; +import { createColumnHelper } from "@tanstack/react-table"; +import { LeaderboardEntry } from "../../lib/api"; +import { UserLink } from "../FeedItem"; + +export interface ExtendedLeaderboardEntry extends LeaderboardEntry { + originalRank: number; +} + +export function createLeaderboardColumns( + expandedRows: number[], + toggleRow: (index: number) => void, +) { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("originalRank", { + header: "Rank", + cell: (info) => { + const rank = info.getValue(); + return ( +
+ {rank === 1 && ( + Gold star - 1st place + )} + {rank === 2 && ( + Silver star - 2nd place + )} + {rank === 3 && ( + Bronze star - 3rd place + )} +
+ {rank} +
+
+ ); + }, + }), + columnHelper.accessor("curatorUsername", { + header: "Username", + cell: (info) => ( +
+ +
+ ), + }), + columnHelper.accessor( + (row) => { + return row.submissionCount > 0 + ? Math.round((row.approvalCount / row.submissionCount) * 100) + : 0; + }, + { + id: "approvalRate", + header: "Approval Rate", + cell: (info) => ( +
{info.getValue()}%
+ ), + }, + ), + columnHelper.accessor("submissionCount", { + header: "Submissions", + cell: (info) => ( +
+ {info.getValue()} +
+ ), + }), + columnHelper.accessor("feedSubmissions", { + header: "Top Feeds", + cell: (info) => { + const feedSubmissions = info.getValue(); + const rowIndex = info.row.index; + + return ( +
+
+ {feedSubmissions && feedSubmissions.length > 0 && ( +
+ #{feedSubmissions[0].feedId} + + {feedSubmissions[0].count}/{feedSubmissions[0].totalInFeed} + +
+ )} + + {feedSubmissions && feedSubmissions.length > 1 && ( + + )} +
+ + {feedSubmissions && expandedRows.includes(rowIndex) && ( +
+ {feedSubmissions.slice(1).map((feed, feedIndex) => ( +
+
+ #{feed.feedId} + + {feed.count}/{feed.totalInFeed} + +
+
+ ))} +
+ )} +
+ ); + }, + }), + ]; +} diff --git a/apps/app/src/components/leaderboard/LeaderboardFilters.tsx b/apps/app/src/components/leaderboard/LeaderboardFilters.tsx new file mode 100644 index 00000000..812a784f --- /dev/null +++ b/apps/app/src/components/leaderboard/LeaderboardFilters.tsx @@ -0,0 +1,144 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronDown, Search } from "lucide-react"; + +interface Feed { + label: string; + value: string; +} + +interface TimeOption { + label: string; + value: string; +} + +interface LeaderboardFiltersProps { + searchQuery: string | null; + onSearchChange: (e: React.ChangeEvent) => void; + feeds: Feed[]; + timeOptions: TimeOption[]; + search: { + feed: string; + timeframe: string; + }; + showFeedDropdown: boolean; + showTimeDropdown: boolean; + onFeedDropdownToggle: () => void; + onTimeDropdownToggle: () => void; + onFeedDropdownClose: () => void; + onTimeDropdownClose: () => void; + feedDropdownRef: React.RefObject; + timeDropdownRef: React.RefObject; +} + +export function LeaderboardFilters({ + searchQuery, + onSearchChange, + feeds, + timeOptions, + search, + showFeedDropdown, + showTimeDropdown, + onFeedDropdownToggle, + onTimeDropdownToggle, + onFeedDropdownClose, + onTimeDropdownClose, + feedDropdownRef, + timeDropdownRef, +}: LeaderboardFiltersProps) { + return ( +
+
+ + +
+
+
+ + {showFeedDropdown && ( +
+ {feeds.map((feed, index) => ( + + {feed.label} + + ))} +
+ )} +
+
+ + {showTimeDropdown && ( +
+ {timeOptions.map((time) => ( + + {time.label} + + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/app/src/components/leaderboard/LeaderboardSkeleton.tsx b/apps/app/src/components/leaderboard/LeaderboardSkeleton.tsx new file mode 100644 index 00000000..311e2646 --- /dev/null +++ b/apps/app/src/components/leaderboard/LeaderboardSkeleton.tsx @@ -0,0 +1,60 @@ +import { TableCell, TableRow } from "../ui/table"; + +function SkeletonRow() { + return ( + + {/* Rank column */} + +
+
+
+
+ + + {/* Username column */} + +
+
+
+ + + {/* Approval Rate column */} + +
+
+
+ + + {/* Submissions column */} + +
+
+
+ + + {/* Top Feeds column */} + +
+
+
+
+
+
+ + + ); +} + +interface LeaderboardSkeletonProps { + rows?: number; +} + +export function LeaderboardSkeleton({ rows = 8 }: LeaderboardSkeletonProps) { + return ( + <> + {Array.from({ length: rows }).map((_, index) => ( + + ))} + + ); +} diff --git a/apps/app/src/components/leaderboard/LeaderboardTable.tsx b/apps/app/src/components/leaderboard/LeaderboardTable.tsx new file mode 100644 index 00000000..97098c87 --- /dev/null +++ b/apps/app/src/components/leaderboard/LeaderboardTable.tsx @@ -0,0 +1,111 @@ +import { flexRender, Table as TanStackTable } from "@tanstack/react-table"; +import { ChevronUp, ChevronDown } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { ExtendedLeaderboardEntry } from "./LeaderboardColumns"; +import { LeaderboardSkeleton } from "./LeaderboardSkeleton"; + +interface LeaderboardTableProps { + table: TanStackTable; + isLoading: boolean; + error: Error | null; + hasData: boolean; +} + +export function LeaderboardTable({ + table, + isLoading, + error, + hasData, +}: LeaderboardTableProps) { + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {header.column.getIsSorted() === "asc" ? ( + + ) : header.column.getIsSorted() === "desc" ? ( + + ) : null} +
+
+ ))} +
+ ))} +
+ + {isLoading && } + + {error && ( + + +

Error loading leaderboard: {error.message}

+
+
+ )} + + {!hasData && !isLoading && !error && ( + + +

No curator data available.

+
+
+ )} + + {!isLoading && !error && table.getRowModel().rows.length > 0 + ? table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + : !isLoading && + !error && + table.getRowModel().rows.length === 0 && ( + + +

No matching results found.

+
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/app/src/components/leaderboard/index.ts b/apps/app/src/components/leaderboard/index.ts new file mode 100644 index 00000000..4cf65642 --- /dev/null +++ b/apps/app/src/components/leaderboard/index.ts @@ -0,0 +1,7 @@ +export { LeaderboardFilters } from "./LeaderboardFilters"; +export { LeaderboardTable } from "./LeaderboardTable"; +export { + createLeaderboardColumns, + type ExtendedLeaderboardEntry, +} from "./LeaderboardColumns"; +export { LeaderboardSkeleton } from "./LeaderboardSkeleton"; diff --git a/apps/app/src/components/profile/ProfileHeader.tsx b/apps/app/src/components/profile/ProfileHeader.tsx index 3ca48d21..64cafcbb 100644 --- a/apps/app/src/components/profile/ProfileHeader.tsx +++ b/apps/app/src/components/profile/ProfileHeader.tsx @@ -78,20 +78,20 @@ export function ProfileHeader({ accountId }: { accountId: string }) { /web3.plungrel diff --git a/apps/app/src/contexts/auth-context.tsx b/apps/app/src/contexts/auth-context.tsx index 53282b43..0209673b 100644 --- a/apps/app/src/contexts/auth-context.tsx +++ b/apps/app/src/contexts/auth-context.tsx @@ -1,24 +1,25 @@ -import { ensureUserProfile } from "../lib/api/users"; -import { toast } from "../hooks/use-toast"; -import { near } from "../lib/near"; +import { sign } from "near-sign-verify"; import React, { createContext, + useCallback, useContext, useEffect, - useRef, useState, - type Dispatch, type ReactNode, - type SetStateAction, } from "react"; +import { toast } from "../hooks/use-toast"; +import { apiClient } from "../lib/api-client"; +import { near } from "../lib/near"; +import { fromHex } from "@fastnear/utils"; interface IAuthContext { currentAccountId: string | null; isSignedIn: boolean; - setCurrentAccountId: Dispatch>; - setIsSignedIn: Dispatch>; + isAuthorized: boolean; handleSignIn: () => Promise; - handleSignOut: () => void; + handleSignOut: () => Promise; + handleAuthorize: () => Promise; + checkAuthorization: () => Promise; } const AuthContext = createContext(undefined); @@ -38,84 +39,113 @@ interface AuthProviderProps { export function AuthProvider({ children, }: AuthProviderProps): React.ReactElement { - const [currentAccountId, setCurrentAccountId] = useState( - near.accountId() ?? null, - ); - const [isSignedIn, setIsSignedIn] = useState( - near.authStatus() === "SignedIn", - ); - const previousAccountIdRef = useRef(currentAccountId); + const [currentAccountId, setCurrentAccountId] = useState(null); + const [isSignedIn, setIsSignedIn] = useState(false); + const [isAuthorized, setIsAuthorized] = useState(false); - useEffect(() => { - const accountListener = near.event.onAccount((newAccountId) => { - setCurrentAccountId(newAccountId); - setIsSignedIn(!!newAccountId); - - if (newAccountId) { - // any init? - // client.setAccountHeader(newAccountId); - } else { - // any clean up? - } - }); - - return () => { - near.event.offAccount(accountListener); - }; + const checkAuthorization = useCallback(async () => { + try { + await apiClient.makeRequest("GET", "/users/me"); + setIsAuthorized(true); + } catch (error) { + console.error("Authorization check failed:", error); + setIsAuthorized(false); + } }, []); useEffect(() => { - console.log("AuthProvider main useEffect triggered:", { - currentAccountId, - prev: previousAccountIdRef.current, - }); + const accountId = near.accountId(); + if (accountId) { + setCurrentAccountId(accountId); + setIsSignedIn(true); + checkAuthorization(); + } + }, [checkAuthorization]); - if (currentAccountId && currentAccountId !== previousAccountIdRef.current) { + const handleSignIn = async (): Promise => { + try { + await near.requestSignIn(); + } catch (e: unknown) { toast({ - title: "Success!", - description: `Connected as: ${currentAccountId}`, - variant: "success", + title: "Sign-in failed", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", }); - } else if (!currentAccountId && previousAccountIdRef.current !== null) { + } + }; + + const handleAuthorize = async (): Promise => { + if (!currentAccountId) { toast({ - title: "Signed out", - description: "You have been signed out successfully.", - variant: "success", + title: "Not Signed In", + description: "Please sign in before authorizing.", + variant: "destructive", }); + return; } - previousAccountIdRef.current = currentAccountId; - }, [currentAccountId]); - - const handleSignIn = async (): Promise => { try { - await near.requestSignIn(); + const { nonce, recipient } = await apiClient.makeRequest<{ + nonce: string; + recipient: string; + }>("POST", "/auth/initiate-login", { accountId: currentAccountId }); + + const message = "Authorize Curate.fun"; + + const authToken = await sign(message, { + signer: near, + recipient, + nonce: fromHex(nonce), + }); + + await apiClient.makeRequest("POST", "/auth/verify-login", { + token: authToken, + accountId: currentAccountId, + }); - // Get the account ID after sign-in - const accountId = near.accountId(); - if (accountId) { - // Ensure user profile exists - await ensureUserProfile(accountId); - } + setIsAuthorized(true); + toast({ + title: "Authorization Successful!", + description: "You have successfully authorized the application.", + variant: "success", + }); } catch (e: unknown) { + setIsAuthorized(false); toast({ - title: "Sign-in failed", + title: "Authorization failed", description: e instanceof Error ? e.message : String(e), variant: "destructive", }); } }; - const handleSignOut = (): void => { + const handleSignOut = async (): Promise => { + try { + await apiClient.makeRequest("POST", "/auth/logout"); + } catch (error) { + console.error( + "Logout failed on backend, signing out on client anyway.", + error, + ); + } near.signOut(); + setCurrentAccountId(null); + setIsSignedIn(false); + setIsAuthorized(false); + toast({ + title: "Signed out", + description: "You have been signed out successfully.", + variant: "success", + }); }; const contextValue: IAuthContext = { currentAccountId, isSignedIn, - setCurrentAccountId, - setIsSignedIn, + isAuthorized, handleSignIn, handleSignOut, + handleAuthorize, + checkAuthorization, }; return ( diff --git a/apps/app/src/hooks/api-client.ts b/apps/app/src/hooks/api-client.ts index cf68f534..06e7dd5c 100644 --- a/apps/app/src/hooks/api-client.ts +++ b/apps/app/src/hooks/api-client.ts @@ -27,13 +27,7 @@ export function useApiQuery< "queryKey" | "queryFn" >, ) { - const { currentAccountId, isSignedIn } = useAuth(); - - const queryFunction = () => - apiClient.makeRequest("GET", path, { - currentAccountId, - isSignedIn, - }); + const queryFunction = () => apiClient.makeRequest("GET", path); const finalQueryOptions: UseQueryOptions< TQueryFnData, @@ -80,9 +74,7 @@ export function useApiMutation< return apiClient.makeRequest( mutationConfig.method, mutationConfig.path, - { currentAccountId, isSignedIn }, variables, // Pass 'variables' from mutate() as requestData - mutationConfig.message, ); }; @@ -118,16 +110,11 @@ export function useApiInfiniteQuery< "queryKey" | "queryFn" | "initialPageParam" // initialPageParam is now required in options > & { initialPageParam: TPageParam }, // Ensure initialPageParam is provided ) { - const { currentAccountId, isSignedIn } = useAuth(); - const queryFunction = ( context: QueryFunctionContext, ) => { const path = pathFn(context.pageParam as TPageParam); - return apiClient.makeRequest("GET", path, { - currentAccountId, - isSignedIn, - }); + return apiClient.makeRequest("GET", path); }; const finalQueryOptions: UseInfiniteQueryOptions< diff --git a/apps/app/src/hooks/useLeaderboard.ts b/apps/app/src/hooks/useLeaderboard.ts new file mode 100644 index 00000000..c0a28a43 --- /dev/null +++ b/apps/app/src/hooks/useLeaderboard.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + SortingState, + useReactTable, + getCoreRowModel, + getSortedRowModel, +} from "@tanstack/react-table"; +import { LeaderboardEntry, useAllFeeds } from "../lib/api"; +import { createLeaderboardColumns } from "../components/leaderboard/LeaderboardColumns"; + +interface LeaderboardSearch { + feed: string; + timeframe: string; +} + +export function useLeaderboard( + leaderboard: LeaderboardEntry[], + search: LeaderboardSearch, +) { + const [expandedRows, setExpandedRows] = useState([]); + const [searchQuery, setSearchQuery] = useState(null); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState< + string | null + >(null); + const [showFeedDropdown, setShowFeedDropdown] = useState(false); + const [showTimeDropdown, setShowTimeDropdown] = useState(false); + const [sorting, setSorting] = useState([]); + const { data: allFeeds = [] } = useAllFeeds(); + const feedDropdownRef = useRef(null); + const timeDropdownRef = useRef(null); + + const timeOptions = [ + { label: "All Time", value: "all" }, + { label: "This Month", value: "month" }, + { label: "This Week", value: "week" }, + { label: "Today", value: "today" }, + ]; + + const feeds = useMemo(() => { + return [ + { + label: "All Feeds", + value: "all feeds", + }, + ...( + allFeeds.map((feed) => ({ + label: feed.name, + value: feed.id, + })) || [] + ).filter((feed) => feed.value !== "all"), + ]; + }, [allFeeds]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + feedDropdownRef.current && + !feedDropdownRef.current.contains(event.target as Node) + ) { + setShowFeedDropdown(false); + } + if ( + timeDropdownRef.current && + !timeDropdownRef.current.contains(event.target as Node) + ) { + setShowTimeDropdown(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery]); + + const toggleRow = useCallback((index: number) => { + setExpandedRows((prev) => + prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], + ); + }, []); + + const handleSearch = useCallback((e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + }, []); + + const handleFeedDropdownToggle = useCallback(() => { + setShowFeedDropdown((prev) => !prev); + }, []); + + const handleTimeDropdownToggle = useCallback(() => { + setShowTimeDropdown((prev) => !prev); + }, []); + + const handleFeedDropdownClose = useCallback(() => { + setShowFeedDropdown(false); + }, []); + + const handleTimeDropdownClose = useCallback(() => { + setShowTimeDropdown(false); + }, []); + + const filteredLeaderboard = useMemo(() => { + return leaderboard?.filter((item) => { + const searchTerm = debouncedSearchQuery?.toLowerCase(); + const feedFilter = + search.feed === "all feeds" + ? true + : item.feedSubmissions?.some((feed) => feed.feedId === search.feed); + + const matchesSearch = + !searchTerm || + item.curatorUsername?.toLowerCase().includes(searchTerm) || + item.feedSubmissions?.some((feed) => + feed.feedId?.toLowerCase().includes(searchTerm), + ); + + return feedFilter && matchesSearch; + }); + }, [leaderboard, debouncedSearchQuery, search.feed]); + + const filteredLeaderboardWithRanks = useMemo(() => { + return filteredLeaderboard?.map((item) => { + const originalIndex = leaderboard?.findIndex( + (entry) => entry.curatorId === item.curatorId, + ); + return { + ...item, + originalRank: originalIndex !== undefined ? originalIndex + 1 : 0, + }; + }); + }, [filteredLeaderboard, leaderboard]); + + const columns = useMemo(() => { + return createLeaderboardColumns(expandedRows, toggleRow); + }, [expandedRows, toggleRow]); + + const table = useReactTable({ + data: filteredLeaderboardWithRanks || [], + columns, + state: { + sorting, + }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return { + // State + searchQuery, + showFeedDropdown, + showTimeDropdown, + feeds, + timeOptions, + + // Handlers + handleSearch, + handleFeedDropdownToggle, + handleTimeDropdownToggle, + handleFeedDropdownClose, + handleTimeDropdownClose, + + // Refs + feedDropdownRef, + timeDropdownRef, + + // Table + table, + hasData: Boolean(leaderboard && leaderboard.length > 0), + }; +} diff --git a/apps/app/src/index.css b/apps/app/src/index.css index 7e6ee811..a71ca83c 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -33,7 +33,7 @@ --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.577 0.245 27.325); + --destructive-foreground: #fff; --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); diff --git a/apps/app/src/lib/api-client.ts b/apps/app/src/lib/api-client.ts index 74af0277..f83bf9ed 100644 --- a/apps/app/src/lib/api-client.ts +++ b/apps/app/src/lib/api-client.ts @@ -1,6 +1,3 @@ -import { near } from "./near"; -import { sign } from "near-sign-verify"; - export class ApiError extends Error { constructor( message: string, @@ -22,9 +19,7 @@ class ApiClient { async makeRequest( method: string, path: string, - auth: { currentAccountId: string | null; isSignedIn: boolean }, requestData?: TRequest, - message?: string, // Descriptive message for signing non-GET requests ): Promise { const fullUrl = new URL(this.baseUrl + path, window.location.origin); const bodyString = @@ -39,30 +34,6 @@ class ApiClient { headers["Content-Type"] = "application/json"; } - if (method === "GET") { - if (auth.currentAccountId) { - headers["X-Near-Account"] = auth.currentAccountId || "anonymous"; - } - } else { - // Non-GET requests (POST, PUT, DELETE, PATCH) - if (!auth.currentAccountId) { - throw new ApiError( - "Account ID missing for authenticated request.", - 400, - ); - } - - const messageForSigning = message || `${method} request to ${path}`; - - const authToken = await sign({ - signer: near, - recipient: "curatefun.near", - message: messageForSigning, - }); - - headers["Authorization"] = `Bearer ${authToken}`; - } - const requestOptions: RequestInit = { method, headers, diff --git a/apps/app/src/lib/api/feed.ts b/apps/app/src/lib/api/feed.ts deleted file mode 100644 index 72165f38..00000000 --- a/apps/app/src/lib/api/feed.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useApiQuery } from "../../hooks/api-client"; -import { useAuth } from "../../contexts/auth-context"; - -interface CanModerateResponse { - canModerate: boolean; - reason?: string; - error?: string; -} - -/** - * Hook to check if the current authenticated user can moderate a specific feed. - * @param feedId The ID of the feed to check. If undefined, the query will not run. - * @returns Query result including `canModerate` boolean. - */ -export const useCanModerateFeed = (feedId: string | undefined) => { - const { isSignedIn, currentAccountId } = useAuth(); - - const enabled = !!feedId && isSignedIn && !!currentAccountId; - - return useApiQuery( - ["can-moderate", feedId, currentAccountId], - `/feeds/${feedId}/can-moderate`, - { - enabled, - staleTime: 5 * 60 * 1000, // 5 minutes - }, - ); -}; diff --git a/apps/app/src/lib/api/feeds.ts b/apps/app/src/lib/api/feeds.ts index a396a0bd..4f76af7c 100644 --- a/apps/app/src/lib/api/feeds.ts +++ b/apps/app/src/lib/api/feeds.ts @@ -1,45 +1,49 @@ -import type { FeedConfig, Submission } from "@curatedotfun/types"; +import type { + CanModerateResponse, + CreateFeedRequest, + FeedResponse, + FeedsWrappedResponse, + FeedWrappedResponse, + Submission, + UpdateFeedRequest, +} from "@curatedotfun/types"; +import { useAuth } from "../../contexts/auth-context"; import { - useApiQuery, - useApiMutation, useApiInfiniteQuery, + useApiMutation, + useApiQuery, } from "../../hooks/api-client"; import type { + PaginatedResponse, SortOrderType, StatusFilterType, SubmissionFilters, - PaginatedResponse, TransformedInfiniteData, } from "./types"; -export interface FeedDetails { - id: string; - name: string; - description: string | null; - config: FeedConfig; - createdAt: string; - updatedAt: string | null; -} - export function useFeed(feedId: string) { - return useApiQuery( + return useApiQuery( ["feed-details", feedId], `/feeds/${feedId}`, - { enabled: !!feedId }, + { + enabled: !!feedId, + select: (data) => data.data, + }, ); } export function useAllFeeds() { - return useApiQuery(["feeds"], `/feeds`); + return useApiQuery( + ["feeds"], + `/feeds`, + { + select: (data) => data.data ?? [], + }, + ); } export function useCreateFeed() { - type CreateFeedVariables = Omit & { - id: string; - name: string; - description?: string | null; - }; - return useApiMutation( + return useApiMutation( { method: "POST", path: `/feeds`, @@ -52,8 +56,7 @@ export function useCreateFeed() { } export function useUpdateFeed(feedId: string) { - type UpdateFeedVariables = { config: FeedConfig }; - return useApiMutation( + return useApiMutation( { method: "PUT", path: `/feeds/${feedId}`, @@ -118,7 +121,7 @@ export function useFeedItems(feedId: string, filters: SubmissionFilters = {}) { }, select: (data) => ({ pages: data.pages, - pageParams: data.pageParams as number[], // Ensure pageParams is number[] + pageParams: data.pageParams as number[], items: data.pages.flatMap((page) => Array.isArray(page.items) ? page.items : [], ), @@ -129,3 +132,23 @@ export function useFeedItems(feedId: string, filters: SubmissionFilters = {}) { enabled: !!feedId, }); } + +/** + * Hook to check if the current authenticated user can moderate a specific feed. + * @param feedId The ID of the feed to check. If undefined, the query will not run. + * @returns Query result including `canModerate` boolean. + */ +export const useCanModerateFeed = (feedId: string | undefined) => { + const { isSignedIn, currentAccountId } = useAuth(); + + const enabled = !!feedId && isSignedIn && !!currentAccountId; + + return useApiQuery( + ["can-moderate", feedId, currentAccountId], + `/feeds/${feedId}/can-moderate`, + { + enabled, + staleTime: 5 * 60 * 1000, // 5 minutes + }, + ); +}; diff --git a/apps/app/src/routeTree.gen.ts b/apps/app/src/routeTree.gen.ts index 880a13f6..72bff3cb 100644 --- a/apps/app/src/routeTree.gen.ts +++ b/apps/app/src/routeTree.gen.ts @@ -20,12 +20,15 @@ import { Route as LayoutCreatePluginRouteImport } from "./routes/_layout/create/ import { Route as LayoutCreateFeedRouteImport } from "./routes/_layout/create/feed"; import { Route as LayoutProfileSettingsIndexRouteImport } from "./routes/_layout/profile/settings/index"; import { Route as LayoutFeedFeedIdIndexRouteImport } from "./routes/_layout/feed/$feedId/index"; +import { Route as LayoutCreateFeedIndexRouteImport } from "./routes/_layout/create/feed/index"; import { Route as LayoutFeedFeedIdTokenRouteImport } from "./routes/_layout/feed/$feedId/token"; import { Route as LayoutFeedFeedIdProposalsRouteImport } from "./routes/_layout/feed/$feedId/proposals"; import { Route as LayoutFeedFeedIdPointsRouteImport } from "./routes/_layout/feed/$feedId/points"; import { Route as LayoutFeedFeedIdMembersRouteImport } from "./routes/_layout/feed/$feedId/members"; import { Route as LayoutFeedFeedIdCurationRouteImport } from "./routes/_layout/feed/$feedId/curation"; import { Route as LayoutEditFeedFeedIdRouteImport } from "./routes/_layout/edit/feed.$feedId"; +import { Route as LayoutCreateFeedSettingsRouteImport } from "./routes/_layout/create/feed/settings"; +import { Route as LayoutCreateFeedReviewRouteImport } from "./routes/_layout/create/feed/review"; import { Route as LayoutFeedFeedIdSettingsIndexRouteImport } from "./routes/_layout/feed/$feedId/settings/index"; import { Route as LayoutFeedFeedIdSettingsConnectedRouteImport } from "./routes/_layout/feed/$feedId/settings/connected"; @@ -84,6 +87,11 @@ const LayoutFeedFeedIdIndexRoute = LayoutFeedFeedIdIndexRouteImport.update({ path: "/", getParentRoute: () => LayoutFeedFeedIdRoute, } as any); +const LayoutCreateFeedIndexRoute = LayoutCreateFeedIndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => LayoutCreateFeedRoute, +} as any); const LayoutFeedFeedIdTokenRoute = LayoutFeedFeedIdTokenRouteImport.update({ id: "/token", path: "/token", @@ -116,6 +124,17 @@ const LayoutEditFeedFeedIdRoute = LayoutEditFeedFeedIdRouteImport.update({ path: "/edit/feed/$feedId", getParentRoute: () => LayoutRoute, } as any); +const LayoutCreateFeedSettingsRoute = + LayoutCreateFeedSettingsRouteImport.update({ + id: "/settings", + path: "/settings", + getParentRoute: () => LayoutCreateFeedRoute, + } as any); +const LayoutCreateFeedReviewRoute = LayoutCreateFeedReviewRouteImport.update({ + id: "/review", + path: "/review", + getParentRoute: () => LayoutCreateFeedRoute, +} as any); const LayoutFeedFeedIdSettingsIndexRoute = LayoutFeedFeedIdSettingsIndexRouteImport.update({ id: "/settings/", @@ -132,18 +151,21 @@ const LayoutFeedFeedIdSettingsConnectedRoute = export interface FileRoutesByFullPath { "/leaderboard": typeof LayoutLeaderboardRoute; "/": typeof LayoutIndexRoute; - "/create/feed": typeof LayoutCreateFeedRoute; + "/create/feed": typeof LayoutCreateFeedRouteWithChildren; "/create/plugin": typeof LayoutCreatePluginRoute; "/feed/$feedId": typeof LayoutFeedFeedIdRouteWithChildren; "/plugin/$pluginId": typeof LayoutPluginPluginIdRoute; "/plugin": typeof LayoutPluginIndexRoute; "/profile": typeof LayoutProfileIndexRoute; + "/create/feed/review": typeof LayoutCreateFeedReviewRoute; + "/create/feed/settings": typeof LayoutCreateFeedSettingsRoute; "/edit/feed/$feedId": typeof LayoutEditFeedFeedIdRoute; "/feed/$feedId/curation": typeof LayoutFeedFeedIdCurationRoute; "/feed/$feedId/members": typeof LayoutFeedFeedIdMembersRoute; "/feed/$feedId/points": typeof LayoutFeedFeedIdPointsRoute; "/feed/$feedId/proposals": typeof LayoutFeedFeedIdProposalsRoute; "/feed/$feedId/token": typeof LayoutFeedFeedIdTokenRoute; + "/create/feed/": typeof LayoutCreateFeedIndexRoute; "/feed/$feedId/": typeof LayoutFeedFeedIdIndexRoute; "/profile/settings": typeof LayoutProfileSettingsIndexRoute; "/feed/$feedId/settings/connected": typeof LayoutFeedFeedIdSettingsConnectedRoute; @@ -152,17 +174,19 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/leaderboard": typeof LayoutLeaderboardRoute; "/": typeof LayoutIndexRoute; - "/create/feed": typeof LayoutCreateFeedRoute; "/create/plugin": typeof LayoutCreatePluginRoute; "/plugin/$pluginId": typeof LayoutPluginPluginIdRoute; "/plugin": typeof LayoutPluginIndexRoute; "/profile": typeof LayoutProfileIndexRoute; + "/create/feed/review": typeof LayoutCreateFeedReviewRoute; + "/create/feed/settings": typeof LayoutCreateFeedSettingsRoute; "/edit/feed/$feedId": typeof LayoutEditFeedFeedIdRoute; "/feed/$feedId/curation": typeof LayoutFeedFeedIdCurationRoute; "/feed/$feedId/members": typeof LayoutFeedFeedIdMembersRoute; "/feed/$feedId/points": typeof LayoutFeedFeedIdPointsRoute; "/feed/$feedId/proposals": typeof LayoutFeedFeedIdProposalsRoute; "/feed/$feedId/token": typeof LayoutFeedFeedIdTokenRoute; + "/create/feed": typeof LayoutCreateFeedIndexRoute; "/feed/$feedId": typeof LayoutFeedFeedIdIndexRoute; "/profile/settings": typeof LayoutProfileSettingsIndexRoute; "/feed/$feedId/settings/connected": typeof LayoutFeedFeedIdSettingsConnectedRoute; @@ -173,18 +197,21 @@ export interface FileRoutesById { "/_layout": typeof LayoutRouteWithChildren; "/_layout/leaderboard": typeof LayoutLeaderboardRoute; "/_layout/": typeof LayoutIndexRoute; - "/_layout/create/feed": typeof LayoutCreateFeedRoute; + "/_layout/create/feed": typeof LayoutCreateFeedRouteWithChildren; "/_layout/create/plugin": typeof LayoutCreatePluginRoute; "/_layout/feed/$feedId": typeof LayoutFeedFeedIdRouteWithChildren; "/_layout/plugin/$pluginId": typeof LayoutPluginPluginIdRoute; "/_layout/plugin/": typeof LayoutPluginIndexRoute; "/_layout/profile/": typeof LayoutProfileIndexRoute; + "/_layout/create/feed/review": typeof LayoutCreateFeedReviewRoute; + "/_layout/create/feed/settings": typeof LayoutCreateFeedSettingsRoute; "/_layout/edit/feed/$feedId": typeof LayoutEditFeedFeedIdRoute; "/_layout/feed/$feedId/curation": typeof LayoutFeedFeedIdCurationRoute; "/_layout/feed/$feedId/members": typeof LayoutFeedFeedIdMembersRoute; "/_layout/feed/$feedId/points": typeof LayoutFeedFeedIdPointsRoute; "/_layout/feed/$feedId/proposals": typeof LayoutFeedFeedIdProposalsRoute; "/_layout/feed/$feedId/token": typeof LayoutFeedFeedIdTokenRoute; + "/_layout/create/feed/": typeof LayoutCreateFeedIndexRoute; "/_layout/feed/$feedId/": typeof LayoutFeedFeedIdIndexRoute; "/_layout/profile/settings/": typeof LayoutProfileSettingsIndexRoute; "/_layout/feed/$feedId/settings/connected": typeof LayoutFeedFeedIdSettingsConnectedRoute; @@ -201,12 +228,15 @@ export interface FileRouteTypes { | "/plugin/$pluginId" | "/plugin" | "/profile" + | "/create/feed/review" + | "/create/feed/settings" | "/edit/feed/$feedId" | "/feed/$feedId/curation" | "/feed/$feedId/members" | "/feed/$feedId/points" | "/feed/$feedId/proposals" | "/feed/$feedId/token" + | "/create/feed/" | "/feed/$feedId/" | "/profile/settings" | "/feed/$feedId/settings/connected" @@ -215,17 +245,19 @@ export interface FileRouteTypes { to: | "/leaderboard" | "/" - | "/create/feed" | "/create/plugin" | "/plugin/$pluginId" | "/plugin" | "/profile" + | "/create/feed/review" + | "/create/feed/settings" | "/edit/feed/$feedId" | "/feed/$feedId/curation" | "/feed/$feedId/members" | "/feed/$feedId/points" | "/feed/$feedId/proposals" | "/feed/$feedId/token" + | "/create/feed" | "/feed/$feedId" | "/profile/settings" | "/feed/$feedId/settings/connected" @@ -241,12 +273,15 @@ export interface FileRouteTypes { | "/_layout/plugin/$pluginId" | "/_layout/plugin/" | "/_layout/profile/" + | "/_layout/create/feed/review" + | "/_layout/create/feed/settings" | "/_layout/edit/feed/$feedId" | "/_layout/feed/$feedId/curation" | "/_layout/feed/$feedId/members" | "/_layout/feed/$feedId/points" | "/_layout/feed/$feedId/proposals" | "/_layout/feed/$feedId/token" + | "/_layout/create/feed/" | "/_layout/feed/$feedId/" | "/_layout/profile/settings/" | "/_layout/feed/$feedId/settings/connected" @@ -336,6 +371,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof LayoutFeedFeedIdIndexRouteImport; parentRoute: typeof LayoutFeedFeedIdRoute; }; + "/_layout/create/feed/": { + id: "/_layout/create/feed/"; + path: "/"; + fullPath: "/create/feed/"; + preLoaderRoute: typeof LayoutCreateFeedIndexRouteImport; + parentRoute: typeof LayoutCreateFeedRoute; + }; "/_layout/feed/$feedId/token": { id: "/_layout/feed/$feedId/token"; path: "/token"; @@ -378,6 +420,20 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof LayoutEditFeedFeedIdRouteImport; parentRoute: typeof LayoutRoute; }; + "/_layout/create/feed/settings": { + id: "/_layout/create/feed/settings"; + path: "/settings"; + fullPath: "/create/feed/settings"; + preLoaderRoute: typeof LayoutCreateFeedSettingsRouteImport; + parentRoute: typeof LayoutCreateFeedRoute; + }; + "/_layout/create/feed/review": { + id: "/_layout/create/feed/review"; + path: "/review"; + fullPath: "/create/feed/review"; + preLoaderRoute: typeof LayoutCreateFeedReviewRouteImport; + parentRoute: typeof LayoutCreateFeedRoute; + }; "/_layout/feed/$feedId/settings/": { id: "/_layout/feed/$feedId/settings/"; path: "/settings"; @@ -395,6 +451,21 @@ declare module "@tanstack/react-router" { } } +interface LayoutCreateFeedRouteChildren { + LayoutCreateFeedReviewRoute: typeof LayoutCreateFeedReviewRoute; + LayoutCreateFeedSettingsRoute: typeof LayoutCreateFeedSettingsRoute; + LayoutCreateFeedIndexRoute: typeof LayoutCreateFeedIndexRoute; +} + +const LayoutCreateFeedRouteChildren: LayoutCreateFeedRouteChildren = { + LayoutCreateFeedReviewRoute: LayoutCreateFeedReviewRoute, + LayoutCreateFeedSettingsRoute: LayoutCreateFeedSettingsRoute, + LayoutCreateFeedIndexRoute: LayoutCreateFeedIndexRoute, +}; + +const LayoutCreateFeedRouteWithChildren = + LayoutCreateFeedRoute._addFileChildren(LayoutCreateFeedRouteChildren); + interface LayoutFeedFeedIdRouteChildren { LayoutFeedFeedIdCurationRoute: typeof LayoutFeedFeedIdCurationRoute; LayoutFeedFeedIdMembersRoute: typeof LayoutFeedFeedIdMembersRoute; @@ -424,7 +495,7 @@ const LayoutFeedFeedIdRouteWithChildren = interface LayoutRouteChildren { LayoutLeaderboardRoute: typeof LayoutLeaderboardRoute; LayoutIndexRoute: typeof LayoutIndexRoute; - LayoutCreateFeedRoute: typeof LayoutCreateFeedRoute; + LayoutCreateFeedRoute: typeof LayoutCreateFeedRouteWithChildren; LayoutCreatePluginRoute: typeof LayoutCreatePluginRoute; LayoutFeedFeedIdRoute: typeof LayoutFeedFeedIdRouteWithChildren; LayoutPluginPluginIdRoute: typeof LayoutPluginPluginIdRoute; @@ -437,7 +508,7 @@ interface LayoutRouteChildren { const LayoutRouteChildren: LayoutRouteChildren = { LayoutLeaderboardRoute: LayoutLeaderboardRoute, LayoutIndexRoute: LayoutIndexRoute, - LayoutCreateFeedRoute: LayoutCreateFeedRoute, + LayoutCreateFeedRoute: LayoutCreateFeedRouteWithChildren, LayoutCreatePluginRoute: LayoutCreatePluginRoute, LayoutFeedFeedIdRoute: LayoutFeedFeedIdRouteWithChildren, LayoutPluginPluginIdRoute: LayoutPluginPluginIdRoute, diff --git a/apps/app/src/routes/_layout.tsx b/apps/app/src/routes/_layout.tsx index 76430ad8..e9e12dc5 100644 --- a/apps/app/src/routes/_layout.tsx +++ b/apps/app/src/routes/_layout.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; -import Header from "../components/Header"; +import Header from "@/components/Header"; export const Route = createFileRoute("/_layout")({ component: RouteComponent, diff --git a/apps/app/src/routes/_layout/create/feed.tsx b/apps/app/src/routes/_layout/create/feed.tsx index a7ae623c..74b5cea3 100644 --- a/apps/app/src/routes/_layout/create/feed.tsx +++ b/apps/app/src/routes/_layout/create/feed.tsx @@ -1,12 +1,26 @@ -import { createFileRoute } from "@tanstack/react-router"; -import CurationFormSteps from "../../../components/CurationFormSteps"; -import { Hero } from "../../../components/Hero"; +import { Hero } from "@/components/Hero"; +import { Progress } from "@/components/ui/progress"; +import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/_layout/create/feed")({ - component: RouteComponent, + component: FeedLayoutComponent, }); -function RouteComponent() { +function FeedLayoutComponent() { + const matchRoute = useMatchRoute(); + + const steps = [ + { id: "/_layout/create/feed/", title: "Basic Information" }, + { id: "/_layout/create/feed/settings", title: "Curation Settings" }, + { id: "/_layout/create/feed/review", title: "Feed Review" }, + ]; + + const currentStepIndex = steps.findIndex((step) => + matchRoute({ to: step.id }), + ); + const currentStep = currentStepIndex !== -1 ? currentStepIndex : 0; + const progressValue = ((currentStep + 1) / steps.length) * 100; + return (
- +
+
+
+ Step {currentStep + 1} of {steps.length} +
+ +
+ {steps.map((step, index) => ( +
+ + {step.title} + +
+ ))} +
+
+
+ +
+
); } diff --git a/apps/app/src/routes/_layout/create/feed/index.tsx b/apps/app/src/routes/_layout/create/feed/index.tsx new file mode 100644 index 00000000..9ac3ddb0 --- /dev/null +++ b/apps/app/src/routes/_layout/create/feed/index.tsx @@ -0,0 +1,150 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { ImageUpload } from "@/components/ImageUpload"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/contexts/auth-context"; +import { useFeedCreationStore } from "@/store/feed-creation-store"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; + +const BasicInformationFormSchema = z.object({ + name: z.string().min(3, "Feed name must be at least 3 characters").optional(), + description: z + .string() + .min(10, "Description must be at least 10 characters") + .optional(), + id: z.string().min(1, "Please provide at least one hashtag").optional(), + image: z.string().optional(), +}); + +type FormValues = z.infer; + +export const Route = createFileRoute("/_layout/create/feed/")({ + validateSearch: BasicInformationFormSchema, + component: BasicInformationComponent, +}); + +function BasicInformationComponent() { + const { isSignedIn, handleSignIn } = useAuth(); + const navigate = useNavigate({ from: Route.fullPath }); + const search = Route.useSearch(); + const { feedConfig, setValues } = useFeedCreationStore(); + + const form = useForm({ + resolver: zodResolver(BasicInformationFormSchema), + defaultValues: { + name: search.name ?? feedConfig.name ?? "", + description: search.description ?? feedConfig.description ?? "", + id: search.id ?? feedConfig.id ?? "", + image: search.image ?? feedConfig.image ?? "", + }, + }); + + const onSubmit = (data: FormValues) => { + setValues(data); + navigate({ + to: "/create/feed/settings", + }); + }; + + return ( +
+ {isSignedIn ? ( +
+ + ( + + + { + field.onChange(ipfsUrl); + }} + recommendedText="Recommended: Square, at least 400x400px. This will be your feed's avatar." + /> + + + + )} + /> + ( + + Feed Name + + + + + + )} + /> + ( + + Description + +