Compare commits

...

No commits in common. "master" and "gh-pages" have entirely different histories.

125 changed files with 182 additions and 17663 deletions

View file

@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

View file

@ -1,25 +0,0 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
"plugin:vue/essential",
"eslint:recommended",
"@vue/typescript/recommended",
"@vue/prettier",
"@vue/prettier/@typescript-eslint",
],
parserOptions: {
ecmaVersion: 2020,
},
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"@typescript-eslint/no-use-before-define": ["error", { functions: false }],
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/class-name-casing": "off",
"@typescript-eslint/no-explicit-any": "off",
"prettier/prettier": 0,
},
};

View file

@ -1,25 +0,0 @@
name: Build and Deploy
on:
push:
branches:
- master
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Install and Build
run: |
npm install
npm run build
- name: Deploy
uses: JamesIves/github-pages-deploy-action@releases/v3 # https://github.com/marketplace/actions/deploy-to-github-pages
with:
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
BRANCH: gh-pages
FOLDER: dist

22
.gitignore vendored
View file

@ -1,22 +0,0 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1 +0,0 @@
* @FischerRene @nikolockenvitz

View file

@ -1,50 +0,0 @@
# Contributing to Fancy Flashcard <!-- omit in toc -->
- [I want to create decks and make them available so that everyone can learn and benefit from it](#i-want-to-create-decks-and-make-them-available-so-that-everyone-can-learn-and-benefit-from-it)
- [I'm a developer and want to implement features and fix bugs](#im-a-developer-and-want-to-implement-features-and-fix-bugs)
- [I have feedback, have an idea, found a bug, ...](#i-have-feedback-have-an-idea-found-a-bug-)
- [I want to translate Fancy Flashcard](#i-want-to-translate-fancy-flashcard)
## I want to create decks and make them available so that everyone can learn and benefit from it
A convenient way to create decks is still in progress but we offer a CLI if you are experienced with the command line.
After creating a deck you have several options, most of them requiring some technical expertise currently.
You can share it with your friends via messenger, email, ... and use "Import JSON File" to import your deck saved as a local file.
You can also upload it to a webserver and import the deck via URL.
Then you can share this URL with everyone you want to have acces to and they can import it easily without exchanging files manually.
But keep in mind that such decks are publicly available and can be used by anyone.
At least you can obscure the URL and configure your webserver to accordingly so that it's hard to find for people that shouldn't know of it.
Additionally, if you actually want to share your decks with all Fancy Flashcard users, we offer the possibility to add it as a _Third Party Deck_.
Therefore it needs to be accessible on the web via URL.
Just edit [third-party-decks.json](third-party-decks.json) and open a [pull request](https://github.com/fancy-flashcard/ffc/pulls).
When adding a new deck you can choose between three types to add or add none at all.
The three types are `official` for decks created by us, `curated` for staff picked decks and ones created by our fellow colleagues and `sponsored` if you want to pay to add a deck.
If none of these types are selected it is simply a 'featured' deck, as described in [the docs](https://github.com/fancy-flashcard/ffc/tree/master/docs#third-party-decks-curated-featured--sponsored-decks).
If you need help with any of the steps, feel free to contact us (via [issues](https://github.com/fancy-flashcard/ffc/issues) or email).
## I'm a developer and want to implement features and fix bugs
You may want to checkout our [documentation and ideas](#docs) first.
As we do not cover all technical details there, feel free to contact us (**[@FischerRene](https://github.com/FischerRene)**, **[@nikolockenvitz](https://github.com/nikolockenvitz)**) if something is not clear.
For bug fixes and minor features you can open a [pull request](https://github.com/fancy-flashcard/ffc/pulls).
Before implementing major features we suggest to discuss it with us (via [issues](https://github.com/fancy-flashcard/ffc/issues) or email).
## I have feedback, have an idea, found a bug, ...
We highly appreciate your feedback as it helps to improve Fancy Flashcard.
Currently the easiest way would be to open an [issue](https://github.com/fancy-flashcard/ffc/issues).
Of course you could also contact us (**[@FischerRene](https://github.com/FischerRene)**, **[@nikolockenvitz](https://github.com/nikolockenvitz)**) via email.
If you have an idea or found a bug we suggest the same, just open an [issue](https://github.com/fancy-flashcard/ffc/issues).
Before doing that you can check whether a similar one has been opened before.
Because Fancy Flashcard is relatively small at the moment, it wouldn't matter if you don't check and possibly open a duplicate issue.
But as we hope to grow over time, avoiding duplicates would help us to focus on improving Fancy Flashcard.
## I want to translate Fancy Flashcard
Internationalization is in our [backlog](docs/README.md#backlog) but we didn't started with it yet.
We have the ability to add German but for other languages we need your help.
Just open a pull request or ask us for more information to add a new language.

View file

@ -1,9 +0,0 @@
FROM node:latest as builder
COPY . /app
WORKDIR /app
ENV ASSET_PATH "/"
RUN npm install && \
npm run build
FROM nginx:latest
COPY --from=builder /app/dist /usr/share/nginx/html

339
LICENSE
View file

@ -1,339 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View file

@ -1,57 +0,0 @@
<!-- README inspired by https://github.com/othneildrew/Best-README-Template -->
<p align="center">
<a href="https://github.com/fancy-flashcard/ffc">
<img src="src/assets/flash.svg" alt="Fancy Flashcard Logo" width="120" height="120">
</a>
<h3 align="center">Fancy Flashcard</h3>
<p align="center">
Lightweight free and open-source PWA to help you learning on all your devices
<br />
<a href="docs/"><strong>Explore the docs »</strong></a>
<br />
<br />
<a href="https://fancy-flashcard.github.io/ffc/">Start Learning</a>
·
<a href="https://github.com/fancy-flashcard/ffc/issues">Report Bug</a>
·
<a href="CONTRIBUTING.md">Contribute</a>
</p>
<p align="center">
<a href="#readme"
><img src="https://img.shields.io/badge/dynamic/json?color=informational&label=version&query=%24.version&url=https%3A%2F%2Fraw.githubusercontent.com%2Ffancy-flashcard%2Fffc%2Fmaster%2Fpackage.json" alt="Version Number"
></a>
<a href="https://github.com/fancy-flashcard/ffc/actions?query=workflow%3A%22Build+and+Deploy%22"
><img src="https://img.shields.io/github/workflow/status/fancy-flashcard/ffc/Build%20and%20Deploy" alt="Build and Deploy status"
></a>
<a href="LICENSE"
><img src="https://img.shields.io/badge/license-GNU%20GPLv2-success" alt="GNU GPLv2 License"
></a>
</p>
</p>
## Screenshots
<img src="docs/img/deck-selection.png" alt="Deck Selection" width="24%" /> <img src="docs/img/deck-selection-selected.png" alt="Deck Selection - one deck selected" width="24%" /> <img src="docs/img/q-and-a.png" alt="Card" width="24%" /> <img src="docs/img/finish.png" alt="Evaluation after finishing learning" width="24%" /> <img src="docs/img/menu.png" alt="Menu" width="24%" /> <img src="docs/img/import.png" alt="Import" width="24%" /> <img src="docs/img/third-party-decks.png" alt="Third Party Decks" width="24%" /> <img src="docs/img/add-decks.png" alt="Successfully add a Deck" width="24%" />
## Deployment
The app is build and deployed to https://fancy-flashcard.github.io/ffc on every push to master branch (via GitHub Actions and GitHub Pages).
## Contributing
Feel free to report bugs or ideas via [Issues](issues).
Also we highly appreciate [Pull Requests](pulls).
Check out the [Contributing Guide](CONTRIBUTING.md) and the [docs](docs) to get started and see how to set up `Vue.js` below.
| Action | Command |
| ---------------------------------------- | --------------- |
| Project setup | `npm install` |
| Compiles and hot-reloads for development | `npm run serve` |
| Compiles and minifies for production | `npm run build` |
| Lints and fixes files | `npm run lint` |
## License
Distributed under the GPL-2.0 License. See [LICENSE](LICENSE) for more information.

View file

@ -1,3 +0,0 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"]
};

View file

@ -1,274 +0,0 @@
const fs = require("fs");
const { v4: uuidv4 } = require('uuid');
const readline = require('readline').createInterface({
input: process.stdin,
output: process.stdout
});
let curFilename;
let curFileContent;
let saveOnClose = false;
readline.on('close', async () => {
if (saveOnClose) await saveCurrentFile();
process.exit(0);
});
const CONSOLE_LOG_COLOR_FG_RED = "\x1b[31m";
const CONSOLE_LOG_COLOR_FG_GREEN = "\x1b[32m";
const CONSOLE_LOG_COLOR_RESET = "\x1b[0m";
let asciiArt = `
__ __ _ _ _
/ _| / _| | | | | |
| |_ __ _ _ __ ___ _ _ | |_| | __ _ ___| |__ ___ __ _ _ __ __| |
| _/ _\` | '_ \\ / __| | | | | _| |/ _\` / __| '_ \\ / __/ _\` | '__/ _\` |
| || (_| | | | | (__| |_| | | | | | (_| \\__ \\ | | | (_| (_| | | | (_| |
|_| \\__,_|_| |_|\\___|\\__, | |_| |_|\\__,_|___/_| |_|\\___\\__,_|_| \\__,_|
__/ |
|___/
`;
async function cli () {
logWelcomeMessage();
let cmd;
while (true) {
cmd = await readCommand(`Commands: new file <filename>, edit file <filename>, exit`);
if (cmd.startsWith("new file ")) {
curFilename = cmd.substring("new file ".length);
if (!curFilename.endsWith(".json")) curFilename += ".json";
if (await doesFileAlreadyExist(curFilename)) {
console.log(`There is already a file ${curFilename}`);
} else {
const author = await readValue("Author");
curFileContent = { meta: { author }, decks: {} };
await saveCurrentFile(false);
await cliEditFile(curFilename);
}
} else if (cmd.startsWith("edit file ")) {
const filename = cmd.substring("edit file ".length)
await cliEditFile(filename);
} else if (cmd === "exit") {
if (saveOnClose) await saveCurrentFile();
break;
} else {
console.log("Your command was not understood...");
}
saveOnClose = false;
}
readline.close();
}
cli();
function logWelcomeMessage () {
console.log(asciiArt);
let msg = `Welcome to the CLI to create files, decks and cards for `
+ `${CONSOLE_LOG_COLOR_FG_GREEN}Fancy Flashcard${CONSOLE_LOG_COLOR_RESET}. `
+ `Everything is autosaved.`;
console.log(msg);
}
async function cliEditFile (filename) {
saveOnClose = true;
curFilename = filename;
if (!curFilename.endsWith(".json")) curFilename += ".json";
curFileContent = await readJSONFromFile(curFilename);
console.log(`now editing ${curFilename}`);
addUUIDToFileIfItExistsNotYet();
while (true) {
const cmd = await readCommand(`Commands: new deck <name>, list decks, edit deck <name>, delete deck <name>, show meta, meta <attr> <value>, whereami, back`);
if (cmd.startsWith("new deck ")) {
let deckName = cmd.substring("new deck ".length);
if (curFileContent.decks[deckName] === undefined) {
const description = await readValue("Description");
curFileContent.decks[deckName] = {
meta: { deck_name: deckName, next_card_id: 0, description },
cards: {}
}
await cliEditDeck(deckName);
} else {
console.log("There is already a deck with that name. You might want to edit it.");
}
} else if (cmd === "list decks") {
for (let deckName of Object.keys(curFileContent.decks)) {
console.log(deckName);
}
} else if (cmd.startsWith("edit deck ")) {
const deckName = cmd.substring("edit deck ".length);
if (deckName in curFileContent.decks) {
await cliEditDeck(deckName);
} else {
console.log("There is no deck with that name. You might want to create it.");
}
} else if (cmd.startsWith("delete deck ")) {
const deckName = cmd.substring("delete deck ".length);
const conf = await readCommand(`Do you really want to delete the deck ${deckName}? (y/n)`);
if (conf.toLowerCase() === "y") {
delete curFileContent.decks[deckName];
console.log(`Deleted deck ${deckName}`);
}
} else if (cmd === "show meta") {
for (let attr in curFileContent.meta) {
console.log(`${attr}: ${curFileContent.meta[attr]}`);
}
} else if (cmd.startsWith("meta ")) {
const attr = cmd.split(" ")[1];
const value = cmd.substring(`meta ${attr} `.length);
curFileContent.meta[attr] = value;
} else if (cmd === "whereami") {
logWhereIAm(curFilename);
} else if (cmd === "back") {
await saveCurrentFile();
break;
} else {
console.log("Your command was not understood...");
}
}
}
function addUUIDToFileIfItExistsNotYet () {
if (!("uuid" in curFileContent.meta)) {
curFileContent.meta.uuid = uuidv4();
}
}
async function cliEditDeck (deckName) {
console.log(`now editing ${deckName} in ${curFilename}`);
while (true) {
const cmd = await readCommand(`Commands: add cards, list cards, edit card <id>, delete card <id>, show meta, meta <attr> <value>, whereami, back`);
if (cmd === "add cards") {
await cliAddCards(deckName);
} else if (cmd === "list cards") {
for (let cardId in curFileContent.decks[deckName].cards) {
const card = curFileContent.decks[deckName].cards[cardId];
console.log(`id: ${cardId} q: ${card.q} a: ${card.a}`)
}
} else if (cmd.startsWith("edit card ")) {
const cardId = cmd.substring("edit card ".length);
const card = curFileContent.decks[deckName].cards[cardId];
console.log(`q: ${card.q} a: ${card.a}`);
console.log("If you leave a field blank, it will not be changed");
const q = await readValue("Q");
if (q) card.q = q;
const a = await readValue("A");
if (a) card.a = a;
console.log(`q: ${card.q} a: ${card.a}`);
} else if (cmd.startsWith("delete card ")) {
const cardId = cmd.substring("delete card ".length);
const card = curFileContent.decks[deckName].cards[cardId];
console.log(`id: ${cardId} q: ${card.q} a: ${card.a}`);
const conf = await readCommand(`Do you really want to delete the card ${cardId}? (y/n)`);
if (conf.toLowerCase() === "y") {
delete curFileContent.decks[deckName].cards[cardId];
console.log(`Deleted card ${cardId}`);
}
} else if (cmd === "show meta") {
for (let attr in curFileContent.decks[deckName].meta) {
console.log(`${attr}: ${curFileContent.decks[deckName].meta[attr]}`);
}
} else if (cmd.startsWith("meta ")) {
const attr = cmd.split(" ")[1];
const value = cmd.substring(`meta ${attr} `.length);
curFileContent.decks[deckName].meta[attr] = value;
} else if (cmd === "whereami") {
logWhereIAm(curFilename, deckName);
} else if (cmd === "back") {
await saveCurrentFile();
break;
} else {
console.log("Your command was not understood...");
}
}
}
async function cliAddCards (deckName) {
console.log("\nEnter questions and answers. When you leave the question blank, you can run commands: continue, back");
while (true) {
console.log("");
const q = await readValue("Q");
if (q === "") {
const cmd = await readCommand(`Commands: continue, back`);
if (cmd === "continue") {
continue;
} else if (cmd === "back") {
await saveCurrentFile();
return;
} else {
console.log("Your command was not understood...");
continue;
}
} else if (["back", "quit", "exit"].includes(q)) {
console.log("(If you want to go back, you need to leave the question blank.)");
}
const a = await readValue("A");
const id = addCardToDeck(deckName, q, a);
console.log(`id: ${id}\n`);
}
}
function addCardToDeck (deckName, q, a) {
const id = curFileContent.decks[deckName].meta.next_card_id;
curFileContent.decks[deckName].cards[id] = { q, a };
curFileContent.decks[deckName].meta.next_card_id = id + 1;
return id;
}
function logWhereIAm (filename, deckName) {
if (deckName) {
return console.log(`Deck: ${filename} > ${deckName}`);
}
console.log(`File: ${filename}`);
}
async function readCommand (question) {
return new Promise((resolve, reject) => {
readline.question(`\n${question}\n>> `, answer => {
resolve(answer);
});
});
}
async function readValue (text) {
return new Promise((resolve, reject) => {
readline.question(`${text}: `, input => {
resolve(input);
});
});
}
async function saveCurrentFile (log=true) {
await writeJSONToFile(curFilename, curFileContent);
if (log) console.log(`\nSaved ${curFilename}`);
}
async function doesFileAlreadyExist (filepath) {
return new Promise((resolve, reject) => {
fs.exists(filepath, (exists) => {
resolve(exists);
});
});
}
async function readJSONFromFile (filepath) {
return new Promise((resolve, reject) => {
fs.readFile(filepath, "utf8", function (err, data) {
if (err) {
reject(err);
} else {
resolve(JSON.parse(data));
}
});
});
}
async function writeJSONToFile (filepath, content) {
return new Promise((resolve, reject) => {
fs.writeFile(filepath, JSON.stringify(content, null, 2), "utf8", function (err) {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}

13
cli/package-lock.json generated
View file

@ -1,13 +0,0 @@
{
"name": "cli",
"version": "0.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"uuid": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.2.0.tgz",
"integrity": "sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q=="
}
}
}

View file

@ -1,16 +0,0 @@
{
"name": "cli",
"version": "0.0.1",
"description": "generate files for ffc",
"main": "index.js",
"scripts": {
"cli": "node index.js",
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Niko Lockenvitz",
"license": "MIT",
"dependencies": {
"uuid": "^8.2.0"
}
}

View file

@ -1,43 +0,0 @@
{
"meta": {
"author": "Niko Lockenvitz & Rene-Pascal Fischer",
"version": "1.1.1",
"uuid": "6d4b5420-3c3e-42bc-addd-019597fd438b"
},
"decks": {
"d0": {
"meta": {
"deck_name": "A test deck",
"next_card_id": 2,
"description": "This is a deck created for testing purposes. It is about \"mathematics\"."
},
"cards": {
"0": {
"q": "2+2?",
"a": "4"
},
"1": {
"q": "3*4*10?",
"a": "5!"
}
}
},
"d1": {
"meta": {
"deck_name": "Another awesome test deck",
"next_card_id": 3,
"description": "This is another deck created for testing purposes. It is about \"time\"."
},
"cards": {
"0": {
"q": "How many seconds in a day (on earth)?",
"a": "86400"
},
"2": {
"q": "Has the year 2000 been a leap year?",
"a": "yes (because each year divisible by 4 is a leap year (except the ones divisible by 100 (except the ones divisible by 400)))"
}
}
}
}
}

1
css/app.949d2d43.css Normal file
View file

@ -0,0 +1 @@
.ffc-icon[data-v-ba98dd5c]{height:200px}.v-list[data-v-ba98dd5c]{padding:0}.bar-container[data-v-898ec282]{display:flex;padding:12px 24px;justify-content:space-evenly;align-items:flex-end}.bar-wrapper[data-v-898ec282]{flex-grow:1;height:100%;margin:0 5%;display:flex;flex-direction:column}.bar-value[data-v-898ec282]{text-align:center;color:#fc0;font-size:.7em}.bar[data-v-898ec282]{width:100%;background:#fc0;min-height:1px}.bar-name[data-v-898ec282]{flex-grow:1;margin:0 5%;text-align:center}.v-list[data-v-114d3862],.v-sheet[data-v-114d3862]{background-color:unset}.v-list[data-v-114d3862]{padding:0 24px}.v-list-item[data-v-114d3862]{padding:0}.v-dialog>.v-card>.v-card__text[data-v-114d3862]{padding:12px 24px}.v-list-item__content[data-v-114d3862]{padding:6px 0}.share-ffc-url[data-v-114d3862]{text-align:center;margin:0 24px}.multiple-messages[data-v-114d3862]{margin-bottom:0}body,html{background-color:#000;overflow-y:auto!important;-ms-scroll-chaining:none;overscroll-behavior:none}.v-card__title{word-break:normal}.deck-input .v-input__control .v-input__slot{margin-bottom:0}.deck-input .v-input__control .v-text-field__details{display:none}.theme--dark.v-btn:hover:before,.theme--dark.v-list-item:hover:before,.v-btn:not(.v-btn--text):not(.v-btn--outlined):hover:before{opacity:0}.theme--dark.v-list-item--active:before,.theme--dark.v-list-item--active:hover:before{opacity:.24}

View file

@ -0,0 +1 @@
.about[data-v-6ffe5d5d]{text-align:center}.copyright[data-v-6ffe5d5d]{margin:auto}.theme--light.v-footer{background-color:#f5f5f5;color:rgba(0,0,0,.87)}.theme--dark.v-footer{background-color:#272727;color:#fff}.v-sheet.v-footer{border-radius:0}.v-sheet.v-footer:not(.v-sheet--outlined){box-shadow:0 0 0 0 rgba(0,0,0,.2),0 0 0 0 rgba(0,0,0,.14),0 0 0 0 rgba(0,0,0,.12)}.v-sheet.v-footer.v-sheet--shaped{border-radius:24px 0}.v-footer{align-items:center;display:flex;flex:0 1 auto!important;flex-wrap:wrap;padding:6px 16px;position:relative;transition-duration:.2s;transition-property:background-color,left,right;transition-timing-function:cubic-bezier(.4,0,.2,1)}.v-footer:not([data-booted=true]){transition:none!important}.v-footer--absolute,.v-footer--fixed{z-index:3}.v-footer--absolute{position:absolute}.v-footer--absolute:not(.v-footer--inset){width:100%}.v-footer--fixed{position:fixed}.v-footer--padless{padding:0}

View file

@ -0,0 +1 @@
#btn-fixed-bottom-right-corner[data-v-16f0e076]{position:fixed;bottom:20px;right:20px;margin:0!important}.hidden[data-v-16f0e076]{opacity:0;transition:.2s}.visible[data-v-16f0e076]{opacity:1;transition:.2s}.rotate-90[data-v-16f0e076]{transform:rotate(90deg)}#no-decks-yet-notice[data-v-16f0e076]{padding:0 16px}.theme--light.v-subheader{color:rgba(0,0,0,.6)}.theme--dark.v-subheader{color:hsla(0,0%,100%,.7)}.v-subheader{align-items:center;display:flex;height:48px;font-size:.875rem;font-weight:400;padding:0 16px 0 16px}.v-subheader--inset{margin-left:56px}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
.button-padding[data-v-6087d784]{padding:16px}.description[data-v-6087d784]{align-items:center;font-size:.875rem;font-weight:400;color:hsla(0,0%,100%,.7)}p .v-icon[data-v-6087d784]{color:inherit}.paragraph[data-v-6087d784]{display:flex}[data-v-6087d784] input,[data-v-6087d784] input::-webkit-inner-spin-button,[data-v-6087d784] input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0;-moz-appearance:textfield}.button-padding[data-v-8d3280ae]{padding:16px}.v-card[data-v-8d3280ae]{display:flex;flex-direction:column}.horizontal-spacer[data-v-8d3280ae]{flex-grow:1}

View file

@ -0,0 +1 @@
.rating[data-v-9d3d01fc]{padding:0 16px;display:flex;justify-content:center}.star-wrapper[data-v-9d3d01fc]{display:flex;flex-flow:column;flex:1 1 0;max-width:20vw;max-height:16vh}.invisible[data-v-9d3d01fc]{visibility:hidden}svg.star[data-v-9d3d01fc]{stroke-width:5;stroke-linejoin:round}svg.star polygon[data-v-9d3d01fc]{cursor:pointer}svg.star.filled polygon[data-v-9d3d01fc]{fill:#fc0;stroke:#fc0;transition:.2s}svg.star polygon[data-v-9d3d01fc]{fill:#222;stroke:#fc0;transition:.5s}.learn[data-v-7b6129e6]{height:100%;display:flex;flex-direction:column;text-align:center}.max-height[data-v-7b6129e6]{display:flex;flex-direction:column;flex:1 1 auto;overflow-y:auto;height:0;align-items:center;padding:0 16px}.flex-center[data-v-7b6129e6]{justify-content:center}.learn[data-v-6cc4e3a0]{height:100%}

View file

@ -0,0 +1 @@
.v-list-item__icon[data-v-5a19b7f8]{margin:8px}.theme--dark.v-list-item--active[data-v-5a19b7f8]:before,.theme--dark.v-list-item--active[data-v-5a19b7f8]:hover:before{opacity:0}.v-subheader .v-icon[data-v-5a19b7f8]{color:inherit}.description[data-v-5a19b7f8]{align-items:center;font-size:.875rem;font-weight:400;padding:0 16px;color:hsla(0,0%,100%,.7)}.theme--light.v-subheader{color:rgba(0,0,0,.6)}.theme--dark.v-subheader{color:hsla(0,0%,100%,.7)}.v-subheader{align-items:center;display:flex;height:48px;font-size:.875rem;font-weight:400;padding:0 16px 0 16px}.v-subheader--inset{margin-left:56px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,439 +0,0 @@
# Documentation <!-- omit in toc -->
## Table of Contents <!-- omit in toc -->
- [What is this about?](#what-is-this-about)
- [The idea behind Fancy Flashcard](#the-idea-behind-fancy-flashcard)
- [Architecture](#architecture)
- [Progressive Web App](#progressive-web-app)
- [Decentralized persistence](#decentralized-persistence)
- [Component based UI with Vue.js](#component-based-ui-with-vuejs)
- [UI Design](#ui-design)
- [Technology](#technology)
- [Why Vue?](#why-vue)
- [Why Vuetify?](#why-vuetify)
- [Local Storage for storing data](#local-storage-for-storing-data)
- [Offline Availability](#offline-availability)
- [Technical insights and how we did it](#technical-insights-and-how-we-did-it)
- [Terminology](#terminology)
- [Learning Progress and Rating of Cards](#learning-progress-and-rating-of-cards)
- [Random Card Selection](#random-card-selection)
- [File Format for External Data Sources](#file-format-for-external-data-sources)
- [Internal Storing of Decks, Cards and Learning Progress](#internal-storing-of-decks-cards-and-learning-progress)
- [Monetization](#monetization)
- [Educational App](#educational-app)
- [Third Party Decks: Curated, Featured & Sponsored Decks](#third-party-decks-curated-featured--sponsored-decks)
- [Arrange contact to private tutors](#arrange-contact-to-private-tutors)
- [Closing words on monetization](#closing-words-on-monetization)
- [License](#license)
- [Testing](#testing)
- [Static tests](#static-tests)
- [Dynamic tests & Manual tests](#dynamic-tests--manual-tests)
- [Backlog](#backlog)
- [Some ideas](#some-ideas)
- [_FFQuiz_ - Fancy FlashQuiz](#ffquiz---fancy-flashquiz)
## What is this about?
First of all this app is part of a series of apps created by students as part of the lecture [mobile applications](https://github.com/michael-spengler/seminararbeiten-wwi17seb) as part of our last semester at university.
We, as used in this context, are @nikolockenvitz and @FischerRene.
We created this [PWA](https://web.dev/progressive-web-apps/) not only as a submission in this lecture, but also for us to use for learning.
With this documentation we want to express what we did, how we did it and especially why we did it, so please take your time reading this.
If you got any questions regarding this documentation or some decisions we made, feel free to open an issue and we will answer it in no time!
(Please label the issues with the `documentation` tag if you create one.)
What does this mean for the future of FFC?
Our plan is to update further update this app with little features we deem to be necessary and important enough to us.
But because of the license we decided upon, you can simply fork this project and create pull requests with features you would like to see in FFC!
## The idea behind Fancy Flashcard
Flashcard apps are usually used for learning a new language.
In this context using more than one deck at a given time will not be useful.
This is the catch of most apps.
You simply can not learn multiple decks at once and have to create new mixed decks if you want to overcome this obstacle.
Our idea was to simply choose which deck you want to include when learning.
This way you do not have to repeat yourself over and over if you want to mix some decks while learning.
There is one particular scenario we had in mind: Our finals.
We have to learn every topic we had lectures on and its simply annoying to create a lot of mixed decks to accomodate for every possible situation.
That is why we created **Fancy Flashcard (FFC)**.
Our goal with this app is to provide a simple and easy to use [Progressive Web App](https://web.dev/progressive-web-apps/) foundation.
This foundation consists of a simple storing approach for Q&A-pairs as well as a progression system to support a users learning journey.
In general we want to make it possible to add/import decks as easy as possible to enable anyone to use the app for their purposes.
FFC originated from the idea of combining the ease of use of index cards and their flexibility.
With physical index cards you can simply mix the learning decks you want, no matter the topic.
We tried to do the same with FFC by allowing the user to combine two or more decks for a learning session.
To us this is a kind of small revolution for digital learning and the concept of a paperless and therefore more environmently oriented world.
We want to shift the focus away from writing thousands of index cards in schools or universities every year and instead embrace the digital world and its possibilities.
With FFC you can easily create decks and share them with your friends.
This will not only make your life easier, because you don't need to write every index card yourself or use a copier to be able to share your decks, it will also make the learning process more social, because you can simply use the decks in learning games.
Because of the simple structure of our decks they can be easily integrated in all sort of apps.
This is where we see one of the best parts of e-learning.
The ability to cooperate with others in e.g. quiz games where you and your friends compete.
Especially in a global crisis, like the COVID-19 pandemic in 2020, digitial learning is one of the most important achievements in modern history.
With FFC we try to give people the opportunity to educate themselves in their own homes and help the young generation to be able to learn everywhere they want for as long as they want.
To sum it up, FFC is as good as physical index cards but you will not have to carry the cards with you to be able to learn on your commute to school/work or when you have a few minutes of free time and don't know what to do.
To understand how it all works please read this README, we try to cover our every thought.
If you have any questions about our ideas, feel free to contact us.
## Architecture
### [Progressive Web App](https://web.dev/progressive-web-apps/)
We decided to create a [Progressive Web App (PWA)](https://web.dev/progressive-web-apps/) instead of a native app because it's easier and cheaper to develop and maintain.
This choice was realitively easy, as we already had experiences with web development and believe in the future of [PWAs](https://web.dev/progressive-web-apps/).
This is simply because [PWAs](https://web.dev/progressive-web-apps/) radically facilitate onboarding for users and can be accessed from any device (even computers) as long as they have a web browser supporting it.
By creating a [PWA](https://web.dev/progressive-web-apps/) we can easily deploy our app on the web and are not forced to offer it on each app store to cater to every mobile user.
This also allows us to focus on developing more features instead of multiple versions of the same application for each major operating system.
In addition we are allowed to publish the [PWA](https://web.dev/progressive-web-apps/) to the Google Play Store via the Trusted Web Activity (TWA) API or get it listed in the Apple App Store.
Apps installed via these stores have the ability to ask the user for more permissions, like accessing certain hardware APIs.
Because we do not require those and do not have other special features in our first iterations, it is simply unnecessary to invest the time and money ([Google Play Store: $25 one time fee](https://play.google.com/apps/publish/) - [Apple App Store: $99 per year](https://developer.apple.com/programs/how-it-works/)) into getting the app into these stores.
This may change with time because most users do not know how to install a web app and simply use the respective app stores for finding new apps.
### Decentralized persistence
To ensure the highest possible level of privacy and data protection we decided for a completely decentralized app and therefore without any dependency on central servers or their availability.
That means all learning progress is stored on users devices so they don't need to fear tracking or espionage, you can read about our motives for this in chapter [Monetization](#monetization).
Anyone can offer decks for download online or simply export them as a file to later be imported by another user, as long as the decks use our supported format.
With our decentralized approach we do not need any kind of authentication, that may be used to gather information about you (e.g. O-Auth via Facebook) or introduces any threats concerning users data.
For the app all users are the same and you simply work with **your local** data.
This may be seen as a disadvantage, because you cannot simply switch devices in learning sessions, but we believe this trade off is worth your privacy.
We maybe add some privacy-preserving sync-mechanism in the future, see our [Backlog](#backlog).
### Component based UI with Vue.js
We are using the frontend framework [Vue.js](https://vuejs.org/).
See [below](#why-vue) why we decided for Vue.js.
In [Vue.js](https://vuejs.org/) the web app is structured into components, each fulfilling a single purpose.
By splitting into small clearly arranged parts, the code is easier to understand and to maintain.
As we use single file components the layout, styling and logic can be found in one file for each component.
One might argue that having HTML, JavaScript and CSS mixed up in one file is contrary to separation of concerns, but we found it very convenient to have all stuff for a certain purpose in one file and not splitted into three.
As highlighted in the [Vue.js docs](https://vuejs.org/v2/guide/single-file-components.html#What-About-Separation-of-Concerns) separation of concerns is not about separation of file types.
[Vue.js](https://vuejs.org/) offers to split JavaScript and CSS into distinct files but as mentioned above it helped to see all code belonging together in one file.
Additionally we moved some of the code to helper files, making it possible to reduce size of the components and thus increasing readability.
## UI Design
The UI design of the app was chosen by us developers, because we are the first users of our app.
We decided to use an overall darkmode for the app to reduce eye strain when learning in the evening or in dark environments.
Every aspect of the app is designed to support one hand use no matter the screen size.
We added the ability to swipe to the right to open the menu and added different design elements (like our logo) at certain locations to naturally move the complete user interaction to the bottom half of the screen. (This aspect is easily seen in the learning process.)
We also decided to use [Vuetify](https://vuetifyjs.com/) and the _Material Design_ provided by it.
_Material Design_, responsiveness and a generally appealing design is a must-have for our app, simply because we expect Fancy Flashcard to be used almost exclusively on mobile devies.
Using Vuetify made development easier and quicker, because we could use already existing UI components and did not have to create everything from scratch.
## Technology
### Why Vue?
[Vue.js](https://vuejs.org/) is a community backed and lightweight frontend framework.
Compared to React and Angular it's easier to learn and get started right away.
Apart from the advantage for us, we also see the chance that others can contribute more easily.
All this is of course aided by the ease of use and broadly available documentation of [Vue.js](https://vuejs.org/).
Additionally [Vue.js](https://vuejs.org/) offers the possibility to use [TypeScript](https://www.typescriptlang.org/) so that we could take advantage of using typing and static code checks.
### Why [Vuetify](https://vuetifyjs.com/)?
[Vuetify](https://vuetifyjs.com/) is a library with many UI components for Material Design.
It's widely used and well documented.
We chose to add it to our project because we wanted our app to comply with [Material Design Guidlines](https://material.io) and it really helped us to create beautiful user interfaces quickly.
### Local Storage for storing data
As we decided for a decentral approach, we somehow needed to store data client-side.
Among all the possibilites we decided for Local Storage because it's very simple and satisfies exactly what we need:
storing key-value-pairs without any overhead.
### Offline Availability
To make [PWAs](https://web.dev/progressive-web-apps/) feel like native apps, a Service Worker is required.
It offers the possibility to cache data and resources which makes our app work offline.
In the future the Service Worker may help us by offering access to notifications.
Additionally we make our app installable by providing a manifest, thus users can add it to their homescreen and it feels like a native app.
### Technical insights and how we did it
#### Terminology
Cards (Q/A-pairs) are grouped into decks.
Those decks can be imported via files.
Files can be stored locally or accessed online via a URL.
Each file contains multiple decks.
Each deck is identified by a short name / id (only inside this file).
It then contains some meta data (full name, description, ...) and multiple cards (questions with corresponding answers), each identified by some incrementing number (the next value is stored in the meta data so that there are no collisions when cards are deleted).
You can find the file format for such files below.
A card (question-answer-pair) is uniquely identified by the data source identifier (e.g. URL where the file is hosted, local filepath, ...), the deck short name / id and the id of the card itself (inside the deck).
#### Learning Progress and Rating of Cards
The learning progress is stored locally in the app.
After revealing an answer, the user can rate how hard/easy it was (e.g. on a scale from 1 to 5; 1=hard, 5=easy).
For each card an array of objects like ``{ "timestamp": "...", "rating": 50 }`` is stored.
Internally the rating is always stored on a scale from 0 to 100 (indepentend of what the users sees and selects; makes more precise adjustments easier in the future).
The rating which is displayed to the user must be mapped, e.g. 1 &rarr; 0, 2 &rarr; 25, ..., 5 &rarr; 100.
#### Random Card Selection
The user can select one or multiple decks for learning (and maybe limit time or number of cards).
The selection of cards is done by an algorithm, which can be replaced or adjusted.
Our first proposal is as follows:
* we downgrade the rating of each card based on time and rating when user rated it the last time
* this leads to a virtual rating for each card
* when the next card needs to be displayed, we choose randomly from all the cards based on the virtual rating (low rating = higher probabilty of being chosen)
#### File Format for External Data Sources
````json
{
"meta": {
"author": "Name of the Author",
"uuid": "Optional UUID to allow updates (especially for local files)",
"url": "This keyword is reserved for internal use and will be overridden. Do not use it.",
"...": "..."
},
"decks": {
"deck_short_name": {
"meta": {
"deck_name": "Full Name of the Deck",
"description": "Description",
"next_card_id": 3,
"short_name": "This keyword is reserved for internal use and will be overridden. Do not use it.",
"...": "..."
},
"cards": {
"0": {
"q": "question",
"a": "answer"
},
"2": {
"q": "question",
"a": "answer"
}
}
},
"...": {}
}
}
````
You can find an example file and a command line interface to create and edit such files in the [cli folder of this repository](../cli).
Such files can be either loaded as a local file or from a URL.
The latter one can easily be done if CORS headers are present but it might be that not everyone is able to configure this (e.g. static file server).
See [no-cors.md](no-cors.md) for ideas how Same-origin policy can be bypassed without CORS.
#### Internal Storing of Decks, Cards and Learning Progress
````json
{
"decks": [{
"id": 1,
"selected": false,
"name": "Name of the Deck (uses deck_name or deck_short_name as a fallback)",
"meta": {
"file": {
"author": "Name of the Author",
"...": "..."
},
"deck": {
"short_name": "Short Name of the Deck",
"name": "Full Name of the Deck",
"description": "Description",
"next_card_id": 3,
"...": "..."
}
},
"cards": [
{
"id": 1,
"q": "question",
"a": "answer",
"r": [
{ "t": "Timestamp When This Card Has Been Rated", "r": 50 },
{ "t": 1590866520000, "r": 99}
]
},
{ "...": "..."}
]
},
{ "...": "..."}
]
}
````
## Monetization
Sooner or later every app publisher thinks about monetization in some form.
A vast majority chooses an ad based monetization system.
On the one hand that doesn't make much sense because we prefer an open source model and anyone could fork the repository and diable ads.
On the other hand users can easily install addons in their browsers to block ads.
Additionally such an ad based monetization system often leads to an app with around 50% of the available display area filled with ads.
Because of this many users despise this sort of monetization and will opt to other apps instead, especially if the app has a small user base or is easily replaceable.
We try to combat this with a few ideas of our own.
### Educational App
Our first idea is about education and ability to easily access it.
Because of this we want to create specialized versions of the app with pre-installed decks or deck selection menus for every customer.
Customers can be private companies and public institutions like schools or universities that pay a comparably small fee to have this web app adjusted to their needs and get help rolling it out.
This repository being open source and under the MIT License will also be available for those customers to adjust on their own without any cost connected to it.
Our goal with this is to accumulate more users and introduce the app as the de facto standard for educational purposes.
How does it work though?
It's pretty simple, we create a deck selection instead of the deck creator on the import page.
This deck selection will be fed by a JSON file hosted by us or the company/school and will include all decks created by the school.
Users will simply choose a deck they want and add it to their own collection.
To avoid misuse we could disable the URL import and only allow users to choose the given decks.
Ease of use is the focus of an educational app and users should enjoy using it.
Because of this our long term goal would be to enable the app to be used for educational purposes simply by submitting a code created for your company or school.
With this idea we eliminate the need of specialized apps as well as a lot of support and thus lower the cost of development in the long term.
### Third Party Decks: Curated, Featured & Sponsored Decks
Another monetization idea are third party decks.
With third party decks we create a space for companies and users to get their decks featured and seen by every user in the app.
These decks will use the same deck selection as the educational app proposes.
This kind of deck library will use a static url to get possible decks that we think are worth your time or have a sponsor and are not contrary to public morality.
To make this possible we think about adding a community based process to decide whether decks are ok to be added and featured in official collections once we have a space for third party decks and are opening it to the public.
Before opening it to the public we will also add a moral guide to decks and what (not) to implement in decks.
This is a very important topic to use, because we do not want to censor any creativity, friendly interaction or limit the freedom of speech, but have to plan for potential abuse of the system as a whole.
Featured decks may also include some sort of charts/trends system to shine light on upcoming decks with a lot of potential and decks that are performing really well over the course of time.
This is to show users that every deck can be interesting and not every deck has to be about the same topic.
We think this may animate users to create own decks and share them with the world and contribute to our goal: *sharing knowledge*.
In the end we might offer others the possibility to sell decks to users for a small fee so that we can get a commission every time someone buys a deck.
This would also help to generate income without the need to maintain a large pool of staff members.
If we give users the opportunity to earn money with our app, they will do their part to make the app successful and boost their and thus our profits.
We think users should be able to see which decks were curated (aka. staff picked, because we like it), featured (aka. loved by our community and used a lot of times) or sponsored by companies or people.
That is why we will introduce different ways to highlight each special deck version to avoid user confusion from the first introduction onward.
Users should not have the feeling they are catered only what they like in order to maximize the time (and money) they spent on our app.
Our goal is to broaden the view of users and let them try out new stuff about new topics even though they might not like a deck we suggest, simply because we as the creators like to learn new and exiting things even if they are foreign to the subjects we spent time with.
Because of the wide range of use cases a deck library will be one of the first things we want to add in the future, but will probably not feature any monetization by then.
### Arrange contact to private tutors
We also thought about some model similar to ads but more focused on the subject of learning.
Users could give their consent to see suitable private tutors for certain subjects they are struggling with.
We could then get a small commision for connecting students with a tutor.
This can also be used together with our educational app approach where older students could offer help for younger ones.
This would also have the advantage that subjects are specific for one institution/school.
### Closing words on monetization
The app will always be open source and free for everyone to fork and create an own version improving what we created.
Because of this we encourage everyone to contribute to this repository in order to create the best application possible.
Ads keep getting more intrusive and are used to get a perfect digital copy of you to target you with even more specialized ads.
We believe apps should not see users as the product they are trying to sell to customers (e.g. advertisers) and that is why we do not want to incorporate any sort of ads in our design.
With our design the focus is shifted to the users anonymity and safety.
They should feel free to learn new or improve their skills.
The constant fear of collecting personalized data and the misuse of it will hinder their learning ability and thus be contraproductive for our purpose.
In general we want to provide our app for free so that anyone can use it.
Additionally we want to let others offer additional services and get commisions on their returns.
## License
Fancy Flashcard was invisioned as [Free Software (FOSS)](https://en.wikipedia.org/wiki/Free_software), because we want to share knowledge and offer persons interested in the app the ability to analyze, contribute and improve Fancy Flashcard.
Especially students learning with Fancy Flashcards should be able to adapt it to their own needs.
During development we thought a lot about licencing and how we want other to see FFC.
Especially the idea of switching from MIT to GPL has occured to us not only once.
When choosing the MIT License at the beginning of our project we had the ease of use for others wanting to build upon our foundation in mind as it has not much restrictions/conditions.
We expected to have more contributions to the Fancy Flashcard ecosystem this way, which lets it grow faster, even if not all additions are open source.
We also like the MIT License because it is really simple and short, so even inexperienced developers will first of all read it and be able understand it way quicker than most other licenses.
After a while we were asked about our thoughts on GPL and why we'd chosen MIT.
Because of this we started to think about it again.
As a result of this thought process and having a more detailed look into free software, we came to the conclusion that we want Fancy Flashcard to be associated with free software.
To enable this we need to make sure that the whole ecosystem is made of free software.
Therefore we decided to update our license to a [_Copyleft License_](https://en.wikipedia.org/wiki/Copyleft).
This might discourage some people to create app on the basis of Fancy Flashcard, but we think this is acceptable, because all improvements made have to be shared and thus are going to be open to anyone.
This will ensure improvements are public and will result in additions that are at least as valuable as the ones made under the MIT license.
Adding to this there will be no competitors build proprietary solutions using FFC as a basis and therefore decreasing Fancy Flashcards market share in favor of non free software.
By choosing a Copyleft license we strengthen the FOSS ecosystem and enable it to have a brighter future with more free software.
We believe this is a step in the right direction, because we want information and knowledge to be accessible to everyone for free and therefore try to help by creating a platform to enable people to share their knowledge.
## Testing
### Static tests
Testing is one of the most important parts of software created for users.
This is why we opted to use [TypeScript](https://www.typescriptlang.org/) as it helps us in development to avoid simple mistakes like using a wrong type.
[TypeScript](https://www.typescriptlang.org/) ultimately helps in development because errors are mostly caught inside the editor or while compiling instead of failing at runtime.
### Dynamic tests & Manual tests
Because of our small team we decided against unit tests and for manual (exploratory) testing.
Not only because it is easier to manage, but we also wanted to get a feeling of the usability and how to interact with the sofware.
We even iterated on some aspects of the usability features like the way to open our menu &rarr; we decided to add a swipe animation for easier one hand use of the software.
In general every aspect of the app is tested manually by both developers, because of the limited development time and our focus on users.
We plan on introducing different tests in the future with one of them being unit tests of each important modules, like our _specialized_ algorithm to select the next card to learn.
We also want to have a look into tour testing as an extension to our current exploraty testing in the future.
## Backlog
### Some ideas
Ideas we want to implement in the future, but did not have enough time to implement or work out completely.
- add security measures when updating decks (e.g. signatures, see [#16](https://github.com/fancy-flashcard/ffc/pull/16))
- automatically update all third party decks (that have been installed) when starting the app &rarr; should be either a background task or triggered manually and only show a snackbar when finishing ("Updated third party decks to newest versions")
- this may need us to include the version number inside the [third-party-decks.json](../third-party-decks.json)
- this would also enable us to control the flow of updates &rarr; security feature? only allow officially tagged "releases" as urls?
- extend format and displaying in app to show not only simple text
- Markdown support
- formulas similar to LaTeX
- image support
- suggested answers to choose from (quiz-like, see below)
- extend deck library / recommended decks / third-party-decks
- instructions how to make it to the third party deck list
- maybe some UI based deck creation
- make sure content of third party decks is okay
- develop some general guidelines / code of conduct
- community based process to evaluate decks?
- based on reputation of authors / type of third party deck: add hash of file to prevent deck from updating
- easy process to report decks violating code of conduct
- optimize random card selection algorithm + make it customizable in app settings
- import decks from a website even if no CORS headers are sent, see [no-cors.md](no-cors.md)
- analytics for insights into individial learning progress
- internationalization
- light color mode
- sync learning progress across multiple devices
- gamification
- charts/trends
- help users get started: usage docs, videos, GitHub Learning Lab, ...
- Chatbot (Telegram)
- publishing the application on the Google Play Store or the Apple App Store
### _FFQuiz_ - Fancy FlashQuiz
The following idea will be its own project, but we still want you to know what it's about.
The _FFQuiz_ is a quiz game using the foundation of FFC focused on the gamification of learning.
With FFQuiz we want to expand the use cases for FFC and make learning more enjoyable and accessible for younger and older generations.
The Quiz should be easy to set up and work for 2 or more players.
The game will consist of different rounds and quiz styles.
Players will be competing against one another in some kind of game show you tend to see on TV.
Depending on the number of players you will face of against one or more of your friends.
To help encourage you to use the app each player will get a score and the one with the most knowledge will be ranked first.
We can also spin this idea further in building an ecosystem of apps that all aim to help learning.
For example we could add a _FFHelp_ app which allowes users to communicate across the globe and share their knowledge or some sort of _Wiki_ for schools or universities in certain districts to help them educate students.
The possibilities to improve and expand these ideas is sheer endless.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1,75 +0,0 @@
# Bypassing Same-origin policy without CORS
For loading decks from external files it is necessary to bypass SOP.
It's not guarenteed that all sites can send CORS headers.
Thus we need another way which works by adjusting the content / file format and not by configuring the server.
## Proxy
Obviously one could setup a server that receives a file URL, reads the corresponding data, adds CORS headers and returns the data.
But the disadvantage is that this requires deploying a server which can handle enough requests.
That would also make our app vulnerable to (D)DoS attacks in some way.
Of course one could also use existing services but then you have to rely on others which should be avoided as much as possible.
## JSONP
One shortly evaluated way of circumventing SOP was to use JSONP.
That means that a `script`-tag is added dynamically that invokes an external JavaScript file.
This JavaScript file could then call a function and pass the JSON data.
It worked to send data from an external site but it also leads to a critical security vulnerability since any code could be injected and not only the desired function call which passes some JSON data.
## iframe and Window.postMessage()
**TODO**: some general introduction
External site which offers decks:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
</head>
<body>
<!-- e.g. some content to promote the file which is served from this website -->
<script>
const data = { "meta": "...", "decks": "..." };
function receiveMessage(event)
{
window.parent.postMessage(JSON.stringify(data), event.origin);
}
window.addEventListener("message", receiveMessage, false);
</script>
</body>
</html>
```
Loading data:
```js
const EXTERNAL_URL = "https://nikolockenvitz.de/ffc/postmessagetest.html";
const iframe = document.createElement("iframe");
iframe.style.display = "none";
iframe.src = EXTERNAL_URL;
iframe.sandbox = "allow-scripts allow-same-origin";
document.body.appendChild(iframe);
function receiveMessage(event) {
// event.origin needs to be checked
// event.data contains the data, e.g. stringified JSON
console.log(event);
}
window.addEventListener("message", receiveMessage, false);
iframe.contentWindow.postMessage("get fancy decks", EXTERNAL_URL)
```
The security of this approach should be investigated further but at the first look it seems ok.
## Sources
* https://stackoverflow.com/questions/3076414/ways-to-circumvent-the-same-origin-policy
* https://www.w3schools.com/js/js_json_jsonp.asp
* https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe

View file

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

Before

Width:  |  Height:  |  Size: 204 B

After

Width:  |  Height:  |  Size: 204 B

View file

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

View file

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View file

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5 KiB

View file

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5 KiB

View file

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View file

Before

Width:  |  Height:  |  Size: 831 B

After

Width:  |  Height:  |  Size: 831 B

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 251 B

1
index.html Normal file
View file

@ -0,0 +1 @@
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"><meta name=author content="Niko Lockenvitz & Rene-Pascal Fischer"><meta name=description content="Learning PWA - Mix selfmade decks for your learning success!"><meta name=robots content="index, nofollow, noarchive"><meta name=google content=notranslate><meta property=og:title content="Fancy Flashcard"><meta property=og:image content=https://fancy-flashcard.github.io/ffc/img/icons/android-chrome-192x192.png><!--[if IE]><link rel="icon" href="/ffc/favicon.ico"><![endif]--><link rel=stylesheet href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"><link rel=stylesheet href=https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css><title>Fancy Flashcard</title><link href=/ffc/css/chunk-065f4018.2db77a3e.css rel=prefetch><link href=/ffc/css/chunk-2eadd278.67146ca8.css rel=prefetch><link href=/ffc/css/chunk-6e220460.506e66e1.css rel=prefetch><link href=/ffc/css/chunk-75fc5bce.905fd344.css rel=prefetch><link href=/ffc/css/chunk-7724eb6e.854399cf.css rel=prefetch><link href=/ffc/css/chunk-c9cd02f0.966620a7.css rel=prefetch><link href=/ffc/css/chunk-f88b53c8.85e964b6.css rel=prefetch><link href=/ffc/js/chunk-065f4018.089c1ae0.js rel=prefetch><link href=/ffc/js/chunk-2eadd278.9c6c8c4b.js rel=prefetch><link href=/ffc/js/chunk-6e220460.eea51f45.js rel=prefetch><link href=/ffc/js/chunk-75fc5bce.31fd23e9.js rel=prefetch><link href=/ffc/js/chunk-7724eb6e.7b085d09.js rel=prefetch><link href=/ffc/js/chunk-c9cd02f0.cd91040b.js rel=prefetch><link href=/ffc/js/chunk-f88b53c8.c362a2c7.js rel=prefetch><link href=/ffc/css/app.949d2d43.css rel=preload as=style><link href=/ffc/css/chunk-vendors.2caf484c.css rel=preload as=style><link href=/ffc/js/app.38aa3ed0.js rel=preload as=script><link href=/ffc/js/chunk-vendors.50cc43f1.js rel=preload as=script><link href=/ffc/css/chunk-vendors.2caf484c.css rel=stylesheet><link href=/ffc/css/app.949d2d43.css rel=stylesheet><link rel=icon type=image/png sizes=32x32 href=/ffc/img/icons/favicon-32x32.png><link rel=icon type=image/png sizes=16x16 href=/ffc/img/icons/favicon-16x16.png><link rel=manifest href=/ffc/manifest.json><meta name=theme-color content=#363636><meta name=apple-mobile-web-app-capable content=no><meta name=apple-mobile-web-app-status-bar-style content=default><meta name=apple-mobile-web-app-title content=ffc><link rel=apple-touch-icon href=/ffc/img/icons/apple-touch-icon-152x152.png><link rel=mask-icon href=/ffc/img/icons/safari-pinned-tab.svg color=#363636><meta name=msapplication-TileImage content=/ffc/img/icons/msapplication-icon-144x144.png><meta name=msapplication-TileColor content=#3F51B5></head><body><noscript><strong>We're sorry but Fancy Flashcard doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=/ffc/js/chunk-vendors.50cc43f1.js></script><script src=/ffc/js/app.38aa3ed0.js></script></body></html>

2
js/app.38aa3ed0.js Normal file

File diff suppressed because one or more lines are too long

1
js/app.38aa3ed0.js.map Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-065f4018"],{"5ed6":function(t,e,a){"use strict";var s=a("6318"),i=a.n(s);i.a},6318:function(t,e,a){},b5b6:function(t,e,a){},f820:function(t,e,a){"use strict";a.r(e);var s=function(){var t=this,e=t.$createElement,a=t._self._c||e;return a("div",{staticClass:"about"},[a("v-container",{attrs:{fluid:""}},[a("v-row",{attrs:{align:"center",justify:"center"}},[a("v-col",{attrs:{cols:"10"}},[a("span",{staticClass:"title"},[t._v("Fancy Flashcard")]),a("br"),a("span",{staticClass:"version"},[t._v("Version "+t._s(t.getVersion()))]),a("br"),a("v-btn",{staticClass:"my-4",attrs:{color:"indigo"},on:{click:t.shareApp}},[t._v("Share Fancy Flashcard")]),a("v-footer",{attrs:{app:""}},[a("span",{staticClass:"px-4 copyright"},[t._v(" © "+t._s((new Date).getFullYear())+" Niko Lockenvitz & Rene-Pascal Fischer "),a("br"),a("a",{attrs:{href:"https://github.com/fancy-flashcard/ffc"}},[t._v("https://github.com/fancy-flashcard/ffc")])])])],1)],1)],1)],1)},i=[],o=a("d4ec"),n=a("bee2"),c=a("262e"),r=a("2caf"),l=a("9ab4"),h=a("2b0e"),f=a("2fe1"),u=a("ebe2");function p(){var t=document.getElementById("ffc-url");t&&(t.select(),t.setSelectionRange(0,t.value.length),document.execCommand("copy"),t.blur())}var d=function(t){Object(c["a"])(a,t);var e=Object(r["a"])(a);function a(){return Object(o["a"])(this,a),e.apply(this,arguments)}return Object(n["a"])(a,[{key:"shareApp",value:function(){if(navigator.share)navigator.share({title:"Fancy Flashcard",url:"https://fancy-flashcard.github.io/ffc/",text:"Try out this cool app I found."}).then((function(){console.log("Thanks for sharing!")})).catch(console.error);else{var t={title:"Share Fancy Flashcard",url:"https://fancy-flashcard.github.io/ffc/",buttons:[{name:"Close",color:"grey"},{name:"Copy URL",color:"indigo",callback:p}]};this.$eventHub.$emit(u["a"].SHOW_CUSTOM_DIALOG,t)}}},{key:"getVersion",value:function(){return"2.2.1"}}]),a}(h["a"]);d=Object(l["a"])([f["a"]],d);var b=d,v=b,g=(a("5ed6"),a("2877")),y=a("6544"),m=a.n(y),O=a("8336"),j=a("62ad"),C=a("a523"),F=(a("a9e3"),a("c7cd"),a("5530")),k=(a("b5b6"),a("8dd9")),_=a("3a66"),w=a("d10f"),$=a("58df"),x=a("80d2"),B=Object($["a"])(k["a"],Object(_["a"])("footer",["height","inset"]),w["a"]).extend({name:"v-footer",props:{height:{default:"auto",type:[Number,String]},inset:Boolean,padless:Boolean,tag:{type:String,default:"footer"}},computed:{applicationProperty:function(){return this.inset?"insetFooter":"footer"},classes:function(){return Object(F["a"])(Object(F["a"])({},k["a"].options.computed.classes.call(this)),{},{"v-footer--absolute":this.absolute,"v-footer--fixed":!this.absolute&&(this.app||this.fixed),"v-footer--padless":this.padless,"v-footer--inset":this.inset})},computedBottom:function(){if(this.isPositioned)return this.app?this.$vuetify.application.bottom:0},computedLeft:function(){if(this.isPositioned)return this.app&&this.inset?this.$vuetify.application.left:0},computedRight:function(){if(this.isPositioned)return this.app&&this.inset?this.$vuetify.application.right:0},isPositioned:function(){return Boolean(this.absolute||this.fixed||this.app)},styles:function(){var t=parseInt(this.height);return Object(F["a"])(Object(F["a"])({},k["a"].options.computed.styles.call(this)),{},{height:isNaN(t)?t:Object(x["f"])(t),left:Object(x["f"])(this.computedLeft),right:Object(x["f"])(this.computedRight),bottom:Object(x["f"])(this.computedBottom)})}},methods:{updateApplication:function(){var t=parseInt(this.height);return isNaN(t)?this.$el?this.$el.clientHeight:0:t}},render:function(t){var e=this.setBackgroundColor(this.color,{staticClass:"v-footer",class:this.classes,style:this.styles});return t(this.tag,e,this.$slots.default)}}),V=a("0fd9"),S=Object(g["a"])(v,s,i,!1,null,"6ffe5d5d",null);e["default"]=S.exports;m()(S,{VBtn:O["a"],VCol:j["a"],VContainer:C["a"],VFooter:B,VRow:V["a"]})}}]);
//# sourceMappingURL=chunk-065f4018.089c1ae0.js.map

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-2eadd278"],{"0bc6":function(e,t,n){},"45e0":function(e,t,n){"use strict";var c=n("ebc8"),i=n.n(c);i.a},bb51:function(e,t,n){"use strict";n.r(t);var c=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("div",{staticClass:"deckselection"},[n("DeckSelection",{attrs:{decks:e.decks,numberOfSelectedDecks:e.numberOfSelectedDecks}})],1)},i=[],s=(n("a9e3"),n("d4ec")),a=n("262e"),o=n("2caf"),r=n("9ab4"),d=n("2b0e"),l=n("2fe1"),u=function(){var e=this,t=e.$createElement,n=e._self._c||t;return n("div",{staticClass:"DeckSelection"},[n("v-subheader",[e._v("Decks")]),e.decks.length>0?n("v-list",[n("v-list-item-group",{attrs:{multiple:"",color:"indigo"},model:{value:e.deckModel,callback:function(t){e.deckModel=t},expression:"deckModel"}},e._l(e.decks,(function(t){return n("v-list-item",{key:t.id,attrs:{value:t.id,id:t.id}},[n("v-list-item-content",[n("v-list-item-title",{domProps:{textContent:e._s(t.name)}})],1),n("v-list-item-icon",{class:{hidden:0===e.numberOfSelectedDecks,visible:e.numberOfSelectedDecks>0}},[t.selected?n("v-icon",[e._v("mdi-check-box-outline")]):n("v-icon",[e._v("mdi-checkbox-blank-outline")])],1)],1)})),1)],1):n("p",{attrs:{id:"no-decks-yet-notice"}},[e._v(" You don't have any decks yet. You might want to add some by clicking on the button in the bottom right corner. ")]),n("v-btn",{staticClass:"mx-2",attrs:{id:"btn-fixed-bottom-right-corner",fab:"",dark:"",color:"indigo"},on:{click:e.onButtonClick}},[n("v-icon",{class:{"rotate-90":e.numberOfSelectedDecks>0},domProps:{textContent:e._s(0===e.numberOfSelectedDecks?"mdi-plus":"mdi-navigation")}})],1)],1)},b=[],k=(n("4de4"),n("7db0"),n("4160"),n("caad"),n("d81d"),n("2532"),n("159b"),n("bee2")),f=d["a"].extend({props:{decks:{type:Array},numberOfSelectedDecks:Number}}),h=function(e){Object(a["a"])(n,e);var t=Object(o["a"])(n);function n(){return Object(s["a"])(this,n),t.apply(this,arguments)}return Object(k["a"])(n,[{key:"onButtonClick",value:function(){0===this.numberOfSelectedDecks?this.$router.push("add"):this.$router.push("learn")}},{key:"deckModel",get:function(){return this.decks.map((function(e){return e.selected?e.id:void 0})).filter((function(e){return void 0!==e}))},set:function(e){this.decks.forEach((function(t){t.selected=e.includes(t.id)}))}},{key:"selectedDeck",get:function(){var e=this;return 1!==this.deckModel.length?null:this.decks.find((function(t){return t.id===e.deckModel[0]}))}}]),n}(f);h=Object(r["a"])([l["a"]],h);var m=h,v=m,p=(n("45e0"),n("2877")),O=n("6544"),j=n.n(O),y=n("8336"),D=n("132d"),S=n("8860"),g=n("da13"),x=n("5d23"),_=n("1baa"),C=n("34c3"),V=n("e0c7"),w=Object(p["a"])(v,u,b,!1,null,"16f0e076",null),I=w.exports;j()(w,{VBtn:y["a"],VIcon:D["a"],VList:S["a"],VListItem:g["a"],VListItemContent:x["a"],VListItemGroup:_["a"],VListItemIcon:C["a"],VListItemTitle:x["b"],VSubheader:V["a"]});var $=d["a"].extend({props:{decks:{type:Array},numberOfSelectedDecks:Number}}),L=function(e){Object(a["a"])(n,e);var t=Object(o["a"])(n);function n(){return Object(s["a"])(this,n),t.apply(this,arguments)}return n}($);L=Object(r["a"])([Object(l["a"])({components:{DeckSelection:I}})],L);var M=L,B=M,E=Object(p["a"])(B,c,i,!1,null,null,null);t["default"]=E.exports},e0c7:function(e,t,n){"use strict";var c=n("5530"),i=(n("0bc6"),n("7560")),s=n("58df");t["a"]=Object(s["a"])(i["a"]).extend({name:"v-subheader",props:{inset:Boolean},render:function(e){return e("div",{staticClass:"v-subheader",class:Object(c["a"])({"v-subheader--inset":this.inset},this.themeClasses),attrs:this.$attrs,on:this.$listeners},this.$slots.default)}})},ebc8:function(e,t,n){}}]);
//# sourceMappingURL=chunk-2eadd278.9c6c8c4b.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-75fc5bce"],{1820:function(t,a,e){"use strict";var r=e("5238"),i=e.n(r);i.a},"26d3":function(t,a,e){"use strict";e.r(a);var r=function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("div",{staticClass:"settings"},[e("SettingsComponent",{attrs:{cardLimit:t.cardLimit}})],1)},i=[],n=e("d4ec"),c=e("262e"),l=e("2caf"),s=e("9ab4"),o=e("2b0e"),d=e("2fe1"),u=function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("div",[e("v-container",{attrs:{fluid:""}},[e("v-row",[e("CardLimit",{attrs:{cardLimit:t.cardLimit}}),e("ClearLocalStorage")],1)],1)],1)},v=[],b=function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("v-col",{attrs:{cols:"12",sm:"12",md:"6",lg:"6",xl:"6"}},[e("v-card",{attrs:{height:"100%",raised:""}},[e("v-card-title",[t._v("Card Limit")]),e("v-card-text",[e("p",{staticClass:"paragraph"},[t._v(" Limit the number of cards that will be in each learning session. "),e("v-icon",{staticClass:"mx-1",attrs:{size:"1em"},on:{click:function(a){t.showHelpText=!t.showHelpText}}},[t._v("mdi-help-circle-outline")])],1),e("p",{directives:[{name:"show",rawName:"v-show",value:t.showHelpText,expression:"showHelpText"}],staticClass:"description"},[t._v("If the given deck(s) has (have) less cards it will default to the number of cards in the given deck(s).")]),e("v-text-field",{attrs:{type:"number",min:"1",label:t.label,"hide-details":"auto",outlined:""},model:{value:t.curCardLimit,callback:function(a){t.curCardLimit=a},expression:"curCardLimit"}})],1),e("v-card-actions",{staticClass:"button-padding"},[e("v-spacer"),e("v-btn",{attrs:{color:"red",right:""},on:{click:t.deactivatecardLimit}},[t._v("Deactivate Limit")])],1)],1)],1)},m=[],p=e("bee2"),h=e("ebe2"),f=o["a"].extend({props:{cardLimit:String}}),C=function(t){Object(c["a"])(e,t);var a=Object(l["a"])(e);function e(){var t;return Object(n["a"])(this,e),t=a.apply(this,arguments),t.noLimitString="Currently no limit",t.defaultLabel="Card Limit",t.showHelpText=!1,t}return Object(p["a"])(e,[{key:"deactivatecardLimit",value:function(){this.$eventHub.$emit(h["a"].UPDATE_CARD_LIMIT,"0")}},{key:"label",get:function(){return this.curCardLimit?this.defaultLabel:this.noLimitString}},{key:"curCardLimit",get:function(){return"0"!==this.cardLimit&&this.cardLimit?this.cardLimit:null},set:function(t){this.$eventHub.$emit(h["a"].UPDATE_CARD_LIMIT,t)}}]),e}(f);C=Object(s["a"])([d["a"]],C);var g=C,L=g,O=(e("5f6b"),e("2877")),j=e("6544"),x=e.n(j),w=e("8336"),_=e("b0af"),S=e("99d9"),V=e("62ad"),k=e("132d"),y=e("2fa4"),T=e("8654"),$=Object(O["a"])(L,b,m,!1,null,"6087d784",null),A=$.exports;x()($,{VBtn:w["a"],VCard:_["a"],VCardActions:S["a"],VCardText:S["b"],VCardTitle:S["c"],VCol:V["a"],VIcon:k["a"],VSpacer:y["a"],VTextField:T["a"]});var E=function(){var t=this,a=t.$createElement,e=t._self._c||a;return e("v-col",{attrs:{cols:"12",sm:"12",md:"6",lg:"6",xl:"6"}},[e("v-card",{attrs:{height:"100%",raised:""}},[e("v-card-title",[t._v("Clear Local Storage")]),e("v-card-text",[e("p",[t._v("Delete all imported decks as well as your learning progess in the app.")])]),e("div",{staticClass:"horizontal-spacer"}),e("v-card-actions",{staticClass:"button-padding"},[e("v-spacer"),e("v-btn",{attrs:{color:"red",right:""},on:{click:t.clearLocalStorage}},[t._v("Clear Local Storage")])],1)],1)],1)},H=[];function D(t){t.$eventHub.$emit("showCustomDialog",{title:"Clear Storage?",message:"Do you really want to clear your local storage? Every deck and learning progress will be lost.",buttons:[{name:"Cancel",color:"grey"},{name:"Clear Storage",color:"orange darken-1",callback:function(){t.$eventHub.$emit(h["a"].CLEAR_LOCAL_STORAGE)}}]})}var I=function(t){Object(c["a"])(e,t);var a=Object(l["a"])(e);function e(){return Object(n["a"])(this,e),a.apply(this,arguments)}return Object(p["a"])(e,[{key:"clearLocalStorage",value:function(){D(this)}}]),e}(o["a"]);I=Object(s["a"])([d["a"]],I);var R=I,z=R,B=(e("1820"),Object(O["a"])(z,E,H,!1,null,"8d3280ae",null)),J=B.exports;x()(B,{VBtn:w["a"],VCard:_["a"],VCardActions:S["a"],VCardText:S["b"],VCardTitle:S["c"],VCol:V["a"],VSpacer:y["a"]});var M=o["a"].extend({props:{cardLimit:String}}),P=function(t){Object(c["a"])(e,t);var a=Object(l["a"])(e);function e(){return Object(n["a"])(this,e),a.apply(this,arguments)}return e}(M);P=Object(s["a"])([Object(d["a"])({components:{CardLimit:A,ClearLocalStorage:J}})],P);var U=P,F=U,G=e("a523"),N=e("0fd9"),q=Object(O["a"])(F,u,v,!1,null,"5f69376a",null),K=q.exports;x()(q,{VContainer:G["a"],VRow:N["a"]});var Q=o["a"].extend({props:{cardLimit:String}}),W=function(t){Object(c["a"])(e,t);var a=Object(l["a"])(e);function e(){return Object(n["a"])(this,e),a.apply(this,arguments)}return e}(Q);W=Object(s["a"])([Object(d["a"])({components:{SettingsComponent:K}})],W);var X=W,Y=X,Z=Object(O["a"])(Y,r,i,!1,null,"2698e76f",null);a["default"]=Z.exports},5238:function(t,a,e){},"5f6b":function(t,a,e){"use strict";var r=e("9d37"),i=e.n(r);i.a},"9d37":function(t,a,e){}}]);
//# sourceMappingURL=chunk-75fc5bce.31fd23e9.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
manifest.json Normal file
View file

@ -0,0 +1 @@
{"name":"Fancy Flashcard","short_name":"Fancy Flashcard","theme_color":"#363636","icons":[{"src":"./img/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"./img/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"},{"src":"./img/icons/android-chrome-maskable-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},{"src":"./img/icons/android-chrome-maskable-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}],"start_url":".","display":"standalone","background_color":"#000000"}

13210
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,43 +0,0 @@
{
"name": "ffc",
"version": "2.2.1",
"author": "Niko Lockenvitz & Rene-Pascal Fischer",
"license": "GNU GPLv2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"start": "vue-cli-service serve"
},
"dependencies": {
"core-js": "^3.6.5",
"register-service-worker": "^1.7.1",
"vue": "^2.6.12",
"vue-class-component": "^7.2.6",
"vue-router": "^3.4.3",
"vuetify": "^2.3.10"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.33.0",
"@vue/cli-plugin-babel": "^4.5.6",
"@vue/cli-plugin-eslint": "^4.5.6",
"@vue/cli-plugin-pwa": "^4.5.6",
"@vue/cli-plugin-router": "^4.5.6",
"@vue/cli-plugin-typescript": "^4.5.6",
"@vue/cli-service": "^4.5.6",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-typescript": "^5.1.0",
"eslint": "^6.7.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-vue": "^6.2.2",
"prettier": "^1.19.1",
"sass": "^1.26.11",
"sass-loader": "^8.0.0",
"typescript": "^3.9.7",
"vue-cli-plugin-vuetify": "^2.0.7",
"vue-template-compiler": "^2.6.12",
"vuetify-loader": "^1.6.0"
}
}

View file

@ -0,0 +1,90 @@
self.__precacheManifest = (self.__precacheManifest || []).concat([
{
"revision": "149db1f447f26f7ddc08",
"url": "/ffc/css/app.949d2d43.css"
},
{
"revision": "3444653deb08da03ceb4",
"url": "/ffc/css/chunk-065f4018.2db77a3e.css"
},
{
"revision": "3d01a1f0577e80be5961",
"url": "/ffc/css/chunk-2eadd278.67146ca8.css"
},
{
"revision": "0030c7708be74006e743",
"url": "/ffc/css/chunk-6e220460.506e66e1.css"
},
{
"revision": "4aeb05811ac9c3340a9a",
"url": "/ffc/css/chunk-75fc5bce.905fd344.css"
},
{
"revision": "199b937336de49e37ed7",
"url": "/ffc/css/chunk-7724eb6e.854399cf.css"
},
{
"revision": "a4ce7c2666c737bb5107",
"url": "/ffc/css/chunk-c9cd02f0.966620a7.css"
},
{
"revision": "d7918134d3b80838e069",
"url": "/ffc/css/chunk-f88b53c8.85e964b6.css"
},
{
"revision": "397647467f4f715f7665",
"url": "/ffc/css/chunk-vendors.2caf484c.css"
},
{
"revision": "3ee015939029b4c7f6601f0e68bef174",
"url": "/ffc/img/flash.3ee01593.svg"
},
{
"revision": "5acd29250e65223a91fe4f1235cd1774",
"url": "/ffc/index.html"
},
{
"revision": "149db1f447f26f7ddc08",
"url": "/ffc/js/app.38aa3ed0.js"
},
{
"revision": "3444653deb08da03ceb4",
"url": "/ffc/js/chunk-065f4018.089c1ae0.js"
},
{
"revision": "3d01a1f0577e80be5961",
"url": "/ffc/js/chunk-2eadd278.9c6c8c4b.js"
},
{
"revision": "0030c7708be74006e743",
"url": "/ffc/js/chunk-6e220460.eea51f45.js"
},
{
"revision": "4aeb05811ac9c3340a9a",
"url": "/ffc/js/chunk-75fc5bce.31fd23e9.js"
},
{
"revision": "199b937336de49e37ed7",
"url": "/ffc/js/chunk-7724eb6e.7b085d09.js"
},
{
"revision": "a4ce7c2666c737bb5107",
"url": "/ffc/js/chunk-c9cd02f0.cd91040b.js"
},
{
"revision": "d7918134d3b80838e069",
"url": "/ffc/js/chunk-f88b53c8.c362a2c7.js"
},
{
"revision": "397647467f4f715f7665",
"url": "/ffc/js/chunk-vendors.50cc43f1.js"
},
{
"revision": "f832b99e9f80295f375e8f77a6bebaec",
"url": "/ffc/manifest.json"
},
{
"revision": "b6216d61c03e6ce0c9aea6ca7808f7ca",
"url": "/ffc/robots.txt"
}
]);

View file

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="author" content="Niko Lockenvitz & Rene-Pascal Fischer">
<meta name="description" content="Learning PWA - Mix selfmade decks for your learning success!">
<meta name="robots" content="index, nofollow, noarchive">
<meta name="google" content="notranslate">
<meta property="og:title" content="Fancy Flashcard">
<meta property="og:image" content="https://fancy-flashcard.github.io/ffc/img/icons/android-chrome-192x192.png">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

34
service-worker.js Normal file
View file

@ -0,0 +1,34 @@
/**
* Welcome to your Workbox-powered service worker!
*
* You'll need to register this file in your web app and you should
* disable HTTP caching for this file too.
* See https://goo.gl/nhQhGp
*
* The rest of the code is auto-generated. Please don't update this file
* directly; instead, make changes to your Workbox build configuration
* and re-run your build process.
* See https://goo.gl/2aRDsh
*/
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
importScripts(
"/ffc/precache-manifest.d2e3d4f8d67bb0a48842b531d52e335e.js"
);
workbox.core.setCacheNameDetails({prefix: "ffc"});
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
/**
* The workboxSW.precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
self.__precacheManifest = [].concat(self.__precacheManifest || []);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});

View file

@ -1,168 +0,0 @@
<template>
<div id="app">
<v-window :touch="{ left: swipeLeft, right: swipeRight }">
<v-app id="inspire">
<v-app id="sandbox">
<v-main>
<NavigationBar
ref="navbar"
title="Fancy Flashcard"
:decks="decks"
:numberOfSelectedDecks="numberOfSelectedDecks"
></NavigationBar>
<router-view
:decks="decks"
:numberOfSelectedDecks="numberOfSelectedDecks"
:cardLimit="cardLimit"
/>
<v-snackbar app v-model="snackbar.snackbar" :timeout="snackbar.timeout">
{{ snackbar.text }}
<template v-slot:action="{ attrs }">
<v-btn
color="orange darken-1"
text
v-bind="attrs"
@click="snackbar.snackbar = false"
>Close</v-btn>
</template>
</v-snackbar>
<CustomDialog ref="customDialog" />
</v-main>
</v-app>
</v-app>
</v-window>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import { Deck, CustomDialogOptions, Event } from "./types";
import NavigationBar from "./components/layout/NavigationBar.vue";
import CustomDialog from "./components/customdialog/CustomDialog.vue";
import { registerEventListenerForMainApp } from "./helpers/eventListener";
import {
readFromLocalStorage,
saveToLocalStorage,
SyncItem
} from "./helpers/localStorageHelper";
import continueCurrentLearningSessionIfPresent from "./helpers/continueLearningHelper";
const AppProps = Vue.extend({
props: {
title: String
}
});
@Component({
components: {
NavigationBar,
CustomDialog
}
})
export default class App extends AppProps {
propertiesToSyncWithLocalStorage = [
{ key: "decks", defaultValue: [] },
{ key: "cardLimit", defaultValue: "" }
] as SyncItem[];
cardLimit = "";
decks = [] as Deck[];
snackbar = {
text: "",
snackbar: false
};
$refs!: {
navbar: NavigationBar;
customDialog: CustomDialog;
};
setSelectedStatusForAllDecks(status: boolean) {
this.decks.forEach(deck => {
deck.selected = status;
});
}
created() {
registerEventListenerForMainApp(this);
for (const item of this.propertiesToSyncWithLocalStorage) {
this.$watch(
item.key,
function() {
saveToLocalStorage(this, item);
},
{ deep: true }
);
}
}
mounted() {
readFromLocalStorage(this);
this.setSelectedStatusForAllDecks(false);
continueCurrentLearningSessionIfPresent(
this.$eventHub,
this.$router,
this.decks
);
}
get numberOfSelectedDecks() {
return this.decks.filter(deck => deck.selected).length;
}
swipeLeft() {
if (this.$route.name === "Learn") {
this.$eventHub.$emit(Event.SWIPE_LEFT_IN_LEARN);
return;
}
this.$refs.navbar.hideDrawer();
}
swipeRight() {
if (this.$route.name === "Learn") {
this.$eventHub.$emit(Event.SWIPE_RIGHT_IN_LEARN);
return;
}
this.$refs.navbar.showDrawer();
}
showCustomDialog(options: CustomDialogOptions) {
this.$refs.customDialog.show(options);
}
}
</script>
<style>
html,
body {
/* apply dark mode to scrollbar in firefox desktop */
background-color: #000;
/* remove scrollbar on desktop when not needed */
overflow-y: auto !important;
overscroll-behavior: none;
}
.v-card__title {
word-break: normal;
}
/* reduce margin of file/url deck input */
.deck-input .v-input__control .v-input__slot {
margin-bottom: 0;
}
.deck-input .v-input__control .v-text-field__details {
display: none;
}
/* fix highlighting problems in vuetify */
.theme--dark.v-list-item:hover::before,
.theme--dark.v-btn:hover::before,
.v-btn:not(.v-btn--text):not(.v-btn--outlined):hover:before {
opacity: 0;
}
.theme--dark.v-list-item--active:hover::before,
.theme--dark.v-list-item--active::before {
opacity: 0.24;
}
</style>

View file

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100">
<rect x="5" y="5" width="90" height="90" rx="5" fill="#222" />
<polygon points="26,44 60,15 52,43 74,56 40,85 48,57"
style="fill:#fc0;stroke:#fc0;stroke-width:5;stroke-linejoin:round;" />
</svg>

Before

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -1 +0,0 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.5 100"><defs><style>.cls-1{fill:#1697f6;}.cls-2{fill:#7bc6ff;}.cls-3{fill:#1867c0;}.cls-4{fill:#aeddff;}</style></defs><title>Artboard 46</title><polyline class="cls-1" points="43.75 0 23.31 0 43.75 48.32"/><polygon class="cls-2" points="43.75 62.5 43.75 100 0 14.58 22.92 14.58 43.75 62.5"/><polyline class="cls-3" points="43.75 0 64.19 0 43.75 48.32"/><polygon class="cls-4" points="64.58 14.58 87.5 14.58 43.75 100 43.75 62.5 64.58 14.58"/></svg>

Before

Width:  |  Height:  |  Size: 539 B

View file

@ -1,35 +0,0 @@
<template>
<div>
<v-container fluid>
<v-row>
<ThirdPartyDeckCard />
<ImportDeckFromURL />
<ImportDeckFromFile />
<DeckCreator />
</v-row>
</v-container>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import ImportDeckFromURL from "./ImportDeckFromURL.vue";
import ImportDeckFromFile from "./ImportDeckFromFile.vue";
import ThirdPartyDeckCard from "./ThirdPartyDeckCard.vue"
import DeckCreator from "./DeckCreator.vue";
@Component({
components: {
ImportDeckFromURL,
ImportDeckFromFile,
ThirdPartyDeckCard,
DeckCreator
}
})
export default class AddNewDeck extends Vue {}
</script>
<style scoped>
</style>

View file

@ -1,21 +0,0 @@
<template>
<v-col cols="12" sm="12" md="6" lg="4" xl="4">
<v-card height="100%" raised>
<v-card-title>Deck Creator</v-card-title>
<v-card-text>
<p>You will be able to natively create a new deck here.</p>
</v-card-text>
</v-card>
</v-col>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
@Component
export default class DeckCreator extends Vue {}
</script>
<style scoped>
</style>

View file

@ -1,60 +0,0 @@
<template>
<v-col cols="12" sm="12" md="6" lg="4" xl="4">
<v-card height="100%" raised>
<v-card-title>Import a JSON File</v-card-title>
<v-card-text>
<v-file-input
class="deck-input"
outlined
clearable
label="Select a JSON File"
accept=".json"
v-model="chosenFile"
></v-file-input>
</v-card-text>
<v-card-actions class="button-padding">
<v-spacer></v-spacer>
<v-btn color="indigo" right @click="importFile">Import File</v-btn>
</v-card-actions>
</v-card>
</v-col>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
@Component
export default class ImportDeckFromFile extends Vue {
chosenFile: File | null = null;
fileContent = "";
importFile() {
const reader = new FileReader();
try {
if (this.chosenFile === null) {
throw new Error("No file chosen.");
}
if (
this.chosenFile.name.substr(this.chosenFile.name.length - 5) !== ".json"
) {
throw new Error("Wrong file format!");
}
reader.readAsText(this.chosenFile);
reader.onload = () => {
this.$eventHub.$emit("addDecksFromFile", reader.result);
};
} catch (e) {
this.$eventHub.$emit("snackbarEvent", e);
}
}
}
</script>
<style scoped>
#file-input-wrapper {
padding-bottom: 0;
}
.button-padding {
padding: 16px;
}
</style>

View file

@ -1,64 +0,0 @@
<template>
<v-col cols="12" sm="12" md="6" lg="4" xl="4">
<v-card height="100%" raised>
<v-card-title>Import Deck From URL</v-card-title>
<v-card-text>
<v-text-field
class="deck-input"
label="Provide a File URL (Raw JSON)"
outlined
clearable
:rules="urlRules"
v-model="chosenURL"
>
<v-icon slot="prepend">mdi-web</v-icon>
</v-text-field>
</v-card-text>
<v-card-actions class="button-padding">
<v-spacer></v-spacer>
<v-btn color="indigo" right @click="loadFileFromURL">Import File</v-btn>
</v-card-actions>
</v-card>
</v-col>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import { Event } from "../../types";
@Component
export default class ImportDeckFromURL extends Vue {
chosenURL = "";
fileContent = "";
urlRules = [
(value: string) =>
new RegExp("^https://.*/.*.json$").exec(value) !== null ||
"Please provide a correct URL"
];
async loadFileFromURL() {
try {
const response = await fetch(this.chosenURL);
const fileContent = await response.json();
this.$eventHub.$emit(Event.ADD_DECKS_FROM_JSON, fileContent, this.chosenURL);
} catch (error) {
// console.log(error);
// TODO: cors?!
this.$eventHub.$emit(
Event.SNACKBAR_EVENT,
"An error occurred while loading the file"
);
}
}
}
</script>
<style scoped>
#file-input-wrapper {
padding-bottom: 0;
}
.button-padding {
padding: 16px;
}
</style>

View file

@ -1,40 +0,0 @@
<template>
<v-col cols="12" sm="12" md="6" lg="4" xl="4">
<v-card height="100%" raised>
<v-card-title>Third Party Decks</v-card-title>
<v-card-text>
Easily import decks created by others.
If you want to add your own deck to the selection, check out our <a href="https://github.com/fancy-flashcard/ffc/">GitHub repository</a>!
</v-card-text>
<div class="horizontal-spacer"></div>
<v-card-actions class="button-padding">
<v-spacer></v-spacer>
<v-btn color="indigo" right @click="$router.push('thirdparty')">View Third Party Decks</v-btn>
</v-card-actions>
</v-card>
</v-col>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
@Component
export default class ThirdPartyDecks extends Vue {}
</script>
<style scoped>
#file-input-wrapper {
padding-bottom: 0;
}
.button-padding {
padding: 16px;
}
.v-card {
display: flex;
flex-direction: column;
}
.horizontal-spacer {
flex-grow: 1;
}
</style>

View file

@ -1,74 +0,0 @@
<template>
<div class="bar-chart">
<div class="bar-container" :style="{ minHeight: 'min(20vw, 100px)' }">
<div v-for="(bar, index) in options.bars" :key="index" class="bar-wrapper">
<div class="bar-value">{{ bar.value }}</div>
<div class="bar" :style="getHeight(bar)"></div>
</div>
</div>
<div class="bar-container" style="padding-top: 0;">
<div v-for="(bar, index) in options.bars" :key="index" class="bar-name">{{ bar.name || "" }}</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import {
CustomDialogOptionsBarChart,
CustomDialogOptionsBarChartBar
} from "../../types";
const BarChartProps = Vue.extend({
props: {
options: { type: Object as () => CustomDialogOptionsBarChart }
}
});
@Component
export default class BarChart extends BarChartProps {
getHeight(bar: CustomDialogOptionsBarChartBar) {
const relValue = this.maxBarValue > 0 ? bar.value / this.maxBarValue : 0;
return {
height: `${relValue*32}vw`,
maxHeight: `${relValue*150}px`
};
}
get maxBarValue (): number {
return Math.max(...this.options.bars.map(bar => bar.value));
}
}
</script>
<style scoped>
.bar-container {
display: flex;
padding: 12px 24px;
justify-content: space-evenly;
align-items: flex-end;
}
.bar-wrapper {
flex-grow: 1;
height: 100%;
margin: 0 5%;
display: flex;
flex-direction: column;
}
.bar-value {
text-align: center;
color: #fc0;
font-size: 0.7em;
}
.bar {
width: 100%;
background: #fc0;
min-height: 1px;
}
.bar-name {
flex-grow: 1;
margin: 0 5%;
text-align: center;
}
</style>

View file

@ -1,145 +0,0 @@
<template>
<v-dialog v-model="showDialog" max-width="400" persistent>
<v-card color="#2e2e2e">
<v-card-title class="headline">
{{ options.title }}
<v-icon v-if="options.type" size="0.9em" color="indigo" class="mx-2">
{{options.type === 'sponsored' ?
'mdi-cash-usd-outline' : options.type === 'curated' ?
'mdi-check-decagram' : options.type === 'official' ?
'mdi-flash-circle' : null}}
</v-icon>
</v-card-title>
<v-card-text v-if="options.message" class="text-left">{{ options.message }}</v-card-text>
<v-card-text v-else-if="options.multipleMessages" class="text-left">
<p
class="multiple-messages"
v-for="message in options.multipleMessages"
:key="message.name"
>
<b>{{message.name}}:</b>
{{message.value}}
</p>
</v-card-text>
<v-list v-if="options.table">
<v-list-item v-if="options.tableHead">
<v-list-item-content class="font-weight-bold">{{ options.tableHead.name }}</v-list-item-content>
<v-list-item-content class="font-weight-bold">{{ options.tableHead.value }}</v-list-item-content>
</v-list-item>
<v-list-item v-for="item in options.table" :key="item.name">
<v-list-item-content>{{ item.name }}</v-list-item-content>
<v-list-item-content>{{ item.value }}</v-list-item-content>
</v-list-item>
</v-list>
<div v-if="options.url" class="share-ffc-url">
<v-text-field type="text" id="ffc-url" :value="options.url"></v-text-field>
</div>
<BarChart v-if="options.barChart" :options="options.barChart" />
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
v-for="btn in options.buttons"
:key="btn.name"
:color="btn.color"
text
@click="close(btn)"
>{{ btn.name }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import { CustomDialogOptions, CustomDialogOptionsButton } from "../../types";
import BarChart from "./BarChart.vue";
@Component({
components: {
BarChart
}
})
export default class Dialog extends Vue {
showDialog = false;
options = {
title: "",
format: "",
message: "",
tableHead: {
name: "",
value: ""
},
table: [
{
name: "",
value: ""
}
],
buttons: [
{
name: "Close",
color: "indigo",
callback: undefined
}
]
} as CustomDialogOptions;
hide() {
this.showDialog = false;
}
show(options: CustomDialogOptions) {
this.showDialog = true;
this.options = options;
if (
!this.options.buttons ||
(this.options.buttons && this.options.buttons.length === 0)
) {
this.options.buttons = [
{
name: "Close",
color: "indigo",
callback: undefined
}
];
}
}
close(btn?: CustomDialogOptionsButton) {
this.showDialog = false;
if (btn && btn.callback) {
btn.callback();
}
}
}
</script>
<style scoped>
.v-list,
.v-sheet {
background-color: unset;
}
.v-list {
padding: 0 24px;
}
.v-list-item {
padding: 0;
}
.v-dialog > .v-card > .v-card__text {
padding: 12px 24px;
}
.v-list-item__content {
padding: 6px 0;
}
.share-ffc-url {
text-align: center;
margin: 0 24px;
}
.multiple-messages {
margin-bottom: 0;
}
</style>

View file

@ -1,111 +0,0 @@
<template>
<div class="DeckSelection">
<v-subheader>Decks</v-subheader>
<v-list v-if="decks.length > 0">
<v-list-item-group multiple color="indigo" v-model="deckModel">
<v-list-item v-for="deck in decks" :key="deck.id" :value="deck.id" :id="deck.id">
<v-list-item-content>
<v-list-item-title v-text="deck.name"></v-list-item-title>
</v-list-item-content>
<v-list-item-icon
:class="{ hidden: numberOfSelectedDecks===0, visible: numberOfSelectedDecks>0 }"
>
<v-icon v-if="deck.selected">mdi-check-box-outline</v-icon>
<v-icon v-else>mdi-checkbox-blank-outline</v-icon>
</v-list-item-icon>
</v-list-item>
</v-list-item-group>
</v-list>
<p v-else id="no-decks-yet-notice">
You don't have any decks yet.
You might want to add some by clicking on the button in the bottom right corner.
</p>
<v-btn
class="mx-2"
id="btn-fixed-bottom-right-corner"
fab
dark
color="indigo"
@click="onButtonClick"
>
<v-icon
v-text="numberOfSelectedDecks === 0 ? 'mdi-plus' : 'mdi-navigation'"
:class="{ 'rotate-90': numberOfSelectedDecks > 0 }"
/>
</v-btn>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import { Deck } from "../../types";
const DeckSelectionProps = Vue.extend({
props: {
decks: { type: Array as () => Deck[] },
numberOfSelectedDecks: Number
}
});
@Component
export default class DeckSelection extends DeckSelectionProps {
get deckModel() {
return this.decks
.map(deck => (deck.selected ? deck.id : undefined))
.filter(id => id !== undefined);
}
set deckModel(newModel) {
this.decks.forEach(deck => {
deck.selected = newModel.includes(deck.id);
});
}
get selectedDeck() {
return this.deckModel.length !== 1
? null
: this.decks.find(deck => deck.id === this.deckModel[0]);
}
onButtonClick() {
if (this.numberOfSelectedDecks === 0) {
this.$router.push("add");
} else {
// start learning with selected decks
this.$router.push("learn");
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
#btn-fixed-bottom-right-corner {
position: fixed;
bottom: 20px;
right: 20px;
margin: 0 !important;
}
.hidden {
opacity: 0;
transition: 0.2s;
}
.visible {
opacity: 1;
transition: 0.2s;
}
.rotate-90 {
-webkit-transform: rotate(90deg);
-moz-transform: rotate(90deg);
-ms-transform: rotate(90deg);
-o-transform: rotate(90deg);
transform: rotate(90deg);
}
#no-decks-yet-notice {
padding: 0 16px;
}
</style>

View file

@ -1,150 +0,0 @@
<template>
<div class="NavBar">
<v-navigation-drawer
v-model="primaryDrawer.model"
:clipped="primaryDrawer.clipped"
:floating="primaryDrawer.floating"
:mini-variant="primaryDrawer.mini"
:permanent="primaryDrawer.type === 'permanent'"
:temporary="primaryDrawer.type === 'temporary'"
app
overflow
>
<v-list>
<v-list-item>
<v-img src="../../assets/flash.svg" class="ffc-icon"></v-img>
</v-list-item>
<v-divider></v-divider>
<v-list-item v-for="navItem in navBarList" :key="navItem.to" :to="navItem.to" link>
<v-list-item-icon>
<v-icon>{{navItem.icon}}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{navItem.title}}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-app-bar :clipped-left="primaryDrawer.clipped" app :class="colorAppBar">
<v-btn icon v-if="isInDeckSelection && numberOfSelectedDecks>0" @click="deselectAll">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-btn icon v-else-if="isInLearning" @click="quitLearning">
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-app-bar-nav-icon v-else @click.stop="togglePrimaryDrawer"></v-app-bar-nav-icon>
<v-toolbar-title>{{ toolbarTitle }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
icon
v-if="isInDeckSelection && numberOfSelectedDecks===1"
@click="showInfoForSelectedDeck"
>
<v-icon>mdi-information</v-icon>
</v-btn>
<v-btn
icon
v-if="isInDeckSelection && numberOfSelectedDecks>0"
@click="selectAll"
:disabled="numberOfSelectedDecks === decks.length"
>
<v-icon>mdi-checkbox-multiple-marked</v-icon>
</v-btn>
<v-btn icon v-if="isInDeckSelection && numberOfSelectedDecks>0" @click="deleteSelected">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-app-bar>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import { Deck, Event, NavBarConfigItem } from '../../types';
import navBarConfigJson from './navbar.json';
import * as selectedDeckDialogHelper from "../../helpers/selectedDeckDialogHelper";
const NavigationBarProps = Vue.extend({
props: {
title: String,
decks: {type: Array as () => Deck[]},
numberOfSelectedDecks: Number,
}
});
@Component
export default class NavigationBar extends NavigationBarProps {
primaryDrawer = {
model: false,
type: "temporary",
clipped: true,
floating: false,
mini: false
};
navBarList = navBarConfigJson as NavBarConfigItem[];
get isInDeckSelection() {
return this.$route.name === "DeckSelection";
}
get isInLearning() {
return this.$route.name === "Learn";
}
get colorAppBar() {
if (this.isInDeckSelection && this.numberOfSelectedDecks > 0) {
return "indigo";
}
return "";
}
get toolbarTitle() {
if (this.isInDeckSelection && this.numberOfSelectedDecks > 0) {
return `${this.numberOfSelectedDecks} deck${
this.numberOfSelectedDecks === 1 ? "" : "s"
} selected`;
}
return this.title;
}
deselectAll() {
this.$eventHub.$emit(Event.DESELECT_ALL_DECKS);
}
selectAll() {
this.$eventHub.$emit(Event.SELECT_ALL_DECKS);
}
deleteSelected() {
selectedDeckDialogHelper.deleteSelected(this);
}
showInfoForSelectedDeck() {
selectedDeckDialogHelper.showInfoForSelectedDeck(this);
}
showDrawer() {
this.primaryDrawer.model = true;
}
hideDrawer() {
this.primaryDrawer.model = false;
}
quitLearning() {
this.$eventHub.$emit(Event.PREPARE_QUIT_LEARNING);
}
togglePrimaryDrawer() {
this.primaryDrawer.model = !this.primaryDrawer.model;
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.ffc-icon {
height: 200px;
}
.v-list {
padding: 0;
}
</style>

View file

@ -1,27 +0,0 @@
[
{
"to": "/",
"icon": "mdi-home",
"title": "Home"
},
{
"to": "/add",
"icon": "mdi-plus",
"title": "Add Deck"
},
{
"to": "/thirdparty",
"icon": "mdi-package-variant",
"title": "Third Party Decks"
},
{
"to": "/settings",
"icon": "mdi-cog",
"title": "Settings"
},
{
"to": "/about",
"icon": "mdi-information",
"title": "About"
}
]

View file

@ -1,261 +0,0 @@
<template>
<div class="learn" v-if="numberOfSelectedDecks>0">
<!-- v-if="numberOfSelectedDecks>0"; otherwise it will be shortly displayed before it is catched by beforeMount -->
<div class="max-height">{{ curLearningElement.card.q }}</div>
<div class="max-height">
<span v-if="curLearningElement.showAnswer">{{ curLearningElement.card.a }}</span>
<v-btn v-else @click="revealAnswer">Reveal Answer</v-btn>
</div>
<Rating
ref="rating"
v-if="curLearningElement.showAnswer"
:numberOfStars="numberOfStarsInRating"
@rated="onRating"
/>
<v-card-actions>
<v-btn
text
:disabled="learningSessionManager.learningSession.currentElementIndex === 0"
color="grey lighten-1"
@click="moveToPrev"
>Previous</v-btn>
<v-spacer></v-spacer>
<v-btn text :color="buttonNext.color" @click="moveToNext">{{buttonNext.text}}</v-btn>
</v-card-actions>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import Rating from "./Rating.vue";
import {
Deck,
LearningSessionElement,
Event,
CustomDialogOptionsBarChartBar
} from "../../types";
import LearningSessionManager from "../../helpers/learningSessionManager";
import FollowUpLearningSessionManager from "../../helpers/followUpLearningSessionManager";
import { finishLearningDialog } from "../../helpers/finishLearningDialogHelper";
import { quitLearningDialog } from "../../helpers/quitLearningDialogHelper";
import {
saveLearningSessionManagerDataToLocalStorage,
getLearningSessionManagerDataFromLocalStorage
} from "../../helpers/learningSessionStorageHelper";
const LearnProps = Vue.extend({
props: {
decks: { type: Array as () => Deck[] },
numberOfSelectedDecks: Number,
cardLimit: String
}
});
@Component({
components: {
Rating
}
})
export default class Learn extends LearnProps {
numberOfStarsInRating = 5;
learningSessionManager = new LearningSessionManager([]);
isLearningSessionFinishedAndComponentWillBeDestroyedSoon = false;
curLearningElement = {
deckId: 0,
cardId: 0,
showAnswer: false,
rating: {},
card: {
id: 0,
q: "",
a: ""
}
} as LearningSessionElement;
$refs!: {
rating: Rating;
};
created() {
this.isLearningSessionFinishedAndComponentWillBeDestroyedSoon = false;
this.$eventHub.$on(Event.SWIPE_LEFT_IN_LEARN, () => {
this.moveToNext();
});
this.$eventHub.$on(Event.SWIPE_RIGHT_IN_LEARN, () => {
this.moveToPrev();
});
this.$eventHub.$on(Event.PREPARE_QUIT_LEARNING, () => {
this.quitLearning();
});
}
destroyed() {
this.$eventHub.$off(Event.SWIPE_LEFT_IN_LEARN);
this.$eventHub.$off(Event.SWIPE_RIGHT_IN_LEARN);
this.$eventHub.$off(Event.PREPARE_QUIT_LEARNING);
}
updateCurLearningElement() {
if (this.isLearningSessionFinishedAndComponentWillBeDestroyedSoon) return;
this.curLearningElement = this.learningSessionManager.getCurrentLearningSessionElementWithCardDetails();
this.updateRatingForCurrentLearningElement();
saveLearningSessionManagerDataToLocalStorage(this.learningSessionManager);
}
updateRatingForCurrentLearningElement() {
let r = 0;
if (this.curLearningElement.rating?.r !== undefined) {
r = this.mapRatingFrom100ToStars(this.curLearningElement.rating.r);
}
// rating component is not mounted yet due to re-rendering
this.$nextTick(() => {
if (this.$refs.rating) {
this.$refs.rating.setRating(r);
}
});
}
beforeMount() {
const learningSessionManagerDataInLocalStorage = getLearningSessionManagerDataFromLocalStorage();
if (learningSessionManagerDataInLocalStorage) {
this.learningSessionManager = new FollowUpLearningSessionManager(
learningSessionManagerDataInLocalStorage
);
} else {
if (this.numberOfSelectedDecks === 0) {
this.$router.replace("/");
return;
}
this.learningSessionManager = new LearningSessionManager(
this.decks.filter(deck => deck.selected)
);
}
this.updateCurLearningElement();
}
checkIfCardIsEndOfSession(): boolean {
const endOfSession =
(this.learningSessionManager.learningSession.currentElementIndex ===
this.learningSessionManager.learningSession.elements.length - 1 &&
this.learningSessionManager.cardsToSelectFrom.length === 0) ||
(this.cardLimit === "0"
? false
: this.learningSessionManager.learningSession.currentElementIndex ===
parseInt(this.cardLimit) - 1);
return endOfSession;
}
moveToNext() {
if (this.checkIfCardIsEndOfSession()) {
this.finishSession();
}
this.learningSessionManager.moveToNextLearningSessionElement();
}
get buttonNext(): { text: string; color: string } {
if (this.checkIfCardIsEndOfSession()) {
return {
text: "Finish",
color: "indigo lighten-1"
};
} else {
return {
text: "Next",
color: "grey lighten-1"
};
}
}
moveToPrev() {
this.learningSessionManager.moveToPrevLearningSessionElement();
}
revealAnswer() {
this.learningSessionManager.revealAnswerForCurrentLearningSessionElement();
}
onRating(rating: number, programmatically = false) {
if (programmatically) return;
const r = this.mapRatingFromStarsTo100(rating);
this.learningSessionManager.saveRatingForCurrentLearningSessionElement(r);
saveLearningSessionManagerDataToLocalStorage(this.learningSessionManager);
}
mapRatingFromStarsTo100(rating: number): number {
// map 1-n -> 0-100
return ((rating - 1) * 100) / (this.numberOfStarsInRating - 1);
}
mapRatingFrom100ToStars(rating: number): number {
// map 0-100 -> 1-n
return (rating * (this.numberOfStarsInRating - 1)) / 100 + 1;
}
finishSession() {
finishLearningDialog(this, this.getBarsForFinishLearningDialog(), () => {
this.isLearningSessionFinishedAndComponentWillBeDestroyedSoon = true;
});
}
getBarsForFinishLearningDialog(): CustomDialogOptionsBarChartBar[] {
const bars = [];
for (let rating = 1; rating <= this.numberOfStarsInRating; rating++) {
bars.push({ name: `${rating}`, value: 0 });
}
for (const element of this.learningSessionManager.learningSession
.elements) {
if (element.rating?.r !== undefined) {
bars[this.mapRatingFrom100ToStars(element.rating.r) - 1].value += 1;
}
}
return bars;
}
quitLearning() {
quitLearningDialog(this, this.getBarsForFinishLearningDialog());
}
updateVerticalCentering() {
for (const el of document.getElementsByClassName("max-height")) {
if (el.scrollHeight > el.clientHeight) {
el.classList.remove("flex-center");
} else {
el.classList.add("flex-center");
}
}
}
mounted() {
this.updateVerticalCentering();
}
updated() {
this.updateVerticalCentering();
this.updateCurLearningElement();
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.learn {
height: 100%;
display: flex;
flex-direction: column;
text-align: center;
}
.max-height {
display: flex;
flex-direction: column;
flex: 1 1 auto;
overflow-y: auto;
height: 0px;
align-items: center;
padding: 0 16px;
}
.flex-center {
justify-content: center;
}
</style>

View file

@ -1,79 +0,0 @@
<template>
<div class="rating">
<div v-for="n in numberOfStars" :key="n" class="star-wrapper">
<span
class="overline grey--text text--darken-1"
:class="{ 'invisible': (n !== 1 && n !== numberOfStars) }"
>{{ n === 1 ? "Hard" : "Easy" }}</span>
<svg class="star" :class="{ 'filled': (numberOfSelectedStar >= n) }" viewBox="0 0 100 100">
<!-- two circles, radius 18% and 42%, 72 degree steps -->
<polygon
points="50,10 39.4,35.4 10.1,37 32.9,55.6 25.3,84 50,68, 74.7,84 67.1,55.6 89.9,37 60.6,35.4"
@click="onClickStar(n)"
/>
</svg>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
const RatingProps = Vue.extend({
props: {
numberOfStars: Number
}
});
@Component
export default class Rating extends RatingProps {
numberOfSelectedStar = 0;
onClickStar(n: number) {
this.numberOfSelectedStar = n;
this.$emit("rated", n);
}
setRating(n: number) {
this.numberOfSelectedStar = n;
this.$emit("rated", n, true);
}
}
</script>
<style scoped>
.rating {
padding: 0 16px;
display: flex;
justify-content: center;
}
.star-wrapper {
display: flex;
flex-flow: column;
flex: 1 1 0;
max-width: 20vw;
max-height: 16vh;
}
.invisible {
visibility: hidden;
}
svg.star {
stroke-width: 5;
stroke-linejoin: round;
}
svg.star polygon {
cursor: pointer;
}
svg.star.filled polygon {
fill: #fc0;
stroke: #fc0;
transition: 0.2s;
}
svg.star polygon {
fill: #222;
stroke: #fc0;
transition: 0.5s;
}
</style>

View file

@ -1,97 +0,0 @@
<template>
<v-col cols="12" sm="12" md="6" lg="6" xl="6">
<v-card height="100%" raised>
<v-card-title>Card Limit</v-card-title>
<v-card-text>
<p class="paragraph">
Limit the number of cards that will be in each learning session.
<v-icon
size="1em"
@click="showHelpText = !showHelpText"
class="mx-1"
>mdi-help-circle-outline</v-icon>
</p>
<p
class="description"
v-show="showHelpText"
>If the given deck(s) has (have) less cards it will default to the number of cards in the given deck(s).</p>
<v-text-field
v-model="curCardLimit"
type="number"
min="1"
:label="label"
hide-details="auto"
outlined
></v-text-field>
</v-card-text>
<v-card-actions class="button-padding">
<v-spacer></v-spacer>
<v-btn color="red" @click="deactivatecardLimit" right>Deactivate Limit</v-btn>
</v-card-actions>
</v-card>
</v-col>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import { Event } from "../../types";
const CardLimitProps = Vue.extend({
props: {
cardLimit: String
}
});
@Component
export default class CardLimit extends CardLimitProps {
noLimitString = "Currently no limit";
defaultLabel = "Card Limit";
showHelpText = false;
get label() {
return this.curCardLimit ? this.defaultLabel : this.noLimitString;
}
get curCardLimit() {
return this.cardLimit === "0" || !this.cardLimit ? null : this.cardLimit;
}
set curCardLimit(newValue) {
this.$eventHub.$emit(Event.UPDATE_CARD_LIMIT, newValue);
}
deactivatecardLimit() {
this.$eventHub.$emit(Event.UPDATE_CARD_LIMIT, "0");
}
}
</script>
<style scoped>
.button-padding {
padding: 16px;
}
.description {
align-items: center;
font-size: 0.875rem;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
}
p .v-icon {
color: inherit;
}
.paragraph {
display: flex;
}
/* Disable stepper in number input */
::v-deep input,
::v-deep input::-webkit-outer-spin-button,
::v-deep input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
-moz-appearance: textfield;
}
</style>

View file

@ -1,42 +0,0 @@
<template>
<v-col cols="12" sm="12" md="6" lg="6" xl="6">
<v-card height="100%" raised>
<v-card-title>Clear Local Storage</v-card-title>
<v-card-text>
<p>Delete all imported decks as well as your learning progess in the app.</p>
</v-card-text>
<div class="horizontal-spacer"></div>
<v-card-actions class="button-padding">
<v-spacer></v-spacer>
<v-btn color="red" @click="clearLocalStorage" right>Clear Local Storage</v-btn>
</v-card-actions>
</v-card>
</v-col>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import * as clearStorageDialogHelper from "../../helpers/clearStorageDialogHelper";
@Component
export default class ClearLocalStorage extends Vue {
clearLocalStorage() {
clearStorageDialogHelper.clearLocalStorageDialog(this);
}
}
</script>
<style scoped>
.button-padding {
padding: 16px;
}
.v-card {
display: flex;
flex-direction: column;
}
.horizontal-spacer {
flex-grow: 1;
}
</style>

View file

@ -1,35 +0,0 @@
<template>
<div>
<v-container fluid>
<v-row>
<CardLimit :cardLimit="cardLimit" />
<ClearLocalStorage />
</v-row>
</v-container>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import CardLimit from "./CardLimit.vue";
import ClearLocalStorage from "./ClearLocalStorage.vue";
const SettingProps = Vue.extend({
props: {
cardLimit: String
}
});
@Component({
components: {
CardLimit,
ClearLocalStorage
}
})
export default class Settings extends SettingProps {}
</script>
<style scoped>
</style>

View file

@ -1,132 +0,0 @@
<template>
<div class="ThirdPartyDeckSelection">
<v-subheader>Third Party Decks
<v-icon size="1em" @click="showHelpText = !showHelpText" class="mx-1">mdi-help-circle-outline</v-icon>
</v-subheader>
<p class="description" v-show="showHelpText">
Easily import decks created by others.
If you want to add your own deck to the selection, check out our
<a
href="https://github.com/fancy-flashcard/ffc/"
>GitHub repository</a>!
Decks can be official
<v-icon size="1em" color="indigo">mdi-flash-circle</v-icon>, curated
<v-icon size="1em" color="indigo">mdi-check-decagram</v-icon>, sponsored
<v-icon size="1em" color="indigo">mdi-cash-usd-outline</v-icon> or just plain old normal decks.
<br>
Click on a deck to see the details and add it by clicking on the download button.
</p>
<v-list>
<v-list-item-group @change="onChange" v-model="deckModel">
<v-list-item
v-for="(deck, index) in thirdPartyList"
:key="index"
:value="deck.url"
:id="deck.url"
>
<v-list-item-content>
<v-list-item-title>
{{ deck.name }}
<v-icon size="1em" color="indigo">
{{deck.type === 'sponsored' ?
'mdi-cash-usd-outline' : deck.type === 'curated' ?
'mdi-check-decagram' : deck.type === 'official' ?
'mdi-flash-circle' : null}}
</v-icon>
</v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-btn icon dark @click.stop="onDownload(deck.url)">
<v-icon>mdi-download</v-icon>
</v-btn>
</v-list-item-icon>
</v-list-item>
</v-list-item-group>
</v-list>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Component from "vue-class-component";
import thirdPartyList from "../../../third-party-decks.json";
import { Event, ThirdPartyDeck } from "../../types";
@Component
export default class ThirdPartyDeckSelection extends Vue {
thirdPartyList: ThirdPartyDeck[] = thirdPartyList;
deckModel = null;
showHelpText = false;
onChange() {
const currentURL = this.deckModel;
const currentDeck = thirdPartyList.find(deck => deck.url === currentURL);
const options = {
title: currentDeck?.name,
type: currentDeck?.type,
multipleMessages: [
{
name: "Author",
value: currentDeck?.author
},
{
name: "Description",
value: currentDeck?.desc
},
{
name: "Type",
value: currentDeck?.type
}
],
buttons: [
{
name: "Close",
color: "indigo",
callback: () => {
this.deckModel = null;
}
}
]
};
this.$eventHub.$emit(Event.SHOW_CUSTOM_DIALOG, options);
}
async onDownload(url: string) {
try {
const response = await fetch(url);
const fileContent = await response.json();
this.$eventHub.$emit(Event.ADD_DECKS_FROM_JSON, fileContent, url);
} catch (error) {
this.$eventHub.$emit(
Event.SNACKBAR_EVENT,
"An error occurred while loading the third party deck"
);
}
this.deckModel = null;
}
}
</script>
<style scoped>
.v-list-item__icon {
margin: 8px;
}
.theme--dark.v-list-item--active:hover::before,
.theme--dark.v-list-item--active::before {
opacity: 0;
}
.v-subheader .v-icon {
color: inherit;
}
.description {
align-items: center;
font-size: 0.875rem;
font-weight: 400;
padding: 0 16px;
color: rgba(255, 255, 255, 0.7);
}
</style>

View file

@ -1,243 +0,0 @@
import { FFCFile, Deck, Card, CustomDialogOptions } from "@/types";
import router from "@/router";
import { showSnackbar } from "./snackbarHelper";
interface Context {
decks: Deck[];
showCustomDialog: Function;
$router: typeof router;
}
export function addDecksFromFile(
context: Context,
fileContent: string,
url?: string
) {
try {
addDecksFromJSON(context, JSON.parse(fileContent), url);
} catch (e) {
showSnackbar(context, e);
}
}
interface addedDeckAndCards {
name: string;
numberOfCards: number;
addedCards?: number;
updatedCards?: number;
deletedCards?: number;
}
export function addDecksFromJSON(
context: Context,
fileContent: FFCFile,
url?: string
) {
const addedDecksAndCards = [] as addedDeckAndCards[];
try {
let updatedInsteadOfAddedFile = false;
for (const deckShortName in fileContent.decks) {
const cards: Card[] = [];
for (const cardId in fileContent.decks[deckShortName].cards) {
cards.push({
id: Number(cardId),
q: fileContent.decks[deckShortName].cards[cardId].q,
a: fileContent.decks[deckShortName].cards[cardId].a,
r: [],
});
}
// check if deck exists already
const updated = updateDeckIfItExistsAndReturnStatusAndNumberOfCards(
context.decks,
deckShortName,
fileContent,
url
);
if (updated.status) {
updatedInsteadOfAddedFile = true;
addedDecksAndCards.push({
name: updated.deckName || "",
numberOfCards: cards.length,
addedCards: updated.addedCards,
updatedCards: updated.updatedCards,
deletedCards: updated.deletedCards,
});
continue;
}
const name =
fileContent.decks[deckShortName].meta.deck_name || deckShortName;
context.decks.push({
id: context.decks.reduce((acc, cur) => Math.max(acc, cur.id), 0) + 1,
selected: false,
name,
meta: {
file: {
...fileContent.meta,
url,
},
deck: {
...fileContent.decks[deckShortName].meta,
short_name: deckShortName,
},
},
cards,
});
addedDecksAndCards.push({ name, numberOfCards: cards.length });
}
const version = fileContent?.meta?.version;
showAddedDecksConfirmation(
context,
addedDecksAndCards,
version,
updatedInsteadOfAddedFile
);
} catch (e) {
showSnackbar(context, e);
}
}
function updateDeckIfItExistsAndReturnStatusAndNumberOfCards(
decks: Deck[],
deckShortName: string,
fileContent: FFCFile,
url?: string
): {
status: boolean;
deckName?: string;
addedCards?: number;
updatedCards?: number;
deletedCards?: number;
} {
for (const deck of decks) {
// if url is known for the local deck, it should only be updated when url matches
// this should prevent from maliciously using same uuid to overwrite other decks
if (
((deck.meta.file.url && deck.meta.file.url === url) ||
(!deck.meta.file.url &&
deck.meta.file.uuid &&
deck.meta.file.uuid === fileContent.meta.uuid)) &&
deck.meta.deck.short_name === deckShortName
) {
// should be updated
let addedCards = 0,
updatedCards = 0,
deletedCards = 0;
deck.name =
fileContent.decks[deckShortName].meta.deck_name || deckShortName;
deck.meta = {
file: {
...fileContent.meta,
url: deck.meta.file.url,
},
deck: {
...fileContent.decks[deckShortName].meta,
short_name: deckShortName,
},
};
const alreadyExistingCardIdMap: { [cardId: number]: boolean } = {};
for (let i = 0; i < deck.cards.length; i++) {
if (deck.cards[i].id in fileContent.decks[deckShortName].cards) {
if (
deck.cards[i].q !==
fileContent.decks[deckShortName].cards[deck.cards[i].id].q ||
deck.cards[i].a !==
fileContent.decks[deckShortName].cards[deck.cards[i].id].a
) {
deck.cards[i].q =
fileContent.decks[deckShortName].cards[deck.cards[i].id].q;
deck.cards[i].a =
fileContent.decks[deckShortName].cards[deck.cards[i].id].a;
updatedCards += 1;
}
alreadyExistingCardIdMap[deck.cards[i].id] = true;
} else {
deck.cards.splice(i, 1);
deletedCards += 1;
}
}
for (const cardId in fileContent.decks[deckShortName].cards) {
if (cardId in alreadyExistingCardIdMap) continue;
deck.cards.push({
id: Number(cardId),
q: fileContent.decks[deckShortName].cards[cardId].q,
a: fileContent.decks[deckShortName].cards[cardId].a,
r: [],
});
addedCards += 1;
}
return {
status: true,
deckName: deck.name,
addedCards,
updatedCards,
deletedCards,
};
}
}
return { status: false };
}
function showAddedDecksConfirmation(
context: Context,
addedDeckAndCards: addedDeckAndCards[],
version: string,
updatedInsteadOfAddedFile: boolean
) {
const numberOfAddedCards = addedDeckAndCards.reduce(
(total, deck) => total + deck.numberOfCards,
0
);
if (numberOfAddedCards === 0) {
throw new Error("No decks have been added");
}
const options = {
persistent: false,
title: "Successfully Imported Decks",
message: updatedInsteadOfAddedFile
? "Following decks have been updated" +
(version ? " to version " + version : "") +
" (number of cards added / updated / deleted):"
: "The following decks" +
(version ? " (version: " + version + ") " : "") +
"have been added:",
tableHead: { name: "Deck", value: "Number of Cards" },
table: addedDeckAndCards.map((deck) => {
return {
name: deck.name,
value:
String(deck.numberOfCards) + showUpdatedInfoForDeckIfUpdated(deck),
};
}),
buttons: [
{
name: "Close",
color: "grey",
},
{
name: "Go Home",
color: "indigo",
callback: function() {
context.$router.push("/");
},
},
],
} as CustomDialogOptions;
context.showCustomDialog(options);
}
function showUpdatedInfoForDeckIfUpdated(deck: addedDeckAndCards): string {
if (
"addedCards" in deck &&
"updatedCards" in deck &&
"deletedCards" in deck
) {
return ` (${deck.addedCards}/${deck.updatedCards}/${deck.deletedCards})`;
}
return "";
}

View file

@ -1,24 +0,0 @@
import { Event } from "../types";
export function clearLocalStorageDialog(context: any) {
context.$eventHub.$emit("showCustomDialog", {
title: "Clear Storage?",
message:
"Do you really want to clear your local storage? Every deck and learning progress will be lost.",
buttons: [
{
name: "Cancel",
color: "grey",
},
{
name: "Clear Storage",
color: "orange darken-1",
callback: () => {
context.$eventHub.$emit(
Event.CLEAR_LOCAL_STORAGE
);
},
},
],
});
}

View file

@ -1,58 +0,0 @@
import Vue from "vue";
import { Deck } from "@/types";
import VueRouter from "vue-router";
import {
getLearningSessionManagerDataFromLocalStorage,
clearLearningSessionManagerDataInLocalStorage,
} from "./learningSessionStorageHelper";
export default function continueCurrentLearningSessionIfPresent(
eventHub: Vue,
router: VueRouter,
decks: Deck[]
) {
const data = getLearningSessionManagerDataFromLocalStorage();
if (data) {
const options = {
title: `Continue Learning?`,
message:
`You didn't finish your last learning session. Do you want to resume learning where you stopped last session?
Your last session included the following decks: ` +
data.decks.reduce(
(str: string, deck: Deck, index: number) =>
str + (index > 0 ? ", " : "") + deck.name,
""
),
buttons: [
{
name: "Discard Session",
color: "grey",
callback: () => {
clearLearningSessionManagerDataInLocalStorage();
if (router.currentRoute.name !== "DeckSelection") {
router.replace("/");
}
},
},
{
name: "Resume",
color: "indigo",
callback: () => {
data.decks.forEach((deckFromLastSession: Deck) => {
const curDeck = decks.find(
(deckInUse) => deckInUse.id === deckFromLastSession.id
);
if (curDeck) {
curDeck.selected = true;
}
});
if (router.currentRoute.name !== "Learn") {
router.replace("learn");
}
},
},
],
};
eventHub.$emit("showCustomDialog", options);
}
}

View file

@ -1,8 +0,0 @@
export function copyToClipboard() {
const ffcURL = document.getElementById("ffc-url") as HTMLInputElement;
if (!ffcURL) return;
ffcURL.select();
ffcURL.setSelectionRange(0, ffcURL.value.length);
document.execCommand("copy");
ffcURL.blur();
}

Some files were not shown because too many files have changed in this diff Show more