diff --git a/.prettierignore b/.prettierignore index e65005c6..5437647c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,5 +4,5 @@ node_modules .turbo .next .docusaurus -packages/shared-db/migrations apps/app/src/routeTree.gen.ts +packages/shared-db/migrations 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..7062e997 --- /dev/null +++ b/apps/app/src/components/feed/EditFeedForm.tsx @@ -0,0 +1,364 @@ +import { type FeedConfig } from "@curatedotfun/types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + useEffect, + useState, + useCallback, + useRef, + forwardRef, + useImperativeHandle, +} from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { Form } from "../ui/form"; +import { + BasicFieldsSection, + SourcesSection, + StreamSettingsSection, + ModerationSection, + // IngestionSection, + FeedConfigFormSchema, + 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(); + const updateTimeoutRef = useRef(); + + // 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 = useCallback( + (values: FormValues, config: FeedConfig) => { + // Clear any existing timeout + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + + // Set a new timeout + updateTimeoutRef.current = setTimeout(() => { + // 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 && + JSON.stringify(values.sources) !== JSON.stringify(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); // 300ms debounce + }, + [onConfigChange], + ); + + // Update config whenever form values change (but only after form is initialized) + useEffect(() => { + if (!currentConfig || !isFormInitialized) return; + debouncedUpdateConfig(watchedValues, currentConfig); + }, [ + watchedValues, + currentConfig, + isFormInitialized, + debouncedUpdateConfig, + ]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + }; + }, []); + + // 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/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 + +