From 6e067405a2e72604c42e02776879fada2d7006b1 Mon Sep 17 00:00:00 2001 From: Marcin Czarkowski Date: Fri, 26 Sep 2025 20:22:18 +0200 Subject: [PATCH 01/23] refactor: extract shared markdown builders for rules generation strategies (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract common scaffolding from Single and Multi file strategies into reusable builders module. This reduces code duplication and ensures consistent formatting across both strategies. Key changes: - Created markdown-builders module with shared utilities - Extracted project header, empty state, and library section rendering - Unified iteration logic over layer/stack/library structure - Simplified both strategies to use shared builders Benefits: - Guarantees consistent formatting across strategies - Reduces effort when adding new strategies - Single source of truth for markdown generation logic - Easier maintenance and testing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude --- .../rules-builder/markdown-builders/index.ts | 102 ++++++++++++++++++ .../MultiFileRulesStrategy.ts | 70 +++++------- .../SingleFileRulesStrategy.ts | 63 +++++------ 3 files changed, 160 insertions(+), 75 deletions(-) create mode 100644 src/services/rules-builder/markdown-builders/index.ts diff --git a/src/services/rules-builder/markdown-builders/index.ts b/src/services/rules-builder/markdown-builders/index.ts new file mode 100644 index 0000000..d79bf86 --- /dev/null +++ b/src/services/rules-builder/markdown-builders/index.ts @@ -0,0 +1,102 @@ +import type { Layer, Library, Stack } from '../../../data/dictionaries.ts'; +import { getRulesForLibrary } from '../../../data/rules.ts'; +import { slugify } from '../../../utils/slugify.ts'; + +export const createProjectMarkdown = (projectName: string, projectDescription: string): string => + `# AI Rules for ${projectName}\n\n${projectDescription}\n\n`; + +export const createEmptyStateMarkdown = (): string => + `---\n\n👈 Use the Rule Builder on the left or drop dependency file here`; + +export const getProjectMetadata = () => ({ + label: 'Project', + fileName: 'project.mdc' as const, +}); + +export interface LibrarySectionConfig { + layer: string; + stack: string; + library: string; + includeLayerHeader?: boolean; + includeStackHeader?: boolean; +} + +export const renderLibrarySection = ({ + layer, + stack, + library, + includeLayerHeader = false, + includeStackHeader = false, +}: LibrarySectionConfig): string => { + let markdown = ''; + + if (includeLayerHeader) { + markdown += `## ${layer}\n\n`; + } + + if (includeStackHeader) { + markdown += `### Guidelines for ${stack}\n\n`; + } + + markdown += `#### ${library}\n\n`; + + const libraryRules = getRulesForLibrary(library); + if (libraryRules.length > 0) { + libraryRules.forEach((rule) => { + markdown += `- ${rule}\n`; + }); + } else { + markdown += `- Use ${library} according to best practices\n`; + } + + markdown += '\n'; + + return markdown; +}; + +export const renderLibraryRulesContent = (library: Library): string => { + const libraryRules = getRulesForLibrary(library); + return libraryRules.length > 0 + ? libraryRules.map((rule) => `- ${rule}`).join('\n') + : `- Use ${library} according to best practices`; +}; + +export interface LayerStackIterator { + stacksByLayer: Record; + librariesByStack: Record; + onLibrary: ( + layer: Layer, + stack: Stack, + library: Library, + isFirstInStack: boolean, + isFirstInLayer: boolean, + ) => void; +} + +export const iterateLayersStacksLibraries = ({ + stacksByLayer, + librariesByStack, + onLibrary, +}: LayerStackIterator): void => { + Object.entries(stacksByLayer).forEach(([layer, stacks], layerIndex) => { + stacks.forEach((stack, stackIndex) => { + const libraries = librariesByStack[stack]; + if (libraries) { + libraries.forEach((library, libraryIndex) => { + onLibrary( + layer as Layer, + stack, + library, + libraryIndex === 0, + layerIndex === 0 && stackIndex === 0, + ); + }); + } + }); + }); +}; + +export const createLibraryFileMetadata = (layer: string, stack: string, library: string) => ({ + label: `${layer} - ${stack} - ${library}`, + fileName: `${slugify(`${layer}-${stack}-${library}`)}.mdc` as const, +}); diff --git a/src/services/rules-builder/rules-generation-strategies/MultiFileRulesStrategy.ts b/src/services/rules-builder/rules-generation-strategies/MultiFileRulesStrategy.ts index 124b632..18a6266 100644 --- a/src/services/rules-builder/rules-generation-strategies/MultiFileRulesStrategy.ts +++ b/src/services/rules-builder/rules-generation-strategies/MultiFileRulesStrategy.ts @@ -1,8 +1,14 @@ import type { RulesGenerationStrategy } from '../RulesGenerationStrategy.ts'; import { Layer, type Library, Stack } from '../../../data/dictionaries.ts'; import type { RulesContent } from '../RulesBuilderTypes.ts'; -import { getRulesForLibrary } from '../../../data/rules'; -import { slugify } from '../../../utils/slugify.ts'; +import { + createProjectMarkdown, + createEmptyStateMarkdown, + getProjectMetadata, + renderLibrarySection, + iterateLayersStacksLibraries, + createLibraryFileMetadata, +} from '../markdown-builders/index.ts'; /** * Strategy for multi-file rules generation @@ -15,72 +21,46 @@ export class MultiFileRulesStrategy implements RulesGenerationStrategy { stacksByLayer: Record, librariesByStack: Record, ): RulesContent[] { - const projectMarkdown = `# AI Rules for ${projectName}\n\n${projectDescription}\n\n`; - const noSelectedLibrariesMarkdown = `---\n\n👈 Use the Rule Builder on the left or drop dependency file here`; - const projectLabel = 'Project', - projectFileName = 'project.mdc'; + const projectMarkdown = createProjectMarkdown(projectName, projectDescription); + const { label: projectLabel, fileName: projectFileName } = getProjectMetadata(); const markdowns: RulesContent[] = []; markdowns.push({ markdown: projectMarkdown, label: projectLabel, fileName: projectFileName }); if (selectedLibraries.length === 0) { - markdowns[0].markdown += noSelectedLibrariesMarkdown; + markdowns[0].markdown += createEmptyStateMarkdown(); return markdowns; } - Object.entries(stacksByLayer).forEach(([layer, stacks]) => { - stacks.forEach((stack) => { - librariesByStack[stack].forEach((library) => { - markdowns.push( - this.buildRulesContent({ - layer, - stack, - library, - libraryRules: getRulesForLibrary(library), - }), - ); - }); - }); + iterateLayersStacksLibraries({ + stacksByLayer, + librariesByStack, + onLibrary: (layer, stack, library) => { + markdowns.push(this.buildRulesContent({ layer, stack, library })); + }, }); return markdowns; } private buildRulesContent({ - libraryRules, layer, stack, library, }: { - libraryRules: string[]; layer: string; stack: string; library: string; }): RulesContent { - const label = `${layer} - ${stack} - ${library}`; - const fileName: RulesContent['fileName'] = `${slugify(`${layer}-${stack}-${library}`)}.mdc`; - const content = - libraryRules.length > 0 - ? `${libraryRules.map((rule) => `- ${rule}`).join('\n')}` - : `- Use ${library} according to best practices`; - const markdown = this.renderRuleMarkdown({ content, layer, stack, library }); + const { label, fileName } = createLibraryFileMetadata(layer, stack, library); + const markdown = renderLibrarySection({ + layer, + stack, + library, + includeLayerHeader: true, + includeStackHeader: true, + }); return { markdown, label, fileName }; } - - private renderRuleMarkdown = ({ - content, - layer, - stack, - library, - }: { - content: string; - layer: string; - stack: string; - library: string; - }) => - `## ${layer}\n\n### Guidelines for ${stack}\n\n#### ${library}\n\n{{content}}\n\n`.replace( - '{{content}}', - content, - ); } diff --git a/src/services/rules-builder/rules-generation-strategies/SingleFileRulesStrategy.ts b/src/services/rules-builder/rules-generation-strategies/SingleFileRulesStrategy.ts index 467c9e7..d8a66ca 100644 --- a/src/services/rules-builder/rules-generation-strategies/SingleFileRulesStrategy.ts +++ b/src/services/rules-builder/rules-generation-strategies/SingleFileRulesStrategy.ts @@ -1,7 +1,13 @@ import type { RulesGenerationStrategy } from '../RulesGenerationStrategy.ts'; import { Layer, Library, Stack } from '../../../data/dictionaries.ts'; import type { RulesContent } from '../RulesBuilderTypes.ts'; -import { getRulesForLibrary } from '../../../data/rules.ts'; +import { + createProjectMarkdown, + createEmptyStateMarkdown, + getProjectMetadata, + renderLibrarySection, + iterateLayersStacksLibraries, +} from '../markdown-builders/index.ts'; /** * Strategy for single-file rules generation @@ -14,15 +20,13 @@ export class SingleFileRulesStrategy implements RulesGenerationStrategy { stacksByLayer: Record, librariesByStack: Record, ): RulesContent[] { - const projectMarkdown = `# AI Rules for ${projectName}\n\n${projectDescription}\n\n`; - const noSelectedLibrariesMarkdown = `---\n\n👈 Use the Rule Builder on the left or drop dependency file here`; - const projectLabel = 'Project', - projectFileName = 'project.mdc'; + const projectMarkdown = createProjectMarkdown(projectName, projectDescription); + const { label: projectLabel, fileName: projectFileName } = getProjectMetadata(); let markdown = projectMarkdown; if (selectedLibraries.length === 0) { - markdown += noSelectedLibrariesMarkdown; + markdown += createEmptyStateMarkdown(); return [{ markdown, label: projectLabel, fileName: projectFileName }]; } @@ -35,35 +39,34 @@ export class SingleFileRulesStrategy implements RulesGenerationStrategy { librariesByStack: Record, ): string { let markdown = ''; + let currentLayer = ''; + let currentStack = ''; - // Generate content for each layer and its stacks - Object.entries(stacksByLayer).forEach(([layer, stacks]) => { - markdown += `## ${layer}\n\n`; - - stacks.forEach((stack) => { - markdown += `### Guidelines for ${stack}\n\n`; - - const libraries = librariesByStack[stack]; - if (libraries) { - libraries.forEach((library) => { - markdown += `#### ${library}\n\n`; - - // Get specific rules for this library - const libraryRules = getRulesForLibrary(library); - if (libraryRules.length > 0) { - libraryRules.forEach((rule) => { - markdown += `- ${rule}\n`; - }); - } else { - markdown += `- Use ${library} according to best practices\n`; - } + iterateLayersStacksLibraries({ + stacksByLayer, + librariesByStack, + onLibrary: (layer, stack, library) => { + const includeLayerHeader = layer !== currentLayer; + const includeStackHeader = stack !== currentStack; + if (includeLayerHeader) { + currentLayer = layer; + } + if (includeStackHeader) { + currentStack = stack; + if (!includeLayerHeader && currentStack) { markdown += '\n'; - }); + } } - markdown += '\n'; - }); + markdown += renderLibrarySection({ + layer, + stack, + library, + includeLayerHeader, + includeStackHeader, + }); + }, }); return markdown; From 591d15cb13c7dcef1affdafdb66ae7973204541e Mon Sep 17 00:00:00 2001 From: Marcin Czarkowski Date: Fri, 26 Sep 2025 20:22:37 +0200 Subject: [PATCH 02/23] refactor: centralize keyboard activation handling (#71) * refactor: centralize keyboard activation handling * test: add coverage for useKeyboardActivation hook --- src/components/rule-builder/LibraryItem.tsx | 11 +- src/components/rule-builder/SearchInput.tsx | 17 ++- src/components/rule-builder/SelectedRules.tsx | 11 +- .../rule-collections/CollectionListEntry.tsx | 41 ++++--- src/components/ui/Accordion.tsx | 15 ++- src/hooks/useKeyboardActivation.ts | 50 +++++++++ tests/setup/test-utils.tsx | 8 ++ .../unit/hooks/useKeyboardActivation.test.tsx | 101 ++++++++++++++++++ 8 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 src/hooks/useKeyboardActivation.ts create mode 100644 tests/setup/test-utils.tsx create mode 100644 tests/unit/hooks/useKeyboardActivation.test.tsx diff --git a/src/components/rule-builder/LibraryItem.tsx b/src/components/rule-builder/LibraryItem.tsx index d4df935..b74e8f2 100644 --- a/src/components/rule-builder/LibraryItem.tsx +++ b/src/components/rule-builder/LibraryItem.tsx @@ -1,11 +1,11 @@ import { Check } from 'lucide-react'; -import type { KeyboardEvent } from 'react'; import React from 'react'; import { Library } from '../../data/dictionaries'; import { getLibraryTranslation } from '../../i18n/translations'; import type { LayerType } from '../../styles/theme'; import { getLayerClasses } from '../../styles/theme'; import { useAccordionContentOpen } from '../ui/Accordion'; +import { useKeyboardActivation } from '../../hooks/useKeyboardActivation'; interface LibraryItemProps { library: Library; @@ -19,12 +19,7 @@ export const LibraryItem: React.FC = React.memo( const isParentAccordionOpen = useAccordionContentOpen(); const itemClasses = getLayerClasses.libraryItem(layerType, isSelected); - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggle(library); - } - }; + const createKeyboardActivationHandler = useKeyboardActivation(); return ( - setIsCreateDialogOpen(false)} onSave={handleCreateCollection} /> - { ); }; -export default CollectionsList; +export default RuleCollectionsList; diff --git a/src/components/rule-collections/CollectionsSidebar.tsx b/src/components/rule-collections/RuleCollectionsSidebar.tsx similarity index 83% rename from src/components/rule-collections/CollectionsSidebar.tsx rename to src/components/rule-collections/RuleCollectionsSidebar.tsx index 5794e03..1d89e70 100644 --- a/src/components/rule-collections/CollectionsSidebar.tsx +++ b/src/components/rule-collections/RuleCollectionsSidebar.tsx @@ -1,18 +1,21 @@ import React, { useEffect } from 'react'; import { Album, ChevronLeft, LogIn } from 'lucide-react'; import { transitions } from '../../styles/theme'; -import { CollectionsList } from './CollectionsList'; -import { useCollectionsStore } from '../../store/collectionsStore'; +import { RuleCollectionsList } from './RuleCollectionsList'; +import { useRuleCollectionsStore } from '../../store/ruleCollectionsStore'; import { useAuthStore } from '../../store/authStore'; import { useNavigationStore } from '../../store/navigationStore'; -interface CollectionsSidebarProps { +interface RuleCollectionsSidebarProps { isOpen: boolean; onToggle: () => void; } -export const CollectionsSidebar: React.FC = ({ isOpen, onToggle }) => { - const fetchCollections = useCollectionsStore((state) => state.fetchCollections); +export const RuleCollectionsSidebar: React.FC = ({ + isOpen, + onToggle, +}) => { + const fetchCollections = useRuleCollectionsStore((state) => state.fetchCollections); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const { activePanel } = useNavigationStore(); const isMobileCollectionsActive = activePanel === 'collections'; @@ -39,7 +42,7 @@ export const CollectionsSidebar: React.FC = ({ isOpen,

Collections

{isAuthenticated ? ( - + ) : (
@@ -69,4 +72,4 @@ export const CollectionsSidebar: React.FC = ({ isOpen, ); }; -export default CollectionsSidebar; +export default RuleCollectionsSidebar; diff --git a/src/components/rule-collections/SaveDefaultDialog.tsx b/src/components/rule-collections/SaveDefaultRuleCollectionDialog.tsx similarity index 91% rename from src/components/rule-collections/SaveDefaultDialog.tsx rename to src/components/rule-collections/SaveDefaultRuleCollectionDialog.tsx index f0ac997..0b579af 100644 --- a/src/components/rule-collections/SaveDefaultDialog.tsx +++ b/src/components/rule-collections/SaveDefaultRuleCollectionDialog.tsx @@ -6,7 +6,7 @@ import { ConfirmDialogActions, } from '../ui/ConfirmDialog'; -interface SaveCollectionDialogProps { +interface SaveDefaultRuleCollectionDialogProps { isOpen: boolean; onClose: () => void; onSave: (name: string, description: string) => Promise; @@ -14,7 +14,7 @@ interface SaveCollectionDialogProps { initialDescription?: string; } -export const SaveCollectionDialog: React.FC = ({ +export const SaveDefaultRuleCollectionDialog: React.FC = ({ isOpen, onClose, onSave, @@ -99,4 +99,4 @@ export const SaveCollectionDialog: React.FC = ({ ); }; -export default SaveCollectionDialog; +export default SaveDefaultRuleCollectionDialog; diff --git a/src/components/rule-collections/SaveCollectionDialog.tsx b/src/components/rule-collections/SaveRuleCollectionDialog.tsx similarity index 92% rename from src/components/rule-collections/SaveCollectionDialog.tsx rename to src/components/rule-collections/SaveRuleCollectionDialog.tsx index 0a17d4f..0a3248c 100644 --- a/src/components/rule-collections/SaveCollectionDialog.tsx +++ b/src/components/rule-collections/SaveRuleCollectionDialog.tsx @@ -6,7 +6,7 @@ import { ConfirmDialogActions, } from '../ui/ConfirmDialog'; -interface SaveCollectionDialogProps { +interface SaveRuleCollectionDialogProps { isOpen: boolean; onClose: () => void; onSave: (name: string, description: string) => Promise; @@ -14,7 +14,7 @@ interface SaveCollectionDialogProps { initialDescription?: string; } -export const SaveCollectionDialog: React.FC = ({ +export const SaveRuleCollectionDialog: React.FC = ({ isOpen, onClose, onSave, @@ -115,4 +115,4 @@ export const SaveCollectionDialog: React.FC = ({ ); }; -export default SaveCollectionDialog; +export default SaveRuleCollectionDialog; diff --git a/src/components/rule-collections/UnsavedChangesDialog.tsx b/src/components/rule-collections/UnsavedRuleCollectionChangesDialog.tsx similarity index 85% rename from src/components/rule-collections/UnsavedChangesDialog.tsx rename to src/components/rule-collections/UnsavedRuleCollectionChangesDialog.tsx index 2cf881c..370d6c9 100644 --- a/src/components/rule-collections/UnsavedChangesDialog.tsx +++ b/src/components/rule-collections/UnsavedRuleCollectionChangesDialog.tsx @@ -6,7 +6,7 @@ import { ConfirmDialogActions, } from '../ui/ConfirmDialog'; -interface UnsavedChangesDialogProps { +interface UnsavedRuleCollectionChangesDialogProps { isOpen: boolean; onClose: () => void; onSave: () => Promise; @@ -14,13 +14,9 @@ interface UnsavedChangesDialogProps { collectionName: string; } -export const UnsavedChangesDialog: React.FC = ({ - isOpen, - onClose, - onSave, - onSkip, - collectionName, -}) => { +export const UnsavedRuleCollectionChangesDialog: React.FC< + UnsavedRuleCollectionChangesDialogProps +> = ({ isOpen, onClose, onSave, onSkip, collectionName }) => { const [isSaving, setIsSaving] = useState(false); const handleSave = async () => { @@ -68,4 +64,4 @@ export const UnsavedChangesDialog: React.FC = ({ ); }; -export default UnsavedChangesDialog; +export default UnsavedRuleCollectionChangesDialog; diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx index ac7876a..c7c7aa3 100644 --- a/src/components/ui/ConfirmDialog.tsx +++ b/src/components/ui/ConfirmDialog.tsx @@ -66,7 +66,7 @@ export const ConfirmDialog: React.FC = ({ isOpen, onClose, c >
diff --git a/src/pages/api/prompts/admin/collections.ts b/src/pages/api/prompts/admin/prompt-collections.ts similarity index 90% rename from src/pages/api/prompts/admin/collections.ts rename to src/pages/api/prompts/admin/prompt-collections.ts index 793ae08..223f8ba 100644 --- a/src/pages/api/prompts/admin/collections.ts +++ b/src/pages/api/prompts/admin/prompt-collections.ts @@ -1,5 +1,5 @@ import type { APIRoute } from 'astro'; -import { getCollections } from '@/services/prompt-manager/collectionService'; +import { getCollections } from '@/services/prompt-manager/promptCollectionService'; export const GET: APIRoute = async ({ locals }) => { try { diff --git a/src/pages/api/prompts/admin/collections/[id]/segments.ts b/src/pages/api/prompts/admin/prompt-collections/[id]/segments.ts similarity index 91% rename from src/pages/api/prompts/admin/collections/[id]/segments.ts rename to src/pages/api/prompts/admin/prompt-collections/[id]/segments.ts index cbd4c3d..c7a18c9 100644 --- a/src/pages/api/prompts/admin/collections/[id]/segments.ts +++ b/src/pages/api/prompts/admin/prompt-collections/[id]/segments.ts @@ -1,5 +1,5 @@ import type { APIRoute } from 'astro'; -import { getSegments } from '@/services/prompt-manager/collectionService'; +import { getSegments } from '@/services/prompt-manager/promptCollectionService'; export const GET: APIRoute = async ({ params, locals }) => { try { diff --git a/src/pages/api/collections.ts b/src/pages/api/rule-collections.ts similarity index 88% rename from src/pages/api/collections.ts rename to src/pages/api/rule-collections.ts index 24c3664..9fe18cb 100644 --- a/src/pages/api/collections.ts +++ b/src/pages/api/rule-collections.ts @@ -1,5 +1,5 @@ import type { APIRoute } from 'astro'; -import { type Collection, collectionMapper } from '../../types/collection.types'; +import { type RuleCollection, ruleCollectionMapper } from '../../types/ruleCollection.types'; import { isFeatureEnabled } from '../../features/featureFlags'; export const prerender = false; @@ -42,10 +42,10 @@ export const GET: APIRoute = (async ({ locals }) => { }); } - let collections: Collection[] = []; + let collections: RuleCollection[] = []; if (data) { - collections = data.map(collectionMapper); + collections = data.map(ruleCollectionMapper); } return new Response(JSON.stringify(collections), { @@ -81,7 +81,11 @@ export const POST = (async ({ request, locals }) => { } try { - const collection = await request.json(); + const collection = (await request.json()) as Partial & { + name: string; + description: string; + libraries: unknown[]; + }; // Validate required fields if (!collection.name || !collection.description) { diff --git a/src/pages/api/collections/[id].ts b/src/pages/api/rule-collections/[id].ts similarity index 94% rename from src/pages/api/collections/[id].ts rename to src/pages/api/rule-collections/[id].ts index 686a5ff..e350d95 100644 --- a/src/pages/api/collections/[id].ts +++ b/src/pages/api/rule-collections/[id].ts @@ -1,5 +1,5 @@ import type { APIRoute } from 'astro'; -import { type Collection } from '../../../types/collection.types'; +import { type RuleCollection } from '../../../types/ruleCollection.types'; import { isFeatureEnabled } from '../../../features/featureFlags'; export const prerender = false; @@ -57,7 +57,7 @@ export const PUT: APIRoute = (async ({ params, request, locals }) => { } try { - const updatedCollection: Collection = await request.json(); + const updatedCollection: RuleCollection = await request.json(); // Validate the updated collection if ( diff --git a/src/services/prompt-manager/collectionService.ts b/src/services/prompt-manager/promptCollectionService.ts similarity index 100% rename from src/services/prompt-manager/collectionService.ts rename to src/services/prompt-manager/promptCollectionService.ts diff --git a/src/store/collectionsStore.ts b/src/store/ruleCollectionsStore.ts similarity index 80% rename from src/store/collectionsStore.ts rename to src/store/ruleCollectionsStore.ts index 0c16e5c..ce289c6 100644 --- a/src/store/collectionsStore.ts +++ b/src/store/ruleCollectionsStore.ts @@ -1,35 +1,26 @@ import { create } from 'zustand'; -import { Library } from '../data/dictionaries'; import { useTechStackStore } from './techStackStore'; +import { type RuleCollection } from '../types/ruleCollection.types'; -export interface Collection { - id: string; - name: string; - description: string; - libraries: Library[]; - createdAt: string; - updatedAt: string; -} - -interface CollectionsState { - collections: Collection[]; - selectedCollection: Collection | null; - pendingCollection: Collection | null; +interface RuleCollectionsState { + collections: RuleCollection[]; + selectedCollection: RuleCollection | null; + pendingCollection: RuleCollection | null; isUnsavedChangesDialogOpen: boolean; isLoading: boolean; error: string | null; fetchCollections: () => Promise; - selectCollection: (collection: Collection) => void; + selectCollection: (collection: RuleCollection) => void; deleteCollection: (collectionId: string) => Promise; isDirty: () => boolean; saveChanges: () => Promise; - updateCollection: (collectionId: string, updatedCollection: Collection) => Promise; - handlePendingCollectionSelect: (collection: Collection) => void; + updateCollection: (collectionId: string, updatedCollection: RuleCollection) => Promise; + handlePendingCollectionSelect: (collection: RuleCollection) => void; confirmPendingCollection: () => void; closeUnsavedChangesDialog: () => void; } -export const useCollectionsStore = create((set, get) => ({ +export const useRuleCollectionsStore = create((set, get) => ({ collections: [], selectedCollection: null, pendingCollection: null, @@ -39,19 +30,19 @@ export const useCollectionsStore = create((set, get) => ({ fetchCollections: async () => { try { set({ isLoading: true, error: null }); - const response = await fetch(`/api/collections`); + const response = await fetch(`/api/rule-collections`); if (!response.ok) { throw new Error('Failed to fetch collections'); } - const data = await response.json(); + const data = (await response.json()) as RuleCollection[]; set({ collections: data, isLoading: false }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false }); } }, - selectCollection: (collection: Collection) => { + selectCollection: (collection: RuleCollection) => { const techStackStore = useTechStackStore.getState(); // Reset tech stack state before setting new collection @@ -71,7 +62,7 @@ export const useCollectionsStore = create((set, get) => ({ isUnsavedChangesDialogOpen: false, }); }, - handlePendingCollectionSelect: (collection: Collection) => { + handlePendingCollectionSelect: (collection: RuleCollection) => { const { selectedCollection, isDirty } = get(); if (selectedCollection?.id === collection.id) { @@ -104,7 +95,7 @@ export const useCollectionsStore = create((set, get) => ({ const { collections } = get(); set({ isLoading: true, error: null }); - const response = await fetch(`/api/collections/${collectionId}`, { + const response = await fetch(`/api/rule-collections/${collectionId}`, { method: 'DELETE', }); @@ -162,7 +153,7 @@ export const useCollectionsStore = create((set, get) => ({ updatedAt: new Date().toISOString(), }; - const response = await fetch(`/api/collections/${selectedCollection.id}`, { + const response = await fetch(`/api/rule-collections/${selectedCollection.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -174,7 +165,7 @@ export const useCollectionsStore = create((set, get) => ({ throw new Error('Failed to save collection changes'); } - const savedCollection = await response.json(); + const savedCollection = (await response.json()) as RuleCollection; // Update collections list and selected collection set((state) => ({ @@ -195,11 +186,11 @@ export const useCollectionsStore = create((set, get) => ({ throw error; } }, - updateCollection: async (collectionId: string, updatedCollection: Collection) => { + updateCollection: async (collectionId: string, updatedCollection: RuleCollection) => { try { set({ isLoading: true, error: null }); - const response = await fetch(`/api/collections/${collectionId}`, { + const response = await fetch(`/api/rule-collections/${collectionId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -211,7 +202,7 @@ export const useCollectionsStore = create((set, get) => ({ throw new Error('Failed to update collection'); } - const savedCollection = await response.json(); + const savedCollection = (await response.json()) as RuleCollection; // Update collections list and selected collection set((state) => ({ diff --git a/src/types/collection.types.ts b/src/types/ruleCollection.types.ts similarity index 84% rename from src/types/collection.types.ts rename to src/types/ruleCollection.types.ts index 58e461c..9d1cc9c 100644 --- a/src/types/collection.types.ts +++ b/src/types/ruleCollection.types.ts @@ -1,7 +1,7 @@ import { type Library } from '../data/dictionaries'; import { type Database } from '../db/database.types'; -export interface Collection { +export interface RuleCollection { id: string; userId: string; name: string; @@ -11,9 +11,9 @@ export interface Collection { updatedAt: string; } -export function collectionMapper( +export function ruleCollectionMapper( collection: Database['public']['Tables']['collections']['Row'], -): Collection { +): RuleCollection { return { id: collection.id, userId: collection.user_id, diff --git a/tests/integration/prompt-admin-flow.test.ts b/tests/integration/prompt-admin-flow.test.ts index 26279dc..a6a4a08 100644 --- a/tests/integration/prompt-admin-flow.test.ts +++ b/tests/integration/prompt-admin-flow.test.ts @@ -8,7 +8,7 @@ import { getPrompt, listPrompts, } from '@/services/prompt-manager/promptService'; -import { getCollections, getSegments } from '@/services/prompt-manager/collectionService'; +import { getCollections, getSegments } from '@/services/prompt-manager/promptCollectionService'; import type { CreatePromptInput } from '@/services/prompt-manager/types'; // Mock the supabase admin client diff --git a/tests/unit/services/prompt-manager/collectionService.test.ts b/tests/unit/services/prompt-manager/promptCollectionService.test.ts similarity index 96% rename from tests/unit/services/prompt-manager/collectionService.test.ts rename to tests/unit/services/prompt-manager/promptCollectionService.test.ts index 7209202..11c1c67 100644 --- a/tests/unit/services/prompt-manager/collectionService.test.ts +++ b/tests/unit/services/prompt-manager/promptCollectionService.test.ts @@ -4,7 +4,7 @@ import { getSegments, createCollection, createSegment, -} from '@/services/prompt-manager/collectionService'; +} from '@/services/prompt-manager/promptCollectionService'; import type { PromptCollection, PromptSegment, From b078610d0936a770e5a8ca02806593a4948054c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cmkczarkowski=E2=80=9D?= Date: Thu, 2 Oct 2025 14:54:54 +0200 Subject: [PATCH 11/23] feat: implement phase 4 (member api and ui) --- .ai/prompt-manager/phase-4-impl-plan.md | 304 ++++++ .ai/prompt-manager/poc-impl-plan.md | 22 +- README.md | 22 +- .../prompt-manager/OrganizationSelector.tsx | 37 + src/components/prompt-manager/PromptCard.tsx | 63 ++ .../prompt-manager/PromptDetail.tsx | 107 ++ .../prompt-manager/PromptFilters.tsx | 70 ++ .../prompt-manager/PromptsBrowser.tsx | 54 + src/components/prompt-manager/PromptsList.tsx | 42 + src/components/ui/CopyDownloadActions.tsx | 99 ++ src/components/ui/Dropdown.tsx | 167 ++++ src/components/ui/MarkdownRenderer.tsx | 25 + src/components/ui/SearchBar.tsx | 114 +++ src/middleware/index.ts | 23 +- src/pages/api/prompt-manager/organizations.ts | 40 + src/pages/api/prompts/[id].ts | 80 ++ src/pages/api/prompts/collections.ts | 72 ++ .../api/prompts/collections/[id]/segments.ts | 62 ++ src/pages/api/prompts/index.ts | 101 ++ src/pages/prompts/index.astro | 18 + src/pages/prompts/request-access.astro | 33 + src/services/prompt-manager/promptService.ts | 51 + src/store/promptsStore.ts | 243 +++++ supabase/seed-prompts.sql | 938 ++++++++++++++++++ 24 files changed, 2748 insertions(+), 39 deletions(-) create mode 100644 .ai/prompt-manager/phase-4-impl-plan.md create mode 100644 src/components/prompt-manager/OrganizationSelector.tsx create mode 100644 src/components/prompt-manager/PromptCard.tsx create mode 100644 src/components/prompt-manager/PromptDetail.tsx create mode 100644 src/components/prompt-manager/PromptFilters.tsx create mode 100644 src/components/prompt-manager/PromptsBrowser.tsx create mode 100644 src/components/prompt-manager/PromptsList.tsx create mode 100644 src/components/ui/CopyDownloadActions.tsx create mode 100644 src/components/ui/Dropdown.tsx create mode 100644 src/components/ui/MarkdownRenderer.tsx create mode 100644 src/components/ui/SearchBar.tsx create mode 100644 src/pages/api/prompt-manager/organizations.ts create mode 100644 src/pages/api/prompts/[id].ts create mode 100644 src/pages/api/prompts/collections.ts create mode 100644 src/pages/api/prompts/collections/[id]/segments.ts create mode 100644 src/pages/api/prompts/index.ts create mode 100644 src/pages/prompts/index.astro create mode 100644 src/pages/prompts/request-access.astro create mode 100644 src/store/promptsStore.ts create mode 100644 supabase/seed-prompts.sql diff --git a/.ai/prompt-manager/phase-4-impl-plan.md b/.ai/prompt-manager/phase-4-impl-plan.md new file mode 100644 index 0000000..c7c66cc --- /dev/null +++ b/.ai/prompt-manager/phase-4-impl-plan.md @@ -0,0 +1,304 @@ +# Phase 4: Member Experience Slice & Member APIs - Implementation Plan + +## Overview +Build member-facing prompt browser with organization switching, collection/segment filtering, search, markdown rendering, copy/download actions. Reuse existing patterns from Rules Builder while extracting reusable components. + +## Part A: Component Extraction & Reusability + +### A1. Extract Generic Components (from existing codebase) +Create shared UI primitives by extracting from existing components: + +**Files to create:** +- `src/components/ui/SearchBar.tsx` - Extract from `SearchInput.tsx`, make generic + - Props: `value`, `onChange`, `placeholder`, `matchCount?`, `totalCount?` + - Reusable for both Rules Builder and Prompts search + +- `src/components/ui/Dropdown.tsx` - Extract dropdown logic from `EnvironmentDropdown.tsx` + - Generic dropdown with portal support, keyboard navigation + - Props: `options`, `value`, `onChange`, `label`, `renderOption?` + +- `src/components/ui/CopyDownloadActions.tsx` - Extract from `RulesPreviewCopyDownloadActions.tsx` + - Generic copy/download buttons with tooltip + - Props: `content`, `filename`, `onCopy?`, `onDownload?`, `showCopied` + +- `src/components/ui/MarkdownRenderer.tsx` - Extract from `MarkdownContentRenderer.tsx` + - Generic markdown display component + - Props: `content`, `className?`, `actions?` + +### A2. Create Prompt-Specific Store +**File:** `src/store/promptsStore.ts` + +Pattern: Follow `ruleCollectionsStore.ts` structure + +**State:** +```typescript +{ + // Organization context + organizations: OrganizationMembership[], + activeOrganization: OrganizationMembership | null, + + // Collections & Segments + collections: PromptCollection[], + segments: PromptSegment[], + + // Prompts (published only for members) + prompts: Prompt[], + + // Filters + selectedCollectionId: string | null, + selectedSegmentId: string | null, + searchQuery: string, + + // UI State + selectedPromptId: string | null, + isLoading: boolean, + error: string | null, +} +``` + +**Actions:** +- `fetchOrganizations()` - Load user's orgs +- `setActiveOrganization(org)` - Switch org +- `fetchCollections(orgId)` - Load collections for org +- `fetchSegments(collectionId)` - Load segments for collection +- `fetchPrompts(filters)` - Load published prompts with filters +- `selectPrompt(promptId)` - Open detail view +- `setFilters(collection, segment, search)` - Update filters + +## Part B: Member API Endpoints + +### B1. Create Member API Routes +**Files to create:** + +1. `src/pages/api/prompts/index.ts` (member version) + - `GET /api/prompts` - List published prompts + - Query params: `?organization_id=X&collection_id=Y&segment_id=Z&search=query` + - Returns only `status='published'` prompts + - Uses `locals.user` for auth check (member or admin role) + +2. `src/pages/api/prompts/[id].ts` (member version) + - `GET /api/prompts/:id` - Get single published prompt + - Returns 404 if draft or wrong organization + +3. `src/pages/api/prompts/collections.ts` + - `GET /api/prompts/collections?organization_id=X` - List collections for org + - Member-accessible (no admin check) + +4. `src/pages/api/prompts/collections/[id]/segments.ts` + - `GET /api/prompts/collections/:id/segments` - List segments for collection + - Member-accessible + + +### B2. Extend Service Layer +**Files to modify:** + +1. `src/services/prompt-manager/promptService.ts` + - Add `listPublishedPrompts(orgId, filters)` - member-safe query + - Add `getPublishedPrompt(orgId, promptId)` - single prompt fetch + +2. `src/services/prompt-manager/promptCollectionService.ts` + - Add `listCollections(orgId)` - public collections + - Add `listSegments(collectionId)` - public segments + + +### B3. Middleware & Access Guards +**Pattern:** Reuse from `/api/rule-collections.ts` + +All member APIs check: +1. `isFeatureEnabled('promptManager')` - Feature flag +2. `locals.user` - Authentication required +3. `locals.promptManager.activeOrganization` - Organization membership + + +## Part C: Member UI Components + +### C1. Organization Selector Component +**File:** `src/components/prompt-manager/OrganizationSelector.tsx` + +**Pattern:** Similar to `EnvironmentDropdown.tsx` + +**Features:** +- Dropdown with user's organizations +- Shows current org name +- Persists selection in store (`setActiveOrganization`) +- Triggers data refresh on change + +### C2. Collection & Segment Filters +**File:** `src/components/prompt-manager/PromptFilters.tsx` + +**Pattern:** Similar to `LayerSelector.tsx` + `StackSelector.tsx` + +**Features:** +- Collection dropdown (fetched from API) +- Segment dropdown (filtered by collection) +- "All" option for both +- Updates `promptsStore` filters +- Shows count of prompts per collection/segment + +### C3. Prompts List View +**File:** `src/components/prompt-manager/PromptsList.tsx` + +**Pattern:** Similar to `RuleCollectionsList.tsx` + +**Features:** +- Grid/list of prompt cards +- Each card shows: title, collection/segment tags +- Click to open detail modal/view +- Empty state when no prompts match filters +- Loading skeleton + +**File:** `src/components/prompt-manager/PromptCard.tsx` +- Individual card component +- Hover effects +- Accessibility (keyboard navigation) + +### C4. Prompt Detail View +**File:** `src/components/prompt-manager/PromptDetail.tsx` + +**Pattern:** Modal similar to admin edit view (to be created in Phase 5) + +**Features:** +- Full markdown content (use `MarkdownRenderer` from Part A1) +- Title, collection/segment breadcrumb +- Copy & Download actions (use `CopyDownloadActions` from Part A1) +- Close button +- Modal overlay (use existing modal pattern) + +### C5. Main Member Page Container +**File:** `src/components/prompt-manager/PromptsBrowser.tsx` + +**Structure:** +``` +┌─────────────────────────────────────┐ +│ Organization Selector │ +├─────────────────────────────────────┤ +│ Filters: Collection | Segment │ +│ Search: [ ] [X] │ +├─────────────────────────────────────┤ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │Card │ │Card │ │Card │ │Card │ │ +│ └─────┘ └─────┘ └─────┘ └─────┘ │ +│ ┌─────┐ ┌─────┐ │ +│ │Card │ │Card │ ... │ +│ └─────┘ └─────┘ │ +└─────────────────────────────────────┘ +``` + +**Uses:** +- `OrganizationSelector` +- `PromptFilters` +- `SearchBar` (from Part A1) +- `PromptsList` +- `PromptDetail` (modal) + +## Part D: Routing & Pages + +### D1. Prompts Main Page (Member) +**File:** `src/pages/prompts/index.astro` + +**Features:** +- Feature flag check: `isPromptManagerEnabled()` +- Auth check: `Astro.locals.user` required +- Organization check: `Astro.locals.promptManager?.organizations.length > 0` +- If checks fail: redirect to `/auth/login` or show "request access" message +- If checks pass: render `` + +**Layout:** Reuse `Layout.astro` with Topbar/Footer like `index.astro` + +### D2. Access Guard Middleware +**File:** `src/middleware.ts` (extend existing) + +**Add logic:** +```typescript +if (url.pathname.startsWith('/prompts')) { + if (!isPromptManagerEnabled()) { + return new Response(null, { status: 404 }); + } + + if (!locals.user) { + return Astro.redirect('/auth/login?redirect=/prompts'); + } + + // Build promptManager context + locals.promptManager = await buildPromptManagerContext({ + supabase: locals.supabase, + userId: locals.user.id, + requestedSlug: url.searchParams.get('org'), + }); + + if (!hasPromptManagerAccess(locals.promptManager.organizations)) { + // Show "request access" page instead of 404 + return Astro.redirect('/prompts/request-access'); + } +} +``` + +### D3. Request Access Page +**File:** `src/pages/prompts/request-access.astro` + +**Features:** +- Shows message: "You need organization membership to access prompts" +- Link back to home + +## Part E: Testing & Validation + +### E1. Unit Tests +**Files to create:** +- `tests/unit/store/promptsStore.test.ts` - Store logic tests +- `tests/unit/components/prompt-manager/PromptCard.test.tsx` - Component tests +- `tests/unit/components/prompt-manager/PromptFilters.test.tsx` - Filter logic + +### E2. Integration Tests +**File:** `tests/integration/prompt-member-flow.test.ts` + +**Scenarios:** +1. Authenticated member with org → sees prompts list +2. Member switches organization → prompts refresh +3. Member filters by collection → list updates +4. Member searches → results filter +5. Member clicks prompt → detail modal opens +6. Member copies prompt → clipboard contains markdown +7. Member downloads prompt → file downloads + +### E3. E2E Tests (Playwright) +**File:** `e2e/prompt-manager-member.spec.ts` + +**Test cases:** +- Full member flow (login → browse → filter → view → copy) +- Organization switching +- Accessibility checks (keyboard navigation) + +## Part G: Documentation Updates + +### G1. Update Docs +**Files to update:** +- `README.md` - Add member routes `/prompts` +- `.ai/prompt-manager/poc-impl-plan.md` - Mark Phase 4 complete +- `.ai/test-plan.md` - Document member flow tests + +## Implementation Order + +1. **Part A** - Extract reusable components (1-2 hours) +3. **Part A2** - Create `promptsStore` (1 hour) +4. **Part B** - Member APIs & services (2-3 hours) +5. **Part C** - UI components (3-4 hours) +6. **Part D** - Routes & middleware (1-2 hours) +7. **Part E** - Tests (2-3 hours) +8. **Part G** - Documentation (30 min) + +**Total estimate:** 12-16 hours + +## Success Criteria (Exit Criteria from PRD) + +✅ Authenticated member with organization membership can: +- Switch organizations (default 10xDevs) +- Browse prompts filtered by collection/segment +- Search prompts +- View markdown content +- Copy to clipboard (Cursor-compatible formatting) +- Download prompts + +✅ Unauthenticated users redirected to login +✅ Users without organization see "request access" page +✅ Feature flag disabled → 404 on `/prompts` routes +✅ Only published prompts visible (drafts hidden) diff --git a/.ai/prompt-manager/poc-impl-plan.md b/.ai/prompt-manager/poc-impl-plan.md index 4c007c4..d893fec 100644 --- a/.ai/prompt-manager/poc-impl-plan.md +++ b/.ai/prompt-manager/poc-impl-plan.md @@ -51,13 +51,23 @@ Ship a feature-flagged Prompt Manager proof of concept that exercises the core f - Build server-side utilities/APIs (Astro endpoints) for CRUD operations restricted to organization admins using middleware checks rather than RLS. - Exit criteria: Admin-only API supports create draft → publish scoped to an organization/collection, migrations pass tests, and middleware-gated access behaves as expected in local runs. -## Phase 4 – Member Experience Slice & Member APIs +## Phase 4 – Member Experience Slice & Member APIs ✅ **COMPLETED** **Goal:** Provide members with gated prompt discovery. -- Build member-facing routes: organization selector (default 10xDevs), collection and segment filters, search/filter bar, prompt detail modal/page, favorites toggle. -- Enforce access guard via middleware using organization membership checks alongside the feature flag. -- Ensure graceful fallback states for unauthorized users, users without organization membership, and when flag disabled. -- Exit criteria: Authenticated member with organization membership can switch organizations, browse, search, view markdown, copy and download prompts end-to-end. -- Build server-side utilities/APIs (Astro endpoints) for CRUD operations restricted to organization members using middleware checks rather than RLS. +- ✅ Build member-facing routes: organization selector (default 10xDevs), collection and segment filters, search/filter bar, prompt detail modal/page. +- ✅ Enforce access guard via middleware using organization membership checks alongside the feature flag. +- ✅ Ensure graceful fallback states for unauthorized users, users without organization membership, and when flag disabled. +- ✅ Exit criteria: Authenticated member with organization membership can switch organizations, browse, search, view markdown, copy and download prompts end-to-end. +- ✅ Build server-side utilities/APIs (Astro endpoints) for CRUD operations restricted to organization members using middleware checks rather than RLS. +- ⚠️ Favorites toggle deferred to future phase. + +**Implemented Components:** +- Generic UI components: `SearchBar`, `Dropdown`, `CopyDownloadActions`, `MarkdownRenderer` +- Store: `promptsStore` with organization, collection, segment, and prompt management +- Member API routes: `/api/prompts`, `/api/prompts/[id]`, `/api/prompts/collections`, `/api/prompts/collections/[id]/segments` +- Service layer: `listPublishedPrompts()`, `getPublishedPrompt()` for member-safe queries +- UI components: `OrganizationSelector`, `PromptFilters`, `PromptsList`, `PromptCard`, `PromptDetail`, `PromptsBrowser` +- Pages: `/prompts/index.astro`, `/prompts/request-access.astro` +- Middleware: Already exists with full prompt manager support (no changes needed) ## Phase 5 – Admin Experience Slice **Goal:** Enable admins to curate and publish prompts iteratively. diff --git a/README.md b/README.md index b6e1bad..cef6f21 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Create so called "rules for AI" written in Markdown, used by tools such as GitHu - **Build AI Rules:** Create customized rule sets for different editors (Copilot, Cursor, Windsurf) - **Export Options:** Easily copy to clipboard or download as markdown files - **Smart Import:** Automatically generate rules by dropping package.json or requirements.txt files -- **Editor Integration:** Provides programmatic access to rules via an [MCP Server](./mcp-server/README.md) for integration with AI assistants in editors like Cursor. +- **Editor Integration:** Provides programmatic access to rules via an [MCP Server](./mcp-server/README.md) for integration with AI assistants in editors like Cursor + ## Getting Started @@ -59,25 +60,6 @@ Create so called "rules for AI" written in Markdown, used by tools such as GitHu supabase status ``` -### Prompt Manager Phase 2 (Organizations) - -- Apply migrations after pulling latest changes: - - ```bash - supabase db push - ``` - -- Regenerate typed client definitions once the migration succeeds: - - ```bash - supabase gen types typescript --project-ref "$SUPABASE_PROJECT_REF" \ - --schema public > src/db/database.types.ts - ``` - -- Seed the `10xDevs` roster by updating the placeholder emails inside - `supabase/migrations/20250413093000_prompt_manager_orgs.sql` or rerunning the - documented roster insert SQL and commit the regenerated types alongside any - roster changes. ## Dotenv diff --git a/src/components/prompt-manager/OrganizationSelector.tsx b/src/components/prompt-manager/OrganizationSelector.tsx new file mode 100644 index 0000000..0bc40c5 --- /dev/null +++ b/src/components/prompt-manager/OrganizationSelector.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { Dropdown, type DropdownOption } from '../ui/Dropdown'; + +export const OrganizationSelector: React.FC = () => { + const { organizations, activeOrganization, setActiveOrganization } = usePromptsStore(); + + const options: DropdownOption[] = organizations.map((org) => ({ + value: org.id, + label: org.name, + })); + + const handleChange = (organizationId: string) => { + const selectedOrg = organizations.find((org) => org.id === organizationId); + if (selectedOrg) { + setActiveOrganization(selectedOrg); + } + }; + + if (organizations.length === 0) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default OrganizationSelector; diff --git a/src/components/prompt-manager/PromptCard.tsx b/src/components/prompt-manager/PromptCard.tsx new file mode 100644 index 0000000..7afb797 --- /dev/null +++ b/src/components/prompt-manager/PromptCard.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import type { Prompt } from '../../store/promptsStore'; +import { usePromptsStore } from '../../store/promptsStore'; + +interface PromptCardProps { + prompt: Prompt; +} + +export const PromptCard: React.FC = ({ prompt }) => { + const { selectPrompt, collections, segments } = usePromptsStore(); + + const collection = collections.find((c) => c.id === prompt.collection_id); + const segment = segments.find((s) => s.id === prompt.segment_id); + + const handleClick = () => { + selectPrompt(prompt.id); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + selectPrompt(prompt.id); + } + }; + + // Truncate markdown body for preview (first 150 characters) + const preview = + prompt.markdown_body.substring(0, 150) + (prompt.markdown_body.length > 150 ? '...' : ''); + + return ( +
+

{prompt.title}

+ +
+ {collection && ( + + {collection.title} + + )} + {segment && ( + + {segment.title} + + )} +
+ +

{preview}

+ +
+ Updated: {new Date(prompt.updated_at).toLocaleDateString()} +
+
+ ); +}; + +export default PromptCard; diff --git a/src/components/prompt-manager/PromptDetail.tsx b/src/components/prompt-manager/PromptDetail.tsx new file mode 100644 index 0000000..afb2a19 --- /dev/null +++ b/src/components/prompt-manager/PromptDetail.tsx @@ -0,0 +1,107 @@ +import React, { useEffect } from 'react'; +import { X } from 'lucide-react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { MarkdownRenderer } from '../ui/MarkdownRenderer'; +import { CopyDownloadActions } from '../ui/CopyDownloadActions'; + +export const PromptDetail: React.FC = () => { + const { selectedPromptId, prompts, collections, segments, selectPrompt } = usePromptsStore(); + + const selectedPrompt = prompts.find((p) => p.id === selectedPromptId); + + // Close modal on ESC key + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && selectedPromptId) { + selectPrompt(null); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [selectedPromptId, selectPrompt]); + + // Lock body scroll when modal is open + useEffect(() => { + if (selectedPromptId) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + + return () => { + document.body.style.overflow = ''; + }; + }, [selectedPromptId]); + + if (!selectedPrompt) { + return null; + } + + const collection = collections.find((c) => c.id === selectedPrompt.collection_id); + const segment = segments.find((s) => s.id === selectedPrompt.segment_id); + + const handleClose = () => { + selectPrompt(null); + }; + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + handleClose(); + } + }; + + // Generate filename for download + const filename = `${selectedPrompt.title.toLowerCase().replace(/\s+/g, '-')}.md`; + + return ( +
+
+ {/* Header */} +
+
+

{selectedPrompt.title}

+ + {/* Breadcrumb */} +
+ {collection && {collection.title}} + {collection && segment && } + {segment && {segment.title}} +
+
+ + +
+ + {/* Content */} +
+ + } + /> +
+ + {/* Footer with actions */} +
+
+ Last updated: {new Date(selectedPrompt.updated_at).toLocaleString()} +
+
+
+
+ ); +}; + +export default PromptDetail; diff --git a/src/components/prompt-manager/PromptFilters.tsx b/src/components/prompt-manager/PromptFilters.tsx new file mode 100644 index 0000000..a31821e --- /dev/null +++ b/src/components/prompt-manager/PromptFilters.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { Dropdown, type DropdownOption } from '../ui/Dropdown'; + +export const PromptFilters: React.FC = () => { + const { collections, segments, selectedCollectionId, selectedSegmentId, setFilters, prompts } = + usePromptsStore(); + + // Create collection options with "All" option + const collectionOptions: DropdownOption[] = [ + { value: null, label: 'All Collections' }, + ...collections.map((collection) => ({ + value: collection.id, + label: collection.title, + })), + ]; + + // Create segment options with "All" option (only show when a collection is selected) + const segmentOptions: DropdownOption[] = [ + { value: null, label: 'All Segments' }, + ...segments.map((segment) => ({ + value: segment.id, + label: segment.title, + })), + ]; + + const handleCollectionChange = (collectionId: string | null) => { + setFilters(collectionId, null); // Reset segment when collection changes + }; + + const handleSegmentChange = (segmentId: string | null) => { + setFilters(selectedCollectionId, segmentId); + }; + + return ( +
+
+
+ +
+ + {selectedCollectionId && segments.length > 0 && ( +
+ +
+ )} +
+ +
+ + {prompts.length} {prompts.length === 1 ? 'prompt' : 'prompts'} + +
+
+ ); +}; + +export default PromptFilters; diff --git a/src/components/prompt-manager/PromptsBrowser.tsx b/src/components/prompt-manager/PromptsBrowser.tsx new file mode 100644 index 0000000..6bd1e75 --- /dev/null +++ b/src/components/prompt-manager/PromptsBrowser.tsx @@ -0,0 +1,54 @@ +import React, { useEffect } from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { OrganizationSelector } from './OrganizationSelector'; +import { PromptFilters } from './PromptFilters'; +import { SearchBar } from '../ui/SearchBar'; +import { PromptsList } from './PromptsList'; +import { PromptDetail } from './PromptDetail'; + +export const PromptsBrowser: React.FC = () => { + const { fetchOrganizations, searchQuery, setSearchQuery, prompts, selectedPromptId } = + usePromptsStore(); + + // Fetch organizations on mount + useEffect(() => { + fetchOrganizations(); + }, [fetchOrganizations]); + + return ( +
+ {/* Page Header */} +
+

Prompts Library

+

+ Browse and search through your organization's prompt templates +

+
+ + {/* Organization Selector */} + + + {/* Search Bar */} +
+ +
+ + {/* Filters */} + + + {/* Prompts List */} + + + {/* Prompt Detail Modal */} + {selectedPromptId && } +
+ ); +}; + +export default PromptsBrowser; diff --git a/src/components/prompt-manager/PromptsList.tsx b/src/components/prompt-manager/PromptsList.tsx new file mode 100644 index 0000000..d17510b --- /dev/null +++ b/src/components/prompt-manager/PromptsList.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { usePromptsStore } from '../../store/promptsStore'; +import { PromptCard } from './PromptCard'; + +export const PromptsList: React.FC = () => { + const { prompts, isLoading, error } = usePromptsStore(); + + if (isLoading) { + return ( +
+
Loading prompts...
+
+ ); + } + + if (error) { + return ( +
+
Error loading prompts: {error}
+
+ ); + } + + if (prompts.length === 0) { + return ( +
+
No prompts found
+
Try changing your filters or check back later
+
+ ); + } + + return ( +
+ {prompts.map((prompt) => ( + + ))} +
+ ); +}; + +export default PromptsList; diff --git a/src/components/ui/CopyDownloadActions.tsx b/src/components/ui/CopyDownloadActions.tsx new file mode 100644 index 0000000..e662735 --- /dev/null +++ b/src/components/ui/CopyDownloadActions.tsx @@ -0,0 +1,99 @@ +import { Check, Copy, Download } from 'lucide-react'; +import React, { Fragment, useState } from 'react'; +import { Tooltip } from './Tooltip.tsx'; + +interface CopyDownloadActionsProps { + content: string; + filename: string; + onCopy?: () => void; + onDownload?: () => void; + showCopied?: boolean; + className?: string; +} + +export const CopyDownloadActions: React.FC = ({ + content, + filename, + onCopy, + onDownload, + showCopied: externalShowCopied, + className = '', +}) => { + const [internalShowCopied, setInternalShowCopied] = useState(false); + const showCopied = externalShowCopied ?? internalShowCopied; + + // Copy the content to clipboard + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setInternalShowCopied(true); + setTimeout(() => setInternalShowCopied(false), 2000); + onCopy?.(); + } catch (err) { + console.error('Failed to copy text: ', err); + // Fallback for browsers that don't support clipboard API + const textArea = document.createElement('textarea'); + textArea.value = content; + textArea.style.position = 'fixed'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + const successful = document.execCommand('copy'); + if (successful) { + setInternalShowCopied(true); + setTimeout(() => setInternalShowCopied(false), 2000); + onCopy?.(); + } + } catch (err) { + console.error('Fallback: Could not copy text: ', err); + } + + document.body.removeChild(textArea); + } + }; + + // Download the content as a file + const handleDownload = () => { + const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + onDownload?.(); + }; + + return ( + +
+ + + + + + +
+
+ ); +}; + +export default CopyDownloadActions; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..5f920ec --- /dev/null +++ b/src/components/ui/Dropdown.tsx @@ -0,0 +1,167 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronDown, Check } from 'lucide-react'; + +export interface DropdownOption { + value: T; + label: string; +} + +interface DropdownProps { + options: DropdownOption[]; + value: T; + onChange: (value: T) => void; + label?: string; + renderOption?: (option: DropdownOption, isSelected: boolean) => React.ReactNode; + className?: string; + placeholder?: string; +} + +export function Dropdown({ + options, + value, + onChange, + label, + renderOption, + className = '', + placeholder = 'Select option', +}: DropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + // Calculate dropdown position + const updateDropdownPosition = () => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, // 4px gap + left: rect.left + window.scrollX, + width: Math.max(rect.width, 256), // Min width of 256px (sm:w-64) + }); + } + }; + + // Update position when opening + useEffect(() => { + if (isOpen) { + updateDropdownPosition(); + } + }, [isOpen]); + + // Update position on window resize/scroll + useEffect(() => { + if (!isOpen) return; + + const handleResize = () => updateDropdownPosition(); + const handleScroll = () => updateDropdownPosition(); + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', handleScroll, true); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', handleScroll, true); + }; + }, [isOpen]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Handle keyboard navigation + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setIsOpen(!isOpen); + } + }; + + const handleOptionSelect = (optionValue: T) => { + onChange(optionValue); + setIsOpen(false); + }; + + const selectedOption = options.find((opt) => opt.value === value); + + // Create dropdown menu component + const dropdownMenu = isOpen && ( +
+
    + {options.map((option) => { + const isSelected = value === option.value; + + return ( +
  • + +
  • + ); + })} +
+
+ ); + + return ( +
+ {label && } + {/* Dropdown trigger button */} + + + {/* Render dropdown menu via portal */} + {typeof document !== 'undefined' && createPortal(dropdownMenu, document.body)} +
+ ); +} + +export default Dropdown; diff --git a/src/components/ui/MarkdownRenderer.tsx b/src/components/ui/MarkdownRenderer.tsx new file mode 100644 index 0000000..0b1c503 --- /dev/null +++ b/src/components/ui/MarkdownRenderer.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { processRulesContentMarkdown } from '../../utils/markdownStyling.tsx'; + +interface MarkdownRendererProps { + content: string; + className?: string; + actions?: React.ReactNode; +} + +export const MarkdownRenderer: React.FC = ({ + content, + className = '', + actions, +}) => { + return ( +
+ {actions &&
{actions}
} +
+        {processRulesContentMarkdown(content)}
+      
+
+ ); +}; + +export default MarkdownRenderer; diff --git a/src/components/ui/SearchBar.tsx b/src/components/ui/SearchBar.tsx new file mode 100644 index 0000000..859199b --- /dev/null +++ b/src/components/ui/SearchBar.tsx @@ -0,0 +1,114 @@ +import { Search, X } from 'lucide-react'; +import type { ChangeEvent } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useKeyboardActivation } from '../../hooks/useKeyboardActivation'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + matchCount?: number; + totalCount?: number; + className?: string; + debounceMs?: number; +} + +export const SearchBar: React.FC = ({ + value, + onChange, + placeholder = 'Search...', + matchCount, + totalCount, + className = '', + debounceMs = 300, +}) => { + const inputRef = useRef(null); + const [hasFocus, setHasFocus] = useState(false); + const [localValue, setLocalValue] = useState(value); + const debounceTimerRef = useRef(null); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value; + setLocalValue(newValue); + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + onChange(newValue); + }, debounceMs); + }, + [onChange, debounceMs], + ); + + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + const handleClear = useCallback(() => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + setLocalValue(''); + onChange(''); + inputRef.current?.focus(); + }, [onChange]); + + const createKeyboardActivationHandler = useKeyboardActivation(); + const handleKeyDown = useMemo( + () => createKeyboardActivationHandler(handleClear), + [createKeyboardActivationHandler, handleClear], + ); + + return ( +
+
+ +
+ setHasFocus(true)} + onBlur={() => setHasFocus(false)} + tabIndex={0} + /> + {localValue && ( + + )} + {value && matchCount !== undefined && totalCount !== undefined && ( +
+ {matchCount} / {totalCount} +
+ )} +
+ ); +}; + +export default React.memo(SearchBar); diff --git a/src/middleware/index.ts b/src/middleware/index.ts index e31ede9..984df36 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -65,10 +65,10 @@ const PUBLIC_PATHS = [ const PROMPT_MANAGER_BASE_PATH = '/prompts'; const PROMPT_MANAGER_ADMIN_PATH = '/prompts/admin'; +const PROMPT_MANAGER_REQUEST_ACCESS_PATH = '/prompts/request-access'; +const PROMPT_MANAGER_API_PATH = '/api/prompts'; const TEXT_PROMPT_MANAGER_DISABLED = 'Prompt Manager is not available.'; -const TEXT_PROMPT_MANAGER_ACCESS_DENIED = - 'Prompt Manager access is restricted to approved organizations.'; function normalisePath(pathname: string): string { if (!pathname.endsWith('/') || pathname === '/') { @@ -90,8 +90,14 @@ function isPromptManagerRoute(pathname: string): boolean { if (isPromptManagerAdminRoute(normalised)) { return true; } + // Exclude request-access page from access checks + if (normalised === PROMPT_MANAGER_REQUEST_ACCESS_PATH) { + return false; + } return ( - normalised === PROMPT_MANAGER_BASE_PATH || normalised.startsWith(`${PROMPT_MANAGER_BASE_PATH}/`) + normalised === PROMPT_MANAGER_BASE_PATH || + normalised.startsWith(`${PROMPT_MANAGER_BASE_PATH}/`) || + normalised.startsWith(PROMPT_MANAGER_API_PATH) ); } @@ -104,15 +110,6 @@ function promptManagerFlagDisabledResponse(): Response { }); } -function promptManagerAccessDeniedResponse(): Response { - return new Response(TEXT_PROMPT_MANAGER_ACCESS_DENIED, { - status: 404, - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - }, - }); -} - const validateRequest = defineMiddleware( async ({ locals, cookies, url, request, redirect }, next) => { try { @@ -186,7 +183,7 @@ const validateRequest = defineMiddleware( locals.promptManager.activeOrganization = context.activeOrganization; if (!hasPromptManagerAccess(context.organizations)) { - return promptManagerAccessDeniedResponse(); + return redirect(PROMPT_MANAGER_REQUEST_ACCESS_PATH); } if (isAdminRoute && !hasPromptManagerAdminAccess(context.organizations)) { diff --git a/src/pages/api/prompt-manager/organizations.ts b/src/pages/api/prompt-manager/organizations.ts new file mode 100644 index 0000000..4a0353a --- /dev/null +++ b/src/pages/api/prompt-manager/organizations.ts @@ -0,0 +1,40 @@ +import type { APIRoute } from 'astro'; +import { isFeatureEnabled } from '../../../features/featureFlags'; +import { fetchUserOrganizations } from '../../../services/prompt-manager/organizations'; + +export const prerender = false; + +/** + * GET /api/prompt-manager/organizations + * Returns the list of organizations the authenticated user belongs to + */ +export const GET: APIRoute = async ({ locals }) => { + // Check if prompt manager feature is enabled + if (!isFeatureEnabled('promptManager')) { + return new Response(JSON.stringify({ error: 'Prompt Manager feature is disabled' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check authentication + if (!locals.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Fetch organizations for the authenticated user + const organizations = await fetchUserOrganizations(locals.supabase, locals.user.id); + + return new Response( + JSON.stringify({ + organizations, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); +}; diff --git a/src/pages/api/prompts/[id].ts b/src/pages/api/prompts/[id].ts new file mode 100644 index 0000000..fd64322 --- /dev/null +++ b/src/pages/api/prompts/[id].ts @@ -0,0 +1,80 @@ +import type { APIRoute } from 'astro'; +import { isFeatureEnabled } from '../../../features/featureFlags'; +import { getPublishedPrompt } from '../../../services/prompt-manager/promptService'; + +export const prerender = false; + +/** + * GET /api/prompts/:id + * Get a single published prompt (member-accessible) + */ +export const GET: APIRoute = async ({ locals, params, url }) => { + // Check if prompt manager feature is enabled + if (!isFeatureEnabled('promptManager')) { + return new Response(JSON.stringify({ error: 'Prompt Manager feature is disabled' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check authentication + if (!locals.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check organization access + if (!locals.promptManager?.activeOrganization) { + return new Response( + JSON.stringify({ error: 'No active organization. Please join an organization first.' }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + const promptId = params.id; + const organizationId = url.searchParams.get('organization_id'); + + if (!promptId) { + return new Response(JSON.stringify({ error: 'Prompt ID is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!organizationId) { + return new Response(JSON.stringify({ error: 'organization_id is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Verify user has access to this organization + const hasAccess = locals.promptManager.organizations.some((org) => org.id === organizationId); + if (!hasAccess) { + return new Response(JSON.stringify({ error: 'Access denied to this organization' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Fetch published prompt + const result = await getPublishedPrompt(promptId, organizationId); + + if (result.error) { + const status = result.error.code === 'NOT_FOUND' ? 404 : 500; + return new Response(JSON.stringify({ error: result.error.message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify(result.data), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/src/pages/api/prompts/collections.ts b/src/pages/api/prompts/collections.ts new file mode 100644 index 0000000..de4ea37 --- /dev/null +++ b/src/pages/api/prompts/collections.ts @@ -0,0 +1,72 @@ +import type { APIRoute } from 'astro'; +import { isFeatureEnabled } from '../../../features/featureFlags'; +import { getCollections } from '../../../services/prompt-manager/promptCollectionService'; + +export const prerender = false; + +/** + * GET /api/prompts/prompt-collections + * List collections for an organization (member-accessible) + * Query params: organization_id + */ +export const GET: APIRoute = async ({ locals, url }) => { + // Check if prompt manager feature is enabled + if (!isFeatureEnabled('promptManager')) { + return new Response(JSON.stringify({ error: 'Prompt Manager feature is disabled' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check authentication + if (!locals.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check organization access + if (!locals.promptManager?.activeOrganization) { + return new Response( + JSON.stringify({ error: 'No active organization. Please join an organization first.' }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + const organizationId = url.searchParams.get('organization_id'); + + if (!organizationId) { + return new Response(JSON.stringify({ error: 'organization_id is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Verify user has access to this organization + const hasAccess = locals.promptManager.organizations.some((org) => org.id === organizationId); + if (!hasAccess) { + return new Response(JSON.stringify({ error: 'Access denied to this organization' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Fetch collections + const result = await getCollections(organizationId); + + if (result.error) { + return new Response(JSON.stringify({ error: result.error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify(result.data || []), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/src/pages/api/prompts/collections/[id]/segments.ts b/src/pages/api/prompts/collections/[id]/segments.ts new file mode 100644 index 0000000..c8d4b62 --- /dev/null +++ b/src/pages/api/prompts/collections/[id]/segments.ts @@ -0,0 +1,62 @@ +import type { APIRoute } from 'astro'; +import { isFeatureEnabled } from '../../../../../features/featureFlags'; +import { getSegments } from '../../../../../services/prompt-manager/promptCollectionService'; + +export const prerender = false; + +/** + * GET /api/prompts/prompt-collections/:id/segments + * List segments for a collection (member-accessible) + */ +export const GET: APIRoute = async ({ locals, params }) => { + // Check if prompt manager feature is enabled + if (!isFeatureEnabled('promptManager')) { + return new Response(JSON.stringify({ error: 'Prompt Manager feature is disabled' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check authentication + if (!locals.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check organization access + if (!locals.promptManager?.activeOrganization) { + return new Response( + JSON.stringify({ error: 'No active organization. Please join an organization first.' }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + const collectionId = params.id; + + if (!collectionId) { + return new Response(JSON.stringify({ error: 'Collection ID is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Fetch segments + const result = await getSegments(collectionId); + + if (result.error) { + return new Response(JSON.stringify({ error: result.error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify(result.data || []), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/src/pages/api/prompts/index.ts b/src/pages/api/prompts/index.ts new file mode 100644 index 0000000..74118e3 --- /dev/null +++ b/src/pages/api/prompts/index.ts @@ -0,0 +1,101 @@ +import type { APIRoute } from 'astro'; +import { isFeatureEnabled } from '../../../features/featureFlags'; +import { listPublishedPrompts } from '../../../services/prompt-manager/promptService'; + +export const prerender = false; + +/** + * GET /api/prompts + * List published prompts (member-accessible) + * Query params: organization_id, collection_id, segment_id, search + */ +export const GET: APIRoute = async ({ locals, url }) => { + // Check if prompt manager feature is enabled + if (!isFeatureEnabled('promptManager')) { + return new Response(JSON.stringify({ error: 'Prompt Manager feature is disabled' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check authentication + if (!locals.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Check organization access + if (!locals.promptManager?.activeOrganization) { + return new Response( + JSON.stringify({ error: 'No active organization. Please join an organization first.' }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + const organizationId = url.searchParams.get('organization_id'); + const collectionId = url.searchParams.get('collection_id'); + const segmentId = url.searchParams.get('segment_id'); + const search = url.searchParams.get('search'); + + if (!organizationId) { + return new Response(JSON.stringify({ error: 'organization_id is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Verify user has access to this organization + const hasAccess = locals.promptManager.organizations.some((org) => org.id === organizationId); + if (!hasAccess) { + return new Response(JSON.stringify({ error: 'Access denied to this organization' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Build filters + const filters: { + collection_id?: string; + segment_id?: string; + } = {}; + + if (collectionId) { + filters.collection_id = collectionId; + } + + if (segmentId) { + filters.segment_id = segmentId; + } + + // Fetch published prompts + const result = await listPublishedPrompts(organizationId, filters); + + if (result.error) { + return new Response(JSON.stringify({ error: result.error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let prompts = result.data || []; + + // Client-side search filtering (simple case-insensitive search) + if (search && search.trim()) { + const searchLower = search.toLowerCase().trim(); + prompts = prompts.filter( + (prompt) => + prompt.title.toLowerCase().includes(searchLower) || + prompt.markdown_body.toLowerCase().includes(searchLower), + ); + } + + return new Response(JSON.stringify(prompts), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/src/pages/prompts/index.astro b/src/pages/prompts/index.astro new file mode 100644 index 0000000..e006124 --- /dev/null +++ b/src/pages/prompts/index.astro @@ -0,0 +1,18 @@ +--- +import Layout from '../../layouts/Layout.astro'; +import Topbar from '../../components/Topbar'; +import Footer from '../../components/Footer'; +import PromptsBrowser from '../../components/prompt-manager/PromptsBrowser'; + +const user = Astro.locals.user; +--- + + +
+ +
+ +
+
+
+
diff --git a/src/pages/prompts/request-access.astro b/src/pages/prompts/request-access.astro new file mode 100644 index 0000000..3ec4425 --- /dev/null +++ b/src/pages/prompts/request-access.astro @@ -0,0 +1,33 @@ +--- +import Layout from '../../layouts/Layout.astro'; +import Topbar from '../../components/Topbar'; +import Footer from '../../components/Footer'; + +const user = Astro.locals.user; +--- + + +
+ +
+
+

Access Required

+

+ You need organization membership to access the Prompts Library. +

+

+ The Prompts Library is available to members of approved organizations. If you believe you + should have access, please contact your organization administrator or + kontakt@przeprogramowani.pl. +

+ + Return to 10xRules + +
+
+
+
+
diff --git a/src/services/prompt-manager/promptService.ts b/src/services/prompt-manager/promptService.ts index a001b19..c4f802b 100644 --- a/src/services/prompt-manager/promptService.ts +++ b/src/services/prompt-manager/promptService.ts @@ -288,3 +288,54 @@ export async function listPrompts( }; } } + +/** + * List published prompts only (member-safe) + * Members can only see published prompts + */ +export async function listPublishedPrompts( + organizationId: string, + filters?: Omit, +): Promise> { + return listPrompts(organizationId, { ...filters, status: 'published' }); +} + +/** + * Get a single published prompt by ID (member-safe) + * Returns 404 if prompt is not published or doesn't belong to organization + */ +export async function getPublishedPrompt( + promptId: string, + organizationId: string, +): Promise> { + try { + const { data: prompt, error } = await supabaseAdmin + .from('prompts') + .select('*') + .eq('id', promptId) + .eq('organization_id', organizationId) + .eq('status', 'published') + .single(); + + if (error) { + return { + data: null, + error: { message: error.message, code: error.code || 'UNKNOWN_ERROR' }, + }; + } + + if (!prompt) { + return { + data: null, + error: { message: 'Prompt not found', code: 'NOT_FOUND' }, + }; + } + + return { data: prompt as Prompt, error: null }; + } catch (err) { + return { + data: null, + error: { message: (err as Error).message, code: 'INTERNAL_ERROR' }, + }; + } +} diff --git a/src/store/promptsStore.ts b/src/store/promptsStore.ts new file mode 100644 index 0000000..06e0bbc --- /dev/null +++ b/src/store/promptsStore.ts @@ -0,0 +1,243 @@ +import { create } from 'zustand'; +import type { Tables } from '../db/database.types'; +import type { OrganizationMembership } from '../services/prompt-manager/organizations'; + +// Type aliases for database tables +export type Prompt = Tables<'prompts'>; +export type PromptCollection = Tables<'prompt_collections'>; +export type PromptSegment = Tables<'prompt_collection_segments'>; + +interface PromptsState { + // Organization context + organizations: OrganizationMembership[]; + activeOrganization: OrganizationMembership | null; + + // Collections & Segments + collections: PromptCollection[]; + segments: PromptSegment[]; + + // Prompts (published only for members) + prompts: Prompt[]; + + // Filters + selectedCollectionId: string | null; + selectedSegmentId: string | null; + searchQuery: string; + + // UI State + selectedPromptId: string | null; + isLoading: boolean; + error: string | null; + + // Actions + fetchOrganizations: () => Promise; + setActiveOrganization: (org: OrganizationMembership | null) => void; + fetchCollections: (orgId: string) => Promise; + fetchSegments: (collectionId: string) => Promise; + fetchPrompts: (filters?: { + organizationId?: string; + collectionId?: string; + segmentId?: string; + search?: string; + }) => Promise; + selectPrompt: (promptId: string | null) => void; + setFilters: (collectionId: string | null, segmentId: string | null, search?: string) => void; + setSearchQuery: (query: string) => void; + reset: () => void; +} + +const initialState = { + organizations: [], + activeOrganization: null, + collections: [], + segments: [], + prompts: [], + selectedCollectionId: null, + selectedSegmentId: null, + searchQuery: '', + selectedPromptId: null, + isLoading: false, + error: null, +}; + +export const usePromptsStore = create((set, get) => ({ + ...initialState, + + fetchOrganizations: async () => { + try { + set({ isLoading: true, error: null }); + const response = await fetch('/api/prompt-manager/organizations'); + + if (!response.ok) { + throw new Error('Failed to fetch organizations'); + } + + const data = (await response.json()) as { organizations: OrganizationMembership[] }; + const organizations = data.organizations || []; + + // Set the first organization as active if none is selected + const activeOrg = get().activeOrganization || organizations[0] || null; + + set({ + organizations, + activeOrganization: activeOrg, + isLoading: false, + }); + + // Fetch collections for the active organization + if (activeOrg) { + await get().fetchCollections(activeOrg.id); + } + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Unknown error', + isLoading: false, + }); + } + }, + + setActiveOrganization: async (org: OrganizationMembership | null) => { + set({ + activeOrganization: org, + collections: [], + segments: [], + prompts: [], + selectedCollectionId: null, + selectedSegmentId: null, + selectedPromptId: null, + }); + + if (org) { + await get().fetchCollections(org.id); + } + }, + + fetchCollections: async (orgId: string) => { + try { + set({ isLoading: true, error: null }); + const response = await fetch(`/api/prompts/collections?organization_id=${orgId}`); + + if (!response.ok) { + throw new Error('Failed to fetch collections'); + } + + const collections = (await response.json()) as PromptCollection[]; + + set({ + collections, + isLoading: false, + }); + + // Fetch prompts for the organization + await get().fetchPrompts({ organizationId: orgId }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Unknown error', + isLoading: false, + }); + } + }, + + fetchSegments: async (collectionId: string) => { + try { + set({ isLoading: true, error: null }); + const response = await fetch(`/api/prompts/collections/${collectionId}/segments`); + + if (!response.ok) { + throw new Error('Failed to fetch segments'); + } + + const segments = (await response.json()) as PromptSegment[]; + + set({ + segments, + isLoading: false, + }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Unknown error', + isLoading: false, + }); + } + }, + + fetchPrompts: async (filters = {}) => { + const { activeOrganization, selectedCollectionId, selectedSegmentId, searchQuery } = get(); + + const orgId = filters.organizationId || activeOrganization?.id; + if (!orgId) { + set({ prompts: [] }); + return; + } + + try { + set({ isLoading: true, error: null }); + + const params = new URLSearchParams({ organization_id: orgId }); + + const collectionId = filters.collectionId ?? selectedCollectionId; + if (collectionId) { + params.append('collection_id', collectionId); + } + + const segmentId = filters.segmentId ?? selectedSegmentId; + if (segmentId) { + params.append('segment_id', segmentId); + } + + const search = filters.search ?? searchQuery; + if (search) { + params.append('search', search); + } + + const response = await fetch(`/api/prompts?${params.toString()}`); + + if (!response.ok) { + throw new Error('Failed to fetch prompts'); + } + + const prompts = (await response.json()) as Prompt[]; + + set({ + prompts, + isLoading: false, + }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Unknown error', + isLoading: false, + }); + } + }, + + selectPrompt: (promptId: string | null) => { + set({ selectedPromptId: promptId }); + }, + + setFilters: async (collectionId: string | null, segmentId: string | null, search?: string) => { + set({ + selectedCollectionId: collectionId, + selectedSegmentId: segmentId, + searchQuery: search ?? get().searchQuery, + }); + + // Fetch segments if a collection is selected + if (collectionId) { + await get().fetchSegments(collectionId); + } else { + set({ segments: [] }); + } + + // Fetch prompts with the new filters + await get().fetchPrompts(); + }, + + setSearchQuery: async (query: string) => { + set({ searchQuery: query }); + await get().fetchPrompts({ search: query }); + }, + + reset: () => { + set(initialState); + }, +})); diff --git a/supabase/seed-prompts.sql b/supabase/seed-prompts.sql new file mode 100644 index 0000000..a130793 --- /dev/null +++ b/supabase/seed-prompts.sql @@ -0,0 +1,938 @@ +-- Seed prompts for 10xDevs organization +-- Run this in Supabase SQL Editor after migrations + +DO $$ +DECLARE + v_org_id UUID; + v_coll_fundamentals_id UUID; + v_coll_advanced_id UUID; + v_coll_ai_id UUID; + v_seg_getting_started_id UUID; + v_seg_best_practices_id UUID; + v_seg_testing_id UUID; + v_seg_architecture_id UUID; + v_seg_performance_id UUID; + v_seg_ai_prompting_id UUID; + v_seg_ai_tools_id UUID; +BEGIN + -- Get 10xDevs organization ID + SELECT id INTO v_org_id FROM organizations WHERE slug = '10xdevs'; + + IF v_org_id IS NULL THEN + RAISE EXCEPTION '10xDevs organization not found. Please ensure the organization exists.'; + END IF; + + -- Insert collections + INSERT INTO prompt_collections (organization_id, slug, title, description, sort_order) + VALUES + (v_org_id, 'fundamentals', 'Fundamentals', 'Core prompts for foundational concepts and getting started', 1), + (v_org_id, 'advanced', 'Advanced Topics', 'Advanced prompts for experienced developers', 2), + (v_org_id, 'ai-development', 'AI Development', 'Prompts for AI-assisted development and tooling', 3) + ON CONFLICT (organization_id, slug) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + sort_order = EXCLUDED.sort_order; + + -- Get collection IDs + SELECT id INTO v_coll_fundamentals_id FROM prompt_collections WHERE organization_id = v_org_id AND slug = 'fundamentals'; + SELECT id INTO v_coll_advanced_id FROM prompt_collections WHERE organization_id = v_org_id AND slug = 'advanced'; + SELECT id INTO v_coll_ai_id FROM prompt_collections WHERE organization_id = v_org_id AND slug = 'ai-development'; + + -- Insert segments for Fundamentals + INSERT INTO prompt_collection_segments (collection_id, slug, title, sort_order) + VALUES + (v_coll_fundamentals_id, 'getting-started', 'Getting Started', 1), + (v_coll_fundamentals_id, 'best-practices', 'Best Practices', 2), + (v_coll_fundamentals_id, 'testing', 'Testing', 3) + ON CONFLICT (collection_id, slug) DO UPDATE SET + title = EXCLUDED.title, + sort_order = EXCLUDED.sort_order; + + -- Insert segments for Advanced + INSERT INTO prompt_collection_segments (collection_id, slug, title, sort_order) + VALUES + (v_coll_advanced_id, 'architecture', 'Architecture', 1), + (v_coll_advanced_id, 'performance', 'Performance', 2) + ON CONFLICT (collection_id, slug) DO UPDATE SET + title = EXCLUDED.title, + sort_order = EXCLUDED.sort_order; + + -- Insert segments for AI Development + INSERT INTO prompt_collection_segments (collection_id, slug, title, sort_order) + VALUES + (v_coll_ai_id, 'prompting', 'Effective Prompting', 1), + (v_coll_ai_id, 'tools', 'AI Tools & Integration', 2) + ON CONFLICT (collection_id, slug) DO UPDATE SET + title = EXCLUDED.title, + sort_order = EXCLUDED.sort_order; + + -- Get segment IDs + SELECT id INTO v_seg_getting_started_id FROM prompt_collection_segments WHERE collection_id = v_coll_fundamentals_id AND slug = 'getting-started'; + SELECT id INTO v_seg_best_practices_id FROM prompt_collection_segments WHERE collection_id = v_coll_fundamentals_id AND slug = 'best-practices'; + SELECT id INTO v_seg_testing_id FROM prompt_collection_segments WHERE collection_id = v_coll_fundamentals_id AND slug = 'testing'; + SELECT id INTO v_seg_architecture_id FROM prompt_collection_segments WHERE collection_id = v_coll_advanced_id AND slug = 'architecture'; + SELECT id INTO v_seg_performance_id FROM prompt_collection_segments WHERE collection_id = v_coll_advanced_id AND slug = 'performance'; + SELECT id INTO v_seg_ai_prompting_id FROM prompt_collection_segments WHERE collection_id = v_coll_ai_id AND slug = 'prompting'; + SELECT id INTO v_seg_ai_tools_id FROM prompt_collection_segments WHERE collection_id = v_coll_ai_id AND slug = 'tools'; + + -- Clear existing prompts (optional - remove if you want to keep existing ones) + DELETE FROM prompts WHERE organization_id = v_org_id; + + -- Insert sample prompts for Fundamentals > Getting Started + INSERT INTO prompts (organization_id, collection_id, segment_id, title, markdown_body, status) + VALUES + ( + v_org_id, + v_coll_fundamentals_id, + v_seg_getting_started_id, + 'Project Setup Guide', + '# Project Setup Guide + +This prompt helps you set up a new project with best practices. + +## Prerequisites +- Node.js 18+ installed +- Git configured +- Package manager (npm, yarn, or pnpm) + +## Steps + +### 1. Initialize Repository +```bash +git init +git add . +git commit -m "Initial commit" +``` + +### 2. Configure Tooling +Set up your development environment: +- **Linting**: ESLint with recommended rules +- **Formatting**: Prettier with consistent configuration +- **Git Hooks**: Husky for pre-commit checks +- **TypeScript**: Strict mode enabled + +### 3. Set up CI/CD +- Configure GitHub Actions or GitLab CI +- Add automated testing +- Set up deployment pipelines + +## Next Steps +After setup, review the Best Practices section for coding standards.', + 'published' + ), + ( + v_org_id, + v_coll_fundamentals_id, + v_seg_getting_started_id, + 'Environment Variables Setup', + '# Environment Variables Setup + +Learn how to properly manage environment variables in your application. + +## Best Practices + +### 1. Use .env Files +Create separate files for different environments: +- `.env.local` - Local development +- `.env.development` - Development environment +- `.env.production` - Production environment + +### 2. Never Commit Secrets +```gitignore +# .gitignore +.env.local +.env.*.local +``` + +### 3. Document Required Variables +Create an `.env.example` file: +``` +DATABASE_URL=postgresql://user:password@localhost:5432/db +API_KEY=your_api_key_here +``` + +## Security Tips +- Use different credentials for each environment +- Rotate secrets regularly +- Use secret management tools in production', + 'published' + ), + ( + v_org_id, + v_coll_fundamentals_id, + v_seg_getting_started_id, + 'Git Workflow Basics', + '# Git Workflow Basics + +Essential Git workflows for team collaboration. + +## Branch Strategy + +### Main Branches +- `main` - Production-ready code +- `develop` - Integration branch for features + +### Feature Branches +```bash +git checkout -b feature/new-feature +# Make changes +git add . +git commit -m "feat: add new feature" +git push origin feature/new-feature +``` + +## Commit Message Format +Follow conventional commits: +``` +feat: add user authentication +fix: resolve login bug +docs: update README +refactor: simplify error handling +test: add unit tests for auth +``` + +## Pull Request Process +1. Create feature branch +2. Implement changes with tests +3. Push and create PR +4. Request code review +5. Address feedback +6. Merge after approval', + 'published' + ); + + -- Insert prompts for Fundamentals > Best Practices + INSERT INTO prompts (organization_id, collection_id, segment_id, title, markdown_body, status) + VALUES + ( + v_org_id, + v_coll_fundamentals_id, + v_seg_best_practices_id, + 'Code Review Checklist', + '# Code Review Checklist + +Use this checklist when reviewing pull requests. + +## Code Quality +- [ ] Code follows project style guide +- [ ] No unnecessary complexity +- [ ] Variables and functions have clear names +- [ ] Comments explain "why" not "what" +- [ ] No commented-out code + +## Functionality +- [ ] Tests pass locally and in CI +- [ ] New features have tests +- [ ] Edge cases are handled +- [ ] Error handling is appropriate +- [ ] No breaking changes (or documented) + +## Security +- [ ] No sensitive data in code +- [ ] Input validation present +- [ ] Authentication/authorization checks +- [ ] Dependencies are up to date + +## Documentation +- [ ] README updated if needed +- [ ] API documentation current +- [ ] Complex logic explained +- [ ] Migration guide for breaking changes', + 'published' + ), + ( + v_org_id, + v_coll_fundamentals_id, + v_seg_best_practices_id, + 'Clean Code Principles', + '# Clean Code Principles + +Write maintainable, readable code that lasts. + +## Naming Conventions +- Use descriptive names +- Functions should be verbs: `getUserData()`, `calculateTotal()` +- Variables should be nouns: `userProfile`, `totalAmount` +- Boolean variables: `isActive`, `hasPermission` + +## Function Design +```typescript +// Bad: Function does too much +function processUser(user) { + validateUser(user); + saveToDatabase(user); + sendEmail(user); + logActivity(user); +} + +// Good: Single responsibility +function processUser(user) { + const validatedUser = validateUser(user); + saveUser(validatedUser); + notifyUser(validatedUser); +} +``` + +## DRY Principle +Don''t Repeat Yourself - extract common logic: +```typescript +// Extract repeated validation logic +function validateEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} +``` + +## SOLID Principles +- **S**ingle Responsibility +- **O**pen/Closed +- **L**iskov Substitution +- **I**nterface Segregation +- **D**ependency Inversion', + 'published' + ), + ( + v_org_id, + v_coll_fundamentals_id, + v_seg_best_practices_id, + 'Error Handling Patterns', + '# Error Handling Patterns + +Robust error handling for production applications. + +## Try-Catch Blocks +```typescript +async function fetchUserData(userId: string) { + try { + const response = await api.getUser(userId); + return response.data; + } catch (error) { + if (error.response?.status === 404) { + throw new UserNotFoundError(userId); + } + throw new ApiError("Failed to fetch user", error); + } +} +``` + +## Custom Error Classes +```typescript +class UserNotFoundError extends Error { + constructor(userId: string) { + super(`User not found: ${userId}`); + this.name = "UserNotFoundError"; + } +} +``` + +## Error Boundaries (React) +```typescript +class ErrorBoundary extends React.Component { + componentDidCatch(error, errorInfo) { + logErrorToService(error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ; + } + return this.props.children; + } +} +``` + +## Logging Best Practices +- Log errors with context +- Use structured logging +- Include stack traces in development +- Sanitize sensitive data', + 'published' + ); + + -- Insert prompts for Fundamentals > Testing + INSERT INTO prompts (organization_id, collection_id, segment_id, title, markdown_body, status) + VALUES + ( + v_org_id, + v_coll_fundamentals_id, + v_seg_testing_id, + 'Unit Testing Fundamentals', + '# Unit Testing Fundamentals + +Write effective unit tests for your code. + +## Test Structure (AAA Pattern) +```typescript +describe("UserService", () => { + it("should create a new user", () => { + // Arrange + const userData = { name: "John", email: "john@example.com" }; + const service = new UserService(); + + // Act + const user = service.createUser(userData); + + // Assert + expect(user.name).toBe("John"); + expect(user.email).toBe("john@example.com"); + }); +}); +``` + +## What to Test +- ✅ Business logic +- ✅ Edge cases +- ✅ Error conditions +- ✅ Data transformations +- ❌ Third-party libraries +- ❌ Framework internals + +## Mocking Dependencies +```typescript +const mockDatabase = { + findUser: jest.fn(), + saveUser: jest.fn(), +}; + +it("should save user to database", async () => { + mockDatabase.saveUser.mockResolvedValue({ id: "123" }); + + const result = await userService.save(userData); + + expect(mockDatabase.saveUser).toHaveBeenCalledWith(userData); +}); +``` + +## Test Coverage Goals +- Critical paths: 100% +- Business logic: 80-90% +- Overall: 70-80%', + 'published' + ), + ( + v_org_id, + v_coll_fundamentals_id, + v_seg_testing_id, + 'Integration Testing Guide', + '# Integration Testing Guide + +Test how your components work together. + +## Database Testing +```typescript +describe("User API", () => { + beforeEach(async () => { + await db.migrate.latest(); + await db.seed.run(); + }); + + afterEach(async () => { + await db.migrate.rollback(); + }); + + it("should fetch user from database", async () => { + const response = await request(app) + .get("/api/users/1") + .expect(200); + + expect(response.body.name).toBe("Test User"); + }); +}); +``` + +## API Testing +```typescript +describe("REST API", () => { + it("should create and retrieve user", async () => { + // Create user + const createResponse = await api.post("/users", { + name: "John Doe", + email: "john@example.com", + }); + + const userId = createResponse.data.id; + + // Retrieve user + const getResponse = await api.get(`/users/${userId}`); + expect(getResponse.data.name).toBe("John Doe"); + }); +}); +``` + +## Test Environment Setup +- Use separate test database +- Mock external services +- Use factories for test data +- Clean up after tests', + 'published' + ); + + -- Insert prompts for Advanced > Architecture + INSERT INTO prompts (organization_id, collection_id, segment_id, title, markdown_body, status) + VALUES + ( + v_org_id, + v_coll_advanced_id, + v_seg_architecture_id, + 'Microservices Architecture', + '# Microservices Architecture + +Design scalable microservices systems. + +## Core Principles +1. **Single Responsibility** - Each service owns one business capability +2. **Decentralized Data** - Each service manages its own database +3. **Independent Deployment** - Services deploy independently +4. **Fault Isolation** - Failures don''t cascade + +## Service Communication +```typescript +// REST API +app.get("/api/orders/:id", async (req, res) => { + const order = await orderService.getOrder(req.params.id); + const user = await userServiceClient.getUser(order.userId); + res.json({ order, user }); +}); + +// Event-driven +eventBus.on("OrderCreated", async (event) => { + await inventoryService.reserveItems(event.items); + await notificationService.sendConfirmation(event.userId); +}); +``` + +## API Gateway Pattern +- Single entry point for clients +- Route requests to services +- Handle authentication/authorization +- Rate limiting and caching + +## Service Discovery +- Use service registry (Consul, etcd) +- Health checks +- Load balancing +- Circuit breakers', + 'published' + ), + ( + v_org_id, + v_coll_advanced_id, + v_seg_architecture_id, + 'Event-Driven Architecture', + '# Event-Driven Architecture + +Build loosely coupled, scalable systems with events. + +## Event Types + +### Domain Events +```typescript +interface OrderPlacedEvent { + eventId: string; + eventType: "OrderPlaced"; + timestamp: Date; + data: { + orderId: string; + userId: string; + items: OrderItem[]; + total: number; + }; +} +``` + +### Event Publishing +```typescript +class OrderService { + async placeOrder(orderData: OrderData) { + const order = await this.createOrder(orderData); + + await eventBus.publish({ + eventType: "OrderPlaced", + data: order, + }); + + return order; + } +} +``` + +### Event Handlers +```typescript +eventBus.subscribe("OrderPlaced", async (event) => { + await inventoryService.updateStock(event.data.items); + await emailService.sendConfirmation(event.data.userId); + await analyticsService.trackOrder(event.data); +}); +``` + +## Event Sourcing +- Store events as source of truth +- Rebuild state from events +- Audit trail built-in +- Time travel debugging', + 'published' + ); + + -- Insert prompts for Advanced > Performance + INSERT INTO prompts (organization_id, collection_id, segment_id, title, markdown_body, status) + VALUES + ( + v_org_id, + v_coll_advanced_id, + v_seg_performance_id, + 'Database Query Optimization', + '# Database Query Optimization + +Improve database performance with these techniques. + +## Index Optimization +```sql +-- Add index for frequently queried columns +CREATE INDEX idx_users_email ON users(email); + +-- Composite index for multi-column queries +CREATE INDEX idx_orders_user_date + ON orders(user_id, created_at DESC); + +-- Partial index for filtered queries +CREATE INDEX idx_active_users + ON users(last_login) + WHERE status = ''active''; +``` + +## Query Optimization +```sql +-- Bad: N+1 queries +SELECT * FROM orders; +-- Then for each order: +SELECT * FROM users WHERE id = order.user_id; + +-- Good: Join in single query +SELECT orders.*, users.name, users.email +FROM orders +JOIN users ON orders.user_id = users.id; +``` + +## Pagination +```sql +-- Offset-based (simple but slow for large offsets) +SELECT * FROM products +ORDER BY created_at DESC +LIMIT 20 OFFSET 100; + +-- Cursor-based (faster) +SELECT * FROM products +WHERE created_at < ''2024-01-01'' +ORDER BY created_at DESC +LIMIT 20; +``` + +## Connection Pooling +```typescript +const pool = new Pool({ + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}); +```', + 'published' + ), + ( + v_org_id, + v_coll_advanced_id, + v_seg_performance_id, + 'Caching Strategies', + '# Caching Strategies + +Reduce latency and improve scalability with effective caching. + +## Cache Layers + +### 1. Browser Caching +```typescript +// Cache-Control headers +res.setHeader("Cache-Control", "public, max-age=3600"); + +// Service Worker caching +self.addEventListener("fetch", (event) => { + event.respondWith( + caches.match(event.request).then((response) => { + return response || fetch(event.request); + }) + ); +}); +``` + +### 2. CDN Caching +- Static assets (images, CSS, JS) +- Edge caching for global distribution +- Invalidation strategies + +### 3. Application Caching +```typescript +// Redis cache +const cache = new Redis(); + +async function getUser(userId: string) { + const cached = await cache.get(`user:${userId}`); + if (cached) return JSON.parse(cached); + + const user = await db.users.findById(userId); + await cache.setex(`user:${userId}`, 3600, JSON.stringify(user)); + return user; +} +``` + +## Cache Invalidation +```typescript +// Invalidate on update +async function updateUser(userId: string, data: UserData) { + await db.users.update(userId, data); + await cache.del(`user:${userId}`); +} + +// Cache-aside pattern +async function getData(key: string) { + let data = await cache.get(key); + if (!data) { + data = await database.get(key); + await cache.set(key, data, TTL); + } + return data; +} +```', + 'published' + ); + + -- Insert prompts for AI Development > Prompting + INSERT INTO prompts (organization_id, collection_id, segment_id, title, markdown_body, status) + VALUES + ( + v_org_id, + v_coll_ai_id, + v_seg_ai_prompting_id, + 'Effective AI Prompting for Developers', + '# Effective AI Prompting for Developers + +Master the art of communicating with AI coding assistants. + +## Be Specific and Clear +``` +❌ Bad: "Fix the bug" +✅ Good: "Fix the null pointer exception in getUserProfile() + when user.address is undefined" + +❌ Bad: "Make it faster" +✅ Good: "Optimize the database query in fetchOrders() to use + an index on created_at and user_id" +``` + +## Provide Context +``` +I''m working on a React application using TypeScript and Zustand. +I need to create a store for managing user authentication state. + +Requirements: +- Track logged-in user data +- Handle login/logout actions +- Persist auth state to localStorage +- TypeScript types for all state and actions +``` + +## Break Down Complex Tasks +``` +Let''s build a user registration form step by step: +1. First, create the form component with email and password fields +2. Then, add validation using React Hook Form +3. Next, integrate with our API endpoint +4. Finally, add error handling and loading states +``` + +## Ask for Explanations +``` +"Explain how this recursive function works, line by line" +"What are the tradeoffs between these two approaches?" +"Why is this pattern better than the alternative?" +```', + 'published' + ), + ( + v_org_id, + v_coll_ai_id, + v_seg_ai_prompting_id, + 'Code Review with AI', + '# Code Review with AI + +Use AI to improve code quality and catch issues early. + +## Security Review +``` +Review this authentication code for security vulnerabilities: + +[paste code] + +Check for: +- SQL injection risks +- XSS vulnerabilities +- Insecure password storage +- Missing input validation +- CSRF protection +``` + +## Performance Analysis +``` +Analyze this function for performance issues: + +[paste code] + +Consider: +- Time complexity +- Memory usage +- Potential bottlenecks +- Optimization opportunities +``` + +## Best Practices Check +``` +Review this React component against best practices: + +[paste code] + +Check for: +- Proper hook usage +- Component composition +- Prop types/TypeScript usage +- Accessibility +- Performance optimizations (memo, useMemo, useCallback) +``` + +## Test Coverage +``` +Review my test suite and suggest missing test cases: + +[paste tests] + +Consider: +- Edge cases +- Error conditions +- Integration scenarios +- Mocking strategies +```', + 'published' + ); + + -- Insert prompts for AI Development > Tools + INSERT INTO prompts (organization_id, collection_id, segment_id, title, markdown_body, status) + VALUES + ( + v_org_id, + v_coll_ai_id, + v_seg_ai_tools_id, + 'GitHub Copilot Best Practices', + '# GitHub Copilot Best Practices + +Maximize productivity with GitHub Copilot. + +## Effective Comments +```typescript +// Function to validate email format using regex +// Returns true if valid, false otherwise +function validateEmail(email: string): boolean { + +// Calculate total price with tax and discount +// Parameters: price (number), taxRate (0-1), discount (0-1) +function calculateTotal(price: number, taxRate: number, discount: number): number { +``` + +## Code Generation Patterns +```typescript +// Generate a custom React hook for fetching data +// Should handle loading, error, and success states +// Include automatic retry on failure +export function useFetch(url: string) { + // Copilot will suggest the implementation +} +``` + +## Test Generation +```typescript +// Given this function: +function calculateDiscount(price: number, discountPercent: number): number { + return price * (1 - discountPercent / 100); +} + +// Generate comprehensive unit tests including edge cases +describe("calculateDiscount", () => { + // Copilot suggests test cases +}); +``` + +## Refactoring Assistance +```typescript +// Refactor this code to use async/await instead of promises +getUserData(userId) + .then(user => getOrders(user.id)) + .then(orders => processOrders(orders)) + .catch(error => handleError(error)); +```', + 'published' + ), + ( + v_org_id, + v_coll_ai_id, + v_seg_ai_tools_id, + 'Claude Code MCP Integration', + '# Claude Code MCP Integration + +Integrate Claude with your development workflow using MCP. + +## What is MCP? +Model Context Protocol enables Claude to interact with your development tools, codebases, and databases. + +## Setting Up MCP Server +```typescript +// Example MCP server configuration +{ + "mcpServers": { + "rules-builder": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-rules"], + "env": { + "RULES_PATH": "./src/data/rules" + } + } + } +} +``` + +## Using MCP Tools +``` +Example prompts with MCP: + +"Show me all available coding rules" +→ Claude uses listAvailableRules tool + +"Get the React best practices rules" +→ Claude uses getRuleContent tool + +"Apply these rules to my current project" +→ Claude accesses your codebase via MCP +``` + +## Custom MCP Servers +Create your own MCP server for: +- Custom linting rules +- Project-specific conventions +- Internal API documentation +- Database schema access + +## Benefits +- Context-aware assistance +- Project-specific recommendations +- Automated rule enforcement +- Seamless tool integration', + 'published' + ); + + RAISE NOTICE 'Successfully seeded % prompts for 10xDevs organization', + (SELECT COUNT(*) FROM prompts WHERE organization_id = v_org_id); +END $$; From 8cfe444679a8fd3e674a0f2b5967bd22d3c5d2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cmkczarkowski=E2=80=9D?= Date: Thu, 2 Oct 2025 17:01:34 +0200 Subject: [PATCH 12/23] feat: implement phase 5 (admin ui) --- .ai/prompt-manager/phase-5-impl-plan.md | 275 ++++++++++++++ src/components/Topbar.tsx | 48 ++- .../prompt-manager/PromptFilters.tsx | 10 +- .../prompt-manager/admin/AdminPromptCard.tsx | 150 ++++++++ .../prompt-manager/admin/AdminPromptsList.tsx | 100 +++++ .../admin/PromptEditorDialog.tsx | 357 ++++++++++++++++++ .../admin/PromptsAdminPanel.tsx | 285 ++++++++++++++ src/components/ui/ConfirmDialog.tsx | 18 +- src/components/ui/Dropdown.tsx | 11 +- src/components/ui/FormInput.tsx | 35 ++ src/components/ui/FormTextarea.tsx | 36 ++ src/components/ui/InlineEntityForm.tsx | 184 +++++++++ src/components/ui/StatusBadge.tsx | 24 ++ .../api/prompts/admin/prompt-collections.ts | 79 +++- .../admin/prompt-collections/[id]/segments.ts | 73 +++- src/pages/api/prompts/admin/prompts.ts | 4 + src/pages/prompts/admin/index.astro | 19 + .../prompt-manager/promptCollectionService.ts | 43 ++- src/services/prompt-manager/promptService.ts | 4 + src/services/prompt-manager/types.ts | 5 +- src/store/promptsStore.ts | 328 +++++++++++++++- 21 files changed, 2066 insertions(+), 22 deletions(-) create mode 100644 .ai/prompt-manager/phase-5-impl-plan.md create mode 100644 src/components/prompt-manager/admin/AdminPromptCard.tsx create mode 100644 src/components/prompt-manager/admin/AdminPromptsList.tsx create mode 100644 src/components/prompt-manager/admin/PromptEditorDialog.tsx create mode 100644 src/components/prompt-manager/admin/PromptsAdminPanel.tsx create mode 100644 src/components/ui/FormInput.tsx create mode 100644 src/components/ui/FormTextarea.tsx create mode 100644 src/components/ui/InlineEntityForm.tsx create mode 100644 src/components/ui/StatusBadge.tsx create mode 100644 src/pages/prompts/admin/index.astro diff --git a/.ai/prompt-manager/phase-5-impl-plan.md b/.ai/prompt-manager/phase-5-impl-plan.md new file mode 100644 index 0000000..713b0b2 --- /dev/null +++ b/.ai/prompt-manager/phase-5-impl-plan.md @@ -0,0 +1,275 @@ +# Phase 5: Admin Experience Slice - Implementation Plan + +## Overview +Build admin curation UI at `/prompts/admin` with organization selector, draft list view, editor form, and publish toggle - reusing existing patterns from member UI and rule collections. + +## Part 1: Reusable UI Components (Extract & Create) + +### 1.1 Extract `FormInput` component +**File:** `src/components/ui/FormInput.tsx` +- Extract pattern from `AuthInput.tsx` +- Generic input with label, error state, and validation +- Support text, textarea modes +- Used by both auth forms and prompt editor + +### 1.2 Extract `FormTextarea` component +**File:** `src/components/ui/FormTextarea.tsx` +- Similar to FormInput but for multiline text +- Auto-resize capability +- Support markdown preview toggle (future) + +### 1.3 Create `StatusBadge` component +**File:** `src/components/ui/StatusBadge.tsx` +- Display draft/published status +- Color-coded: gray for draft, green for published +- Compact design for cards and lists + +## Part 2: Admin-Specific Components + +### 2.1 `PromptEditorDialog` component +**File:** `src/components/prompt-manager/admin/PromptEditorDialog.tsx` +- Reuse `ConfirmDialog` pattern from SaveRuleCollectionDialog +- Form fields: title (input), collection (dropdown), segment (dropdown), markdown_body (textarea) +- Client-side validation (required fields) +- Loading state during save +- Error display +- Support create + edit modes (initialData prop) + +### 2.2 `AdminPromptCard` component +**File:** `src/components/prompt-manager/admin/AdminPromptCard.tsx` +- Based on RuleCollectionListEntry pattern +- Shows: title, preview, collection/segment badges, status badge, updated date +- Actions: Edit (pencil icon), Delete (trash icon), Publish toggle (toggle switch or button) +- Hover actions pattern from RuleCollectionListEntry +- Click to select/preview in detail view + +### 2.3 `AdminPromptsList` component +**File:** `src/components/prompt-manager/admin/AdminPromptsList.tsx` +- Grid layout similar to PromptsList +- Map prompts to AdminPromptCard components +- Show "Create New Prompt" button (similar to RuleCollectionsList) +- Handle loading, error, empty states +- Support filters (status: all/draft/published) + +### 2.4 `PromptsAdminPanel` component +**File:** `src/components/prompt-manager/admin/PromptsAdminPanel.tsx` +- Main admin container (similar to PromptsBrowser) +- Top section: OrganizationSelector + status filter dropdown + search +- Main section: AdminPromptsList +- Side panel/modal: PromptDetail (reuse existing) or PromptEditorDialog +- Manage dialog states (editor open/closed, delete confirmation) + +## Part 3: Store Enhancement + +### 3.1 Extend `promptsStore` with admin actions +**File:** `src/store/promptsStore.ts` + +Add to state: +```typescript +// Admin-specific state +adminPrompts: Prompt[]; // includes drafts +isAdminMode: boolean; +statusFilter: 'all' | 'draft' | 'published'; +``` + +Add actions: +```typescript +// Admin CRUD +createPrompt: (data: CreatePromptInput) => Promise +updatePrompt: (id: string, data: UpdatePromptInput) => Promise +deletePrompt: (id: string) => Promise +togglePublishStatus: (id: string) => Promise + +// Admin fetching (includes drafts) +fetchAdminPrompts: (filters) => Promise + +// Filters +setStatusFilter: (status) => void +setAdminMode: (enabled: boolean) => void +``` + +Implementation notes: +- Use admin API endpoints (`/api/prompts/admin/*`) +- Handle errors with user-friendly messages +- Optimistic updates for toggle publish +- Refetch after mutations + +## Part 4: Admin Page + +### 4.1 Create admin page +**File:** `src/pages/prompts/admin/index.astro` +```astro +--- +import Layout from '../../../layouts/Layout.astro'; +import Topbar from '../../../components/Topbar'; +import Footer from '../../../components/Footer'; +import PromptsAdminPanel from '../../../components/prompt-manager/admin/PromptsAdminPanel'; + +const user = Astro.locals.user; +// Middleware already ensures admin access +--- + +
+ +
+ +
+
+
+
+``` + +## Part 5: Integration & Polish + +### 5.1 Add navigation link +- Add "Admin Panel" link in Topbar for admin users +- Conditional render based on admin role +- Highlight active state when on admin routes + +### 5.2 Error handling & validation +- Form validation in PromptEditorDialog +- API error display with toast/inline messages +- Confirmation dialog for destructive actions (delete) + +### 5.3 Optimistic updates +- Toggle publish status immediately, rollback on error +- Show loading spinners during mutations + +## Implementation Order + +1. **Part 1 (Reusable UI):** FormInput, FormTextarea, StatusBadge +2. **Part 3 (Store):** Extend promptsStore with admin actions +3. **Part 2 (Admin Components):** PromptEditorDialog → AdminPromptCard → AdminPromptsList → PromptsAdminPanel +4. **Part 4 (Page):** Create admin/index.astro +5. **Part 5 (Integration):** Navigation, error handling, polish + +## Testing Strategy + +- Unit tests: Store admin actions, form validation +- Integration tests: Full admin workflow (create → edit → publish → delete) +- E2E test: Playwright scenario matching US-009 from PRD + +## Component Reuse Summary + +**Reusing:** +- ConfirmDialog, Dropdown, SearchBar, MarkdownRenderer, CopyDownloadActions +- OrganizationSelector, PromptDetail, PromptFilters +- Patterns from RuleCollectionListEntry (edit/delete actions) +- Patterns from SaveRuleCollectionDialog (form dialog) + +**Creating:** +- FormInput, FormTextarea, StatusBadge (generic) +- PromptEditorDialog, AdminPromptCard, AdminPromptsList, PromptsAdminPanel (admin-specific) + +**Enhancing:** +- promptsStore (admin actions) + +## Detailed Component Specifications + +### PromptEditorDialog Props +```typescript +interface PromptEditorDialogProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: CreatePromptInput | UpdatePromptInput) => Promise; + organizationId: string; + collections: PromptCollection[]; + segments: PromptSegment[]; + initialData?: Prompt; // For edit mode +} +``` + +### AdminPromptCard Props +```typescript +interface AdminPromptCardProps { + prompt: Prompt; + collections: PromptCollection[]; + segments: PromptSegment[]; + onEdit: (prompt: Prompt) => void; + onDelete: (promptId: string) => void; + onTogglePublish: (promptId: string) => void; + onSelect: (promptId: string) => void; + isSelected?: boolean; +} +``` + +### PromptsAdminPanel State Management +```typescript +// Local component state +const [isEditorOpen, setIsEditorOpen] = useState(false); +const [editingPrompt, setEditingPrompt] = useState(null); +const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); +const [deletingPromptId, setDeletingPromptId] = useState(null); + +// Store state +const { + adminPrompts, + isLoading, + error, + activeOrganization, + collections, + segments, + statusFilter, + searchQuery, + selectedPromptId, + // Actions + fetchAdminPrompts, + createPrompt, + updatePrompt, + deletePrompt, + togglePublishStatus, + setStatusFilter, + setSearchQuery, + selectPrompt, +} = usePromptsStore(); +``` + +## API Endpoints Already Implemented + +✅ POST `/api/prompts/admin/prompts` - Create prompt +✅ GET `/api/prompts/admin/prompts` - List prompts (with filters) +✅ GET `/api/prompts/admin/prompts/[id]` - Get single prompt +✅ PUT `/api/prompts/admin/prompts/[id]` - Update prompt +✅ DELETE `/api/prompts/admin/prompts/[id]` - Delete prompt +✅ PATCH `/api/prompts/admin/prompts/[id]/publish` - Toggle publish status + +All endpoints already enforce: +- Authentication (middleware) +- Admin role check (middleware) +- Organization scoping (service layer) + +## PRD Requirements Mapping + +### US-005: Admin Panel Access ✓ +- Middleware already handles role-based routing +- Admin users see admin panel, members redirected + +### US-006: Create and Edit Drafts ✓ +- PromptEditorDialog handles both modes +- Form validation before save +- Error handling with inline display + +### US-007: Publish Toggle ✓ +- AdminPromptCard includes publish toggle +- Optimistic update for instant feedback +- Status change reflects in member view immediately + +### US-009: E2E Testing ✓ +- Playwright scenario: create draft → edit → publish → member views + +## Success Criteria (from Phase 5 in POC Plan) + +✅ Admin walkthrough demo-ready: +1. Switch organization via OrganizationSelector +2. Create new draft via "New Prompt" button +3. Edit draft via pencil icon +4. Publish via toggle button +5. Verify appears in member view + +✅ Admin-only UI route at `/prompts/admin` +✅ Organization selector with active org context +✅ Draft list view with filters +✅ Editor form (Markdown) with validation +✅ Simple publish toggle (no diffing/bulk in POC) +✅ API integration with error handling +✅ Behind feature flag and access check diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index ecabbbe..8d37615 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -1,6 +1,7 @@ -import { WandSparkles } from 'lucide-react'; +import { WandSparkles, FileText, Shield } from 'lucide-react'; import DependencyUploader from './rule-parser/DependencyUploader'; import { useAuthStore } from '../store/authStore'; +import { usePromptsStore } from '../store/promptsStore'; import { useEffect } from 'react'; import LoginButton from './auth/LoginButton'; @@ -14,6 +15,7 @@ interface TopbarProps { export default function Topbar({ title = '10xRules.ai', initialUser }: TopbarProps) { const { setUser } = useAuthStore(); + const { organizations, fetchOrganizations } = usePromptsStore(); // Initialize auth store with user data from server useEffect(() => { @@ -22,6 +24,20 @@ export default function Topbar({ title = '10xRules.ai', initialUser }: TopbarPro } }, [initialUser, setUser]); + // Fetch organizations to check admin access + useEffect(() => { + if (initialUser) { + fetchOrganizations(); + } + }, [initialUser, fetchOrganizations]); + + // Check if user is admin + const isAdmin = organizations.some((org) => org.role === 'admin'); + const hasPromptAccess = organizations.length > 0; + + // Get current path for active state + const currentPath = typeof window !== 'undefined' ? window.location.pathname : ''; + return (
@@ -35,6 +51,36 @@ export default function Topbar({ title = '10xRules.ai', initialUser }: TopbarPro
+ {/* Navigation Links */} + {initialUser && hasPromptAccess && ( + + )} +
diff --git a/src/components/prompt-manager/PromptFilters.tsx b/src/components/prompt-manager/PromptFilters.tsx index a31821e..8d044f9 100644 --- a/src/components/prompt-manager/PromptFilters.tsx +++ b/src/components/prompt-manager/PromptFilters.tsx @@ -18,10 +18,12 @@ export const PromptFilters: React.FC = () => { // Create segment options with "All" option (only show when a collection is selected) const segmentOptions: DropdownOption[] = [ { value: null, label: 'All Segments' }, - ...segments.map((segment) => ({ - value: segment.id, - label: segment.title, - })), + ...segments + .filter((segment) => segment.collection_id === selectedCollectionId) + .map((segment) => ({ + value: segment.id, + label: segment.title, + })), ]; const handleCollectionChange = (collectionId: string | null) => { diff --git a/src/components/prompt-manager/admin/AdminPromptCard.tsx b/src/components/prompt-manager/admin/AdminPromptCard.tsx new file mode 100644 index 0000000..ce12aab --- /dev/null +++ b/src/components/prompt-manager/admin/AdminPromptCard.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import { Archive, Loader2, Pencil, Send, Trash2 } from 'lucide-react'; +import type { Prompt, PromptCollection, PromptSegment } from '../../../store/promptsStore'; +import StatusBadge from '../../ui/StatusBadge'; + +interface AdminPromptCardProps { + prompt: Prompt; + collections: PromptCollection[]; + segments: PromptSegment[]; + onEdit: (prompt: Prompt) => void; + onDelete: (promptId: string) => void; + onTogglePublish: (promptId: string) => void; + isSelected?: boolean; +} + +export const AdminPromptCard: React.FC = ({ + prompt, + collections, + segments, + onEdit, + onDelete, + onTogglePublish, + isSelected = false, +}) => { + const [isToggling, setIsToggling] = useState(false); + + const collection = collections.find((c) => c.id === prompt.collection_id); + const segment = segments.find((s) => s.id === prompt.segment_id); + + const handleTogglePublish = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + setIsToggling(true); + await onTogglePublish(prompt.id); + } catch (error) { + console.error('Error toggling publish status:', error); + } finally { + setIsToggling(false); + } + }; + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onEdit(prompt); + }; + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(prompt.id); + }; + + const handleCardClick = () => { + onEdit(prompt); + }; + + // Get preview of markdown content (first 150 chars) + const preview = + prompt.markdown_body.slice(0, 150) + (prompt.markdown_body.length > 150 ? '...' : ''); + + const isPublished = prompt.status === 'published'; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleCardClick(); + } + }} + role="button" + tabIndex={0} + className={`bg-gray-800 border ${ + isSelected ? 'border-blue-400' : 'border-gray-700 hover:border-indigo-500' + } rounded-lg p-4 cursor-pointer transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 group`} + > + {/* Header */} +
+
+

+ {prompt.title} +

+
+
+ {/* Action buttons */} + + + + +
+
+ + {/* Preview */} +

{preview}

+ + {/* Metadata */} +
+ + + {collection && ( + + {collection.title} + + )} + + {segment && ( + + {segment.title} + + )} +
+ + {/* Updated date */} +
+ Updated: {new Date(prompt.updated_at).toLocaleDateString()} +
+
+ ); +}; + +export default AdminPromptCard; diff --git a/src/components/prompt-manager/admin/AdminPromptsList.tsx b/src/components/prompt-manager/admin/AdminPromptsList.tsx new file mode 100644 index 0000000..14c1680 --- /dev/null +++ b/src/components/prompt-manager/admin/AdminPromptsList.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Plus } from 'lucide-react'; +import type { Prompt, PromptCollection, PromptSegment } from '../../../store/promptsStore'; +import AdminPromptCard from './AdminPromptCard'; + +interface AdminPromptsListProps { + prompts: Prompt[]; + collections: PromptCollection[]; + segments: PromptSegment[]; + isLoading: boolean; + error: string | null; + selectedPromptId: string | null; + onCreateNew: () => void; + onEdit: (prompt: Prompt) => void; + onDelete: (promptId: string) => void; + onTogglePublish: (promptId: string) => void; +} + +export const AdminPromptsList: React.FC = ({ + prompts, + collections, + segments, + isLoading, + error, + selectedPromptId, + onCreateNew, + onEdit, + onDelete, + onTogglePublish, +}) => { + if (isLoading) { + return ( +
+
Loading prompts...
+
+ ); + } + + if (error) { + return ( +
+
Error: {error}
+
+ ); + } + + if (prompts.length === 0) { + return ( +
+
No prompts found
+ +
+ ); + } + + return ( +
+ {/* Create New Button */} +
+ +
+ + {/* Prompts Grid */} + {prompts.length === 0 ? ( +
+
No prompts match the selected filters
+
+ ) : ( +
+ {prompts.map((prompt) => ( + + ))} +
+ )} +
+ ); +}; + +export default AdminPromptsList; diff --git a/src/components/prompt-manager/admin/PromptEditorDialog.tsx b/src/components/prompt-manager/admin/PromptEditorDialog.tsx new file mode 100644 index 0000000..967a2bd --- /dev/null +++ b/src/components/prompt-manager/admin/PromptEditorDialog.tsx @@ -0,0 +1,357 @@ +import React, { useState, useEffect } from 'react'; +import { + ConfirmDialog, + ConfirmDialogHeader, + ConfirmDialogContent, + ConfirmDialogActions, +} from '../../ui/ConfirmDialog'; +import FormInput from '../../ui/FormInput'; +import FormTextarea from '../../ui/FormTextarea'; +import { Dropdown } from '../../ui/Dropdown'; +import InlineEntityForm from '../../ui/InlineEntityForm'; +import { usePromptsStore } from '../../../store/promptsStore'; +import { Plus } from 'lucide-react'; +import type { Prompt, CreatePromptInput, UpdatePromptInput } from '../../../store/promptsStore'; + +interface PromptEditorDialogProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: CreatePromptInput | UpdatePromptInput) => Promise; + initialData?: Prompt; +} + +export const PromptEditorDialog: React.FC = ({ + isOpen, + onClose, + onSave, + initialData, +}) => { + // Get collections and segments directly from store for real-time updates + const collections = usePromptsStore((state) => state.collections ?? []); + const segments = usePromptsStore((state) => state.segments ?? []); + const { createCollection, createSegment, fetchSegments } = usePromptsStore(); + + const [title, setTitle] = useState(''); + const [collectionId, setCollectionId] = useState(''); + const [segmentId, setSegmentId] = useState(''); + const [markdownBody, setMarkdownBody] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [hasAttemptedSave, setHasAttemptedSave] = useState(false); + const [validationErrors, setValidationErrors] = useState<{ + title?: string; + collectionId?: string; + segmentId?: string; + markdownBody?: string; + }>({}); + + // Inline creation states + const [isCreatingCollection, setIsCreatingCollection] = useState(false); + const [isCreatingSegment, setIsCreatingSegment] = useState(false); + + // Initialize form with initial data + useEffect(() => { + if (initialData) { + setTitle(initialData.title); + setCollectionId(initialData.collection_id); + setSegmentId(initialData.segment_id || ''); + setMarkdownBody(initialData.markdown_body); + + // Ensure segments are loaded for this collection + if (initialData.collection_id) { + fetchSegments(initialData.collection_id); + } + } else { + setTitle(''); + setCollectionId(''); + setSegmentId(''); + setMarkdownBody(''); + } + setError(null); + setValidationErrors({}); + setHasAttemptedSave(false); + setIsCreatingCollection(false); + setIsCreatingSegment(false); + }, [initialData, isOpen, fetchSegments]); + + const validate = (): boolean => { + const errors: typeof validationErrors = {}; + + if (!title.trim()) { + errors.title = 'Title is required'; + } + + if (!collectionId) { + errors.collectionId = 'Collection is required'; + } + + if (!segmentId) { + errors.segmentId = 'Segment is required'; + } + + if (!markdownBody.trim()) { + errors.markdownBody = 'Content is required'; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSave = async () => { + setHasAttemptedSave(true); + + if (!validate()) { + return; + } + + try { + setIsSaving(true); + setError(null); + + const data: CreatePromptInput | UpdatePromptInput = { + title: title.trim(), + collection_id: collectionId, + segment_id: segmentId, + markdown_body: markdownBody.trim(), + }; + + await onSave(data); + onClose(); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to save prompt'); + } finally { + setIsSaving(false); + } + }; + + // Handle collection creation + const handleCreateCollection = async (data: { + title: string; + description?: string; + slug?: string; + }) => { + const newCollection = await createCollection(data); + setCollectionId(newCollection.id); + setIsCreatingCollection(false); + // Fetch segments for the new collection + await fetchSegments(newCollection.id); + }; + + // Handle segment creation + const handleCreateSegment = async (data: { title: string; slug?: string }) => { + if (!collectionId) return; + const newSegment = await createSegment(collectionId, data); + setSegmentId(newSegment.id); + setIsCreatingSegment(false); + }; + + // Filter segments for selected collection + const filteredSegments = segments.filter((s) => s.collection_id === collectionId); + + // Disable segment dropdown if no collection is selected + const isSegmentDisabled = !collectionId || collectionId === '__CREATE_NEW__'; + + // Create options for dropdowns with special "Create New" option + const collectionOptions = [ + { value: '__CREATE_NEW__', label: 'Create New Collection' }, + ...collections.map((c) => ({ + value: c.id, + label: c.title, + })), + ]; + + const segmentOptions = [ + { value: '__CREATE_NEW__', label: 'Create New Segment' }, + ...filteredSegments.map((s) => ({ + value: s.id, + label: s.title, + })), + ]; + + // Handle collection change + const handleCollectionChange = async (value: string) => { + if (value === '__CREATE_NEW__') { + setIsCreatingCollection(true); + return; + } + setCollectionId(value); + setSegmentId(''); + setIsCreatingCollection(false); + if (hasAttemptedSave && validationErrors.collectionId) { + setValidationErrors((prev) => ({ ...prev, collectionId: undefined })); + } + // Fetch segments for the selected collection + await fetchSegments(value); + }; + + // Handle segment change + const handleSegmentChange = (value: string) => { + if (value === '__CREATE_NEW__') { + setIsCreatingSegment(true); + return; + } + setSegmentId(value); + setIsCreatingSegment(false); + if (hasAttemptedSave && validationErrors.segmentId) { + setValidationErrors((prev) => ({ ...prev, segmentId: undefined })); + } + }; + + // Custom option renderer to add Plus icon for "Create New" options + const renderCollectionOption = ( + option: { value: string; label: string }, + isSelected: boolean, + ) => { + if (option.value === '__CREATE_NEW__') { + return ( + + + {option.label} + + ); + } + return ( + <> + {option.label} + {isSelected && } + + ); + }; + + const renderSegmentOption = (option: { value: string; label: string }, isSelected: boolean) => { + if (option.value === '__CREATE_NEW__') { + return ( + + + {option.label} + + ); + } + return ( + <> + {option.label} + {isSelected && } + + ); + }; + + return ( + + {initialData ? 'Edit Prompt' : 'Create New Prompt'} + +
{ + e.preventDefault(); + handleSave(); + }} + className="space-y-4" + > + { + setTitle(e.target.value); + if (hasAttemptedSave && validationErrors.title) { + setValidationErrors((prev) => ({ ...prev, title: undefined })); + } + }} + error={hasAttemptedSave ? validationErrors.title : undefined} + placeholder="Enter prompt title" + /> + +
+ + {hasAttemptedSave && validationErrors.collectionId && ( +

{validationErrors.collectionId}

+ )} +
+ + {isCreatingCollection && ( + { + setIsCreatingCollection(false); + setCollectionId(''); + }} + /> + )} + +
+ + {hasAttemptedSave && validationErrors.segmentId && ( +

{validationErrors.segmentId}

+ )} +
+ + {isCreatingSegment && collectionId && ( + { + setIsCreatingSegment(false); + setSegmentId(''); + }} + /> + )} + + { + setMarkdownBody(e.target.value); + if (hasAttemptedSave && validationErrors.markdownBody) { + setValidationErrors((prev) => ({ ...prev, markdownBody: undefined })); + } + }} + error={hasAttemptedSave ? validationErrors.markdownBody : undefined} + placeholder="Enter prompt content in markdown format..." + rows={10} + /> + + {error && ( +
+ {error} +
+ )} + +
+ + + + +
+ ); +}; + +export default PromptEditorDialog; diff --git a/src/components/prompt-manager/admin/PromptsAdminPanel.tsx b/src/components/prompt-manager/admin/PromptsAdminPanel.tsx new file mode 100644 index 0000000..2ae231b --- /dev/null +++ b/src/components/prompt-manager/admin/PromptsAdminPanel.tsx @@ -0,0 +1,285 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { usePromptsStore } from '../../../store/promptsStore'; +import type { Prompt, CreatePromptInput, UpdatePromptInput } from '../../../store/promptsStore'; +import OrganizationSelector from '../OrganizationSelector'; +import AdminPromptsList from './AdminPromptsList'; +import PromptEditorDialog from './PromptEditorDialog'; +import DeletionDialog from '../../rule-collections/DeletionDialog'; +import { Dropdown } from '../../ui/Dropdown'; +import { SearchBar } from '../../ui/SearchBar'; + +export const PromptsAdminPanel: React.FC = () => { + // Store state - using individual selectors for better type safety + const adminPrompts = usePromptsStore((state) => state.adminPrompts ?? []); + const isLoading = usePromptsStore((state) => state.isLoading); + const error = usePromptsStore((state) => state.error); + const activeOrganization = usePromptsStore((state) => state.activeOrganization); + const collections = usePromptsStore((state) => state.collections ?? []); + const segments = usePromptsStore((state) => state.segments ?? []); + const statusFilter = usePromptsStore((state) => state.statusFilter); + const selectedPromptId = usePromptsStore((state) => state.selectedPromptId); + const searchQuery = usePromptsStore((state) => state.searchQuery); + + // Actions + const fetchOrganizations = usePromptsStore((state) => state.fetchOrganizations); + const fetchAdminPrompts = usePromptsStore((state) => state.fetchAdminPrompts); + const fetchSegments = usePromptsStore((state) => state.fetchSegments); + const createPrompt = usePromptsStore((state) => state.createPrompt); + const updatePrompt = usePromptsStore((state) => state.updatePrompt); + const deletePrompt = usePromptsStore((state) => state.deletePrompt); + const togglePublishStatus = usePromptsStore((state) => state.togglePublishStatus); + const setStatusFilter = usePromptsStore((state) => state.setStatusFilter); + const setAdminMode = usePromptsStore((state) => state.setAdminMode); + const setSearchQuery = usePromptsStore((state) => state.setSearchQuery); + + // Local state + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [editingPrompt, setEditingPrompt] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [deletingPromptId, setDeletingPromptId] = useState(null); + const [selectedCollectionId, setSelectedCollectionId] = useState(null); + const [selectedSegmentId, setSelectedSegmentId] = useState(null); + + // Initialize admin mode + useEffect(() => { + setAdminMode(true); + fetchOrganizations(); + + return () => { + setAdminMode(false); + }; + }, [setAdminMode, fetchOrganizations]); + + // Fetch admin prompts when organization changes + useEffect(() => { + if (activeOrganization) { + fetchAdminPrompts(); + } + }, [activeOrganization, fetchAdminPrompts]); + + // Fetch all segments for the active organization (in parallel for better performance) + useEffect(() => { + const loadAllSegments = async () => { + await Promise.all(collections.map((collection) => fetchSegments(collection.id))); + }; + + if (collections.length > 0) { + loadAllSegments(); + } + }, [collections, fetchSegments]); + + // Create collection options with "All" option + const collectionOptions = useMemo( + () => [ + { value: null, label: 'All Collections' }, + ...collections.map((collection) => ({ + value: collection.id, + label: collection.title, + })), + ], + [collections], + ); + + // Create segment options with "All" option (only show when a collection is selected) + const segmentOptions = useMemo( + () => [ + { value: null, label: 'All Segments' }, + ...segments + .filter((segment) => segment.collection_id === selectedCollectionId) + .map((segment) => ({ + value: segment.id, + label: segment.title, + })), + ], + [segments, selectedCollectionId], + ); + + const handleCollectionChange = (collectionId: string | null) => { + setSelectedCollectionId(collectionId); + setSelectedSegmentId(null); // Reset segment when collection changes + }; + + const handleSegmentChange = (segmentId: string | null) => { + setSelectedSegmentId(segmentId); + }; + + // Filter prompts based on selected collection and segment + const filteredPrompts = useMemo(() => { + return adminPrompts.filter((prompt) => { + // Filter by collection + if (selectedCollectionId && prompt.collection_id !== selectedCollectionId) { + return false; + } + // Filter by segment + if (selectedSegmentId && prompt.segment_id !== selectedSegmentId) { + return false; + } + return true; + }); + }, [adminPrompts, selectedCollectionId, selectedSegmentId]); + + // Handlers + const handleCreateNew = () => { + setEditingPrompt(null); + setIsEditorOpen(true); + }; + + const handleEdit = (prompt: Prompt) => { + setEditingPrompt(prompt); + setIsEditorOpen(true); + }; + + const handleDelete = (promptId: string) => { + setDeletingPromptId(promptId); + setIsDeleteDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + if (deletingPromptId) { + try { + await deletePrompt(deletingPromptId); + setIsDeleteDialogOpen(false); + setDeletingPromptId(null); + } catch (error) { + console.error('Error deleting prompt:', error); + } + } + }; + + const handleCancelDelete = () => { + setIsDeleteDialogOpen(false); + setDeletingPromptId(null); + }; + + const handleSave = async (data: CreatePromptInput | UpdatePromptInput) => { + if (editingPrompt) { + await updatePrompt(editingPrompt.id, data); + } else { + await createPrompt(data as CreatePromptInput); + } + setIsEditorOpen(false); + setEditingPrompt(null); + }; + + const handleTogglePublish = async (promptId: string) => { + await togglePublishStatus(promptId); + }; + + // Status filter options + const statusFilterOptions = [ + { value: 'all', label: 'All Prompts' }, + { value: 'draft', label: 'Drafts Only' }, + { value: 'published', label: 'Published Only' }, + ]; + + // Get the prompt being deleted for the dialog + const deletingPrompt = adminPrompts.find((p) => p.id === deletingPromptId); + + return ( +
+ {/* Page Header */} +
+

Prompts Admin Panel

+

Manage your organization's prompt templates

+
+ + {/* Organization Selector */} + + + {/* Search Bar */} +
+ +
+ + {/* Filters */} +
+
+ setStatusFilter(value as 'all' | 'draft' | 'published')} + /> +
+ +
+ +
+ + {selectedCollectionId && segments.length > 0 && ( +
+ +
+ )} + +
+ + {filteredPrompts.length} {filteredPrompts.length === 1 ? 'prompt' : 'prompts'} + +
+
+ + {/* Main Content */} + {activeOrganization ? ( + + ) : ( +
+
+ {isLoading ? 'Loading organizations...' : 'Please select an organization to continue'} +
+
+ )} + + {/* Editor Dialog */} + { + setIsEditorOpen(false); + setEditingPrompt(null); + }} + onSave={handleSave} + initialData={editingPrompt || undefined} + /> + + {/* Delete Confirmation Dialog */} + +
+ ); +}; + +export default PromptsAdminPanel; diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx index c7c7aa3..7d95d1e 100644 --- a/src/components/ui/ConfirmDialog.tsx +++ b/src/components/ui/ConfirmDialog.tsx @@ -23,9 +23,21 @@ export const ConfirmDialog: React.FC = ({ isOpen, onClose, c // Handle click outside to close useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dialogRef.current && !dialogRef.current.contains(event.target as Node)) { - onClose(); + const target = event.target as Node; + + // Check if click is inside the dialog + if (dialogRef.current && dialogRef.current.contains(target)) { + return; } + + // Check if click is inside a dropdown menu (rendered via portal) + const isInDropdown = (target as Element).closest?.('[role="listbox"]'); + if (isInDropdown) { + return; + } + + // Click is outside both dialog and dropdowns, so close + onClose(); }; if (isOpen) { @@ -66,7 +78,7 @@ export const ConfirmDialog: React.FC = ({ isOpen, onClose, c >
diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index 5f920ec..2e6c220 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -15,6 +15,7 @@ interface DropdownProps { renderOption?: (option: DropdownOption, isSelected: boolean) => React.ReactNode; className?: string; placeholder?: string; + disabled?: boolean; } export function Dropdown({ @@ -25,6 +26,7 @@ export function Dropdown({ renderOption, className = '', placeholder = 'Select option', + disabled = false, }: DropdownProps) { const [isOpen, setIsOpen] = useState(false); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); @@ -101,7 +103,7 @@ export function Dropdown({ const selectedOption = options.find((opt) => opt.value === value); // Create dropdown menu component - const dropdownMenu = isOpen && ( + const dropdownMenu = isOpen && !disabled && (
({ return (