Compare commits
No commits in common. "master" and "gh-pages" have entirely different histories.
|
@ -1,3 +0,0 @@
|
|||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
25
.eslintrc.js
|
@ -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,
|
||||
},
|
||||
};
|
25
.github/workflows/deploy-gh-pages.yml
vendored
|
@ -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
|
@ -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?
|
|
@ -1 +0,0 @@
|
|||
* @FischerRene @nikolockenvitz
|
|
@ -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.
|
|
@ -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
|
@ -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.
|
57
README.md
|
@ -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.
|
|
@ -1,3 +0,0 @@
|
|||
module.exports = {
|
||||
presets: ["@vue/cli-plugin-babel/preset"]
|
||||
};
|
274
cli/index.js
|
@ -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
|
@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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}
|
1
css/chunk-065f4018.2db77a3e.css
Normal 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}
|
1
css/chunk-2eadd278.67146ca8.css
Normal 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}
|
1
css/chunk-6e220460.506e66e1.css
Normal file
1
css/chunk-75fc5bce.905fd344.css
Normal 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}
|
1
css/chunk-7724eb6e.854399cf.css
Normal 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%}
|
1
css/chunk-c9cd02f0.966620a7.css
Normal 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}
|
1
css/chunk-f88b53c8.85e964b6.css
Normal file
5
css/chunk-vendors.2caf484c.css
Normal file
439
docs/README.md
|
@ -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 → 0, 2 → 25, ..., 5 → 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 → 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 → 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 → 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.
|
Before Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 21 KiB |
|
@ -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
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 204 B After Width: | Height: | Size: 204 B |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 5 KiB |
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 831 B After Width: | Height: | Size: 831 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 251 B After Width: | Height: | Size: 251 B |
1
index.html
Normal 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
1
js/app.38aa3ed0.js.map
Normal file
2
js/chunk-065f4018.089c1ae0.js
Normal 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
|
1
js/chunk-065f4018.089c1ae0.js.map
Normal file
2
js/chunk-2eadd278.9c6c8c4b.js
Normal 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
|
1
js/chunk-2eadd278.9c6c8c4b.js.map
Normal file
2
js/chunk-6e220460.eea51f45.js
Normal file
1
js/chunk-6e220460.eea51f45.js.map
Normal file
2
js/chunk-75fc5bce.31fd23e9.js
Normal 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
|
1
js/chunk-75fc5bce.31fd23e9.js.map
Normal file
2
js/chunk-7724eb6e.7b085d09.js
Normal file
1
js/chunk-7724eb6e.7b085d09.js.map
Normal file
2
js/chunk-c9cd02f0.cd91040b.js
Normal file
1
js/chunk-c9cd02f0.cd91040b.js.map
Normal file
2
js/chunk-f88b53c8.c362a2c7.js
Normal file
1
js/chunk-f88b53c8.c362a2c7.js.map
Normal file
18
js/chunk-vendors.50cc43f1.js
Normal file
1
js/chunk-vendors.50cc43f1.js.map
Normal file
1
manifest.json
Normal 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
43
package.json
|
@ -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"
|
||||
}
|
||||
}
|
90
precache-manifest.d2e3d4f8d67bb0a48842b531d52e335e.js
Normal 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"
|
||||
}
|
||||
]);
|
|
@ -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
|
@ -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, {});
|
168
src/App.vue
|
@ -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>
|
|
@ -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 |
Before Width: | Height: | Size: 6.7 KiB |
|
@ -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 |
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 "";
|
||||
}
|
|
@ -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
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|