import { useEffect, useState } from 'react'; import { ReplaceIcon, SparklesIcon, WandSparklesIcon } from 'lucide-react'; import { toast } from 'sonner'; import { OptionRow } from '../../components/seo/option-row'; import { Button } from '../../components/ui/button'; import { Checkbox } from '../../components/ui/checkbox'; import ConfirmDialog from '../../components/ui/confirm-dialog'; import { Input } from '../../components/ui/input'; import { Label } from '../../components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select'; import { useAppStateContext } from '../../context/user.data.context'; import { recomaze_ai_personalization_env } from '../../env'; import { canUseAi, NO_AI_ACCESS } from '../../lib/seo/ai-access'; import { getCreditBalance } from '../../service/credits/credits.service'; import type { ISeoImageStats } from '../../service/seo/seo.interface'; import { fetchSeoImageStats, fillSeoImageAlt, replaceSeoImageText, writeSeoImageAltWithAi, } from '../../service/seo/seo.service'; /** What one run takes, and what the agent charges per image. */ const AI_BATCH = 20; const AI_CREDITS_PER_IMAGE = 2; const FIELDS = [ { value: 'alt', label: 'Alt text' }, { value: 'title', label: 'Title' }, { value: 'caption', label: 'Caption' }, ]; /** The media library side of Image SEO: how it stands, fill the alts, find and replace. */ const SeoImagesTools = (): JSX.Element => { const [stats, setStats] = useState(null); const [field, setField] = useState<'alt' | 'title' | 'caption'>('alt'); const [find, setFind] = useState(''); const [replace, setReplace] = useState(''); const [busy, setBusy] = useState(false); const [preview, setPreview] = useState(null); const [matchCase, setMatchCase] = useState(false); const [samples, setSamples] = useState< { id: number; before: string; after: string }[] >([]); const [written, setWritten] = useState< { id: number; alt: string; source: string }[] >([]); const { token, clientId } = useAppStateContext(); const mayUseAi = canUseAi(recomaze_ai_personalization_env?.seo_caps); /** The credits left while asking to spend them; undefined when unknown. */ const [asking, setAsking] = useState<{ balance?: number } | null>(null); useEffect(() => { fetchSeoImageStats() .then(setStats) .catch(reason => toast.error((reason as Error).message)); }, []); const fill = async (): Promise => { setBusy(true); try { const result = await fillSeoImageAlt(); setStats(result); toast.success( `${result.changed} image${result.changed === 1 ? '' : 's'} got alt text${ result.missing_alt > 0 ? `, ${result.missing_alt} left: run it again` : '' }` ); } catch (reason) { toast.error((reason as Error).message); } finally { setBusy(false); } }; // One click wrote twenty alt texts and spent their credits: the count, // the cost and what is left are said first. const askWithAi = async (): Promise => { setBusy(true); const balance = token ? (await getCreditBalance(token, clientId)).balance : undefined; setBusy(false); setAsking({ balance }); }; const writeWithAi = async (): Promise => { setAsking(null); setBusy(true); try { const result = await writeSeoImageAltWithAi(AI_BATCH); setStats(result); setWritten(result.items.slice(0, 3)); toast.success( `${result.written} image${result.written === 1 ? '' : 's'} described` + (result.credits > 0 ? `, ${result.credits} credits` : '') + (result.missing_alt > 0 ? `. ${result.missing_alt} left: run it again` : '') ); } catch (reason) { toast.error((reason as Error).message); } finally { setBusy(false); } }; const runReplace = async (dryRun: boolean): Promise => { setBusy(true); try { const result = await replaceSeoImageText({ field, find, replace, dry_run: dryRun, match_case: matchCase, }); setStats(result); if (dryRun) { setPreview(result.matched); setSamples(result.samples); } else { setPreview(null); setSamples([]); toast.success( `${result.matched} image${result.matched === 1 ? '' : 's'} changed. SEO → Checkup → Changes gives it back.` ); } } catch (reason) { toast.error((reason as Error).message); } finally { setBusy(false); } }; return (
{stats ? (

{stats.images}{' '} images ·{' '} {stats.missing_alt} {' '} without alt text ·{' '} {stats.missing_title} {' '} without a title

{!mayUseAi && (

{NO_AI_ACCESS}

)}

The format fills up to 200 images per run from the pattern above. Recomaze looks at the picture itself and describes what it shows,{' '} {AI_BATCH} images per run at {AI_CREDITS_PER_IMAGE} credits each; every alt text it writes is journaled under SEO → Checkup → Changes.

{written.length > 0 && (
    {written.map(item => (
  • {item.alt} {item.source === 'text' && ' (from the file name)'}
  • ))}
)}
) : (

Loading…

)}
{ setFind(event.target.value); setPreview(null); }} />
{ setReplace(event.target.value); setPreview(null); }} />
{preview !== null && ( {preview === 0 ? 'No image would change.' : `${preview} image${preview === 1 ? '' : 's'} would change.`} )}
{preview !== null && samples.length > 0 && (
    {samples.map(sample => (
  • {sample.before} → {sample.after || '(empty)'}
  • ))}
)}
{stats && asking && ( !open && setAsking(null)} title={`Describe ${Math.min(AI_BATCH, stats.missing_alt)} images with AI?`} description={describeCost( Math.min(AI_BATCH, stats.missing_alt), asking.balance )} confirmLabel="Describe them" busy={busy} onConfirm={() => { if ( asking.balance === undefined || asking.balance >= Math.min(AI_BATCH, stats.missing_alt) * AI_CREDITS_PER_IMAGE ) { void writeWithAi(); } else { setAsking(null); toast.error('Not enough credits for this run.'); } }} /> )}
); }; /** * What a run costs and what is left, in the words of the confirmation. * * @param images - Images this run describes. * @param balance - Credits left, when known. * @return The sentence. */ const describeCost = (images: number, balance?: number): string => { const cost = images * AI_CREDITS_PER_IMAGE; const left = balance === undefined ? '' : balance < cost ? ` You have ${balance}, not enough for this run.` : ` You have ${balance}.`; return `Recomaze looks at each picture and writes its alt text into the media library: up to ${cost} credits (${AI_CREDITS_PER_IMAGE} an image).${left} Each alt text is journaled under SEO → Checkup → Changes and can be undone.`; }; export default SeoImagesTools;