From 897fd797ddd066c848416c820bccbf2130f6067b Mon Sep 17 00:00:00 2001 From: tetiana zorii Date: Wed, 25 Mar 2026 14:22:22 -0400 Subject: [PATCH 1/3] fix: navigation issues, AI explain validation, and user data sync Fixed Back button to use history navigation instead of redirecting to homepage Corrected footer DevLovers link to properly navigate to homepage Added frontend validation for AI Explain to prevent multi-line input errors and avoid 400 requests Improved user feedback for invalid text selection in AI Explain Implemented backend persistence for learned AI terms to sync across devices via user account --- frontend/actions/ai.ts | 142 + .../components/auth/fields/PasswordField.tsx | 17 +- .../dashboard/ExplainedTermsCard.tsx | 56 +- frontend/components/legal/LegalBackButton.tsx | 17 + frontend/components/legal/LegalPageShell.tsx | 11 +- frontend/components/q&a/AIWordHelper.tsx | 18 + frontend/components/shared/Footer.tsx | 15 +- frontend/db/schema/ai.ts | 32 + frontend/db/schema/index.ts | 1 + frontend/drizzle/meta/0033_snapshot.json | 6853 +++++++++++++++++ frontend/drizzle/meta/_journal.json | 9 +- frontend/messages/en.json | 4 + frontend/messages/pl.json | 4 + frontend/messages/uk.json | 4 + 14 files changed, 7142 insertions(+), 41 deletions(-) create mode 100644 frontend/actions/ai.ts create mode 100644 frontend/components/legal/LegalBackButton.tsx create mode 100644 frontend/db/schema/ai.ts create mode 100644 frontend/drizzle/meta/0033_snapshot.json diff --git a/frontend/actions/ai.ts b/frontend/actions/ai.ts new file mode 100644 index 00000000..608f2068 --- /dev/null +++ b/frontend/actions/ai.ts @@ -0,0 +1,142 @@ +'use server'; + +import { and, asc, eq } from 'drizzle-orm'; + +import { db } from '@/db'; +import { aiLearnedTerms } from '@/db/schema/ai'; +import { getCurrentUser } from '@/lib/auth'; +import type { ExplanationResponse } from '@/lib/ai/prompts'; + +function normalizeTerm(term: string): string { + return term.toLowerCase().trim(); +} + +export async function saveLearnedTerm( + term: string, + explanation: ExplanationResponse +): Promise<{ success: boolean; error?: string }> { + const user = await getCurrentUser(); + if (!user) return { success: false, error: 'Unauthorized' }; + + const normalized = normalizeTerm(term); + if (!normalized) return { success: false, error: 'Invalid term' }; + + try { + await db + .insert(aiLearnedTerms) + .values({ + userId: user.id, + term: normalized, + explanationUk: explanation.uk, + explanationEn: explanation.en, + explanationPl: explanation.pl, + sortOrder: 0, + }) + .onConflictDoUpdate({ + target: [aiLearnedTerms.userId, aiLearnedTerms.term], + set: { + explanationUk: explanation.uk, + explanationEn: explanation.en, + explanationPl: explanation.pl, + }, + }); + + return { success: true }; + } catch (error) { + console.error('[ai] Failed to save learned term:', error); + return { success: false, error: 'Failed to save term' }; + } +} + +export async function getLearnedTerms(): Promise< + | { + success: true; + terms: { + term: string; + explanationUk: string; + explanationEn: string; + explanationPl: string; + isHidden: boolean; + sortOrder: number; + }[]; + } + | { success: false; error: string } +> { + const user = await getCurrentUser(); + if (!user) return { success: false, error: 'Unauthorized' }; + + try { + const rows = await db + .select({ + term: aiLearnedTerms.term, + explanationUk: aiLearnedTerms.explanationUk, + explanationEn: aiLearnedTerms.explanationEn, + explanationPl: aiLearnedTerms.explanationPl, + isHidden: aiLearnedTerms.isHidden, + sortOrder: aiLearnedTerms.sortOrder, + }) + .from(aiLearnedTerms) + .where(eq(aiLearnedTerms.userId, user.id)) + .orderBy(asc(aiLearnedTerms.sortOrder), asc(aiLearnedTerms.createdAt)); + + return { success: true, terms: rows }; + } catch (error) { + console.error('[ai] Failed to fetch learned terms:', error); + return { success: false, error: 'Failed to fetch terms' }; + } +} + +export async function setTermHidden( + term: string, + isHidden: boolean +): Promise<{ success: boolean; error?: string }> { + const user = await getCurrentUser(); + if (!user) return { success: false, error: 'Unauthorized' }; + + const normalized = normalizeTerm(term); + + try { + await db + .update(aiLearnedTerms) + .set({ isHidden }) + .where( + and( + eq(aiLearnedTerms.userId, user.id), + eq(aiLearnedTerms.term, normalized) + ) + ); + + return { success: true }; + } catch (error) { + console.error('[ai] Failed to update term visibility:', error); + return { success: false, error: 'Failed to update term' }; + } +} + +export async function updateTermsOrder( + orderedTerms: string[] +): Promise<{ success: boolean; error?: string }> { + const user = await getCurrentUser(); + if (!user) return { success: false, error: 'Unauthorized' }; + + try { + await Promise.all( + orderedTerms.map((term, index) => + db + .update(aiLearnedTerms) + .set({ sortOrder: index }) + .where( + and( + eq(aiLearnedTerms.userId, user.id), + eq(aiLearnedTerms.term, normalizeTerm(term)) + ) + ) + ) + ); + + return { success: true }; + } catch (error) { + console.error('[ai] Failed to update term order:', error); + return { success: false, error: 'Failed to update order' }; + } +} diff --git a/frontend/components/auth/fields/PasswordField.tsx b/frontend/components/auth/fields/PasswordField.tsx index 7b60ff65..ef8f7b66 100644 --- a/frontend/components/auth/fields/PasswordField.tsx +++ b/frontend/components/auth/fields/PasswordField.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl'; import { useState } from 'react'; +import { utf8ByteLength } from '@/lib/auth/password-bytes'; import { PASSWORD_MAX_BYTES, PASSWORD_MIN_LEN, @@ -43,6 +44,13 @@ export function PasswordField({ return; } + if (utf8ByteLength(input.value) > PASSWORD_MAX_BYTES) { + input.setCustomValidity( + t('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES }) + ); + return; + } + if (input.validity.tooShort && minLength) { input.setCustomValidity(t('validation.passwordTooShort', { minLength })); return; @@ -61,7 +69,14 @@ export function PasswordField({ }; const handleInput = (e: React.FormEvent) => { - e.currentTarget.setCustomValidity(''); + const input = e.currentTarget; + if (utf8ByteLength(input.value) > PASSWORD_MAX_BYTES) { + input.setCustomValidity( + t('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES }) + ); + } else { + input.setCustomValidity(''); + } }; const resolvedPlaceholder = placeholder ?? t('password'); diff --git a/frontend/components/dashboard/ExplainedTermsCard.tsx b/frontend/components/dashboard/ExplainedTermsCard.tsx index a9a8bc2d..8e703df4 100644 --- a/frontend/components/dashboard/ExplainedTermsCard.tsx +++ b/frontend/components/dashboard/ExplainedTermsCard.tsx @@ -16,14 +16,13 @@ import { useState, } from 'react'; -import AIWordHelper from '@/components/q&a/AIWordHelper'; -import { getCachedTerms } from '@/lib/ai/explainCache'; import { - getHiddenTerms, - hideTermFromDashboard, - unhideTermFromDashboard, -} from '@/lib/ai/hiddenTerms'; -import { saveTermOrder, sortTermsByOrder } from '@/lib/ai/termOrder'; + getLearnedTerms, + setTermHidden, + updateTermsOrder, +} from '@/actions/ai'; +import AIWordHelper from '@/components/q&a/AIWordHelper'; +import { setCachedExplanation } from '@/lib/ai/explainCache'; export function ExplainedTermsCard() { const t = useTranslations('dashboard.explainedTerms'); @@ -31,19 +30,26 @@ export function ExplainedTermsCard() { const [hiddenTerms, setHiddenTerms] = useState([]); useEffect(() => { - const cached = getCachedTerms(); - const hidden = getHiddenTerms(); - startTransition(() => { - setTerms( - sortTermsByOrder( - cached.filter(term => !hidden.has(term.toLowerCase().trim())) - ) - ); - setHiddenTerms( - sortTermsByOrder( - cached.filter(term => hidden.has(term.toLowerCase().trim())) - ) - ); + getLearnedTerms().then(result => { + if (!result.success) return; + const visible: string[] = []; + const hidden: string[] = []; + for (const row of result.terms) { + setCachedExplanation(row.term, { + uk: row.explanationUk, + en: row.explanationEn, + pl: row.explanationPl, + }); + if (row.isHidden) { + hidden.push(row.term); + } else { + visible.push(row.term); + } + } + startTransition(() => { + setTerms(visible); + setHiddenTerms(hidden); + }); }); }, []); const [showMore, setShowMore] = useState(false); @@ -59,19 +65,19 @@ export function ExplainedTermsCard() { } | null>(null); const handleRemoveTerm = (term: string) => { - hideTermFromDashboard(term); setTerms(prevTerms => prevTerms.filter(t => t !== term)); setHiddenTerms(prevHidden => [...prevHidden, term]); + setTermHidden(term, true).catch(() => {}); }; const handleRestoreTerm = (term: string) => { - unhideTermFromDashboard(term); setHiddenTerms(prevHidden => prevHidden.filter(t => t !== term)); setTerms(prevTerms => { const updated = [...prevTerms, term]; - saveTermOrder(updated); + updateTermsOrder(updated).catch(() => {}); return updated; }); + setTermHidden(term, false).catch(() => {}); }; const handleDragStart = (index: number) => { @@ -93,7 +99,7 @@ export function ExplainedTermsCard() { const [dragged] = newTerms.splice(draggedIndex, 1); newTerms.splice(targetIndex, 0, dragged); - saveTermOrder(newTerms); + updateTermsOrder(newTerms).catch(() => {}); return newTerms; }); @@ -195,7 +201,7 @@ export function ExplainedTermsCard() { const newTerms = [...prevTerms]; const [dragged] = newTerms.splice(fromIndex, 1); newTerms.splice(toIndex, 0, dragged); - saveTermOrder(newTerms); + updateTermsOrder(newTerms).catch(() => {}); return newTerms; }); } diff --git a/frontend/components/legal/LegalBackButton.tsx b/frontend/components/legal/LegalBackButton.tsx new file mode 100644 index 00000000..bd659c22 --- /dev/null +++ b/frontend/components/legal/LegalBackButton.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +export default function LegalBackButton({ label }: { label: string }) { + const router = useRouter(); + + return ( + + ); +} diff --git a/frontend/components/legal/LegalPageShell.tsx b/frontend/components/legal/LegalPageShell.tsx index ce32a938..3406bfe5 100644 --- a/frontend/components/legal/LegalPageShell.tsx +++ b/frontend/components/legal/LegalPageShell.tsx @@ -1,6 +1,6 @@ import { getTranslations } from 'next-intl/server'; -import { Link } from '@/i18n/routing'; +import LegalBackButton from '@/components/legal/LegalBackButton'; type Props = { title: string; @@ -23,17 +23,12 @@ export default async function LegalPageShell({ /> - {hasTerms ? ( + {loadError ? ( +
+

+ {t('loadError')} +

+
+ ) : hasTerms ? ( <>

{t('termCount', { count: terms.length })} diff --git a/frontend/components/legal/LegalBackButton.tsx b/frontend/components/legal/LegalBackButton.tsx index bd659c22..f1ab9ff5 100644 --- a/frontend/components/legal/LegalBackButton.tsx +++ b/frontend/components/legal/LegalBackButton.tsx @@ -5,10 +5,18 @@ import { useRouter } from 'next/navigation'; export default function LegalBackButton({ label }: { label: string }) { const router = useRouter(); + const handleBack = () => { + if (window.history.length > 1) { + router.back(); + } else { + router.push('/'); + } + }; + return (