diff --git a/.prettierignore b/.prettierignore index d1945779..5437647c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,5 @@ node_modules .turbo .next .docusaurus -packages/shared-db/migrations \ No newline at end of file +apps/app/src/routeTree.gen.ts +packages/shared-db/migrations diff --git a/apps/api/src/routes/api/processing.ts b/apps/api/src/routes/api/processing.ts index a94bed8b..0c0b256b 100644 --- a/apps/api/src/routes/api/processing.ts +++ b/apps/api/src/routes/api/processing.ts @@ -230,6 +230,117 @@ const StepIdParamSchema = z.object({ stepId: z.string(), }); +// Reprocess a job with the latest feed config +processingRoutes.post( + "/jobs/:jobId/reprocess", + zValidator("param", JobIdParamSchema), + async (c) => { + const { jobId } = c.req.valid("param"); + const sp = c.var.sp; + + try { + const processingService = sp.getProcessingService(); + const newJob = await processingService.reprocessWithLatestConfig(jobId); + + return c.json( + ProcessingJobRetryResponseSchema.parse({ + statusCode: 200, + success: true, + data: { + job: newJob, + message: "Job reprocessing initiated successfully.", + }, + }), + ); + } catch (error: unknown) { + sp.getLogger().error( + { error, jobId }, + "Error in processingRoutes.post('/jobs/:jobId/reprocess')", + ); + + if (error instanceof NotFoundError || error instanceof ServiceError) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: error.statusCode as ContentfulStatusCode, + success: false, + error: { message: error.message }, + }), + error.statusCode as ContentfulStatusCode, + ); + } + + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 500, + success: false, + error: { message: "Failed to reprocess job" }, + }), + 500, + ); + } + }, +); + +// Tweak a step's input and reprocess from that point +const TweakStepBodySchema = z.object({ + newInput: z.string(), +}); + +processingRoutes.post( + "/steps/:stepId/tweak", + zValidator("param", StepIdParamSchema), + zValidator("json", TweakStepBodySchema), + async (c) => { + const { stepId } = c.req.valid("param"); + const { newInput } = c.req.valid("json"); + const sp = c.var.sp; + + try { + const processingService = sp.getProcessingService(); + const newJob = await processingService.tweakAndReprocessStep( + stepId, + newInput, + ); + + return c.json( + ProcessingJobRetryResponseSchema.parse({ + statusCode: 200, + success: true, + data: { + job: newJob, + message: "Step tweak and reprocess initiated successfully.", + }, + }), + ); + } catch (error: unknown) { + sp.getLogger().error( + { error, stepId }, + "Error in processingRoutes.post('/steps/:stepId/tweak')", + ); + + if (error instanceof NotFoundError || error instanceof ServiceError) { + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: error.statusCode as ContentfulStatusCode, + success: false, + error: { message: error.message }, + }), + error.statusCode as ContentfulStatusCode, + ); + } + + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 500, + success: false, + error: { message: "Failed to tweak and reprocess step" }, + }), + 500, + ); + } + }, +); + // Retry processing from a specific failed step processingRoutes.post( "/steps/:stepId/retry", diff --git a/apps/api/src/routes/api/users.ts b/apps/api/src/routes/api/users.ts index aababcaa..c4733ca0 100644 --- a/apps/api/src/routes/api/users.ts +++ b/apps/api/src/routes/api/users.ts @@ -1,6 +1,8 @@ +import { FeedService } from "@curatedotfun/core-services"; import { ApiErrorResponseSchema, CreateUserRequestSchema, + FeedsWrappedResponseSchema, UpdateUserRequestSchema, UserDeletedWrappedResponseSchema, UserNearAccountIdParamSchema, @@ -333,4 +335,41 @@ usersRoutes.get( }, ); +usersRoutes.get( + "/:nearAccountId/feeds", + zValidator("param", UserNearAccountIdParamSchema), + async (c) => { + const { nearAccountId } = c.req.valid("param"); + const sp = c.var.sp; + + try { + const feedService: FeedService = sp.getFeedService(); + const feeds = await feedService.getFeedsByCreator(nearAccountId); + + return c.json( + FeedsWrappedResponseSchema.parse({ + statusCode: 200, + success: true, + data: feeds.map((feed) => ({ + ...feed, + config: feed.config, + })), + }), + ); + } catch (error) { + c.var.sp + .getLogger() + .error({ error }, `Error fetching feeds for ${nearAccountId}`); + return c.json( + ApiErrorResponseSchema.parse({ + statusCode: 500, + success: false, + error: { message: "Failed to fetch feeds" }, + }), + 500, + ); + } + }, +); + export { usersRoutes }; diff --git a/apps/app/package.json b/apps/app/package.json index 203d7499..e67a04e7 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -34,6 +34,7 @@ "@tanstack/react-query": "^5.81.2", "@tanstack/react-router": "^1.121.34", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", "@tanstack/zod-form-adapter": "^0.42.1", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", @@ -42,7 +43,7 @@ "date-fns": "^4.1.0", "fastintear": "latest", "immer": "^10.1.1", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "lucide-react": "^0.483.0", "near-sign-verify": "^0.4.1", "pinata-web3": "^0.5.4", @@ -66,7 +67,7 @@ "@rsbuild/plugin-react": "1.1.0", "@tanstack/router-devtools": "1.97.23", "@tanstack/router-plugin": "^1.121.34", - "@types/lodash": "^4.17.18", + "@types/lodash-es": "^4.17.12", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^4.6.0", diff --git a/apps/app/src/components/FilterControls.tsx b/apps/app/src/components/FilterControls.tsx index 5a14d708..cec3f715 100644 --- a/apps/app/src/components/FilterControls.tsx +++ b/apps/app/src/components/FilterControls.tsx @@ -3,6 +3,7 @@ import { Filter, Search } from "lucide-react"; import React, { ChangeEvent, useEffect, useRef, useState } from "react"; import { SortOrderType, StatusFilterType } from "../lib/api"; import { Button } from "./ui/button"; +import { debounce } from "lodash-es"; import { Input } from "./ui/input"; import { Select, @@ -36,7 +37,6 @@ const FilterControls: React.FC = ({ initialSortOrder || "newest", ); const [showFiltersDropdown, setShowFiltersDropdown] = useState(false); - const debounceTimerRef = useRef(null); type TargetSearchSchema = FileRouteTypes["fileRoutesById"][typeof parentRouteId]["preLoaderRoute"]["validateSearch"]["_output"]; @@ -48,31 +48,27 @@ const FilterControls: React.FC = ({ setSortOrder(initialSortOrder || "newest"); }, [initialQ, initialStatus, initialSortOrder]); - const handleSearchChange = (event: ChangeEvent) => { - const newValue = event.target.value; - setSearchQuery(newValue); - - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - debounceTimerRef.current = setTimeout(() => { + const debouncedNavigate = useRef( + debounce((newValue) => { navigate({ // @ts-expect-error tanstack router types are hard for a dynamic route search: (prev: TargetSearchSchema) => ({ ...prev, q: newValue || undefined, - // page: 1, // Optional: Reset page on filter change }), replace: true, }); - }, 300); + }, 300), + ).current; + + const handleSearchChange = (event: ChangeEvent) => { + const newValue = event.target.value; + setSearchQuery(newValue); + debouncedNavigate(newValue); }; const handleApplyFiltersClick = () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } + debouncedNavigate.cancel(); console.log("status", status); navigate({ // @ts-expect-error tanstack router types are hard for a dynamic route @@ -91,9 +87,7 @@ const FilterControls: React.FC = ({ useEffect(() => { // Cleanup debounce timer return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } + debouncedNavigate.cancel(); }; }, []); diff --git a/apps/app/src/components/Leaderboard.tsx b/apps/app/src/components/Leaderboard.tsx index 48099bff..ccc7286b 100644 --- a/apps/app/src/components/Leaderboard.tsx +++ b/apps/app/src/components/Leaderboard.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; import { LeaderboardEntry } from "../lib/api"; import { Container } from "./Container"; import { Hero } from "./Hero"; @@ -33,6 +34,8 @@ export default React.memo(function Leaderboard({ handleTimeDropdownToggle, handleFeedDropdownClose, handleTimeDropdownClose, + expandAllRows, + collapseAllRows, feedDropdownRef, timeDropdownRef, table, @@ -63,6 +66,25 @@ export default React.memo(function Leaderboard({ timeDropdownRef={timeDropdownRef} /> + {hasData && ( +
+ + +
+ )} + )} -

+

{getUserDisplayName()}

+
+
+ +

{title}

+
+ + + + Coming Soon + + + {description && ( +

+ {description} +

+ )} +
+ + {features && features.length > 0 && ( +
+

What to expect:

+
    + {features.map((feature, index) => ( +
  • +
    + {feature} +
  • + ))} +
+
+ )} + + ); +} diff --git a/apps/app/src/components/feed/EditFeedActions.tsx b/apps/app/src/components/feed/EditFeedActions.tsx new file mode 100644 index 00000000..603f91af --- /dev/null +++ b/apps/app/src/components/feed/EditFeedActions.tsx @@ -0,0 +1,60 @@ +import type { FeedConfig } from "@curatedotfun/types"; +import { Button } from "../ui/button"; +import { Loading } from "../ui/loading"; + +interface EditFeedActionsProps { + currentConfig: FeedConfig | null; + onSaveChanges: () => Promise; + onDeleteFeed: () => Promise; + updateFeedMutation: { + isPending: boolean; + }; + deleteFeedMutation: { + isPending: boolean; + }; +} + +export function EditFeedActions({ + onSaveChanges, + onDeleteFeed, + updateFeedMutation, + deleteFeedMutation, +}: EditFeedActionsProps) { + return ( +
+
+ + +
+
+ ); +} diff --git a/apps/app/src/components/feed/EditFeedConfigSection.tsx b/apps/app/src/components/feed/EditFeedConfigSection.tsx new file mode 100644 index 00000000..b9c21a3f --- /dev/null +++ b/apps/app/src/components/feed/EditFeedConfigSection.tsx @@ -0,0 +1,96 @@ +import type { FeedConfig } from "@curatedotfun/types"; +import { useState, useRef } from "react"; +import { JsonEditor } from "../content-progress/JsonEditor"; +import { Button } from "../ui/button"; +import { Label } from "../ui/label"; +import { EditFeedForm, type EditFeedFormRef } from "./EditFeedForm"; +import { toast } from "@/hooks/use-toast"; + +interface EditFeedConfigSectionProps { + jsonString: string; + currentConfig: FeedConfig | null; + onJsonChange: (newJsonString: string) => void; + onConfigChange: (config: FeedConfig) => void; +} + +export function EditFeedConfigSection({ + jsonString, + currentConfig, + onJsonChange, + onConfigChange, +}: EditFeedConfigSectionProps) { + const [isJsonMode, setIsJsonMode] = useState(false); + const formRef = useRef(null); + + const handleConfigChange = (config: FeedConfig) => { + onConfigChange(config); + try { + onJsonChange(JSON.stringify(config, null, 2)); + } catch (error) { + console.error("Failed to stringify config:", error); + toast({ + title: "Error", + description: + "Failed to update JSON configuration. Please check the console for details.", + variant: "destructive", + }); + } + }; + + const handleSwitchToFormMode = () => { + setIsJsonMode(false); + // Trigger form update after switching to form mode + setTimeout(() => { + formRef.current?.updateFromConfig(); + }, 0); + }; + + return ( +
+
+
+
+

Feed Configuration

+

+ {isJsonMode + ? "Edit the JSON configuration directly to fine-tune your feed settings" + : "Use the form below to easily configure your feed settings"} +

+
+
+ + +
+
+ + {isJsonMode ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/app/src/components/feed/EditFeedForm.tsx b/apps/app/src/components/feed/EditFeedForm.tsx new file mode 100644 index 00000000..054d4474 --- /dev/null +++ b/apps/app/src/components/feed/EditFeedForm.tsx @@ -0,0 +1,343 @@ +import { type FeedConfig } from "@curatedotfun/types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { debounce, isEqual } from "lodash-es"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { Form } from "../ui/form"; +import { + BasicFieldsSection, + // IngestionSection, + FeedConfigFormSchema, + ModerationSection, + SourcesSection, + StreamSettingsSection, + type FormValues, +} from "./form"; + +interface EditFeedFormProps { + currentConfig: FeedConfig | null; + onConfigChange: (config: FeedConfig) => void; +} + +export interface EditFeedFormRef { + updateFromConfig: () => void; +} + +export const EditFeedForm = forwardRef( + function EditFeedForm({ currentConfig, onConfigChange }, ref) { + const form = useForm({ + resolver: zodResolver(FeedConfigFormSchema), + defaultValues: { + name: "", + description: "", + enabled: true, + pollingIntervalMs: undefined, + ingestionEnabled: false, + ingestionSchedule: "", + sources: [], + streamEnabled: false, + streamTransforms: [], + streamDistributors: [], + recaps: [], + moderationApprovers: {}, + moderationBlacklist: {}, + }, + }); + + // Track when form should update from config changes + const isUpdatingFromForm = useRef(false); + + // Function to update form from config + const updateFormFromConfig = useCallback( + (config: FeedConfig) => { + if (isUpdatingFromForm.current) return; + + form.reset({ + name: config.name || "", + description: config.description || "", + enabled: config.enabled ?? true, + pollingIntervalMs: config.pollingIntervalMs || undefined, + ingestionEnabled: config.ingestion?.enabled ?? false, + ingestionSchedule: config.ingestion?.schedule || "", + sources: config.sources || [], + streamEnabled: config.outputs?.stream?.enabled ?? false, + streamTransforms: (config.outputs?.stream?.transform || []).map( + (t) => ({ + ...t, + config: t.config || {}, + }), + ), + streamDistributors: (config.outputs?.stream?.distribute || []).map( + (d) => ({ + ...d, + config: d.config || {}, + }), + ), + recaps: config.outputs?.recap || [], + moderationApprovers: config.moderation?.approvers || {}, + moderationBlacklist: config.moderation?.blacklist || {}, + }); + }, + [form], + ); + + // Update form whenever currentConfig changes + useEffect(() => { + if (!currentConfig) return; + updateFormFromConfig(currentConfig); + }, [currentConfig, updateFormFromConfig]); + + const { + fields: sourceFields, + append: appendSource, + remove: removeSource, + } = useFieldArray({ + control: form.control, + name: "sources", + }); + + const { + fields: transformFields, + append: appendTransform, + remove: removeTransform, + } = useFieldArray({ + control: form.control, + name: "streamTransforms", + }); + + const { + fields: distributorFields, + append: appendDistributor, + remove: removeDistributor, + } = useFieldArray({ + control: form.control, + name: "streamDistributors", + }); + + // const { + // fields: recapFields, + // append: appendRecap, + // remove: removeRecap, + // } = useFieldArray({ + // control: form.control, + // name: "recaps", + // }); + + // Watch for form changes and auto-update with debouncing + const watchedValues = form.watch(); + + // Track if form has been initialized to avoid triggering updates during initial load + const [isFormInitialized, setIsFormInitialized] = useState(false); + + // Mark form as initialized after first config load + useEffect(() => { + if (currentConfig && !isFormInitialized) { + setIsFormInitialized(true); + } + }, [currentConfig, isFormInitialized]); + + // Debounced config update function + const debouncedUpdateConfig = useRef( + debounce((values: FormValues, config: FeedConfig) => { + // Start with the original config and only update fields that have been explicitly set in the form + const updatedConfig: FeedConfig = { + ...config, // Preserve all existing data first + }; + + // Only update fields that have been explicitly changed in the form + if (values.name !== undefined && values.name !== config.name) { + updatedConfig.name = values.name; + } + + if ( + values.description !== undefined && + values.description !== config.description + ) { + updatedConfig.description = values.description; + } + + if (values.enabled !== undefined && values.enabled !== config.enabled) { + updatedConfig.enabled = values.enabled; + } + + if ( + values.pollingIntervalMs !== undefined && + values.pollingIntervalMs !== config.pollingIntervalMs + ) { + updatedConfig.pollingIntervalMs = values.pollingIntervalMs; + } + + // Only update sources if they exist in the form + if ( + values.sources !== undefined && + !isEqual(values.sources, config.sources) + ) { + updatedConfig.sources = values.sources; + } + + // Only update ingestion if form values are different from current + if ( + values.ingestionEnabled !== undefined || + values.ingestionSchedule !== undefined + ) { + const currentIngestionEnabled = config.ingestion?.enabled ?? false; + const currentIngestionSchedule = config.ingestion?.schedule ?? ""; + + if ( + values.ingestionEnabled !== currentIngestionEnabled || + values.ingestionSchedule !== currentIngestionSchedule + ) { + updatedConfig.ingestion = { + enabled: values.ingestionEnabled ?? currentIngestionEnabled, + schedule: values.ingestionSchedule ?? currentIngestionSchedule, + }; + } + } + + // Handle outputs - only update if stream settings or transforms/distributors have changed + if ( + values.streamEnabled !== undefined || + values.streamTransforms !== undefined || + values.streamDistributors !== undefined || + values.recaps !== undefined + ) { + const currentStreamEnabled = config.outputs?.stream?.enabled ?? false; + const currentTransforms = config.outputs?.stream?.transform ?? []; + const currentDistributors = config.outputs?.stream?.distribute ?? []; + const currentRecaps = config.outputs?.recap ?? []; + + // Check if stream output settings have changed + const streamChanged = + values.streamEnabled !== currentStreamEnabled || + JSON.stringify(values.streamTransforms) !== + JSON.stringify(currentTransforms) || + JSON.stringify(values.streamDistributors) !== + JSON.stringify(currentDistributors); + + const recapsChanged = + JSON.stringify(values.recaps) !== JSON.stringify(currentRecaps); + + if (streamChanged || recapsChanged) { + updatedConfig.outputs = { + ...config.outputs, + }; + + if (streamChanged) { + updatedConfig.outputs.stream = { + ...config.outputs?.stream, + enabled: values.streamEnabled ?? currentStreamEnabled, + transform: values.streamTransforms ?? currentTransforms, + distribute: values.streamDistributors ?? currentDistributors, + }; + } + + if (recapsChanged) { + updatedConfig.outputs.recap = values.recaps ?? currentRecaps; + } + } + } + + // Handle moderation - only update if values have changed + if ( + values.moderationApprovers !== undefined || + values.moderationBlacklist !== undefined + ) { + const currentApprovers = config.moderation?.approvers ?? {}; + const currentBlacklist = config.moderation?.blacklist ?? {}; + + if ( + JSON.stringify(values.moderationApprovers) !== + JSON.stringify(currentApprovers) || + JSON.stringify(values.moderationBlacklist) !== + JSON.stringify(currentBlacklist) + ) { + updatedConfig.moderation = { + ...config.moderation, + approvers: values.moderationApprovers ?? currentApprovers, + blacklist: values.moderationBlacklist ?? currentBlacklist, + }; + } + } + + // Only update if the config has actually changed + if (JSON.stringify(updatedConfig) !== JSON.stringify(config)) { + isUpdatingFromForm.current = true; + onConfigChange(updatedConfig); + // Reset the flag in the next tick to ensure state updates have propagated + Promise.resolve().then(() => { + isUpdatingFromForm.current = false; + }); + } + }, 300), + ).current; + + // Update config whenever form values change (but only after form is initialized) + useEffect(() => { + if (!currentConfig || !isFormInitialized) return; + debouncedUpdateConfig(watchedValues, currentConfig); + }, [watchedValues, currentConfig, isFormInitialized]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + debouncedUpdateConfig.cancel(); + }; + }, []); + + // Expose updateFromConfig method to parent component + useImperativeHandle(ref, () => ({ + updateFromConfig: () => { + if (currentConfig) { + // Force immediate update when switching from JSON to form view + updateFormFromConfig(currentConfig); + } + }, + })); + + return ( +
+
+
+ + + + + + {/* + */} + + + + {/* */} +
+
+
+ ); + }, +); diff --git a/apps/app/src/components/feed/EditFeedHeader.tsx b/apps/app/src/components/feed/EditFeedHeader.tsx new file mode 100644 index 00000000..bac812a6 --- /dev/null +++ b/apps/app/src/components/feed/EditFeedHeader.tsx @@ -0,0 +1,21 @@ +import type { FeedConfig } from "@curatedotfun/types"; + +interface EditFeedHeaderProps { + feedId: string; + currentConfig: FeedConfig | null; +} + +export function EditFeedHeader({ feedId, currentConfig }: EditFeedHeaderProps) { + return ( +
+
+

+ Edit Feed: {currentConfig?.name || feedId} +

+

+ Modify your feed configuration and settings +

+
+
+ ); +} diff --git a/apps/app/src/components/feed/EditFeedImageSection.tsx b/apps/app/src/components/feed/EditFeedImageSection.tsx new file mode 100644 index 00000000..4acf04c9 --- /dev/null +++ b/apps/app/src/components/feed/EditFeedImageSection.tsx @@ -0,0 +1,36 @@ +import type { FeedConfig } from "@curatedotfun/types"; +import { ImageUpload } from "../ImageUpload"; +import { toast } from "../../hooks/use-toast"; + +interface EditFeedImageSectionProps { + currentConfig: FeedConfig | null; + feedId: string; + onImageUploaded: (ipfsHash: string, ipfsUrl: string) => void; +} + +export function EditFeedImageSection({ + currentConfig, + onImageUploaded, +}: EditFeedImageSectionProps) { + const handleImageUploaded = (ipfsHash: string, ipfsUrl: string) => { + onImageUploaded(ipfsHash, ipfsUrl); + toast({ + title: "Image Updated", + description: "Image URL has been updated in the JSON config.", + }); + }; + + return ( +
+
+

Feed Image

+ +
+
+ ); +} diff --git a/apps/app/src/components/feed/EditFeedLoadingState.tsx b/apps/app/src/components/feed/EditFeedLoadingState.tsx new file mode 100644 index 00000000..20c277c3 --- /dev/null +++ b/apps/app/src/components/feed/EditFeedLoadingState.tsx @@ -0,0 +1,46 @@ +import { Terminal } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Loading } from "../ui/loading"; + +interface EditFeedLoadingStateProps { + isLoadingFeed: boolean; + feedError: Error | null; + feedData: unknown; +} + +export function EditFeedLoadingState({ + isLoadingFeed, + feedError, + feedData, +}: EditFeedLoadingStateProps) { + if (isLoadingFeed) { + return ( +
+
+ +

Loading feed details...

+
+
+ ); + } + + if (feedError) { + return ( + + + Error Loading Feed + {feedError.message} + + ); + } + + if (!feedData) { + return ( +
+

Feed not found.

+
+ ); + } + + return null; +} diff --git a/apps/app/src/components/feed/FeedCard.tsx b/apps/app/src/components/feed/FeedCard.tsx new file mode 100644 index 00000000..9808bf5d --- /dev/null +++ b/apps/app/src/components/feed/FeedCard.tsx @@ -0,0 +1,74 @@ +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { RssFeedItem } from "@/types/rss"; + +interface FeedCardProps { + item: RssFeedItem; + feedName: string; + feedId: string; + feedImage?: string; +} + +export function FeedCard({ item, feedName, feedId, feedImage }: FeedCardProps) { + const handleClick = () => { + if (item.link) { + window.open(item.link, "_blank", "noopener,noreferrer"); + } + }; + + return ( + + + {/* Image */} + {item.image && ( +
+ {item.title} { + e.currentTarget.style.display = "none"; + }} + /> +
+ )} + + {/* Content - flex-grow to push footer to bottom */} +
+ {/* Title */} +

+ {item.title} +

+ + {/* Description */} + {item.description && ( +

+ {item.description} +

+ )} +
+ + {/* Footer Badge - always at bottom */} +
+ {feedName} { + e.currentTarget.src = "/images/feed-image.png"; + }} + /> + + {feedName} + + + #{feedId} + +
+
+
+ ); +} diff --git a/apps/app/src/components/feed/RecentContent.tsx b/apps/app/src/components/feed/RecentContent.tsx new file mode 100644 index 00000000..99398d8e --- /dev/null +++ b/apps/app/src/components/feed/RecentContent.tsx @@ -0,0 +1,103 @@ +import { useState, useMemo } from "react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useRssFeed } from "@/hooks/use-rss-feed"; +import { FeedCard } from "./FeedCard"; + +interface RecentContentProps { + feedId: string; + feedName: string; + feedImage?: string; +} + +export function RecentContent({ + feedId, + feedName, + feedImage, +}: RecentContentProps) { + const [currentPage, setCurrentPage] = useState(0); + const { hasRssFeed, rssData, isLoading, isError } = useRssFeed(feedId); + + // Get recent items (latest 9 items, 3 pages of 3 items each) + const recentItems = useMemo(() => { + if (!rssData || rssData.length === 0) return []; + + // Sort by date (most recent first) and take first 9 items + const sortedItems = [...rssData] + .sort( + (a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime(), + ) + .slice(0, 9); + + // Group into pages of 3 + const pages = []; + for (let i = 0; i < sortedItems.length; i += 3) { + pages.push(sortedItems.slice(i, i + 3)); + } + + return pages; + }, [rssData]); + + const maxPages = recentItems.length; + + const handlePrevious = () => { + setCurrentPage((prev) => (prev > 0 ? prev - 1 : maxPages - 1)); + }; + + const handleNext = () => { + setCurrentPage((prev) => (prev < maxPages - 1 ? prev + 1 : 0)); + }; + + // Don't render if no RSS feed or no data + if (!hasRssFeed || isLoading || isError || recentItems.length === 0) { + return null; + } + + const currentItems = recentItems[currentPage] || []; + + return ( +
+ {/* Header with Navigation - Responsive */} +
+

+ Recent Content +

+ {maxPages > 1 && ( +
+ + +
+ )} +
+ + {/* Content Cards - Responsive Grid */} +
+ {currentItems.map((item, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/app/src/components/feed/form/BasicFieldsSection.tsx b/apps/app/src/components/feed/form/BasicFieldsSection.tsx new file mode 100644 index 00000000..7f82f9f1 --- /dev/null +++ b/apps/app/src/components/feed/form/BasicFieldsSection.tsx @@ -0,0 +1,106 @@ +import { memo } from "react"; +import { Control } from "react-hook-form"; +import { Checkbox } from "../../ui/checkbox"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../ui/form"; +import { Input } from "../../ui/input"; +import { Textarea } from "../../ui/textarea"; +import { FormValues } from "./types"; + +interface BasicFieldsSectionProps { + control: Control; +} + +export const BasicFieldsSection = memo(function BasicFieldsSection({ + control, +}: BasicFieldsSectionProps) { + return ( +
+ ( + + Feed Name + + + + + + )} + /> + + ( + + Description + +