diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17c905d..0d13bc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, dev] + branches: [production, dev] pull_request: - branches: [main, dev] + branches: [production, dev] jobs: build: diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml index 3ef2504..02d4b1e 100644 --- a/.github/workflows/pr-target-check.yml +++ b/.github/workflows/pr-target-check.yml @@ -2,21 +2,21 @@ name: PR Target Check on: pull_request: - branches: [main] + branches: [production] jobs: check-source-branch: runs-on: ubuntu-latest steps: - - name: Only allow PRs from dev to main - 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 'main' 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 'main' — 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 b14de89..3b25b40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,16 +1,21 @@ # CLAUDE.md — OGCOPS +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + ## What is this? -OGCOPS is a free, open-source OG image generator and social media preview checker. Built with Astro + React Islands, deployed to Vercel. +OGCOPS is a free, open-source OG image generator and social media preview checker. Built with Astro SSR + React Islands, deployed to Vercel at og.codercops.com. GitHub: github.com/codercops/ogcops. MIT licensed. ## Commands ```bash -npm run dev # Start dev server -npm run build # Production build -npm run preview # Preview production build -npm run test # Run vitest -npm run test:watch # Watch mode -npm run check # Type-check (astro check + tsc) +npm run dev # Start dev server (port 4321) +npm run build # Production build +npm run preview # Preview production build +npm run test # Run vitest (single run) +npm run test:watch # Watch mode +npm run test:ui # Vitest UI +npm run check # Type-check (astro check + tsc --noEmit) +npm run lint # Astro linting +npm run generate:favicons # Generate favicon assets ``` ## Architecture @@ -19,20 +24,39 @@ npm run check # Type-check (astro check + tsc) - **Satori** runs client-side for instant SVG preview (zero server calls during editing) - **Satori + resvg-wasm** runs server-side for PNG generation (`/api/og`) - **No database** — state lives in URL query params + client-side useReducer -- **CORS-open API** for developer use +- **CORS-open API** — no API key, no rate limits ## Key Directories -- `src/templates/` — 109 templates across 12 categories. Each template is a `.ts` file exporting a `TemplateDefinition`. -- `src/lib/` — Core engine (og-engine.ts, font-loader.ts, meta-fetcher.ts, meta-analyzer.ts) -- `src/components/editor/` — React components for the OG image editor -- `src/components/preview/` — React components for the social media preview checker -- `src/pages/api/` — API endpoints (og, preview, templates) +- `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/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/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/, ai/, lib/, templates/) -## Conventions -- CSS custom properties only (no Tailwind). Accent: `#E07A5F`. -- TypeScript strict mode. Path alias `@/*` → `src/*`. -- Fonts bundled as `.woff` in `public/fonts/`. -- Templates follow `TemplateDefinition` interface in `src/templates/types.ts`. +## Pages & API Endpoints + +**Pages:** +- `/` — Homepage +- `/create/` — OG image editor +- `/templates` — Template gallery +- `/preview` — Social media preview checker +- `/api-docs` — API documentation + +**API (all CORS-open, no auth):** +- `GET /api/og?template={id}&...` — Generate PNG (1200x630, 24h cache) +- `GET /api/preview?url={url}` — Fetch and analyze a URL's meta tags +- `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` @@ -41,13 +65,63 @@ npm run check # Type-check (astro check + tsc) - 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 + +120 templates across 12 categories: blog, product, saas, github, event, podcast, developer, newsletter, quote, ecommerce, job, tutorial. -## Template Contribution -1. Create `src/templates/{category}/{id}.ts` -2. Export a `TemplateDefinition` -3. Register in `src/templates/{category}/index.ts` +**Adding a template:** +1. Create `src/templates/{category}/{id}.ts` exporting a `TemplateDefinition` +2. Register in `src/templates/{category}/index.ts` +3. Add import + registration in `src/templates/registry.ts` 4. Run `npm run test` to verify +**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/*`. +- Fonts bundled as `.woff` in `public/fonts/` (Inter Regular/Medium/SemiBold/Bold, Playfair Display Regular/Bold, JetBrains Mono Regular/Bold). +- Node 22 (`.nvmrc`). +- Testing: Vitest with node environment. Tests in `tests/**/*.test.ts`. Globals enabled. + +## CI/CD +- Runs on push to `production`/`dev` and PRs to those branches +- Steps: `npm run check` → `npm run test` → `npm run build` +- **Branch strategy:** `dev` is the default branch. PRs target `dev`. Releases go `dev` → `production`. Direct PRs to `production` are blocked unless from `dev`. + +## Environment Variables (`.env.local`, all optional) +- `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` — Optional visitor counter + ## Gotchas / Constraints - Satori does **not** support CSS grid — only flexbox - Every `div` must have `display: 'flex'` explicitly in its style @@ -55,6 +129,7 @@ npm run check # Type-check (astro check + tsc) - Font files must be listed in `astro.config.mjs` `includeFiles` array for Vercel deployment - WASM imports need `optimizeDeps.exclude` in the Vite config - `renderToPng` returns `ArrayBuffer` (not `Buffer`) for `BodyInit` compatibility +- Canvas is always 1200x630px ## Do NOT - Add Tailwind CSS — the project uses CSS custom properties only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b228e9..e912fb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,7 +113,7 @@ npm run build # Full production build 1. Fork the repo and create a branch: `git checkout -b feat/my-feature` 2. Make your changes 3. Ensure `npm run check` and `npm run test` pass -4. Push and open a PR against `main` +4. Push and open a PR against `dev` 5. Fill out the [PR template](.github/PULL_REQUEST_TEMPLATE.md) — screenshots are required for visual changes 6. Wait for review diff --git a/README.md b/README.md index 4ca02ab..19b6b1d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ OGCOPS is different: ## Quick Start ```bash -git clone https://github.com/codercops/ogcops.git +git clone -b dev https://github.com/codercops/ogcops.git cd ogcops npm install npm run dev @@ -114,6 +114,16 @@ The build output in `dist/` can be deployed to any Node.js hosting platform. Contributions are welcome — templates, bug fixes, features, docs, and more. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines. +> **Important:** Always fork and branch from `dev` (the default branch). The `production` branch is for releases only. PRs targeting `production` directly will be closed. + +```bash +# Fork the repo on GitHub, then: +git clone https://github.com//ogcops.git +cd ogcops +git checkout dev +git checkout -b your-feature-branch +``` + - [Open an issue](https://github.com/codercops/ogcops/issues) — bug reports and feature requests - [Start a discussion](https://github.com/codercops/ogcops/discussions) — questions, ideas, show & tell 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/SECURITY.md b/SECURITY.md index 64fdfe5..a12dbb6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,7 +35,7 @@ The following are **out of scope:** | Version | Supported | |---------|-----------| -| Latest (main branch) | Yes | +| Latest (production branch) | Yes | | Older releases | No | ## Recognition 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/Footer.astro b/src/components/Footer.astro index b11e817..805026b 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -20,6 +20,7 @@ const year = new Date().getFullYear(); Templates API Docs GitHub + Feedback diff --git a/src/components/Header.astro b/src/components/Header.astro index 086b61a..7ae7f71 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -36,41 +36,82 @@ const navItems = [ - - - -
- + 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 e8f9035..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,11 +59,22 @@ 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(); const [mobileTab, setMobileTab] = useState<'templates' | 'customize' | 'export'>('customize'); + const [isMobile, setIsMobile] = useState(() => + typeof window !== 'undefined' ? window.innerWidth <= 768 : false + ); + + useEffect(() => { + const mq = window.matchMedia('(max-width: 768px)'); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + setIsMobile(mq.matches); + return () => mq.removeEventListener('change', handler); + }, []); // Find current template definition (use registry lookup to ensure render function is present) const currentTemplate = useMemo( @@ -59,8 +103,11 @@ export function EditorApp({ initialCategory }: EditorAppProps) { const handleTemplateSelect = useCallback( (template: TemplateDefinition) => { setTemplate(template); + if (isMobile) { + setMobileTab('customize'); + } }, - [setTemplate] + [setTemplate, isMobile] ); const handleReset = useCallback(() => { @@ -69,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 */} @@ -80,6 +150,7 @@ export function EditorApp({ initialCategory }: EditorAppProps) { Template: {currentTemplate.name}
+
@@ -114,22 +185,38 @@ export function EditorApp({ initialCategory }: EditorAppProps) { />
- {/* Right: Customize */} + {/* Right: Customize / Export */}
{mobileTab === 'export' ? (
+
+ +
+
) : ( + <> } /> + )}
+ + {/* AI Settings Modal */} + ); } diff --git a/src/components/editor/ExportBar.tsx b/src/components/editor/ExportBar.tsx index f96c399..7dab93b 100644 --- a/src/components/editor/ExportBar.tsx +++ b/src/components/editor/ExportBar.tsx @@ -117,13 +117,15 @@ export function ExportBar({ apiUrl, downloadUrl, params, templateId }: ExportBar className="export-btn export-btn-ghost" onClick={() => copyToClipboard(apiUrl, 'url')} > - {copied === 'url' ? 'Copied!' : 'Copy URL'} + + {copied === 'url' ? 'Copied!' : 'Copy Image URL'} 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)} /> +