Compare commits

...

10 Commits

Author SHA1 Message Date
f7d0dfdfb6 feat: UI polish — AI enhancements branding, footer year, admin background fix
- Header: add "+ AI enhancements" italic subtitle below logo
- Footer: update copyright year to 2021–current, add "AI enhancements by Cloud Host" link
- Admin page: wrap all states in pageBg Box so dark-mode background shows correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 11:21:34 +01:00
171b40f525 fix: serialise local Cog model calls with a queue to prevent 409 conflicts 2026-03-10 10:39:06 +01:00
41d2aa7295 fix: respect JigsawStack 5000-char limit with proper batching
- buildBatches: groups texts into chunks that fit within 4800 chars
  (200 char safety margin) when joined with the separator
- translateLongText: splits individual cells/paragraphs that exceed
  the limit at paragraph/sentence boundaries, translates each chunk,
  then rejoins — instead of hitting the API with oversized input
- Process batches sequentially to avoid overloading the local model
- Separator fallback still works: if separator gets translated,
  falls back to individual calls per text within that batch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:26:22 +01:00
38c3e3e2cb fix: chown entire /app to nextjs before build so .next dir can be created 2026-03-10 10:03:55 +01:00
b0f7f30f17 fix: replace Progress component with plain Box bar + use npm ci in Docker
- DocumentTranslator: replace Chakra Progress (broken types in 2.2.1
  with fresh installs) with a simple Box-based progress bar — no type
  issues, same visual result
- Dockerfile: switch from npm install to npm ci so Docker uses exact
  locked versions from package-lock.json, preventing type discrepancies
  between local and Docker builds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 10:00:04 +01:00
c795ef5a54 fix: replace colorScheme on Progress with sx workaround for Chakra 2.2.1 type strictness 2026-03-10 09:52:48 +01:00
a435e8c749 fix: add explicit types to all event handler params in DocumentTranslator 2026-03-10 09:45:51 +01:00
7a30d14f0f fix: skip Cypress binary download in Docker build (CYPRESS_INSTALL_BINARY=0) 2026-03-10 09:42:48 +01:00
2354ddf9be fix: add explicit React.DragEvent type on onDragOver handler 2026-03-10 09:38:06 +01:00
466459373a fix: move next build to image build time, drop yarn, add runner stage
- Build Next.js app during docker build (not on container start) —
  fixes yarn cache permission errors and makes container startup instant
- Remove yarn.lock during build so nothing can accidentally invoke yarn
- Add lean runner stage: copies only .next, node_modules, public —
  reduces final image size
- npm start is now the only thing that runs at container start

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 09:18:23 +01:00
6 changed files with 174 additions and 45 deletions

View File

@@ -1,30 +1,58 @@
# https://nextjs.org/docs/deployment#docker-image # ── Stage 1: install dependencies ──────────────────────────────────────────
FROM node:lts-alpine AS deps FROM node:lts-alpine AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps # Skip Cypress binary download — not needed in production
ENV CYPRESS_INSTALL_BINARY=0
# Use ci to install exact versions from package-lock.json
RUN npm ci --legacy-peer-deps
# ── Stage 2: build the Next.js app ──────────────────────────────────────────
FROM node:lts-alpine AS builder FROM node:lts-alpine AS builder
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# Copy source and deps
COPY --chown=nextjs:nodejs . .
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
# Remove yarn.lock so nothing accidentally invokes yarn
RUN rm -f yarn.lock
# Give nextjs user ownership of the entire workdir (WORKDIR creates it as root)
RUN chown -R nextjs:nodejs /app
# Build the app at image build time (not at container start)
USER nextjs
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ── Stage 3: production runner ───────────────────────────────────────────────
FROM node:lts-alpine AS runner
RUN apk add --no-cache curl RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \ RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001 adduser -S nextjs -u 1001
COPY --chown=nextjs:nodejs . . # Copy only what's needed to run
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/next.config.js ./next.config.js
# Ensure the data directory exists and is writable by the nextjs user
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data && chmod 755 /app/data RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data && chmod 755 /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production
ENV NODE_ENV production ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_TELEMETRY_DISABLED 1
HEALTHCHECK --interval=1m --timeout=3s CMD curl -f http://localhost:3000/ || exit 1 HEALTHCHECK --interval=1m --timeout=3s CMD curl -f http://localhost:3000/ || exit 1
@@ -32,4 +60,4 @@ CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain \
NEXT_PUBLIC_FORCE_DEFAULT_THEME=$force_default_theme \ NEXT_PUBLIC_FORCE_DEFAULT_THEME=$force_default_theme \
NEXT_PUBLIC_DEFAULT_SOURCE_LANG=$default_source_lang \ NEXT_PUBLIC_DEFAULT_SOURCE_LANG=$default_source_lang \
NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \ NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \
npm run build && npm start npm start

View File

@@ -1,7 +1,7 @@
import { FC, useState, useCallback, useRef } from "react"; import { FC, useState, useCallback, useRef } from "react";
import { import {
Box, Button, VStack, HStack, Text, Input, Alert, AlertIcon, Box, Button, VStack, HStack, Text, Input, Alert, AlertIcon,
Progress, Select, Badge, Icon, useColorModeValue Select, Badge, Icon, useColorModeValue
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { FiUpload, FiDownload, FiFile } from "react-icons/fi"; import { FiUpload, FiDownload, FiFile } from "react-icons/fi";
import { languageList, LangCode } from "lingva-scraper"; import { languageList, LangCode } from "lingva-scraper";
@@ -140,7 +140,7 @@ const DocumentTranslator: FC = () => {
textAlign="center" textAlign="center"
cursor="pointer" cursor="pointer"
onDrop={onDrop} onDrop={onDrop}
onDragOver={e => e.preventDefault()} onDragOver={(e: React.DragEvent) => e.preventDefault()}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
_hover={{ borderColor: "lingva.400" }} _hover={{ borderColor: "lingva.400" }}
transition="border-color 0.2s" transition="border-color 0.2s"
@@ -150,7 +150,7 @@ const DocumentTranslator: FC = () => {
type="file" type="file"
display="none" display="none"
accept=".pdf,.docx,.xlsx,.xls,.csv" accept=".pdf,.docx,.xlsx,.xls,.csv"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/> />
{file ? ( {file ? (
<VStack spacing={1}> <VStack spacing={1}>
@@ -177,7 +177,7 @@ const DocumentTranslator: FC = () => {
<Box flex={1}> <Box flex={1}>
<Select <Select
value={target} value={target}
onChange={e => setTarget(e.target.value)} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setTarget(e.target.value)}
size="md" size="md"
aria-label="Target language" aria-label="Target language"
> >
@@ -211,7 +211,9 @@ const DocumentTranslator: FC = () => {
{/* Progress */} {/* Progress */}
{stage === "translating" && ( {stage === "translating" && (
<Box w="full"> <Box w="full">
<Progress value={progress} colorScheme="lingva" borderRadius="full" hasStripe isAnimated /> <Box w="full" h="8px" bg="gray.200" borderRadius="full" overflow="hidden">
<Box h="full" w={`${progress}%`} bg="lingva.400" borderRadius="full" transition="width 0.4s ease" />
</Box>
<Text fontSize="sm" textAlign="center" mt={1} color="gray.500"> <Text fontSize="sm" textAlign="center" mt={1} color="gray.500">
Translating this may take a moment for large documents. Translating this may take a moment for large documents.
</Text> </Text>

View File

@@ -20,12 +20,16 @@ const Footer: FC<Props> = (props) => (
{...props} {...props}
> >
<Link href="https://github.com/thedaviddelta/lingva-translate/blob/main/LICENSE" isExternal={true}> <Link href="https://github.com/thedaviddelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
<Text as="span">&#169; 2021 thedaviddelta & contributors</Text> <Text as="span">&#169; 2021{new Date().getFullYear()} thedaviddelta & contributors</Text>
</Link> </Link>
<Text as="span" display={["none", null, "unset"]}>·</Text> <Text as="span" display={["none", null, "unset"]}>·</Text>
<Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}> <Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}>
<Text as="span">Licensed under AGPLv3</Text> <Text as="span">Licensed under AGPLv3</Text>
</Link> </Link>
<Text as="span" display={["none", null, "unset"]}>·</Text>
<Link href="https://cloudhost.es" isExternal={true}>
<Text as="span">AI enhancements by Cloud Host</Text>
</Link>
{vercelSponsor && ( {vercelSponsor && (
<> <>
<Text as="span" display={["none", null, "unset"]}>·</Text> <Text as="span" display={["none", null, "unset"]}>·</Text>

View File

@@ -1,7 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import Head from "next/head"; import Head from "next/head";
import NextLink from "next/link"; import NextLink from "next/link";
import { Flex, HStack, IconButton, Link, useColorModeValue } from "@chakra-ui/react"; import { Flex, HStack, IconButton, Link, Text, VStack, useColorModeValue } from "@chakra-ui/react";
import { FaGithub } from "react-icons/fa"; import { FaGithub } from "react-icons/fa";
import { FiSettings } from "react-icons/fi"; import { FiSettings } from "react-icons/fi";
import Image from "next/image"; import Image from "next/image";
@@ -28,13 +28,18 @@ const Header: FC<Props> = (props) => (
{...props} {...props}
> >
<NextLink href="/" passHref={true}> <NextLink href="/" passHref={true}>
<Link display="flex"> <Link display="flex" alignItems="center">
<Image <VStack spacing={0} align="flex-start">
src={useColorModeValue("/banner_light.svg", "/banner_dark.svg")} <Image
alt="Logo" src={useColorModeValue("/banner_light.svg", "/banner_dark.svg")}
width={110} alt="Logo"
height={64} width={110}
/> height={64}
/>
<Text fontSize="xs" fontStyle="italic" opacity={0.7} ml={1}>
+ AI enhancements
</Text>
</VStack>
</Link> </Link>
</NextLink> </NextLink>
<HStack spacing={3}> <HStack spacing={3}>

View File

@@ -46,6 +46,7 @@ const AdminPage: NextPage = () => {
const [testResult, setTestResult] = useState<string | null>(null); const [testResult, setTestResult] = useState<string | null>(null);
const [testLoading, setTestLoading] = useState(false); const [testLoading, setTestLoading] = useState(false);
const pageBg = useColorModeValue("gray.50", "gray.900");
const cardBg = useColorModeValue("white", "gray.800"); const cardBg = useColorModeValue("white", "gray.800");
const borderCol = useColorModeValue("gray.200", "gray.600"); const borderCol = useColorModeValue("gray.200", "gray.600");
const codeBg = useColorModeValue("gray.100", "gray.700"); const codeBg = useColorModeValue("gray.100", "gray.700");
@@ -140,7 +141,7 @@ const AdminPage: NextPage = () => {
if (authed === null) { if (authed === null) {
return ( return (
<Box w="full" display="flex" justifyContent="center" pt={20}> <Box w="full" minH="100%" bg={pageBg} display="flex" justifyContent="center" pt={20}>
<Spinner size="xl" color="lingva.400" /> <Spinner size="xl" color="lingva.400" />
</Box> </Box>
); );
@@ -150,6 +151,7 @@ const AdminPage: NextPage = () => {
return ( return (
<> <>
<CustomHead home={false} /> <CustomHead home={false} />
<Box w="full" minH="100%" bg={pageBg}>
<Box <Box
w="full" maxW="400px" mx="auto" mt={10} p={8} w="full" maxW="400px" mx="auto" mt={10} p={8}
bg={cardBg} borderWidth={1} borderColor={borderCol} bg={cardBg} borderWidth={1} borderColor={borderCol}
@@ -181,6 +183,7 @@ const AdminPage: NextPage = () => {
</NextLink> </NextLink>
</VStack> </VStack>
</Box> </Box>
</Box>
</> </>
); );
} }
@@ -188,7 +191,8 @@ const AdminPage: NextPage = () => {
return ( return (
<> <>
<CustomHead home={false} /> <CustomHead home={false} />
<Box w="full" maxW="700px" mx="auto" px={4} pb={10}> <Box w="full" minH="100%" bg={pageBg}>
<Box w="full" maxW="700px" mx="auto" px={4} pb={10} pt={2}>
<HStack justify="space-between" mb={6}> <HStack justify="space-between" mb={6}>
<NextLink href="/" passHref> <NextLink href="/" passHref>
<Button as={Link} leftIcon={<FiArrowLeft />} variant="ghost" size="sm">Back</Button> <Button as={Link} leftIcon={<FiArrowLeft />} variant="ghost" size="sm">Back</Button>
@@ -384,6 +388,7 @@ const AdminPage: NextPage = () => {
</Button> </Button>
</VStack> </VStack>
</Box> </Box>
</Box>
</> </>
); );
}; };

View File

@@ -90,12 +90,13 @@ async function translateViaCloud(
return extractText(data.output as ReplicateOutput); return extractText(data.output as ReplicateOutput);
} }
async function translateViaLocal( // The local Cog model handles exactly one prediction at a time.
text: string, // This queue ensures calls are strictly serialised regardless of
targetLanguage: string // how many batches / concurrent requests come in.
): Promise<string> { let localQueue: Promise<void> = Promise.resolve();
const settings = readSettings();
async function callLocalModel(text: string, targetLanguage: string): Promise<string> {
const settings = readSettings();
if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured"); if (!settings.jigsawApiKey) throw new Error("JigsawStack API key not configured");
const endpoint = (settings.localEndpoint || "http://localhost:5030/predictions").replace(/\/$/, ""); const endpoint = (settings.localEndpoint || "http://localhost:5030/predictions").replace(/\/$/, "");
@@ -118,14 +119,24 @@ async function translateViaLocal(
} }
const data: CogOutput = await response.json(); const data: CogOutput = await response.json();
if (data.error) throw new Error(`Local model error: ${data.error}`); if (data.error) throw new Error(`Local model error: ${data.error}`);
// Cog wraps output in { status, output } or returns output directly
const output = data.output !== undefined ? data.output : data; const output = data.output !== undefined ? data.output : data;
return extractText(output); return extractText(output);
} }
async function translateViaLocal(
text: string,
targetLanguage: string
): Promise<string> {
// Enqueue — wait for any in-flight local call to finish first
let resolve!: () => void;
const slot = new Promise<void>(r => { resolve = r; });
const turn = localQueue.then(() => callLocalModel(text, targetLanguage));
localQueue = turn.then(resolve, resolve); // advance queue whether success or error
return turn;
}
export async function replicateTranslate( export async function replicateTranslate(
text: string, text: string,
targetLanguage: string targetLanguage: string
@@ -136,24 +147,98 @@ export async function replicateTranslate(
: translateViaCloud(text, targetLanguage); : translateViaCloud(text, targetLanguage);
} }
// Batch translate using separator trick to minimize API calls
const SEPARATOR = "\n{{SEP}}\n"; const SEPARATOR = "\n{{SEP}}\n";
const MAX_CHARS = 4800; // JigsawStack limit is 5000 — leave headroom
/**
* Split a single text that exceeds MAX_CHARS into translatable chunks,
* preferring paragraph/sentence boundaries.
*/
async function translateLongText(text: string, targetLanguage: string): Promise<string> {
const chunks: string[] = [];
let remaining = text;
while (remaining.length > MAX_CHARS) {
let splitAt = MAX_CHARS;
const para = remaining.lastIndexOf("\n", MAX_CHARS);
const sentence = remaining.lastIndexOf(". ", MAX_CHARS);
if (para > MAX_CHARS * 0.5) splitAt = para + 1;
else if (sentence > MAX_CHARS * 0.5) splitAt = sentence + 2;
chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt);
}
if (remaining.trim()) chunks.push(remaining);
const results = await Promise.all(chunks.map(c => replicateTranslate(c, targetLanguage)));
return results.join(" ");
}
/**
* Build groups of texts that fit within MAX_CHARS when joined with SEPARATOR.
* Texts that individually exceed MAX_CHARS are kept alone for chunked translation.
*/
function buildBatches(texts: string[]): { indices: number[]; long: boolean }[] {
const batches: { indices: number[]; long: boolean }[] = [];
let current: number[] = [];
let currentLen = 0;
for (let i = 0; i < texts.length; i++) {
const t = texts[i];
if (t.length > MAX_CHARS) {
// Flush current group first
if (current.length > 0) { batches.push({ indices: current, long: false }); current = []; currentLen = 0; }
batches.push({ indices: [i], long: true });
continue;
}
const added = currentLen === 0 ? t.length : currentLen + SEPARATOR.length + t.length;
if (added > MAX_CHARS && current.length > 0) {
batches.push({ indices: current, long: false });
current = [i];
currentLen = t.length;
} else {
current.push(i);
currentLen = added;
}
}
if (current.length > 0) batches.push({ indices: current, long: false });
return batches;
}
export async function replicateTranslateBatch( export async function replicateTranslateBatch(
texts: string[], texts: string[],
targetLanguage: string targetLanguage: string
): Promise<string[]> { ): Promise<string[]> {
if (texts.length === 0) return []; if (texts.length === 0) return [];
if (texts.length === 1) {
return [await replicateTranslate(texts[0], targetLanguage)]; const results: string[] = new Array(texts.length);
const batches = buildBatches(texts);
// Process batches sequentially to avoid hammering the model
for (const batch of batches) {
if (batch.long) {
// Single oversized text — chunk it
results[batch.indices[0]] = await translateLongText(texts[batch.indices[0]], targetLanguage);
} else if (batch.indices.length === 1) {
results[batch.indices[0]] = await replicateTranslate(texts[batch.indices[0]], targetLanguage);
} else {
// Multi-text batch within limit
const joined = batch.indices.map(i => texts[i]).join(SEPARATOR);
const translated = await replicateTranslate(joined, targetLanguage);
const parts = translated.split(SEPARATOR);
if (parts.length === batch.indices.length) {
batch.indices.forEach((idx, i) => { results[idx] = parts[i]; });
} else {
// Separator got translated — fall back to individual calls
const individual = await Promise.all(
batch.indices.map(i => replicateTranslate(texts[i], targetLanguage))
);
batch.indices.forEach((idx, i) => { results[idx] = individual[i]; });
}
}
} }
const joined = texts.join(SEPARATOR); return results;
const translated = await replicateTranslate(joined, targetLanguage);
const parts = translated.split(SEPARATOR);
if (parts.length === texts.length) return parts;
// Fallback: translate individually if separator got mangled
return Promise.all(texts.map(t => replicateTranslate(t, targetLanguage)));
} }