Add OpenAI auto-translate for listing form
This commit is contained in:
parent
6306256d4c
commit
9681d76a9c
3 changed files with 183 additions and 123 deletions
128
app/api/listings/translate/route.ts
Normal file
128
app/api/listings/translate/route.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { requireAuth } from '../../../../lib/jwt';
|
||||
import type { Locale } from '../../../../lib/i18n';
|
||||
|
||||
type LocaleFields = { title: string; teaser: string; description: string };
|
||||
const SUPPORTED_LOCALES: Locale[] = ['en', 'fi', 'sv'];
|
||||
|
||||
function loadApiKey() {
|
||||
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
|
||||
const keyPath = path.join(process.cwd(), 'creds', 'openai.key');
|
||||
try {
|
||||
return fs.readFileSync(keyPath, 'utf8').trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
await requireAuth(req);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = loadApiKey();
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 500 });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
|
||||
const incoming = body?.translations as Record<Locale, LocaleFields> | undefined;
|
||||
const currentLocale = (body?.currentLocale as Locale) ?? 'en';
|
||||
if (!incoming) {
|
||||
return NextResponse.json({ error: 'Missing translations' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = SUPPORTED_LOCALES.reduce(
|
||||
(acc, loc) => ({
|
||||
...acc,
|
||||
[loc]: {
|
||||
title: incoming[loc]?.title || '',
|
||||
teaser: incoming[loc]?.teaser || '',
|
||||
description: incoming[loc]?.description || '',
|
||||
},
|
||||
}),
|
||||
{} as Record<Locale, LocaleFields>,
|
||||
);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are translating holiday rental listing copy between Finnish, Swedish, and English. Fill in missing locales, keep existing text unchanged, preserve meaning and tone, and respond with JSON only.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify(
|
||||
{
|
||||
sourceLocale: currentLocale,
|
||||
targetLocales: SUPPORTED_LOCALES.filter((l) => l !== currentLocale),
|
||||
locales: payload,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Return JSON with top-level "locales" containing keys en, fi, sv. Each locale has title, teaser, description. Do not include explanations.',
|
||||
},
|
||||
];
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.2,
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
return NextResponse.json({ error: 'AI request failed', detail: errText }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
content = data?.choices?.[0]?.message?.content ?? '';
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'AI request failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
const jsonText = content.match(/\{[\s\S]*\}/)?.[0] ?? content;
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
const locales = parsed?.locales || parsed;
|
||||
if (!locales) throw new Error('missing locales');
|
||||
const result = SUPPORTED_LOCALES.reduce(
|
||||
(acc, loc) => ({
|
||||
...acc,
|
||||
[loc]: {
|
||||
title: locales[loc]?.title ?? '',
|
||||
teaser: locales[loc]?.teaser ?? '',
|
||||
description: locales[loc]?.description ?? '',
|
||||
},
|
||||
}),
|
||||
{} as Record<Locale, LocaleFields>,
|
||||
);
|
||||
return NextResponse.json({ translations: result });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Could not parse AI response' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useI18n } from '../../components/I18nProvider';
|
||||
import type { Locale } from '../../../lib/i18n';
|
||||
|
||||
|
|
@ -58,8 +58,7 @@ export default function NewListingPage() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAuthed, setIsAuthed] = useState(false);
|
||||
const [aiResponse, setAiResponse] = useState('');
|
||||
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [slugStatus, setSlugStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'error'>('idle');
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -143,67 +142,39 @@ export default function NewListingPage() {
|
|||
return 'partial';
|
||||
}
|
||||
|
||||
const aiPrompt = useMemo(() => {
|
||||
const payload = {
|
||||
task: 'Translate this localization file for a holiday rental listing.',
|
||||
instructions: [
|
||||
'Preserve meaning, tone, numbers, and any markup.',
|
||||
'Return valid JSON only with the same keys.',
|
||||
'Fill missing translations; keep existing text unchanged.',
|
||||
'Leave out the task part from output',
|
||||
],
|
||||
sourceLocale: currentLocale,
|
||||
targetLocales: SUPPORTED_LOCALES.filter((loc) => loc !== currentLocale),
|
||||
locales: SUPPORTED_LOCALES.reduce(
|
||||
(acc, loc) => ({
|
||||
...acc,
|
||||
[loc]: {
|
||||
title: translations[loc].title,
|
||||
teaser: translations[loc].teaser,
|
||||
description: translations[loc].description,
|
||||
},
|
||||
}),
|
||||
{} as Record<Locale, LocaleFields>
|
||||
),
|
||||
};
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}, [translations, currentLocale]);
|
||||
|
||||
function applyAiResponse() {
|
||||
async function autoTranslate() {
|
||||
if (aiLoading) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const parsed = JSON.parse(aiResponse);
|
||||
if (!parsed?.locales || typeof parsed.locales !== 'object') {
|
||||
throw new Error('Missing locales');
|
||||
}
|
||||
const next = { ...translations };
|
||||
SUPPORTED_LOCALES.forEach((loc) => {
|
||||
if (parsed.locales[loc]) {
|
||||
const incoming = parsed.locales[loc];
|
||||
next[loc] = {
|
||||
title: typeof incoming.title === 'string' ? incoming.title : next[loc].title,
|
||||
teaser: typeof incoming.teaser === 'string' ? incoming.teaser : next[loc].teaser,
|
||||
description: typeof incoming.description === 'string' ? incoming.description : next[loc].description,
|
||||
};
|
||||
}
|
||||
const res = await fetch('/api/listings/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ translations, currentLocale }),
|
||||
});
|
||||
setTranslations(next);
|
||||
setMessage(t('aiApplySuccess'));
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.translations) {
|
||||
throw new Error('bad response');
|
||||
}
|
||||
const incoming = data.translations as Record<Locale, LocaleFields>;
|
||||
setTranslations((prev) => {
|
||||
const next = { ...prev };
|
||||
SUPPORTED_LOCALES.forEach((loc) => {
|
||||
const aiLoc = incoming?.[loc];
|
||||
next[loc] = {
|
||||
title: prev[loc].title || aiLoc?.title || '',
|
||||
teaser: prev[loc].teaser || aiLoc?.teaser || '',
|
||||
description: prev[loc].description || aiLoc?.description || '',
|
||||
};
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setMessage(t('aiAutoSuccess'));
|
||||
} catch (err) {
|
||||
setError(t('aiApplyError'));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAiPrompt() {
|
||||
try {
|
||||
if (!navigator?.clipboard) throw new Error('clipboard unavailable');
|
||||
await navigator.clipboard.writeText(aiPrompt);
|
||||
setCopyStatus('copied');
|
||||
setTimeout(() => setCopyStatus('idle'), 1500);
|
||||
} catch (err) {
|
||||
setCopyStatus('error');
|
||||
setTimeout(() => setCopyStatus('idle'), 2000);
|
||||
setError(t('aiAutoError'));
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -401,40 +372,10 @@ export default function NewListingPage() {
|
|||
</div>
|
||||
<div className="panel" style={{ border: '1px solid rgba(148,163,184,0.3)', background: 'rgba(255,255,255,0.02)' }}>
|
||||
<h3 style={{ marginTop: 0 }}>{t('aiHelperTitle')}</h3>
|
||||
<p style={{ color: '#cbd5e1', marginTop: 4 }}>{t('aiHelperLead')}</p>
|
||||
<div style={{ display: 'grid', gap: 6, marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{t('aiPromptLabel')}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{copyStatus === 'copied' ? (
|
||||
<span style={{ color: '#34d399', fontSize: 13 }}>{t('aiPromptCopied')}</span>
|
||||
) : null}
|
||||
{copyStatus === 'error' ? <span style={{ color: '#f87171', fontSize: 13 }}>{t('aiCopyError')}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
onClick={copyAiPrompt}
|
||||
style={{ minHeight: 0, padding: '8px 12px' }}
|
||||
>
|
||||
{t('aiCopyPrompt')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea value={aiPrompt} readOnly rows={10} style={{ fontFamily: 'monospace' }} />
|
||||
</div>
|
||||
<label style={{ display: 'grid', gap: 6, marginTop: 8 }}>
|
||||
<span>{t('aiResponseLabel')}</span>
|
||||
<textarea
|
||||
value={aiResponse}
|
||||
onChange={(e) => setAiResponse(e.target.value)}
|
||||
rows={6}
|
||||
placeholder='{"locales":{"fi":{"title":"..."}}}'
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="button secondary" onClick={() => applyAiResponse()}>
|
||||
{t('aiApply')}
|
||||
<p style={{ color: '#cbd5e1', marginTop: 4 }}>{t('aiAutoExplain')}</p>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="button secondary" onClick={autoTranslate} disabled={aiLoading}>
|
||||
{aiLoading ? t('aiAutoTranslating') : t('aiAutoTranslate')}
|
||||
</button>
|
||||
<span style={{ color: '#cbd5e1', fontSize: 13 }}>{t('aiHelperNote')}</span>
|
||||
</div>
|
||||
|
|
|
|||
51
lib/i18n.ts
51
lib/i18n.ts
|
|
@ -141,16 +141,13 @@ const baseMessages = {
|
|||
localePartial: 'In progress',
|
||||
localeMissing: 'Missing',
|
||||
aiHelperTitle: 'AI translation helper',
|
||||
aiHelperLead: 'Copy the prompt to your AI assistant, let it translate missing locales, and paste the JSON reply back.',
|
||||
aiPromptLabel: 'Prompt to send to AI',
|
||||
aiCopyPrompt: 'Copy prompt',
|
||||
aiPromptCopied: 'Copied',
|
||||
aiCopyError: 'Copy failed',
|
||||
aiResponseLabel: 'Paste AI response (JSON)',
|
||||
aiApply: 'Apply AI response',
|
||||
aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.',
|
||||
aiApplySuccess: 'Translations updated from AI response.',
|
||||
aiHelperNote: 'The AI should return only JSON with the same keys.',
|
||||
aiHelperLead: 'Let AI fill the other languages for you.',
|
||||
aiAutoExplain: 'Enter the texts in any one language and click the button; AI will fill the remaining locales.',
|
||||
aiAutoTranslate: 'Auto-translate missing languages',
|
||||
aiAutoTranslating: 'Translating…',
|
||||
aiAutoSuccess: 'Translations updated with AI.',
|
||||
aiAutoError: 'AI translation failed. Please try again.',
|
||||
aiHelperNote: 'Uses your OpenAI key to fill missing locales.',
|
||||
translationMissing: 'Add at least one language with a title and description.',
|
||||
loginToCreate: 'Please log in first to create a listing.',
|
||||
slugLabel: 'Slug',
|
||||
|
|
@ -292,16 +289,13 @@ const baseMessages = {
|
|||
localePartial: 'Kesken',
|
||||
localeMissing: 'Puuttuu',
|
||||
aiHelperTitle: 'AI-käännösapu',
|
||||
aiHelperLead: 'Kopioi prompti tekoälylle, käännä puuttuvat kielet ja liitä JSON-vastaus takaisin.',
|
||||
aiPromptLabel: 'Prompti tekoälylle',
|
||||
aiCopyPrompt: 'Kopioi prompti',
|
||||
aiPromptCopied: 'Kopioitu',
|
||||
aiCopyError: 'Kopiointi epäonnistui',
|
||||
aiResponseLabel: 'Liitä tekoälyn vastaus (JSON)',
|
||||
aiApply: 'Käytä AI-vastausta',
|
||||
aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
|
||||
aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.',
|
||||
aiHelperNote: 'Tekoälyn tulisi palauttaa vain samaa avainrakennetta noudattava JSON.',
|
||||
aiHelperLead: 'Anna tekoälyn täydentää muut kielet puolestasi.',
|
||||
aiAutoExplain: 'Täytä tekstit yhdellä kielellä ja paina nappia; AI täyttää loput kielet.',
|
||||
aiAutoTranslate: 'Käännä puuttuvat kielet',
|
||||
aiAutoTranslating: 'Käännetään…',
|
||||
aiAutoSuccess: 'Käännökset päivitetty AI:lla.',
|
||||
aiAutoError: 'Käännös epäonnistui. Yritä uudelleen.',
|
||||
aiHelperNote: 'Käyttää OpenAI-avaintasi puuttuvien kielten täyttämiseen.',
|
||||
translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.',
|
||||
ctaViewSample: 'Katso esimerkkikohde',
|
||||
ctaHealth: 'Tarkista health-päätepiste',
|
||||
|
|
@ -564,16 +558,13 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
|||
localePartial: 'Pågår',
|
||||
localeMissing: 'Saknas',
|
||||
aiHelperTitle: 'AI-översättningshjälp',
|
||||
aiHelperLead: 'Kopiera prompten till din AI-assistent, låt den översätta saknade språk och klistra in JSON-svaret här.',
|
||||
aiPromptLabel: 'Prompt till AI',
|
||||
aiCopyPrompt: 'Kopiera prompt',
|
||||
aiPromptCopied: 'Kopierad',
|
||||
aiCopyError: 'Kopiering misslyckades',
|
||||
aiResponseLabel: 'Klistra in AI-svar (JSON)',
|
||||
aiApply: 'Använd AI-svar',
|
||||
aiApplyError: 'Kunde inte läsa AI-svaret. Kontrollera att det är giltig JSON med locales-nyckeln.',
|
||||
aiApplySuccess: 'Översättningar uppdaterade från AI-svaret.',
|
||||
aiHelperNote: 'AI:n ska bara returnera JSON med samma nycklar.',
|
||||
aiHelperLead: 'Låt AI fylla i de andra språken åt dig.',
|
||||
aiAutoExplain: 'Fyll texterna på ett språk och klicka på knappen så fyller AI i resten.',
|
||||
aiAutoTranslate: 'Översätt saknade språk',
|
||||
aiAutoTranslating: 'Översätter…',
|
||||
aiAutoSuccess: 'Översättningar uppdaterades med AI.',
|
||||
aiAutoError: 'AI-översättning misslyckades. Försök igen.',
|
||||
aiHelperNote: 'Använder din OpenAI-nyckel för att fylla saknade språk.',
|
||||
translationMissing: 'Lägg till minst ett språk med titel och beskrivning.',
|
||||
slugChecking: 'Kontrollerar tillgänglighet…',
|
||||
slugAvailable: 'Sluggen är ledig',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue