Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -51,13 +53,22 @@ 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`
- Shared utilities: `src/templates/utils.ts` (truncate, autoFontSize, commonFields)
- 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

Expand All @@ -72,6 +83,29 @@ 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/*`.
Expand Down
2 changes: 1 addition & 1 deletion src/components/BackToTop.astro
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<style>
.back-to-top {
position: fixed;
bottom: 90px;
bottom: 80px;
right: var(--space-xl, 24px);
z-index: 8000;
width: 40px;
Expand Down
58 changes: 58 additions & 0 deletions src/components/FeedbackButton.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<!-- Feedback button: fixed bottom-right, opens GitHub issues -->
<a
href="https://github.com/codercops/ogcops/issues/new/choose"
target="_blank"
rel="noopener"
class="feedback-btn"
aria-label="Send feedback"
title="Send feedback"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span class="feedback-btn-label">Feedback</span>
</a>

<style>
.feedback-btn {
position: fixed;
bottom: var(--space-xl, 24px);
right: var(--space-xl, 24px);
z-index: 8000;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--bg-elevated, #fff);
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--radius-full, 999px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
color: var(--text-muted, #666);
font-size: 12px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}

.feedback-btn:hover {
color: var(--accent-primary, #e07a5f);
border-color: var(--accent-primary, #e07a5f);
box-shadow: 0 4px 12px rgba(224, 122, 95, 0.15);
}

.feedback-btn svg {
flex-shrink: 0;
}

@media (max-width: 480px) {
.feedback-btn {
right: var(--space-md, 12px);
padding: 8px 12px;
}

.feedback-btn-label {
display: none;
}
}
</style>
149 changes: 149 additions & 0 deletions src/components/editor/AIAutofill.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>; colors?: Record<string, string> }) => 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<Step>('idle');
const [error, setError] = useState<string | null>(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 (
<button
type="button"
className="ai-autofill-trigger"
onClick={() => setExpanded(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3l1.912 5.813a2 2 0 0 0 1.275 1.275L21 12l-5.813 1.912a2 2 0 0 0-1.275 1.275L12 21l-1.912-5.813a2 2 0 0 0-1.275-1.275L3 12l5.813-1.912a2 2 0 0 0 1.275-1.275L12 3z" />
</svg>
Smart Autofill
</button>
);
}

return (
<div className="ai-autofill">
<div className="ai-autofill-header">
<span className="ai-autofill-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3l1.912 5.813a2 2 0 0 0 1.275 1.275L21 12l-5.813 1.912a2 2 0 0 0-1.275 1.275L12 21l-1.912-5.813a2 2 0 0 0-1.275-1.275L3 12l5.813-1.912a2 2 0 0 0 1.275-1.275L12 3z" />
</svg>
Smart Autofill
</span>
<button type="button" className="ai-autofill-close" onClick={() => { setExpanded(false); setStep('idle'); setError(null); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<p className="ai-autofill-desc">Paste a URL and we'll generate your OG image automatically.</p>
<div className="ai-autofill-input-row">
<input
type="url"
className="ai-autofill-input"
placeholder="https://example.com/blog-post"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAutofill(); }}
disabled={step !== 'idle' && step !== 'error'}
/>
<button
type="button"
className="ai-btn ai-btn-validate"
onClick={handleAutofill}
disabled={!url.trim() || (step !== 'idle' && step !== 'error')}
style={{ flexShrink: 0 }}
>
{step === 'idle' || step === 'error' ? 'Generate' : 'Working...'}
</button>
</div>

{(step !== 'idle' && step !== 'error') && (
<div className="ai-autofill-steps">
<div className={`ai-autofill-step ${step === 'fetching' || step === 'analyzing' || step === 'generating' || step === 'done' ? 'done' : ''}`}>
{step === 'fetching' ? <span className="ai-spinner-dark" /> : '✓'} Fetching page content
</div>
<div className={`ai-autofill-step ${step === 'analyzing' || step === 'generating' || step === 'done' ? 'active' : ''} ${step === 'generating' || step === 'done' ? 'done' : ''}`}>
{step === 'analyzing' ? <span className="ai-spinner-dark" /> : step === 'generating' || step === 'done' ? '✓' : '○'} Selecting best template
</div>
<div className={`ai-autofill-step ${step === 'generating' || step === 'done' ? 'active' : ''} ${step === 'done' ? 'done' : ''}`}>
{step === 'generating' ? <span className="ai-spinner-dark" /> : step === 'done' ? '✓' : '○'} Generating content
</div>
</div>
)}

{error && (
<div className="ai-status ai-status-error" style={{ marginTop: '8px' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
</svg>
{error}
</div>
)}
</div>
);
}
Loading
Loading