mrfakename's picture
Upload 5 files
ad425c4 verified
/* global React, ReactDOM, MaterialUI */
const {
AppBar,
Toolbar,
Box,
Paper,
IconButton,
Tabs,
Tab,
Select,
MenuItem,
Button,
CircularProgress,
Tooltip,
Snackbar,
Alert,
Divider,
Typography,
ThemeProvider,
createTheme,
CssBaseline,
} = MaterialUI;
const { useState, useEffect, useMemo, useRef, useCallback } = React;
const theme = createTheme({
palette: {
mode: "light",
primary: { main: "#1a73e8" },
background: { default: "#ffffff", paper: "#ffffff" },
text: { primary: "#202124", secondary: "#5f6368" },
divider: "#dadce0",
},
typography: {
fontFamily:
'"Google Sans", "Roboto", "Helvetica", "Arial", sans-serif',
},
shape: { borderRadius: 8 },
components: {
MuiButton: { styleOverrides: { root: { textTransform: "none", fontWeight: 500 } } },
MuiTab: {
styleOverrides: {
root: {
textTransform: "none",
fontSize: 14,
fontWeight: 500,
minHeight: 48,
minWidth: 0,
padding: "12px 16px",
color: "#5f6368",
"&.Mui-selected": { color: "#1a73e8" },
},
},
},
MuiTabs: { styleOverrides: { indicator: { height: 3, borderRadius: "3px 3px 0 0" } } },
},
});
const MI = ({ name, sx }) => (
<span className="material-symbols-outlined" style={sx}>{name}</span>
);
const POPULAR = ["en", "zh", "es", "fr"];
function pickPopular(languages, includeAuto) {
const codes = (includeAuto ? ["auto"] : []).concat(POPULAR);
return codes.filter((c) => languages[c]);
}
function LanguageTabs({ languages, value, onChange, includeAuto }) {
const popular = pickPopular(languages, includeAuto);
const isPopular = popular.includes(value);
const tabValue = isPopular ? value : "__more__";
const handleTab = (_, newValue) => {
if (newValue !== "__more__") onChange(newValue);
};
const otherEntries = Object.entries(languages).filter(
([code]) => !popular.includes(code) && (includeAuto || code !== "auto"),
);
return (
<Box sx={{ display: "flex", alignItems: "center", flex: 1, minWidth: 0 }}>
<Tabs
value={tabValue}
onChange={handleTab}
variant="scrollable"
scrollButtons={false}
sx={{ minHeight: 48, flex: 1, minWidth: 0 }}
>
{popular.map((code) => (
<Tab key={code} value={code} label={languages[code]} />
))}
<Tab
value="__more__"
sx={{ p: 0, minWidth: 0 }}
label={
<Select
value={isPopular ? "" : value}
onChange={(e) => onChange(e.target.value)}
displayEmpty
variant="standard"
disableUnderline
renderValue={(selected) => {
const label = selected ? languages[selected] : "More";
return (
<Box sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
color: isPopular ? "#5f6368" : "#1a73e8",
fontSize: 14,
fontWeight: 500,
px: 2,
py: 1.5,
textTransform: "none",
}}>
{label}
<MI name="arrow_drop_down" />
</Box>
);
}}
sx={{
"& .MuiSelect-select": { p: 0, pr: "0 !important" },
"& .MuiSelect-icon": { display: "none" },
}}
MenuProps={{
PaperProps: { sx: { maxHeight: 360, mt: 0.5, borderRadius: 2 } },
}}
>
{otherEntries.map(([code, name]) => (
<MenuItem key={code} value={code}>{name}</MenuItem>
))}
</Select>
}
/>
</Tabs>
</Box>
);
}
function App() {
const [languages, setLanguages] = useState({});
const [maxInputChars, setMaxInputChars] = useState(6000);
const [modelId, setModelId] = useState("");
const [source, setSource] = useState("auto");
const [target, setTarget] = useState("en");
const [inputText, setInputText] = useState("");
const [translation, setTranslation] = useState("");
const [transliteration, setTransliteration] = useState("");
const [transliterationLabel, setTransliterationLabel] = useState("");
const [cached, setCached] = useState(false);
const [elapsed, setElapsed] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [toast, setToast] = useState(null);
const debounceRef = useRef(null);
useEffect(() => {
fetch("/api/config")
.then((r) => {
if (!r.ok) throw new Error("Could not load app config.");
return r.json();
})
.then((c) => {
setLanguages(c.languages);
setMaxInputChars(c.max_input_chars);
setModelId(c.model_id);
})
.catch((e) => setError(e.message));
}, []);
const runTranslate = useCallback(
async (text, src, tgt) => {
const value = text.trim();
if (!value) {
setTranslation("");
setTransliteration("");
setTransliterationLabel("");
setCached(false);
setElapsed(null);
setLoading(false);
return;
}
setLoading(true);
setError("");
try {
const response = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: value,
source_language: src,
target_language: tgt,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || "Translation failed.");
setTranslation(data.translation);
setTransliteration(data.transliteration || "");
setTransliterationLabel(data.transliteration_label || "");
setCached(Boolean(data.cached));
setElapsed(data.elapsed_ms);
} catch (e) {
setTranslation("");
setTransliteration("");
setTransliterationLabel("");
setCached(false);
setElapsed(null);
setError(e.message);
} finally {
setLoading(false);
}
},
[],
);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!inputText.trim()) {
setTranslation("");
setTransliteration("");
setTransliterationLabel("");
setCached(false);
setElapsed(null);
setLoading(false);
return;
}
debounceRef.current = setTimeout(() => {
runTranslate(inputText, source, target);
}, 700);
return () => debounceRef.current && clearTimeout(debounceRef.current);
}, [inputText, source, target, runTranslate]);
const swap = () => {
if (source === "auto") return;
const oldSource = source;
setSource(target);
setTarget(oldSource);
if (translation) {
setInputText(translation);
setTranslation("");
setTransliteration("");
setTransliterationLabel("");
setCached(false);
}
};
const charCount = inputText.length;
const overLimit = charCount > maxInputChars;
const copy = async () => {
if (!translation) return;
try {
await navigator.clipboard.writeText(translation);
setToast({ severity: "success", message: "Copied translation" });
} catch {
setToast({ severity: "error", message: "Copy failed" });
}
};
const speak = (text, lang) => {
if (!text || !window.speechSynthesis) return;
const utter = new SpeechSynthesisUtterance(text);
if (lang && lang !== "auto") utter.lang = lang;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utter);
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh", bgcolor: "#fff" }}>
<AppBar
position="static"
color="transparent"
elevation={0}
sx={{ borderBottom: "1px solid #ebebeb" }}
>
<Toolbar sx={{ minHeight: 64, px: { xs: 2, md: 3 } }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}>
<Box sx={{
width: 36, height: 36, borderRadius: "50%",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#1a73e8",
}}>
<MI name="translate" sx={{ fontSize: 28 }} />
</Box>
<Typography
sx={{
color: "#5f6368",
fontSize: 22,
letterSpacing: 0.2,
fontFamily: '"Google Sans", "Product Sans", Roboto, sans-serif',
}}
>
Translate
</Typography>
</Box>
<Box sx={{ flex: 1 }} />
<Tooltip title={modelId || "Model"}>
<Button
href="https://huggingface.co/AngelSlim/Hy-MT1.5-1.8B-1.25bit"
target="_blank"
rel="noreferrer"
sx={{
color: "#5f6368",
borderRadius: 999,
px: 2,
"&:hover": { bgcolor: "#f1f3f4" },
}}
startIcon={<MI name="open_in_new" sx={{ fontSize: 18 }} />}
>
Hugging Face
</Button>
</Tooltip>
</Toolbar>
</AppBar>
<Box sx={{
flex: 1,
width: "100%",
maxWidth: 1280,
mx: "auto",
px: { xs: 1.5, md: 3 },
py: { xs: 2, md: 3 },
}}>
<Paper
elevation={0}
sx={{
border: "1px solid #dadce0",
borderRadius: 2,
overflow: "hidden",
boxShadow: "0 1px 3px rgba(60,64,67,0.08)",
}}
>
<Box sx={{
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
alignItems: "center",
borderBottom: "1px solid #dadce0",
bgcolor: "#fff",
}}>
<LanguageTabs
languages={languages}
value={source}
onChange={setSource}
includeAuto
/>
<Tooltip title="Swap languages">
<span>
<IconButton
onClick={swap}
disabled={source === "auto"}
sx={{
mx: 1,
color: "#5f6368",
border: "1px solid #dadce0",
width: 40,
height: 40,
"&:hover": { bgcolor: "#f1f3f4" },
}}
>
<MI name="swap_horiz" />
</IconButton>
</span>
</Tooltip>
<LanguageTabs
languages={languages}
value={target}
onChange={setTarget}
includeAuto={false}
/>
</Box>
<Box sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1px 1fr" },
minHeight: { xs: "auto", md: 360 },
}}>
<Box sx={{ display: "flex", flexDirection: "column", position: "relative" }}>
<Box sx={{ position: "relative", flex: 1, display: "flex" }}>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Enter text"
spellCheck="true"
style={{
width: "100%",
minHeight: 260,
flex: 1,
padding: "20px 56px 56px 24px",
border: "none",
outline: "none",
resize: "none",
fontSize: 24,
lineHeight: 1.45,
color: "#202124",
background: "transparent",
}}
/>
{inputText && (
<IconButton
onClick={() => setInputText("")}
size="small"
sx={{
position: "absolute",
top: 8,
right: 8,
color: "#5f6368",
"&:hover": { bgcolor: "#f1f3f4" },
}}
aria-label="Clear"
>
<MI name="close" sx={{ fontSize: 20 }} />
</IconButton>
)}
</Box>
<Box sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 1,
py: 1,
minHeight: 48,
}}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title="Listen">
<span>
<IconButton
onClick={() => speak(inputText, source === "auto" ? null : source)}
disabled={!inputText}
size="small"
sx={{ color: "#5f6368" }}
>
<MI name="volume_up" />
</IconButton>
</span>
</Tooltip>
</Box>
<Typography
variant="caption"
sx={{
color: overLimit ? "#d93025" : "#5f6368",
pr: 1,
fontSize: 13,
}}
>
{charCount} / {maxInputChars}
</Typography>
</Box>
</Box>
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", md: "block" } }} />
<Divider sx={{ display: { xs: "block", md: "none" } }} />
<Box sx={{
display: "flex",
flexDirection: "column",
bgcolor: "#f8f9fa",
position: "relative",
}}>
<Box sx={{
flex: 1,
minHeight: 260,
padding: "20px 24px 56px 24px",
fontSize: 24,
lineHeight: 1.45,
color: translation ? "#202124" : "#80868b",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
position: "relative",
}}>
{loading && !translation && (
<Box sx={{
position: "absolute",
top: 24,
left: 24,
display: "flex",
alignItems: "center",
gap: 1.5,
color: "#5f6368",
fontSize: 16,
}}>
<CircularProgress size={18} thickness={5} />
<span>Translating…</span>
</Box>
)}
{!loading && !translation && !error && (
<span style={{ fontSize: 24 }}>Translation</span>
)}
{error && (
<Box sx={{ color: "#d93025", fontSize: 16 }}>{error}</Box>
)}
{translation}
{transliteration && (
<Box sx={{ mt: 2, pt: 2, borderTop: "1px solid #e8eaed" }}>
{transliterationLabel && (
<Typography
variant="caption"
sx={{
display: "block",
color: "#80868b",
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 0.6,
mb: 0.5,
}}
>
{transliterationLabel}
</Typography>
)}
<Box
sx={{
color: "#5f6368",
fontSize: 18,
lineHeight: 1.45,
fontStyle: "italic",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{transliteration}
</Box>
</Box>
)}
</Box>
<Box sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 1,
py: 1,
minHeight: 48,
}}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title="Listen">
<span>
<IconButton
onClick={() => speak(translation, target)}
disabled={!translation}
size="small"
sx={{ color: "#5f6368" }}
>
<MI name="volume_up" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Copy translation">
<span>
<IconButton
onClick={copy}
disabled={!translation}
size="small"
sx={{ color: "#5f6368" }}
>
<MI name="content_copy" sx={{ fontSize: 20 }} />
</IconButton>
</span>
</Tooltip>
</Box>
<Box sx={{ pr: 1, display: "flex", alignItems: "center", gap: 1 }}>
{cached && (
<Tooltip title="Served from local SQLite cache">
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
color: "#188038",
bgcolor: "#e6f4ea",
px: 1,
py: 0.25,
borderRadius: 999,
fontSize: 12,
fontWeight: 500,
}}
>
<MI name="bolt" sx={{ fontSize: 14 }} />
Cached
</Box>
</Tooltip>
)}
{elapsed !== null && (
<Typography variant="caption" sx={{ color: "#5f6368", fontSize: 13 }}>
{elapsed} ms
</Typography>
)}
</Box>
</Box>
</Box>
</Box>
</Paper>
<Box sx={{
mt: 2,
display: "flex",
justifyContent: "center",
color: "#80868b",
fontSize: 12,
}}>
<Typography variant="caption">
Powered by {modelId || "AngelSlim"}
</Typography>
</Box>
</Box>
<Snackbar
open={Boolean(toast)}
autoHideDuration={2200}
onClose={() => setToast(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{toast ? (
<Alert severity={toast.severity} variant="filled" onClose={() => setToast(null)}>
{toast.message}
</Alert>
) : undefined}
</Snackbar>
</Box>
</ThemeProvider>
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);