Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
d7b18e8f86 | |||
6a5b1589ef | |||
8d265c400c | |||
3b62968dd7 | |||
eeebc91723 | |||
9660dfda3f | |||
7d7a6ea24d | |||
776ed212f2 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
*.sw?
|
||||
dist/
|
||||
|
15
Makefile
Normal file
15
Makefile
Normal file
@ -0,0 +1,15 @@
|
||||
deploy: clean build
|
||||
aws s3 sync dist s3://flashcards.jdbernard.com
|
||||
rm -r dist
|
||||
|
||||
serve-local:
|
||||
(cd dist && python -m SimpleHTTPServer &)
|
||||
./makewatch build
|
||||
|
||||
build: flashcards.*
|
||||
-mkdir dist
|
||||
cp flashcards.* dist
|
||||
git describe --always --dirty --tags | xargs --replace=INSERTED -- sed -i -e 's/%VERSION%/INSERTED/' dist/*
|
||||
|
||||
clean:
|
||||
-rm -r dist
|
13
README.md
Normal file
13
README.md
Normal file
@ -0,0 +1,13 @@
|
||||
Simple Flashcards takes a set of text values, one value per line, and shows
|
||||
them to the user one at a time. It has the following features:
|
||||
|
||||
* Support for prompts only or prompts and answers.
|
||||
* Image URLs can be used as values for pictoral flashcards.
|
||||
* Configurable timing between cards and answers.
|
||||
* Configurable size of the text (small, medium, and large).
|
||||
* In-order, reverse-order, and random-order traversal of the cards.
|
||||
* Support for saving/loading settings and inputs (locally to the browser).
|
||||
* Support for importing/exporting saved card sets as a file.
|
||||
* Support for importing saved card sets from a URL.
|
||||
|
||||
Simple Flashcards is available at http://flashcards.jdbernard.com
|
@ -57,10 +57,12 @@ button {
|
||||
|
||||
#settings .visibility-indicator {
|
||||
display: inline-block;
|
||||
font-size: 80%;
|
||||
transition: all linear 0.2s;
|
||||
}
|
||||
|
||||
#settings #adv-settings {
|
||||
font-size: 80%;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height linear 0.3s;
|
||||
@ -68,7 +70,7 @@ button {
|
||||
|
||||
#settings.adv-settings-visible #adv-settings {
|
||||
max-height: 40%;
|
||||
overflow-y: scroll;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
#settings.adv-settings-visible .visibility-indicator {
|
||||
@ -95,6 +97,13 @@ button {
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
#adv-settings label input[type="radio"] {
|
||||
min-width: unset;
|
||||
margin: 0 0.25em 0 1.5em;
|
||||
}
|
||||
|
||||
#adv-settings label input[type="radio"]:first-of-type { margin-left: 0; }
|
||||
|
||||
#adv-settings button {
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
@ -109,12 +118,58 @@ input[name=importFileName] {
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
justify-content: space-around;
|
||||
|
||||
}
|
||||
|
||||
#card-content { margin: auto; }
|
||||
#card-content.small-text{ font-size: 100%; }
|
||||
#card-content.medium-text{ font-size: 200%; }
|
||||
#card-content.large-text{ font-size: 800%; }
|
||||
#card-input {
|
||||
flex-grow: 2;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#card-input label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
flex-grow: 1;
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card.current { display: flex; }
|
||||
|
||||
.card div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card img {
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.answer { opacity: 0; }
|
||||
.card.show-answer .answer { opacity: 1; }
|
||||
|
||||
.card.small img { width: 50%; }
|
||||
.card.medium img { width: 75%; }
|
||||
.card.large img { width: 100%; }
|
||||
|
||||
.card.small .prompt { font-size: 100%; }
|
||||
.card.medium .prompt { font-size: 200%; }
|
||||
.card.large .prompt { font-size: 800%; }
|
||||
|
||||
.card.small .answer { font-size: 75%; }
|
||||
.card.medium .answer { font-size: 150%; }
|
||||
.card.large .answer { font-size: 400%; }
|
||||
|
||||
/* Card View Visible */
|
||||
#settings {
|
||||
@ -130,6 +185,14 @@ input[name=importFileName] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#card-input label.only-with-answers,
|
||||
#adv-settings label.only-with-answers
|
||||
{ display: none; }
|
||||
|
||||
.cards-have-answers #card-input label.only-with-answers,
|
||||
.cards-have-answers #adv-settings label.only-with-answers
|
||||
{ display: flex; }
|
||||
|
||||
/* Mobile View */
|
||||
@media ( max-width: 600px ) {
|
||||
body {
|
||||
@ -160,7 +223,7 @@ input[name=importFileName] {
|
||||
height: calc(100vh - 2em);
|
||||
width: calc(100vw - 2em);
|
||||
}
|
||||
|
||||
|
||||
#settings > button, #cards > button {
|
||||
font-size: 125%;
|
||||
margin: 1em auto;
|
||||
@ -187,10 +250,8 @@ input[name=importFileName] {
|
||||
}
|
||||
|
||||
#debug {
|
||||
/* display: none; */
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
|
||||
|
@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<title>Simple Flashcards</title>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content='width=device-width,initial-scale=1'>
|
||||
<meta name=application-name content='Simple Flashcards'>
|
||||
|
||||
<link rel=stylesheet href=flashcards.css type="text/css">
|
||||
<script src='flashcards.js'></script>
|
||||
@ -11,15 +13,30 @@
|
||||
<body class=settings-visible>
|
||||
<h1 class=title>Simple Flashcards</h1>
|
||||
<form id=settings action=#>
|
||||
<label>Items</label>
|
||||
<textarea id=items-input></textarea>
|
||||
<div id=card-input>
|
||||
<label>Cards
|
||||
<textarea id=prompts-input></textarea>
|
||||
</label>
|
||||
<label class="only-with-answers">Answers
|
||||
<textarea id=answers-input></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<label class=toggle-adv-settings>Advanced Settings
|
||||
<span class=visibility-indicator>➤</span>
|
||||
</label>
|
||||
<div id=adv-settings>
|
||||
<label>
|
||||
<span>Seconds per slide:</span>
|
||||
<input type=number name=slidePeriod value=3>
|
||||
<span>Cards have answers:</span>
|
||||
<label><input type=radio name=hasAnswers value="yes">yes</label>
|
||||
<label><input type=radio name=hasAnswers value="no" checked>no</label>
|
||||
</label>
|
||||
<label>
|
||||
<span>Seconds per prompt:</span>
|
||||
<input type=number name=promptPeriod value=3>
|
||||
</label>
|
||||
<label class="only-with-answers">
|
||||
<span>Seconds per answer:</span>
|
||||
<input type=number name=answerPeriod value=3>
|
||||
</label>
|
||||
<label>
|
||||
<span>Show cards </span>
|
||||
@ -55,14 +72,19 @@
|
||||
<button id=import>Import Cards</button>
|
||||
<input name=importFileName type=file accept='.json'>
|
||||
</label>
|
||||
<label>
|
||||
<span>Import from URL: </span>
|
||||
<input name=importURL type=text>
|
||||
<button id=importURL>Import</button>
|
||||
</label>
|
||||
</div>
|
||||
<button class=btn-primary id=start-button>Go!</button>
|
||||
</form>
|
||||
<div id=cards>
|
||||
<div id=card-content></div>
|
||||
<button id=stop-button>Stop</button>
|
||||
</div>
|
||||
<div id=debug>
|
||||
Version %VERSION%
|
||||
<span class=small-only>small</span>
|
||||
<span class=medium-only>medium</span>
|
||||
<span class=large-only>large</span>
|
||||
|
135
flashcards.js
135
flashcards.js
@ -1,13 +1,18 @@
|
||||
(function() {
|
||||
const FC = {
|
||||
currentSet: [],
|
||||
items: [],
|
||||
cards: [],
|
||||
curCardEls: [], // array of card HTMLElements
|
||||
nextCardIdx: 0,
|
||||
cardOrder: [], // elements are objects: { name: 'abc', cards: 'xyz' }
|
||||
savedSets: [],
|
||||
$: document.querySelector.bind(document)
|
||||
cardOrder: [],
|
||||
savedSets: [], // elements are objects: { name: 'abc', cards: 'xyz' }
|
||||
$: document.querySelector.bind(document),
|
||||
version: "%VERSION%"
|
||||
};
|
||||
|
||||
const IMG_REGEX = /(http|https|file):\/\/(.*)\.(jpg|jpeg|gif|png|svg)/;
|
||||
const URL_REGEX = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/;
|
||||
|
||||
FC.shuffle = function(inArray) {
|
||||
/* http://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array
|
||||
* modified to be a pure function, not mutating it's input */
|
||||
@ -34,6 +39,10 @@
|
||||
return outArray;
|
||||
};
|
||||
|
||||
FC.zipItems = function(prompts, answers) {
|
||||
return prompts.map((prompt, idx) => ({ prompt, answer: answers[idx] || null }));
|
||||
}
|
||||
|
||||
FC.isRunning = function() {
|
||||
return !FC.bodyEl.classList.contains('settings-visible');
|
||||
};
|
||||
@ -46,11 +55,12 @@
|
||||
if (ev && ev.preventDefault) ev.preventDefault();
|
||||
|
||||
const newSet = {
|
||||
name: (FC.currentSet || {}).name ||
|
||||
FC.$('input[name=saveSetName]').value ||
|
||||
'new set',
|
||||
cards: FC.itemsEl.value,
|
||||
slidePeriod: parseInt(FC.$('input[name=slidePeriod]').value) || 3,
|
||||
name: FC.$('input[name=saveSetName]').value || 'new set',
|
||||
prompts: FC.promptsInputEl.value,
|
||||
answers: FC.answersInputEl.value,
|
||||
cardsHaveAnswers: FC.$('input[name=hasAnswers]:checked')?.value === 'yes',
|
||||
promptPeriod: parseInt(FC.$('input[name=promptPeriod]').value) || 3,
|
||||
answerPeriod: parseInt(FC.$('input[name=answerPeriod]').value) || 3,
|
||||
textSize: FC.$('select[name=textSize]').value || 'small',
|
||||
sortOrder: FC.$('select[name=cardOrder]').value || 'in-order'
|
||||
};
|
||||
@ -60,10 +70,15 @@
|
||||
|
||||
FC.populateSettingsFromSet = function(set) {
|
||||
FC.$('input[name=saveSetName]').value = set.name;
|
||||
FC.itemsEl.value = set.cards;
|
||||
FC.$('input[name=slidePeriod]').value = set.slidePeriod;
|
||||
FC.promptsInputEl.value = set.prompts;
|
||||
FC.answersInputEl.value = set.answers;
|
||||
FC.$('input[name=promptPeriod]').value = set.promptPeriod;
|
||||
FC.$('input[name=answerPeriod]').value = set.answerPeriod;
|
||||
FC.$('select[name=cardOrder]').value = set.sortOrder;
|
||||
FC.$('select[name=textSize]').value = set.textSize;
|
||||
FC.$('input[name=hasAnswers][value="' +
|
||||
(set.cardsHaveAnswers?'yes':'no') + '"]').checked = true;
|
||||
FC.setCardsHaveAnswers();
|
||||
};
|
||||
|
||||
FC.startCards = function(ev) {
|
||||
@ -72,15 +87,18 @@
|
||||
ev.preventDefault();
|
||||
|
||||
FC.currentSet = FC.makeSetFromSettings();
|
||||
FC.items = FC.currentSet.cards.split('\n');
|
||||
FC.cards = FC.zipItems(
|
||||
FC.currentSet.prompts.split('\n'),
|
||||
FC.currentSet.answers?.split('\n')
|
||||
);
|
||||
|
||||
FC.cardContentEl.classList.remove(
|
||||
'small-text', 'medium-text', 'large-text');
|
||||
FC.cardContentEl.classList.add(FC.currentSet.textSize + '-text');
|
||||
FC.curCardEls = FC.cards.map(FC.makeCard);
|
||||
|
||||
FC.curCardEls.forEach(cardEl => FC.cardsEl.prepend(cardEl));
|
||||
|
||||
FC.nextCardIdx = 0;
|
||||
|
||||
const orderedIndices = [...Array(FC.items.length).keys()];
|
||||
const orderedIndices = [...Array(FC.cards.length).keys()];
|
||||
switch (FC.currentSet.sortOrder) {
|
||||
default:
|
||||
case 'in-order':
|
||||
@ -93,20 +111,64 @@
|
||||
FC.cardOrder = FC.shuffle(orderedIndices);
|
||||
}
|
||||
|
||||
FC.$('html').requestFullscreen();
|
||||
FC.showNextCard();
|
||||
FC.runningInterval = setInterval(FC.showNextCard, FC.currentSet.slidePeriod * 1000);
|
||||
const fullInterval = (FC.currentSet.promptPeriod * 1000) +
|
||||
(FC.currentSet.cardsHaveAnswers ? FC.currentSet.answerPeriod * 1000 : 0);
|
||||
FC.runningInterval = setInterval(FC.showNextCard, fullInterval);
|
||||
FC.bodyEl.classList.remove('settings-visible');
|
||||
};
|
||||
|
||||
FC.makeCard = function(item, idx) {
|
||||
const newCardDiv = document.createElement("div");
|
||||
newCardDiv.classList.add('card', FC.currentSet.textSize);
|
||||
newCardDiv.dataset.index = idx;
|
||||
|
||||
const promptDiv = document.createElement("div");
|
||||
promptDiv.classList.add('prompt');
|
||||
promptDiv.innerHTML = FC.transformContent(item.prompt);
|
||||
newCardDiv.appendChild(promptDiv);
|
||||
|
||||
if (item.answer) {
|
||||
const answerDiv = document.createElement("div");
|
||||
answerDiv.classList.add('answer');
|
||||
answerDiv.innerHTML = FC.transformContent(item.answer);
|
||||
newCardDiv.appendChild(answerDiv);
|
||||
}
|
||||
|
||||
return newCardDiv;
|
||||
};
|
||||
|
||||
FC.transformContent = function(content) {
|
||||
const matchesImg = content.toLowerCase().trim().match(IMG_REGEX);
|
||||
if (matchesImg) {
|
||||
return '<img src="' + content.trim() + '"/>';
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
FC.stopCards = function(ev) {
|
||||
clearInterval(FC.runningInterval);
|
||||
document.exitFullscreen();
|
||||
FC.bodyEl.classList.add('settings-visible');
|
||||
document.querySelectorAll('.card').forEach(el => el.remove());
|
||||
};
|
||||
|
||||
FC.showNextCard = function() {
|
||||
const curItem = FC.items[FC.cardOrder[FC.nextCardIdx]];
|
||||
FC.cardContentEl.innerHTML = curItem;
|
||||
FC.nextCardIdx = (FC.nextCardIdx + 1) % FC.items.length;
|
||||
FC.$('.card.current')?.classList?.remove('current', 'show-answer');
|
||||
|
||||
const curCardEl = FC.$('.card[data-index="' + FC.cardOrder[FC.nextCardIdx] + '"]');
|
||||
curCardEl.classList.add('current');
|
||||
|
||||
FC.nextCardIdx = (FC.nextCardIdx + 1) % FC.cards.length;
|
||||
|
||||
if (FC.currentSet.cardsHaveAnswers) {
|
||||
setTimeout(
|
||||
() => { curCardEl.classList.add('show-answer') },
|
||||
FC.currentSet.promptPeriod * 1000
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
FC.handleLoadSet = function(ev) {
|
||||
@ -211,13 +273,39 @@
|
||||
reader.readAsText(fileToImport);
|
||||
};
|
||||
|
||||
FC.importCardSetFromURL = function(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
fetch(FC.$('input[name=importURL]').value)
|
||||
.then(resp => resp.json())
|
||||
.then(set => {
|
||||
if (!set.name) throw "Invalid set definition file (missing name)";
|
||||
FC.saveSet(set);
|
||||
FC.storeSets(FC.savedSets);
|
||||
FC.updateSetList(FC.savedSets);
|
||||
FC.populateSettingsFromSet(set);
|
||||
})
|
||||
.catch(err => {
|
||||
alert("Unable to load set from URL: " + err);
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
FC.setCardsHaveAnswers = function(ev) {
|
||||
if (FC.$('input[name=hasAnswers]:checked')?.value === 'yes') {
|
||||
FC.bodyEl.classList.add('cards-have-answers');
|
||||
} else {
|
||||
FC.bodyEl.classList.remove('cards-have-answers');
|
||||
}
|
||||
};
|
||||
|
||||
window.onload = function() {
|
||||
// Cached element references
|
||||
FC.settingsEl = FC.$('#settings');
|
||||
FC.itemsEl = FC.$('#items-input');
|
||||
FC.promptsInputEl = FC.$('#prompts-input');
|
||||
FC.answersInputEl = FC.$('#answers-input');
|
||||
FC.bodyEl = FC.$('body');
|
||||
FC.cardsEl = FC.$('#cards');
|
||||
FC.cardContentEl = FC.$('#card-content');
|
||||
|
||||
// Event handlers
|
||||
FC.$('.toggle-adv-settings').addEventListener('click', FC.toggleAdvSettings);
|
||||
@ -225,8 +313,11 @@
|
||||
FC.$('#stop-button').addEventListener('click', FC.stopCards);
|
||||
FC.$('#export').addEventListener('click', FC.exportCardSet);
|
||||
FC.$('#import').addEventListener('click', FC.importCardSet);
|
||||
FC.$('#importURL').addEventListener('click', FC.importCardSetFromURL);
|
||||
FC.$('input[name=importFileName]')
|
||||
.addEventListener('change', FC.handleImport);
|
||||
document.querySelectorAll('input[name=hasAnswers]').forEach(inpEl =>
|
||||
inpEl.addEventListener('change', FC.setCardsHaveAnswers));
|
||||
|
||||
FC.$('#save-set').addEventListener('click', FC.handleSaveSet);
|
||||
FC.$('#load-set').addEventListener('click', FC.handleLoadSet);
|
||||
|
48
makewatch
Normal file
48
makewatch
Normal file
@ -0,0 +1,48 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
# Don Marti <dmarti@zgp.org>
|
||||
# (If you need a license on this in order to use it,
|
||||
# mail me and I'll put a license on it. Otherwise,
|
||||
# de minimis non curat lex.)
|
||||
|
||||
# re-run make, with the supplied arguments, when a
|
||||
# Makefile prerequisite changes. Example:
|
||||
# makewatch test
|
||||
# Fun to use with Auto Reload (Firefox) or Tincr (Chrome) and Pandoc
|
||||
# https://addons.mozilla.org/en-US/firefox/addon/auto-reload/
|
||||
# http://tin.cr/
|
||||
# http://johnmacfarlane.net/pandoc/
|
||||
# Requires inotifywait. Probably requires GNU Make to
|
||||
# get the right "-dnr" output. Not tested with other
|
||||
# "make" implementations
|
||||
|
||||
# If $MAKEWATCH is set to an existing file or
|
||||
# space-separated list of files, also checks those.
|
||||
|
||||
make_prereqs() {
|
||||
# Make "make" figure out what files it's interested in.
|
||||
echo "Makefile"
|
||||
find $MAKEWATCH
|
||||
gmake -dnr $* | tr ' ' '\n' | \
|
||||
grep ".*'.$" | grep -o '\w.*\b'
|
||||
}
|
||||
|
||||
prereq_files() {
|
||||
# prerequisites mentioned in a Makefile
|
||||
# that are extant files
|
||||
echo ' '
|
||||
for f in `make_prereqs $* | sort -u`; do
|
||||
[ -e $f ] && echo -n "$f ";
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
make $*
|
||||
while true; do
|
||||
fl=$(prereq_files $*)
|
||||
ev=$(inotifywait --quiet --format %e $fl)
|
||||
if [ "xOPEN" != "x$ev" ]; then
|
||||
sleep 1
|
||||
make $*
|
||||
fi
|
||||
done
|
Loading…
x
Reference in New Issue
Block a user