First real commit. Setting up for full CI/CD and testing pipeline

This commit is contained in:
2026-11-04 22:30:59 +01:00
parent cbb843344e
commit e08c997914
26 changed files with 3561 additions and 198 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fast_Reader</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1173
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
frontend/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "fast_reader-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc && vite build",
"dev": "vite"
},
"devDependencies": {
"typescript": "^5.8.3",
"vite": "^6.2.0"
},
"allowScripts": {
"esbuild@0.25.12": true
}
}

431
frontend/src/app.ts Normal file
View File

@@ -0,0 +1,431 @@
import {
PrepareText,
GetSettings,
SaveSettings,
ResetSettings,
} from "../wailsjs/go/app/App";
interface Word {
text: string;
prefix: string;
focus: string;
suffix: string;
}
interface AppSettings {
readingSpeed: number;
fontSize: number;
theme: string;
}
const root = document.querySelector<HTMLDivElement>("#app")!;
root.innerHTML = `
<main class="layout">
<header>
<div>
<h1>Fast Reader Training</h1>
<p class="subtitle">Focus on one word at a time.</p>
</div>
<span class="version">v0.1.0</span>
</header>
<section class="input-section">
<label for="text-input">Your text</label>
<textarea id="text-input"
placeholder="Paste or type the text you want to read..."
></textarea>
<div class="input-footer">
<span id="word-count">0 words</span>
<button id="load-demo" class="secondary">Load demo text</button>
</div>
</section>
<section class="reader">
<div class="reader-top">
<span id="reading-status">Ready to read</span>
<span id="current-position">0 / 0</span>
</div>
<div class="word-display" id="word-display">
<span class="word-prefix"></span><span class="word-focus">F</span><span class="word-suffix">lashRead</span>
</div>
<div class="progress-track">
<div id="progress-bar"></div>
</div>
<div class="controls">
<button id="reset" class="secondary" title="Reset (R)">↺</button>
<button id="play" class="primary" title="Play / Pause (Space)">▶ Start reading</button>
<button id="skip" class="secondary" title="Next word (→)">→</button>
</div>
<div class="settings">
<div class="setting-group">
<label for="speed">Reading speed</label>
<div class="speed-control">
<input
id="speed"
type="range"
min="100"
max="1000"
step="25"
value="300"
/>
<strong id="speed-value">300 WPM</strong>
</div>
</div>
<div class="setting-group">
<label for="font-size">Word font size</label>
<div class="speed-control">
<input
id="font-size"
type="range"
min="24"
max="96"
step="2"
value="48"
/>
<strong id="font-size-value">48 px</strong>
</div>
</div>
<div class="setting-group">
<label for="theme">Theme</label>
<select id="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div class="settings-footer">
<span id="settings-status" role="status"></span>
<button id="reset-settings" class="secondary">
Reset settings
</button>
</div>
</div>
</section>
<footer>
<span>Space: Play/Pause</span>
<span>← →: Navigate</span>
<span>R: Reset</span>
</footer>
</main>
`;
const textInput = document.querySelector<HTMLTextAreaElement>("#text-input")!;
const wordCount = document.querySelector<HTMLSpanElement>("#word-count")!;
const display = document.querySelector<HTMLDivElement>("#word-display")!;
const prefixEl = display.querySelector<HTMLSpanElement>(".word-prefix")!;
const focusEl = display.querySelector<HTMLSpanElement>(".word-focus")!;
const suffixEl = display.querySelector<HTMLSpanElement>(".word-suffix")!;
const playButton = document.querySelector<HTMLButtonElement>("#play")!;
const resetButton = document.querySelector<HTMLButtonElement>("#reset")!;
const skipButton = document.querySelector<HTMLButtonElement>("#skip")!;
const speedInput = document.querySelector<HTMLInputElement>("#speed")!;
const speedValue = document.querySelector<HTMLElement>("#speed-value")!;
const progressBar = document.querySelector<HTMLDivElement>("#progress-bar")!;
const positionLabel =
document.querySelector<HTMLSpanElement>("#current-position")!;
const statusLabel = document.querySelector<HTMLSpanElement>("#reading-status")!;
const demoButton = document.querySelector<HTMLButtonElement>("#load-demo")!;
const fontSizeInput = document.querySelector<HTMLInputElement>("#font-size")!;
const fontSizeValue = document.querySelector<HTMLElement>("#font-size-value")!;
const themeInput = document.querySelector<HTMLSelectElement>("#theme")!;
const resetSettingsButton =
document.querySelector<HTMLButtonElement>("#reset-settings")!;
const settingsStatus =
document.querySelector<HTMLSpanElement>("#settings-status")!;
let words: Word[] = [];
let currentIndex = 0;
let isPlaying = false;
let timer: number | undefined;
let wpm = 300;
let fontSize = 48;
let theme = "dark";
let currentSettings: AppSettings = {
readingSpeed: 300,
fontSize: 48,
theme: "dark",
};
function applySettings(settings: AppSettings) {
currentSettings = settings;
wpm = settings.readingSpeed;
fontSize = settings.fontSize;
theme = settings.theme;
speedInput.value = String(wpm);
speedValue.textContent = `${wpm} WPM`;
fontSizeInput.value = String(fontSize);
fontSizeValue.textContent = `${fontSize} px`;
themeInput.value = theme;
document.documentElement.dataset.theme = theme;
display.style.fontSize = `${fontSize}px`;
}
async function persistSettings() {
currentSettings = {
readingSpeed: wpm,
fontSize,
theme,
};
settingsStatus.textContent = "Saving...";
try {
await SaveSettings(currentSettings);
settingsStatus.textContent = "Settings saved";
} catch (error) {
console.error("Could not save settings:", error);
settingsStatus.textContent = "Could not save settings";
}
}
async function loadSettings() {
try {
const saved = await GetSettings();
applySettings(saved);
} catch (error) {
console.error("Could not load settings:", error);
settingsStatus.textContent = "Using default settings";
}
}
function updateWordCount() {
const count = textInput.value.trim()
? textInput.value.trim().split(/\s+/).length
: 0;
wordCount.textContent = `${count} words`;
}
function renderWord() {
if (words.length === 0) {
prefixEl.textContent = "";
focusEl.textContent = "F";
suffixEl.textContent = "lashRead";
positionLabel.textContent = "0 / 0";
progressBar.style.width = "0%";
return;
}
const word = words[currentIndex];
prefixEl.textContent = word.prefix;
focusEl.textContent = word.focus;
suffixEl.textContent = word.suffix;
positionLabel.textContent = `${currentIndex + 1} / ${words.length}`;
progressBar.style.width = `${((currentIndex + 1) / words.length) * 100}%`;
}
function stopPlayback() {
isPlaying = false;
if (timer !== undefined) {
window.clearTimeout(timer);
timer = undefined;
}
playButton.textContent = "▶ Start reading";
statusLabel.textContent = "Paused";
}
function advance() {
if (!isPlaying) return;
if (currentIndex >= words.length - 1) {
stopPlayback();
statusLabel.textContent = "Finished reading";
return;
}
const currentWord = words[currentIndex].text;
currentIndex++;
renderWord();
const delay = getDuration(currentWord);
timer = window.setTimeout(advance, delay);
}
function getDuration(word: string): number {
let duration = 60000 / wpm;
if (/[.!?]$/.test(word)) {
duration += 300;
} else if (/[,;:]$/.test(word)) {
duration += 150;
}
if ([...word].length >= 10) {
duration += 50;
}
return duration;
}
async function startPlayback() {
if (words.length === 0) {
const text = textInput.value.trim();
if (!text) {
textInput.focus();
return;
}
words = await PrepareText(text);
currentIndex = 0;
renderWord();
}
if (currentIndex >= words.length - 1) {
currentIndex = 0;
renderWord();
}
isPlaying = true;
playButton.textContent = "⏸ Pause";
statusLabel.textContent = "Reading";
timer = window.setTimeout(advance, getDuration(words[currentIndex].text));
}
playButton.addEventListener("click", () => {
if (isPlaying) {
stopPlayback();
} else {
void startPlayback();
}
});
resetButton.addEventListener("click", () => {
stopPlayback();
currentIndex = 0;
renderWord();
statusLabel.textContent = "Ready to read";
});
skipButton.addEventListener("click", () => {
if (currentIndex < words.length - 1) {
currentIndex++;
renderWord();
}
});
speedInput.addEventListener("input", () => {
wpm = Number(speedInput.value);
speedValue.textContent = `${wpm} WPM`;
});
speedInput.addEventListener("change", () => {
void persistSettings();
});
fontSizeInput.addEventListener("input", () => {
fontSize = Number(fontSizeInput.value);
fontSizeValue.textContent = `${fontSize} px`;
display.style.fontSize = `${fontSize}px`;
});
fontSizeInput.addEventListener("change", () => {
void persistSettings();
});
themeInput.addEventListener("change", () => {
theme = themeInput.value;
document.documentElement.dataset.theme = theme;
void persistSettings();
});
textInput.addEventListener("input", () => {
stopPlayback();
words = [];
currentIndex = 0;
updateWordCount();
renderWord();
statusLabel.textContent = "Ready to read";
});
demoButton.addEventListener("click", () => {
textInput.value =
"The quick brown fox jumps over the lazy dog. " +
"Speed reading helps you focus on one word at a time, " +
"reducing the need for eye movement across a page. " +
"Experiment with different speeds and discover what works best for you.";
updateWordCount();
words = [];
currentIndex = 0;
stopPlayback();
renderWord();
});
resetSettingsButton.addEventListener("click", async () => {
try {
settingsStatus.textContent = "Resetting...";
const defaults = await ResetSettings();
applySettings(defaults);
settingsStatus.textContent = "Settings reset";
} catch (error) {
console.error("Could not reset settings:", error);
settingsStatus.textContent = "Could not reset settings";
}
});
document.addEventListener("keydown", (event) => {
const target = event.target as HTMLElement;
if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") {
return;
}
switch (event.key) {
case " ":
event.preventDefault();
playButton.click();
break;
case "ArrowRight":
skipButton.click();
break;
case "ArrowLeft":
if (currentIndex > 0) {
currentIndex--;
renderWord();
}
break;
case "r":
case "R":
resetButton.click();
break;
case "Escape":
stopPlayback();
break;
}
});
updateWordCount();
renderWord();
void loadSettings();

2
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,2 @@
import "./styles/style.css";
import "./app";

View File

@@ -0,0 +1,306 @@
:root {
font-family:
Inter,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--background: #121218;
--text: #e8e8ed;
--surface: #1c1c24;
--border: #30303a;
--muted: #92929e;
--accent: #8b7cf6;
--red: #ff4c55;
--secondary-background: #292932;
--secondary-border: #3a3a45;
--secondary-text: #d0d0da;
--progress-track: #32323c;
color: var(--text);
background: var(--background);
}
:root[data-theme="light"] {
--background: #f4f4f8;
--text: #24242d;
--surface: #ffffff;
--border: #dedee7;
--muted: #707080;
--accent: #6958df;
--red: #d93645;
--secondary-background: #ededf3;
--secondary-border: #d5d5df;
--secondary-text: #33333e;
--progress-track: #dedee7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 600px;
min-height: 100vh;
background: var(--background);
color: var(--text);
}
button,
textarea,
input {
font: inherit;
}
.layout {
max-width: 1000px;
margin: 0 auto;
padding: 36px 48px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.8px;
margin: 0;
}
.subtitle {
margin: 6px 0 0;
color: var(--muted);
font-size: 14px;
}
.version {
color: var(--muted);
font-size: 12px;
border: 1px solid var(--border);
padding: 5px 10px;
border-radius: 20px;
}
.input-section {
margin-bottom: 24px;
}
.input-section label,
.settings label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
}
textarea {
display: block;
width: 100%;
height: 130px;
resize: vertical;
padding: 14px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
outline: none;
line-height: 1.6;
font-size: 14px;
}
textarea:focus {
border-color: var(--accent);
}
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
#word-count {
font-size: 12px;
color: var(--muted);
}
button {
border: 0;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover {
opacity: 0.85;
}
.primary {
background: var(--accent);
color: white;
padding: 12px 28px;
font-weight: 600;
}
.secondary {
color: var(--secondary-text);
background: var(--secondary-background);
border: 1px solid var(--secondary-border);
padding: 10px 16px;
}
.reader {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 24px;
}
.reader-top {
display: flex;
justify-content: space-between;
color: var(--muted);
font-size: 12px;
}
.word-display {
display: flex;
align-items: baseline;
justify-content: center;
min-height: 180px;
padding: 48px 0;
font-size: clamp(36px, 6vw, 64px);
font-weight: 500;
letter-spacing: 0.5px;
font-variant-ligatures: none;
}
.word-prefix,
.word-focus,
.word-suffix {
white-space: pre;
}
.word-focus {
color: var(--red);
font-weight: 700;
}
.progress-track {
height: 4px;
border-radius: 4px;
overflow: hidden;
background: var(--progress-track);
}
#progress-bar {
height: 100%;
width: 0;
background: var(--accent);
transition: width 0.08s linear;
}
.controls {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin: 24px 0 32px;
}
.settings {
max-width: 500px;
margin: 0 auto;
}
.speed-control {
display: flex;
align-items: center;
gap: 20px;
}
input[type="range"] {
flex: 1;
accent-color: var(--accent);
cursor: pointer;
}
#speed-value {
min-width: 80px;
font-size: 13px;
text-align: right;
}
footer {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 24px;
margin-top: 24px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 700px) {
.layout {
padding: 24px 18px;
}
.reader {
padding: 16px;
}
.word-display {
min-height: 140px;
padding: 36px 0;
}
}
.setting-group {
margin-bottom: 20px;
}
.settings select {
width: 100%;
padding: 10px 12px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
outline: none;
color-scheme: light dark;
}
.settings select option {
color: var(--text);
background-color: var(--surface);
}
.settings select:focus {
border-color: var(--accent);
}
.settings-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-top: 24px;
}
#settings-status {
color: var(--muted);
font-size: 12px;
}

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": [
"src"
]
}