diff --git a/frontend/actions/ai.ts b/frontend/actions/ai.ts new file mode 100644 index 00000000..4305b0cc --- /dev/null +++ b/frontend/actions/ai.ts @@ -0,0 +1,143 @@ +'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); + if (!normalized) return { success: false, error: 'Invalid 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..b9b0f661 100644 --- a/frontend/components/dashboard/ExplainedTermsCard.tsx +++ b/frontend/components/dashboard/ExplainedTermsCard.tsx @@ -16,35 +16,52 @@ 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'); const [terms, setTerms] = useState([]); const [hiddenTerms, setHiddenTerms] = useState([]); + const [loadError, setLoadError] = useState(false); + const [isInitialLoad, setIsInitialLoad] = useState(true); 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) { + setLoadError(true); + 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); + setIsInitialLoad(false); + }); + }) + .catch(() => { + setLoadError(true); + setIsInitialLoad(false); + }); }, []); const [showMore, setShowMore] = useState(false); const [selectedTerm, setSelectedTerm] = useState(null); @@ -59,18 +76,28 @@ export function ExplainedTermsCard() { } | null>(null); const handleRemoveTerm = (term: string) => { - hideTermFromDashboard(term); - setTerms(prevTerms => prevTerms.filter(t => t !== term)); - setHiddenTerms(prevHidden => [...prevHidden, term]); + const prevTerms = terms; + const prevHidden = hiddenTerms; + setTerms(prevTerms.filter(t => t !== term)); + setHiddenTerms([...prevHidden, term]); + setTermHidden(term, true).catch(() => { + setTerms(prevTerms); + setHiddenTerms(prevHidden); + }); }; const handleRestoreTerm = (term: string) => { - unhideTermFromDashboard(term); - setHiddenTerms(prevHidden => prevHidden.filter(t => t !== term)); - setTerms(prevTerms => { - const updated = [...prevTerms, term]; - saveTermOrder(updated); - return updated; + const prevTerms = terms; + const prevHidden = hiddenTerms; + const updatedTerms = [...prevTerms, term]; + setTerms(updatedTerms); + setHiddenTerms(prevHidden.filter(t => t !== term)); + Promise.all([ + setTermHidden(term, false), + updateTermsOrder(updatedTerms), + ]).catch(() => { + setTerms(prevTerms); + setHiddenTerms(prevHidden); }); }; @@ -88,16 +115,15 @@ export function ExplainedTermsCard() { return; } - setTerms(prevTerms => { - const newTerms = [...prevTerms]; - const [dragged] = newTerms.splice(draggedIndex, 1); - newTerms.splice(targetIndex, 0, dragged); - - saveTermOrder(newTerms); - return newTerms; - }); - + const prevTerms = [...terms]; + const newTerms = [...terms]; + const [dragged] = newTerms.splice(draggedIndex, 1); + newTerms.splice(targetIndex, 0, dragged); + setTerms(newTerms); setDraggedIndex(null); + updateTermsOrder(newTerms).catch(() => { + setTerms(prevTerms); + }); }; const touchDragIndex = useRef(null); @@ -191,12 +217,13 @@ export function ExplainedTermsCard() { const toIndex = dragTargetIndex.current; if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) { - setTerms(prevTerms => { - const newTerms = [...prevTerms]; - const [dragged] = newTerms.splice(fromIndex, 1); - newTerms.splice(toIndex, 0, dragged); - saveTermOrder(newTerms); - return newTerms; + const prevTerms = [...termsRef.current]; + const newTerms = [...termsRef.current]; + const [dragged] = newTerms.splice(fromIndex, 1); + newTerms.splice(toIndex, 0, dragged); + setTerms(newTerms); + updateTermsOrder(newTerms).catch(() => { + setTerms(prevTerms); }); } @@ -255,7 +282,13 @@ export function ExplainedTermsCard() { - {hasTerms ? ( + {loadError ? ( +
+

+ {t('loadError')} +

+
+ ) : hasTerms ? ( <>

{t('termCount', { count: terms.length })} @@ -318,7 +351,7 @@ export function ExplainedTermsCard() { })} - ) : ( + ) : !isInitialLoad ? (

{t('empty')} @@ -327,7 +360,7 @@ export function ExplainedTermsCard() { {t('emptyHint')}

- )} + ) : null} {/* Explained Terms Section */}
diff --git a/frontend/components/legal/LegalBackButton.tsx b/frontend/components/legal/LegalBackButton.tsx new file mode 100644 index 00000000..f1ab9ff5 --- /dev/null +++ b/frontend/components/legal/LegalBackButton.tsx @@ -0,0 +1,25 @@ +'use client'; + +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 ( + + ); +} 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({ />