diff --git a/frontend/apps/app/features/projects/actions/updateKnowledgeSuggestionContent.ts b/frontend/apps/app/features/projects/actions/updateKnowledgeSuggestionContent.ts new file mode 100644 index 0000000000..af24cb8c84 --- /dev/null +++ b/frontend/apps/app/features/projects/actions/updateKnowledgeSuggestionContent.ts @@ -0,0 +1,45 @@ +'use server' + +import { createClient } from '@/libs/db/server' +import * as v from 'valibot' + +const formDataSchema = v.object({ + suggestionId: v.pipe( + v.string(), + v.transform((value) => Number(value)), + ), + content: v.string(), +}) + +export const updateKnowledgeSuggestionContent = async (formData: FormData) => { + const formDataObject = { + suggestionId: formData.get('suggestionId'), + content: formData.get('content'), + } + + const parsedData = v.safeParse(formDataSchema, formDataObject) + + if (!parsedData.success) { + throw new Error(`Invalid form data: ${JSON.stringify(parsedData.issues)}`) + } + + const { suggestionId, content } = parsedData.output + + try { + const supabase = await createClient() + + const { error: updateError } = await supabase + .from('KnowledgeSuggestion') + .update({ content, updatedAt: new Date().toISOString() }) + .eq('id', suggestionId) + + if (updateError) { + throw new Error('Failed to update knowledge suggestion content') + } + + return { success: true } + } catch (error) { + console.error('Error updating knowledge suggestion content:', error) + throw error + } +} diff --git a/frontend/apps/app/features/projects/components/DiffDisplay/DiffDisplay.module.css b/frontend/apps/app/features/projects/components/DiffDisplay/DiffDisplay.module.css new file mode 100644 index 0000000000..48fd8b9818 --- /dev/null +++ b/frontend/apps/app/features/projects/components/DiffDisplay/DiffDisplay.module.css @@ -0,0 +1,22 @@ +.diffContent { + font-family: monospace; + white-space: pre-wrap; + overflow-x: auto; + padding: 1rem; + border-radius: 4px; + background-color: var(--color-background-secondary); +} + +.diffAdded { + background-color: rgba(0, 255, 0, 0.1); + color: var(--color-success); +} + +.diffRemoved { + background-color: rgba(255, 0, 0, 0.1); + color: var(--color-error); +} + +.diffUnchanged { + color: var(--color-text-primary); +} diff --git a/frontend/apps/app/features/projects/components/DiffDisplay/DiffDisplay.tsx b/frontend/apps/app/features/projects/components/DiffDisplay/DiffDisplay.tsx new file mode 100644 index 0000000000..347e316030 --- /dev/null +++ b/frontend/apps/app/features/projects/components/DiffDisplay/DiffDisplay.tsx @@ -0,0 +1,62 @@ +import * as diffLib from 'diff' +import type { FC } from 'react' +import styles from './DiffDisplay.module.css' + +interface DiffDisplayProps { + originalContent: string | null + newContent: string +} + +export const DiffDisplay: FC = ({ + originalContent, + newContent, +}) => { + if (!originalContent) { + return ( +
+ {newContent.split('\n').map((line, index) => ( +
+ index + }`} + className={styles.diffAdded} + > + + {line} +
+ ))} +
+ ) + } + + const diff = diffLib.diffTrimmedLines(originalContent, newContent) + + return ( +
+ {diff.map((part, index) => { + const className = part.added + ? styles.diffAdded + : part.removed + ? styles.diffRemoved + : styles.diffUnchanged + + const prefix = part.added ? '+ ' : part.removed ? '- ' : ' ' + + return part.value.split('\n').map((line, lineIndex) => { + if (lineIndex === part.value.split('\n').length - 1 && line === '') { + return null + } + return ( +
+ {prefix} + {line} +
+ ) + }) + })} +
+ ) +} diff --git a/frontend/apps/app/features/projects/components/EditableContent/EditableContent.module.css b/frontend/apps/app/features/projects/components/EditableContent/EditableContent.module.css new file mode 100644 index 0000000000..6a25ac1855 --- /dev/null +++ b/frontend/apps/app/features/projects/components/EditableContent/EditableContent.module.css @@ -0,0 +1,121 @@ +.container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.sectionTitle { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--color-primary); + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.editButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + background-color: var(--color-background-tertiary); + color: var(--color-primary); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.editButton:hover { + background-color: var(--color-background-tertiary-hover); + transform: translateY(-1px); +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.contentTextarea { + padding: 1.25rem; + background-color: var(--color-background-code); + border-radius: 0.5rem; + font-family: monospace; + font-size: 0.875rem; + line-height: 1.6; + border: 1px solid rgba(0, 0, 0, 0.1); + resize: vertical; + min-height: 200px; + width: 100%; +} + +.actionButtons { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1rem; +} + +.cancelButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + background-color: var(--color-background-tertiary); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.cancelButton:hover { + background-color: var(--color-background-tertiary-hover); +} + +.saveButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + background-color: var(--color-primary); + color: white; + border: none; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.saveButton:hover { + background-color: var(--color-primary-dark); + transform: translateY(-1px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); +} + +.saveButton:disabled, +.cancelButton:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.content { + display: flex; + flex-direction: column; + gap: 2rem; +} diff --git a/frontend/apps/app/features/projects/components/EditableContent/EditableContent.tsx b/frontend/apps/app/features/projects/components/EditableContent/EditableContent.tsx new file mode 100644 index 0000000000..e0e66bab0d --- /dev/null +++ b/frontend/apps/app/features/projects/components/EditableContent/EditableContent.tsx @@ -0,0 +1,106 @@ +'use client' + +import React, { type ReactNode, useState } from 'react' +import { updateKnowledgeSuggestionContent } from '../../actions/updateKnowledgeSuggestionContent' +import { DiffDisplay } from '../DiffDisplay/DiffDisplay' +import styles from './EditableContent.module.css' + +type EditableContentProps = { + content: string + suggestionId: number + className?: string + originalContent: string | null + isApproved: boolean +} + +export const EditableContent = ({ + content, + suggestionId, + className, + originalContent, + isApproved, +}: EditableContentProps) => { + const [isEditing, setIsEditing] = useState(false) + const [editedContent, setEditedContent] = useState(content) + const [isSaving, setIsSaving] = useState(false) + const [savedContent, setSavedContent] = useState(content) + + const handleEditClick = () => { + setIsEditing(true) + } + + const handleCancelClick = () => { + setEditedContent(savedContent) + setIsEditing(false) + } + + const handleSave = async (formData: FormData) => { + try { + setIsSaving(true) + await updateKnowledgeSuggestionContent(formData) + setSavedContent(editedContent) + setIsEditing(false) + } catch (error) { + console.error('Error saving content:', error) + } finally { + setIsSaving(false) + } + } + + return ( +
+
+
Content
+ {!isEditing && ( + + )} +
+ + {isEditing ? ( +
+ +