Initial commit

This commit is contained in:
aNNiMON 2024-07-15 16:39:31 +03:00
commit 812df7d66e
7 changed files with 288 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
db

61
esentences.own Normal file
View File

@ -0,0 +1,61 @@
use std, jdbc, functional, server
class Phrases {
def Phrases(database) {
conn = getConnection("jdbc:sqlite:" + database)
statement = conn.createStatement()
this.queryByWordsLength = conn.prepareStatement("
SELECT * FROM phrases
WHERE words BETWEEN ? AND ?
ORDER BY RANDOM()
LIMIT 1")
this.wordsByGroup = this.getWordsCountByGroup()
}
def getRandomPhrase() = this.readPhrase(
this.statement.executeQuery("SELECT * FROM phrases ORDER BY RANDOM() LIMIT 1")
)
def getRandomPhrase(minWords, maxWords = minWords) {
this.queryByWordsLength.setInt(1, minWords)
this.queryByWordsLength.setInt(2, maxWords)
return this.readPhrase(this.queryByWordsLength.executeQuery())
}
def readPhrase(rs) {
if (!rs.next()) {
return "Can't find any phrase"
}
words = rs.getInt("words")
return {
"id": rs.getInt("id"),
"words": words,
"en": rs.getString("en"),
"ru": rs.getString("ru"),
"countInGroup": this.wordsByGroup[words]
}
}
def getWordsCountByGroup() {
rs = statement.executeQuery(
"SELECT words, COUNT(*) FROM phrases GROUP BY words")
result = {}
while (rs.next()) {
result[rs.getInt(1)] = rs.getInt(2)
}
return result
}
}
db = new Phrases("phrases-easy.db")
server = newServer({"externalDirs": ["public"]})
server
.get("/shutdown", def(ctx) = server.stop().close())
.get("/phrase", def(ctx) = ctx.json(db.getRandomPhrase()))
.get("/phrase/{words}", def(ctx) {
words = parseInt(ctx.pathParam("words"))
ctx.json(db.getRandomPhrase(words))
})
.error(404, def(ctx) = ctx.result("Phrases not found"))
.start(8081)

43
public/app.css Normal file
View File

@ -0,0 +1,43 @@
:root {
--color-primary: #5652c8;
}
.row {
margin-left: 0;
margin-right: 0;
}
html.dark {
filter: invert(1) hue-rotate(180deg);
}
.blur {
filter: blur(10px);
transition: all 0.1s ease-out;
}
.blur:hover {
filter: none;
}
.part {
margin-right: 1rem;
margin-bottom: 0.5rem;
background: var(--color-lightGrey) !important;
padding: 0.1rem 1rem;
}
.userPart {
color: var(--color-primary)
}
.px-2 {
padding-left: 1rem;
padding-right: 1rem;
}
.ml-2 {
margin-left: 1rem;
}
.mt-2 {
margin-top: 1rem;
}
.mt-3 {
margin-top: 1.6rem;
}

96
public/app.js Normal file
View File

@ -0,0 +1,96 @@
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
const app = new Vue({
el: '#app',
data() {
return {
phrase: {ru: 'wait', en: ''},
levels: {easy: {name: 'Easy', from: 4, to: 8},
medium: {name: 'Medium', from: 9, to: 16},
hard: {name: 'Hard', from: 17, to: 26},
expert: {name: 'Expert', from: 27, to: 40}
},
level: 'medium',
userInput: '',
partsMode: false,
parts: [],
userParts: []
}
},
mounted() {
this.newPhrase()
},
computed: {
isValid: function() {
return this.userInput.toLowerCase() === this.phrase.en.toLowerCase()
},
censored: function() {
let input = this.userInput.toLowerCase()
let phrase = this.phrase.en.toLowerCase()
let phraseLength = phrase.length
let min = Math.min(input.length, phraseLength)
if (min == 0) return '*'.repeat(phraseLength)
for (let i = 0; i < min; i++) {
// Valid part of a word
if (input[i] !== phrase[i]) {
return this.phrase.en.substr(0, i) + '*'.repeat(phraseLength - i)
}
}
return this.phrase.en.substr(0, min) + '*'.repeat(phraseLength - min)
}
},
methods: {
newPhrase: function() {
this.userInput = ""
this.userParts = []
this.parts = []
let levelInfo = this.levels[this.level]
let min = levelInfo.from
let max = levelInfo.to
let wordsCount = Math.floor(Math.random() * (max - min) + min)
fetch('/phrase/' + wordsCount)
.then(resp => resp.json())
.then(data => {
this.phrase = data
this.createParts()
})
},
onLevelChange: function() {
this.newPhrase()
},
togglePartsMode: function() {
this.partsMode = !this.partsMode
this.createParts()
},
createParts: function() {
if (!this.partsMode) return
this.parts = this.phrase.en.split(" ")
.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
this.userParts = []
},
insertPart: function(p, id) {
this.parts.splice(id, 1)
this.userParts.push(p)
this.userInput = this.userParts.join(' ')
if (this.parts.length == 0) {
window.nextPhraseButton.focus()
}
},
removePart: function(p, id) {
this.userParts.splice(id, 1)
this.parts.push(p)
this.userInput = this.userParts.join(' ')
},
shutdown: function() {
fetch('/shutdown/')
this.phrase = {id: 0, ru: 'Сервер остановлен', en: 'Server is stopped'}
this.createParts()
}
}
});

1
public/chota@0.8.0.css Normal file

File diff suppressed because one or more lines are too long

80
public/index.html Normal file
View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>eSentences</title>
<script src="/vue@2.6.11.js"></script>
<link rel="stylesheet" href="/chota@0.8.0.css">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="container" id="app">
<div class="nav">
<div class="nav-left">
<span class="brand">eSentence</span>
</div>
<div class="nav-center">
<div class="brand">
<span class="text-success">W{{ phrase.words }}</span>
<span class="px-2"></span>
<span class="text-grey">ID{{ phrase.id }}</span>
</div>
</div>
<div class="nav-right">
<select name="level" @change="onLevelChange()" v-model="level">
<option selected disabled value="">Choose level</option>
<option v-for="(info, id) in levels" :key="id" :value="id">{{ info.name }}</option>
</select>
<button class="ml-2" @click="shutdown" title="Shutdown"></button>
</div>
</div>
<!-- Phrase -->
<div class="row">
<h2 class="text-primary">{{ phrase.ru }}</h2>
</div>
<!-- Progress -->
<div class="row">
<h3 class="text-success" v-show="isValid">{{ phrase.en }}</h3>
<h3 class="text-dark" v-show="!isValid">{{ censored }}</h3>
</div>
<!-- Input mode -->
<div v-show="!partsMode">
<h5 class="row" @click="togglePartsMode">Input mode</h5>
<div class="row mt-2">
<input id="translationField"
type="text" placeholder="Enter a translation"
@keyup.enter="newPhrase" v-model="userInput"/>
</div>
<!-- Blurred tip -->
<div class="row mt-2">
<h4 class="text-dark blur">{{ phrase.en }}</h4>
</div>
</div>
<!-- Parts mode -->
<div v-show="partsMode">
<h5 class="row" @click="togglePartsMode">Parts mode</h5>
<div class="row">
<h4 class="part userPart"
v-for="(p, id) in userParts" :key="id"
@click="removePart(p, id)">{{ p }}</h4>
</div>
<div class="row mt-3">
<span class="part"
v-for="(p, id) in parts" :key="id"
@click="insertPart(p, id)">{{ p }}</span>
</div>
<button id="nextPhraseButton" class="mt-2"
v-show="isValid"
@click="newPhrase">Next phrase</button>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

6
public/vue@2.6.11.js Normal file

File diff suppressed because one or more lines are too long