diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 7dde21a..02d4b1e 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -8,15 +8,15 @@ jobs: check-source-branch: runs-on: ubuntu-latest steps: - - name: Only allow PRs from dev to production - if: github.head_ref != 'dev' + - name: Only allow PRs from dev or release branches to production + if: github.head_ref != 'dev' && !startsWith(github.head_ref, 'release/') run: | - echo "::error::PRs targeting 'production' are only allowed from the 'dev' branch." + echo "::error::PRs targeting 'production' are only allowed from 'dev' or 'release/*' branches." echo "Please target 'dev' instead, or merge your branch into 'dev' first." echo "" echo " Source: ${{ github.head_ref }}" echo " Target: ${{ github.base_ref }}" exit 1 - name: PR source branch is valid - if: github.head_ref == 'dev' - run: echo "PR from 'dev' to 'production' — allowed." + if: github.head_ref == 'dev' || startsWith(github.head_ref, 'release/') + run: echo "PR from '${{ github.head_ref }}' to 'production' — allowed." diff --git a/CLAUDE.md b/CLAUDE.md index 03c7366..3b25b40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,12 +29,14 @@ npm run generate:favicons # Generate favicon assets ## Key Directories - `src/templates/` — 120 templates across 12 categories. Each is a `.ts` file exporting a `TemplateDefinition`. - `src/lib/` — Core engine (og-engine.ts, font-loader.ts, meta-fetcher.ts, meta-analyzer.ts, api-validation.ts) -- `src/components/editor/` — React components for the OG image editor -- `src/components/preview/` — React components for the social media preview checker +- `src/lib/ai/` — AI integration (types, providers, storage, validation, prompts, generate, autofill, recommender) +- `src/components/editor/` — React components for the OG image editor (includes AI components) +- `src/components/preview/` — React components for the social media preview checker (includes AIAnalyzer) - `src/pages/api/` — API endpoints -- `src/styles/` — CSS files (global.css, editor.css, preview.css, api-docs.css) +- `src/pages/api/ai/` — AI proxy endpoints (generate, validate, autofill) +- `src/styles/` — CSS files (global.css, editor.css, preview.css, api-docs.css, ai.css) - `public/fonts/` — Bundled .woff fonts (Inter, Playfair Display, JetBrains Mono) -- `tests/` — Vitest tests (api/, lib/, templates/) +- `tests/` — Vitest tests (api/, ai/, lib/, templates/) ## Pages & API Endpoints @@ -51,6 +53,11 @@ npm run generate:favicons # Generate favicon assets - `GET /api/templates` — List all templates as JSON - `GET /api/templates/[id]/thumbnail.png` — Template thumbnail (1-week cache) +**AI API (BYOK — user provides their own API key):** +- `POST /api/ai/generate` — Proxy text generation to any supported provider +- `POST /api/ai/validate` — Validate an AI provider API key +- `POST /api/ai/autofill` — Analyze a URL and auto-fill template fields via AI + ## Reference Files - Template interface: `src/templates/types.ts` - Reference template: `src/templates/blog/minimal-dark.ts` @@ -58,6 +65,10 @@ npm run generate:favicons # Generate favicon assets - OG engine: `src/lib/og-engine.ts` - API validation schemas: `src/lib/api-validation.ts` - Template registry: `src/templates/registry.ts` +- AI types & provider configs: `src/lib/ai/types.ts`, `src/lib/ai/providers.ts` +- AI validation schemas: `src/lib/ai/validation.ts` +- AI prompt builders: `src/lib/ai/prompts.ts` +- AI context & hook: `src/components/editor/AIContext.tsx` ## Template System @@ -72,6 +83,30 @@ npm run generate:favicons # Generate favicon assets **Template field types:** text, textarea, color, select, number, toggle, image. **Field groups:** Content, Style, Brand. +## AI Integration (BYOK) + +OGCOPS supports AI-powered features using a Bring Your Own Key (BYOK) model. Users configure their own API keys in the browser; keys are stored in localStorage and passed through server-side proxy endpoints (never stored server-side). + +**Supported providers:** OpenAI, Anthropic, Google (Gemini), Groq, OpenRouter. +**Three API formats:** OpenAI-compatible (OpenAI/Groq/OpenRouter), Anthropic, Google Gemini. + +**AI Features:** +- **AI Copy Generator** — Generate title/subtitle/field suggestions inline in the editor +- **Smart Autofill** — Paste a URL to auto-select a template and fill all fields +- **AI Template Recommender** — Natural language template search in the template panel +- **AI Meta Analyzer** — AI-powered analysis of meta tag quality in the preview checker + +**Key files:** +- `src/lib/ai/` — All AI logic (types, providers, storage, validation, prompts, generate, autofill, recommender) +- `src/components/editor/AIContext.tsx` — React context providing `generate()`, `isConfigured`, `openSettings` +- `src/components/editor/AISettingsModal.tsx` — Provider/key/model configuration modal +- `src/components/editor/AIGenerateButton.tsx` + `AISuggestionPicker.tsx` — Inline copy generation +- `src/components/editor/AIAutofill.tsx` — Smart autofill from URL +- `src/components/editor/AITemplateSearch.tsx` — AI template recommender +- `src/components/preview/AIAnalyzer.tsx` — AI meta tag analysis +- `src/styles/ai.css` — All AI component styles + + ## Conventions - CSS custom properties only (no Tailwind). Accent: `#E07A5F`. - TypeScript strict mode. Path alias `@/*` → `src/*`. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..b7bb0d3 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,162 @@ +# Release Process + +This document describes the step-by-step process for creating a production release of OGCOPS. + +## Branch Strategy + +``` +feature branches → PR to dev (upstream) → merge → PR to production → merge → release → sync back to dev +``` + +| Branch | Purpose | +|--------|---------| +| `dev` | Default branch. All feature PRs target this branch. | +| `production` | Stable release branch. Only accepts PRs from `dev`. | + +## Versioning + +We follow [Semantic Versioning](https://semver.org/): + +- **`patch`** (1.0.0 → 1.0.1): Bug fixes, minor CSS tweaks +- **`minor`** (1.0.0 → 1.1.0): New features, new templates, UI improvements +- **`major`** (1.0.0 → 2.0.0): Breaking API changes, major redesigns + +## Pre-Release Checklist + +- [ ] All feature PRs merged into `dev` +- [ ] `npm run check` passes (TypeScript + Astro type-check) +- [ ] `npm run test` passes +- [ ] `npm run build` succeeds +- [ ] Manual testing on desktop and mobile +- [ ] No unresolved critical issues + +## Release Steps + +### 1. Verify dev is clean + +```bash +cd ogcops +git fetch upstream +git checkout dev +git pull upstream dev +npm run check && npm run test && npm run build +``` + +### 2. Create a release PR (dev → production) + +```bash +gh pr create \ + --repo codercops/ogcops \ + --head dev \ + --base production \ + --title "release: vX.Y.Z — Short description" \ + --body "$(cat <<'EOF' +## Release vX.Y.Z + +Brief summary of what's in this release. + +### Fixes +- ... + +### Enhancements +- ... + +### Docs & Chores +- ... + +### Test Plan +- [ ] ... + +EOF +)" +``` + +### 3. Review and merge the PR + +- Ensure CI passes on the PR +- Review the diff one final time +- **Squash and merge** into `production` + +### 4. Create the GitHub Release + +```bash +gh release create vX.Y.Z \ + --repo codercops/ogcops \ + --target production \ + --title "vX.Y.Z — Short description" \ + --notes "$(cat <<'EOF' +## What's Changed + +### Fixes +- ... + +### Enhancements +- ... + +### Docs & Chores +- ... + +**Full Changelog**: https://github.com/codercops/ogcops/compare/vPREVIOUS...vX.Y.Z + +EOF +)" +``` + +### 5. Sync production back into dev + +Since `dev` has branch protection, create a sync PR: + +```bash +gh pr create \ + --repo codercops/ogcops \ + --head production \ + --base dev \ + --title "chore: sync production into dev after vX.Y.Z release" \ + --body "Syncs production back into dev after the [vX.Y.Z release](https://github.com/codercops/ogcops/releases/tag/vX.Y.Z)." +``` + +Then **merge** this PR (use regular merge, NOT squash, to keep history aligned). + +### 6. Update your fork + +```bash +git fetch upstream +git checkout dev +git pull upstream dev +git push origin dev +``` + +## Quick Reference (Copy-Paste) + +Replace `X.Y.Z` with the actual version and `PREVIOUS` with the last release tag. + +```bash +# Step 1: Verify +npm run check && npm run test && npm run build + +# Step 2: Release PR +gh pr create --repo codercops/ogcops --head dev --base production \ + --title "release: vX.Y.Z — Description" + +# Step 3: Merge the PR on GitHub (squash and merge) + +# Step 4: Create release +gh release create vX.Y.Z --repo codercops/ogcops --target production \ + --title "vX.Y.Z — Description" --generate-notes + +# Step 5: Sync PR +gh pr create --repo codercops/ogcops --head production --base dev \ + --title "chore: sync production into dev after vX.Y.Z release" + +# Step 6: Merge the sync PR on GitHub (regular merge, NOT squash) + +# Step 7: Update fork +git fetch upstream && git checkout dev && git pull upstream dev && git push origin dev +``` + +## Notes + +- The `pr-target-check.yml` workflow enforces that only `dev` can PR into `production`. +- CI runs on all PRs to both `dev` and `production` branches. +- Vercel auto-deploys `production` after merge. +- Use `--generate-notes` flag on `gh release create` to auto-generate changelog from commits. diff --git a/src/components/BackToTop.astro b/src/components/BackToTop.astro index c49130b..261c223 100644 --- a/src/components/BackToTop.astro +++ b/src/components/BackToTop.astro @@ -31,7 +31,7 @@ diff --git a/src/components/editor/AIAutofill.tsx b/src/components/editor/AIAutofill.tsx new file mode 100644 index 0000000..3ad2e1d --- /dev/null +++ b/src/components/editor/AIAutofill.tsx @@ -0,0 +1,149 @@ +import { useState, useCallback } from 'react'; +import { useAI } from './AIContext'; +import { getApiKey, getSelectedProvider, getSelectedModel } from '@/lib/ai/storage'; +import { getDefaultModel } from '@/lib/ai/providers'; + +interface AIAutofillProps { + onAutofill: (result: { templateId: string; fields: Record; colors?: Record }) => void; +} + +type Step = 'idle' | 'fetching' | 'analyzing' | 'generating' | 'done' | 'error'; + +export function AIAutofill({ onAutofill }: AIAutofillProps) { + const { isConfigured, openSettings } = useAI(); + const [url, setUrl] = useState(''); + const [step, setStep] = useState('idle'); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(false); + + const handleAutofill = useCallback(async () => { + if (!url.trim()) return; + + if (!isConfigured) { + openSettings(); + return; + } + + const provider = getSelectedProvider(); + const apiKey = provider ? getApiKey(provider) : null; + const model = provider ? (getSelectedModel(provider) ?? getDefaultModel(provider)) : null; + + if (!provider || !apiKey || !model) { + openSettings(); + return; + } + + setError(null); + setStep('fetching'); + + try { + setStep('analyzing'); + + const res = await fetch('/api/ai/autofill', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider, + apiKey, + model, + url: url.trim().startsWith('http') ? url.trim() : `https://${url.trim()}`, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: 'Autofill failed' })); + throw new Error(data.error || `HTTP ${res.status}`); + } + + setStep('generating'); + const result = await res.json(); + + onAutofill(result); + setStep('done'); + setTimeout(() => { + setStep('idle'); + setExpanded(false); + }, 1500); + } catch (err) { + setError(err instanceof Error ? err.message : 'Autofill failed'); + setStep('error'); + } + }, [url, isConfigured, openSettings, onAutofill]); + + if (!expanded) { + return ( + + ); + } + + return ( +
+
+ + + + + Smart Autofill + + +
+

Paste a URL and we'll generate your OG image automatically.

+
+ setUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleAutofill(); }} + disabled={step !== 'idle' && step !== 'error'} + /> + +
+ + {(step !== 'idle' && step !== 'error') && ( +
+
+ {step === 'fetching' ? : '✓'} Fetching page content +
+
+ {step === 'analyzing' ? : step === 'generating' || step === 'done' ? '✓' : '○'} Selecting best template +
+
+ {step === 'generating' ? : step === 'done' ? '✓' : '○'} Generating content +
+
+ )} + + {error && ( +
+ + + + {error} +
+ )} +
+ ); +} diff --git a/src/components/editor/AIContext.tsx b/src/components/editor/AIContext.tsx new file mode 100644 index 0000000..86e815b --- /dev/null +++ b/src/components/editor/AIContext.tsx @@ -0,0 +1,116 @@ +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; +import type { AIProvider } from '@/lib/ai/types'; +import { + getApiKey, + getSelectedProvider, + getSelectedModel, + hasAnyKeyConfigured, + setSelectedProvider as storeProvider, + setSelectedModel as storeModel, +} from '@/lib/ai/storage'; +import { getDefaultModel } from '@/lib/ai/providers'; + +interface AIContextValue { + /** Whether any provider has a saved key */ + isConfigured: boolean; + /** Currently selected provider */ + provider: AIProvider | null; + /** Currently selected model */ + model: string | null; + /** Open the AI settings modal */ + openSettings: () => void; + /** Close the AI settings modal */ + closeSettings: () => void; + /** Whether the settings modal is open */ + settingsOpen: boolean; + /** Call the AI generate endpoint */ + generate: (prompt: string, systemPrompt?: string) => Promise; + /** Refresh state from localStorage (call after settings change) */ + refresh: () => void; +} + +const AICtx = createContext(null); + +export function AIProvider({ children }: { children: ReactNode }) { + const [settingsOpen, setSettingsOpen] = useState(false); + const [isConfigured, setIsConfigured] = useState(false); + const [provider, setProvider] = useState(null); + const [model, setModel] = useState(null); + + const refresh = useCallback(() => { + setIsConfigured(hasAnyKeyConfigured()); + const p = getSelectedProvider(); + setProvider(p); + setModel(p ? getSelectedModel(p) ?? getDefaultModel(p) ?? null : null); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const openSettings = useCallback(() => setSettingsOpen(true), []); + const closeSettings = useCallback(() => { + setSettingsOpen(false); + refresh(); + }, [refresh]); + + const generate = useCallback( + async (prompt: string, systemPrompt?: string): Promise => { + const currentProvider = getSelectedProvider(); + if (!currentProvider) throw new Error('No AI provider configured'); + + const apiKey = getApiKey(currentProvider); + if (!apiKey) throw new Error('No API key configured'); + + const currentModel = + getSelectedModel(currentProvider) ?? getDefaultModel(currentProvider); + if (!currentModel) throw new Error('No model selected'); + + const res = await fetch('/api/ai/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: currentProvider, + apiKey, + model: currentModel, + prompt, + systemPrompt, + maxTokens: 300, + temperature: 0.8, + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Generation failed' })); + throw new Error(err.error || `HTTP ${res.status}`); + } + + const data = await res.json(); + return data.content; + }, + [] + ); + + return ( + + {children} + + ); +} + +export function useAI(): AIContextValue { + const ctx = useContext(AICtx); + if (!ctx) throw new Error('useAI must be used within '); + return ctx; +} diff --git a/src/components/editor/AIGenerateButton.tsx b/src/components/editor/AIGenerateButton.tsx new file mode 100644 index 0000000..3f9e269 --- /dev/null +++ b/src/components/editor/AIGenerateButton.tsx @@ -0,0 +1,98 @@ +import { useState, useCallback } from 'react'; +import { useAI } from './AIContext'; +import { generateSuggestions } from '@/lib/ai/generate'; +import { AISuggestionPicker } from './AISuggestionPicker'; +import type { GenerateContext } from '@/lib/ai/prompts'; + +interface AIGenerateButtonProps { + fieldName: string; + category?: string; + currentValues?: Record; + onSelect: (value: string) => void; +} + +export function AIGenerateButton({ fieldName, category, currentValues, onSelect }: AIGenerateButtonProps) { + const { isConfigured, openSettings } = useAI(); + const [showPicker, setShowPicker] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const doGenerate = useCallback(async () => { + setLoading(true); + setError(null); + setSuggestions([]); + setShowPicker(true); + + const ctx: GenerateContext = { + fieldName, + category, + currentValues, + count: 3, + }; + + try { + const results = await generateSuggestions(ctx); + setSuggestions(results); + } catch (err) { + setError(err instanceof Error ? err.message : 'Generation failed'); + setShowPicker(false); + } finally { + setLoading(false); + } + }, [fieldName, category, currentValues]); + + const handleClick = useCallback(() => { + if (!isConfigured) { + openSettings(); + return; + } + doGenerate(); + }, [isConfigured, openSettings, doGenerate]); + + const handleSelect = useCallback( + (value: string) => { + onSelect(value); + setShowPicker(false); + }, + [onSelect] + ); + + const handleClose = useCallback(() => { + setShowPicker(false); + }, []); + + return ( +
+ + {showPicker && ( + + )} + {error && !showPicker && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/src/components/editor/AISettingsModal.tsx b/src/components/editor/AISettingsModal.tsx new file mode 100644 index 0000000..e76b45f --- /dev/null +++ b/src/components/editor/AISettingsModal.tsx @@ -0,0 +1,293 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { AIProvider as AIProviderType } from '@/lib/ai/types'; +import { AI_PROVIDERS } from '@/lib/ai/types'; +import { getAllProviders, getModelsForProvider, getDefaultModel } from '@/lib/ai/providers'; +import { + getApiKey, + setApiKey, + removeApiKey, + getSelectedProvider, + setSelectedProvider, + getSelectedModel, + setSelectedModel, +} from '@/lib/ai/storage'; + +interface AISettingsModalProps { + open: boolean; + onClose: () => void; +} + +type ValidationStatus = 'idle' | 'validating' | 'valid' | 'invalid' | 'error'; + +const PROVIDERS = getAllProviders(); + +export function AISettingsModal({ open, onClose }: AISettingsModalProps) { + const [activeProvider, setActiveProvider] = useState(() => getSelectedProvider() ?? 'openai'); + const [keyValue, setKeyValue] = useState(''); + const [showKey, setShowKey] = useState(false); + const [selectedModel, setModelLocal] = useState(''); + const [status, setStatus] = useState('idle'); + const [statusMessage, setStatusMessage] = useState(''); + const overlayRef = useRef(null); + const inputRef = useRef(null); + + // Load stored values when provider changes + useEffect(() => { + const storedKey = getApiKey(activeProvider) ?? ''; + setKeyValue(storedKey); + setShowKey(false); + const storedModel = getSelectedModel(activeProvider) ?? getDefaultModel(activeProvider) ?? ''; + setModelLocal(storedModel); + setStatus(storedKey ? 'idle' : 'idle'); + setStatusMessage(''); + }, [activeProvider]); + + // Focus input on open + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [open]); + + // ESC to close + useEffect(() => { + if (!open) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [open, onClose]); + + const handleProviderChange = useCallback((id: AIProviderType) => { + setActiveProvider(id); + setSelectedProvider(id); + }, []); + + const handleKeyChange = useCallback( + (value: string) => { + const trimmed = value.trim(); + setKeyValue(trimmed); + setStatus('idle'); + setStatusMessage(''); + // Auto-save as user types + if (trimmed) { + setApiKey(activeProvider, trimmed); + } + }, + [activeProvider] + ); + + const handleModelChange = useCallback( + (modelId: string) => { + setModelLocal(modelId); + setSelectedModel(activeProvider, modelId); + }, + [activeProvider] + ); + + const handleValidate = useCallback(async () => { + if (!keyValue) return; + setStatus('validating'); + setStatusMessage(''); + + try { + const res = await fetch('/api/ai/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: activeProvider, apiKey: keyValue }), + }); + + const data = await res.json(); + if (data.valid) { + setStatus('valid'); + setStatusMessage('Key is valid — ready to use'); + } else { + setStatus('invalid'); + setStatusMessage(data.error || 'Invalid API key'); + } + } catch { + setStatus('error'); + setStatusMessage('Could not reach validation endpoint'); + } + }, [activeProvider, keyValue]); + + const handleClear = useCallback(() => { + removeApiKey(activeProvider); + setKeyValue(''); + setStatus('idle'); + setStatusMessage(''); + setShowKey(false); + }, [activeProvider]); + + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current) onClose(); + }, + [onClose] + ); + + if (!open) return null; + + const providerConfig = PROVIDERS.find((p) => p.id === activeProvider)!; + const models = getModelsForProvider(activeProvider); + + return ( +
+
+ {/* Header */} +
+

+ + + + AI Settings +

+ +
+ + {/* Body */} +
+ {/* Provider Selection */} +
+ +
+ {PROVIDERS.map((p) => ( + + ))} +
+
+ + {/* API Key */} +
+ +
+ handleKeyChange(e.target.value)} + placeholder={`Paste your ${providerConfig.name} API key`} + autoComplete="off" + spellCheck={false} + /> + +
+ + Get your {providerConfig.name} API key + + + + +
+ + {/* Model */} +
+ + +
+ + {/* Actions */} +
+ + +
+ + {/* Status */} + {statusMessage && ( +
+ {status === 'valid' && ( + + + + )} + {(status === 'invalid' || status === 'error') && ( + + + + )} + {statusMessage} +
+ )} +
+ + {/* Footer */} +
+ + + + Your API key is stored locally in your browser. It is never stored on our servers. +
+
+
+ ); +} diff --git a/src/components/editor/AISuggestionPicker.tsx b/src/components/editor/AISuggestionPicker.tsx new file mode 100644 index 0000000..573f8af --- /dev/null +++ b/src/components/editor/AISuggestionPicker.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useState } from 'react'; + +interface AISuggestionPickerProps { + suggestions: string[]; + onSelect: (value: string) => void; + onRegenerate: () => void; + onClose: () => void; + loading?: boolean; +} + +export function AISuggestionPicker({ + suggestions, + onSelect, + onRegenerate, + onClose, + loading, +}: AISuggestionPickerProps) { + const [activeIndex, setActiveIndex] = useState(-1); + const containerRef = useRef(null); + + // Click outside to close + useEffect(() => { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [onClose]); + + // Keyboard navigation + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1)); + } else if (e.key === 'Enter' && activeIndex >= 0) { + e.preventDefault(); + onSelect(suggestions[activeIndex]); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [activeIndex, suggestions, onSelect, onClose]); + + return ( +
+
+ + + + + Pick a suggestion + +
+
+ {loading ? ( +
+ + Generating... +
+ ) : ( + suggestions.map((s, i) => ( + + )) + )} +
+
+ + +
+
+ ); +} diff --git a/src/components/editor/AITemplateSearch.tsx b/src/components/editor/AITemplateSearch.tsx new file mode 100644 index 0000000..b0cf45e --- /dev/null +++ b/src/components/editor/AITemplateSearch.tsx @@ -0,0 +1,143 @@ +import { useState, useCallback, useRef } from 'react'; +import type { TemplateDefinition } from '@/templates/types'; +import { getApiKey, getSelectedProvider, getSelectedModel, hasAnyKeyConfigured } from '@/lib/ai/storage'; +import { getDefaultModel } from '@/lib/ai/providers'; +import { buildRecommenderPrompt, parseRecommenderResponse, type TemplateRecommendation } from '@/lib/ai/recommender'; + +interface AITemplateSearchProps { + templates: TemplateDefinition[]; + onTemplateSelect: (template: TemplateDefinition) => void; +} + +export function AITemplateSearch({ templates, onTemplateSelect }: AITemplateSearchProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState<(TemplateRecommendation & { template: TemplateDefinition })[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const debounceRef = useRef>(undefined); + + const isConfigured = hasAnyKeyConfigured(); + + const doSearch = useCallback(async (searchQuery: string) => { + if (!searchQuery.trim() || searchQuery.trim().length < 3) { + setResults([]); + return; + } + + const provider = getSelectedProvider(); + const apiKey = provider ? getApiKey(provider) : null; + const model = provider ? (getSelectedModel(provider) ?? getDefaultModel(provider)) : null; + + if (!provider || !apiKey || !model) return; + + setLoading(true); + setError(null); + + try { + const { prompt, systemPrompt } = buildRecommenderPrompt(searchQuery); + + const res = await fetch('/api/ai/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider, + apiKey, + model, + prompt, + systemPrompt, + maxTokens: 400, + temperature: 0.3, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: 'Search failed' })); + throw new Error(data.error || `HTTP ${res.status}`); + } + + const data = await res.json(); + const recommendations = parseRecommenderResponse(data.content); + + // Match recommendations to actual templates + const matched = recommendations + .map((rec) => { + const template = templates.find((t) => t.id === rec.id); + return template ? { ...rec, template } : null; + }) + .filter((r): r is NonNullable => r !== null); + + setResults(matched); + } catch (err) { + setError(err instanceof Error ? err.message : 'Search failed'); + } finally { + setLoading(false); + } + }, [templates]); + + const handleInputChange = useCallback((value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (!value.trim()) { + setResults([]); + return; + } + // Don't auto-search — wait for Enter + }, []); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + doSearch(query); + } + }, [query, doSearch]); + + if (!isConfigured) return null; + + return ( +
+
+ + + + handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + /> + {loading && } +
+ + {error && ( +
{error}
+ )} + + {results.length > 0 && ( +
+ {results.map((r) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/editor/CustomizePanel.tsx b/src/components/editor/CustomizePanel.tsx index 3d25c0d..8e1e1eb 100644 --- a/src/components/editor/CustomizePanel.tsx +++ b/src/components/editor/CustomizePanel.tsx @@ -1,19 +1,23 @@ -import type { TemplateField } from '@/templates/types'; +import { useMemo, type ReactNode } from 'react'; +import type { TemplateField, TemplateCategory } from '@/templates/types'; import { TextInput } from './TextInput'; import { ColorPicker } from './ColorPicker'; import { SliderControl } from './SliderControl'; import { ToggleSwitch } from './ToggleSwitch'; import { FontSelector } from './FontSelector'; import { ImageUploader } from './ImageUploader'; +import { AIGenerateButton } from './AIGenerateButton'; interface CustomizePanelProps { fields: TemplateField[]; params: Record; onParamChange: (key: string, value: any) => void; onReset: () => void; + category?: TemplateCategory; + autofill?: ReactNode; } -export function CustomizePanel({ fields, params, onParamChange, onReset }: CustomizePanelProps) { +export function CustomizePanel({ fields, params, onParamChange, onReset, category, autofill }: CustomizePanelProps) { // Group fields const groups: Record = {}; for (const field of fields) { @@ -25,6 +29,15 @@ export function CustomizePanel({ fields, params, onParamChange, onReset }: Custo const groupOrder = ['Content', 'Style', 'Brand']; const sortedGroups = groupOrder.filter((g) => groups[g]).map((g) => [g, groups[g]] as const); + // Build string values map for AI context + const currentValues = useMemo(() => { + const vals: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (typeof v === 'string' && v.trim()) vals[k] = v; + } + return vals; + }, [params]); + return (
@@ -33,11 +46,14 @@ export function CustomizePanel({ fields, params, onParamChange, onReset }: Custo Reset
+ {autofill}
{sortedGroups.map(([groupName, groupFields]) => (

{groupName}

- {groupFields.map((field) => renderField(field, params[field.key] ?? field.defaultValue, onParamChange))} + {groupFields.map((field) => + renderField(field, params[field.key] ?? field.defaultValue, onParamChange, category, currentValues) + )}
))}
@@ -45,7 +61,13 @@ export function CustomizePanel({ fields, params, onParamChange, onReset }: Custo ); } -function renderField(field: TemplateField, value: any, onChange: (key: string, value: any) => void) { +function renderField( + field: TemplateField, + value: any, + onChange: (key: string, value: any) => void, + category?: TemplateCategory, + currentValues?: Record +) { switch (field.type) { case 'text': return ( @@ -56,6 +78,14 @@ function renderField(field: TemplateField, value: any, onChange: (key: string, v onChange={(v) => onChange(field.key, v)} placeholder={field.placeholder} required={field.required} + action={ + onChange(field.key, v)} + /> + } /> ); case 'textarea': @@ -68,6 +98,14 @@ function renderField(field: TemplateField, value: any, onChange: (key: string, v placeholder={field.placeholder} required={field.required} multiline + action={ + onChange(field.key, v)} + /> + } /> ); case 'color': diff --git a/src/components/editor/EditorApp.tsx b/src/components/editor/EditorApp.tsx index b992b98..c7708e8 100644 --- a/src/components/editor/EditorApp.tsx +++ b/src/components/editor/EditorApp.tsx @@ -10,12 +10,45 @@ import { CustomizePanel } from './CustomizePanel'; import { ExportBar } from './ExportBar'; import { PlatformPreviewStrip } from './PlatformPreviewStrip'; import { MobileEditorTabs } from './MobileEditorTabs'; +import { AIProvider, useAI } from './AIContext'; +import { AISettingsModal } from './AISettingsModal'; +import { AIAutofill } from './AIAutofill'; interface EditorAppProps { initialCategory?: TemplateCategory; } export function EditorApp({ initialCategory }: EditorAppProps) { + return ( + + + + ); +} + +function AITopbarButton() { + const { isConfigured, openSettings } = useAI(); + return ( + + ); +} + +function AISettingsWrapper() { + const { settingsOpen, closeSettings } = useAI(); + return ; +} + +function EditorAppInner({ initialCategory }: EditorAppProps) { // Import templates directly — functions can't cross the Astro→React serialization boundary const templates = useMemo(() => getAllTemplates(), []); @@ -26,7 +59,7 @@ export function EditorApp({ initialCategory }: EditorAppProps) { return templates[0]; }, [templates, initialCategory]); - const { state, setTemplate, setParam, resetDefaults, setCategory, setSearch, apiUrl, downloadUrl } = + const { state, setTemplate, setParam, setParams, resetDefaults, setCategory, setSearch, apiUrl, downloadUrl } = useEditorState(defaultTemplate); const { svg, loading, error, render } = useSatoriRenderer(); @@ -83,6 +116,29 @@ export function EditorApp({ initialCategory }: EditorAppProps) { } }, [currentTemplate, resetDefaults]); + const handleAutofill = useCallback( + (result: { templateId: string; fields: Record; colors?: Record }) => { + // Switch template + const template = getTemplate(result.templateId); + if (template) { + setTemplate(template); + // Apply autofilled fields after template switch + setTimeout(() => { + const merged = { ...result.fields }; + if (result.colors) Object.assign(merged, result.colors); + setParams(merged); + }, 0); + } else { + // If template not found, just apply fields to current template + const merged = { ...result.fields }; + if (result.colors) Object.assign(merged, result.colors); + setParams(merged); + } + if (isMobile) setMobileTab('customize'); + }, + [setTemplate, setParams, isMobile] + ); + return (
{/* Top Bar */} @@ -94,6 +150,7 @@ export function EditorApp({ initialCategory }: EditorAppProps) { Template: {currentTemplate.name}
+
@@ -144,15 +201,22 @@ export function EditorApp({ initialCategory }: EditorAppProps) { /> ) : ( + <> } /> + )} + + {/* AI Settings Modal */} + ); } diff --git a/src/components/editor/TemplatePanel.tsx b/src/components/editor/TemplatePanel.tsx index d483ef8..c027cb0 100644 --- a/src/components/editor/TemplatePanel.tsx +++ b/src/components/editor/TemplatePanel.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import type { TemplateDefinition, TemplateCategory } from '@/templates/types'; import { CATEGORY_META, ALL_CATEGORIES } from '@/templates/types'; import { TemplateThumbnail } from './TemplateThumbnail'; +import { AITemplateSearch } from './AITemplateSearch'; interface TemplatePanelProps { templates: TemplateDefinition[]; @@ -49,6 +50,7 @@ export function TemplatePanel({ value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} /> +