Merge pull request #2 from fancy-flashcard/first-design-adjustments

First design adjustments
This commit is contained in:
Rene Fischer 2020-06-19 14:58:17 +02:00 committed by GitHub
commit 981df2dde3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 6272 additions and 1905 deletions

View file

@ -14,8 +14,6 @@ jobs:
- name: Install and Build
run: |
git config user.email "gh-pages-deploy@nikolockenvitz.de"
git config user.name "Niko Lockenvitz"
npm install
npm run build

View file

@ -87,37 +87,45 @@ Our first proposal is as follows:
````
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
{
"deck_id": {
"decks": [{
"id": 1,
"selected": false,
"name": "Name of the Deck (uses deck_name or deck_short_name as a fallback)",
"meta": {
"url": "URL of File",
"last_updated": "Timestamp of Last Update",
"file_meta": {
"file": {
"author": "Name of the Author",
"...": "..."
},
"deck_meta": {
"deck": {
"short_name": "Short Name of the Deck",
"deck_name": "Full Name of the Deck",
"name": "Full Name of the Deck",
"description": "Description",
"next_card_id": 3,
"...": "..."
}
},
"cards": {
"0": {
"cards": [
{
"id": 1,
"q": "question",
"a": "answer",
"r": [
{ "t": "Timestamp When This Card Has Been Rated", "r": 50 }
{ "t": "Timestamp When This Card Has Been Rated", "r": 50 },
{ "t": 1590866520000, "r": 99}
]
},
"...": "..."
}
{ "...": "..."}
]
},
"...": "..."
{ "...": "..."}
]
}
````

75
no-cors.md Normal file
View file

@ -0,0 +1,75 @@
# 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

6722
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,11 +5,12 @@
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"lint": "vue-cli-service lint",
"start": "vue-cli-service serve"
},
"dependencies": {
"core-js": "^3.6.4",
"pwa": "^1.9.7",
"register-service-worker": "^1.7.1",
"vue": "^2.6.11",
"vue-router": "^3.1.6",
"vuetify": "^2.2.11"
@ -17,6 +18,7 @@
"devDependencies": {
"@vue/cli-plugin-babel": "~4.3.0",
"@vue/cli-plugin-eslint": "~4.3.0",
"@vue/cli-plugin-pwa": "^4.4.1",
"@vue/cli-plugin-router": "^4.3.1",
"@vue/cli-service": "~4.3.0",
"babel-eslint": "^10.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00251 14.9297L0 1.07422H6.14651L8.00251 4.27503L9.84583 1.07422H16L8.00251 14.9297Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Disallow:

View file

@ -1,20 +1,42 @@
<template>
<div id="app">
<v-app id="inspire">
<v-app id="sandbox">
<v-content>
<NavigationBar title="Fancy Flashcard"></NavigationBar>
<router-view />
<Footer></Footer>
</v-content>
<v-window :touch="{ left: swipeLeft, right: swipeRight }">
<v-app id="inspire">
<v-app id="sandbox">
<v-content>
<NavigationBar
ref="navbar"
title="Fancy Flashcard"
v-bind:decks="decks"
v-bind:numberOfSelectedDecks="numberOfSelectedDecks"
v-bind:navBarList="navBarList"
></NavigationBar>
<router-view v-bind:decks="decks" v-bind:numberOfSelectedDecks="numberOfSelectedDecks" />
<v-snackbar app v-model="snackbar.snackbar" :timeout="snackbar.timeout">
{{ snackbar.text }}
<template>
<v-btn color="indigo" text @click="snackbar.snackbar = false">Close</v-btn>
</template>
</v-snackbar>
<CustomDialog ref="customDialog" />
</v-content>
</v-app>
</v-app>
</v-app>
</v-window>
</div>
</template>
<script>
import NavigationBar from "./components/layout/NavigationBar.vue";
import Footer from "./components/layout/Footer.vue";
import CustomDialog from "./components/customdialog/CustomDialog.vue";
import {
readFromLocalStorage,
saveToLocalStorage,
clearLocalStorage
} from "./helpers/localStorageHelper";
import { addDecksFromFile, addDecksFromJSON } from "./helpers/addDecksHelper";
const DEFAULT_SNACKBAR_TIMEOUT = 2000;
export default {
props: {
@ -22,17 +44,120 @@ export default {
},
components: {
NavigationBar,
Footer
CustomDialog
},
created() {
this.$eventHub.$on("deleteDecks", decksToBeDeleted => {
this.decks = this.decks.filter(
deck => !decksToBeDeleted.includes(deck.id)
);
});
this.$eventHub.$on("addDecksFromFile", fileContent => {
addDecksFromFile(this, fileContent);
});
this.$eventHub.$on("addDecksFromJSON", fileContent => {
addDecksFromJSON(this, fileContent);
});
this.$eventHub.$on("snackbarEvent", output => {
this.showSnackbar(output);
});
this.$eventHub.$on("clearLocalStorage", () => {
clearLocalStorage(this);
});
for (const item of this.propertiesToSyncWithLocalStorage) {
this.$watch(
item.key,
function() {
saveToLocalStorage(this, item);
},
{ deep: true }
);
}
},
data() {
return {
propertiesToSyncWithLocalStorage: [{ key: "decks", defaultValue: [] }],
decks: [],
navBarList: [
{
to: "/",
icon: "mdi-home",
title: "Home"
},
{
to: "/add",
icon: "mdi-plus",
title: "Add Deck"
},
{
to: "/settings",
icon: "mdi-cog",
title: "Settings"
},
{
to: "/about",
icon: "mdi-information",
title: "About"
}
],
snackbar: {
text: "",
snackbar: false,
timeout: DEFAULT_SNACKBAR_TIMEOUT
}
};
},
mounted() {
readFromLocalStorage(this);
},
computed: {
numberOfSelectedDecks() {
return this.decks.filter(deck => deck.selected).length;
}
},
methods: {
swipeLeft() {
if (this.$route.name === "Learn") {
return;
}
this.$refs.navbar.hideDrawer();
},
swipeRight() {
if (this.$route.name === "Learn") {
return;
}
this.$refs.navbar.showDrawer();
},
showSnackbar(text, timeout) {
// timeout: {value?: number, factor?: number}
this.snackbar.text = text;
this.snackbar.timeout = timeout
? timeout.value || (timeout.factor || 1) * DEFAULT_SNACKBAR_TIMEOUT
: DEFAULT_SNACKBAR_TIMEOUT;
this.snackbar.snackbar = true;
},
showCustomDialog (options) {
this.$refs.customDialog.show(options);
},
}
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
html,
body {
/* apply dark mode to scrollbar in firefox desktop */
background-color: #000;
/* remove scrollbar on desktop when not needed */
overflow-y: auto !important;
}
/* reduce margin of file input */
.deck-input .v-input__control .v-input__slot {
margin-bottom: 0;
}
.deck-input .v-input__control .v-text-field__details {
display: none;
}
</style>

View file

@ -0,0 +1,24 @@
<template>
<div class="settings">
<v-container fluid>
<v-row>
<ImportDeckFromURL />
<ImportDeckFromFile />
<DeckCreator />
</v-row>
</v-container>
</div>
</template>
<script>
import ImportDeckFromURL from "./ImportDeckFromURL.vue";
import ImportDeckFromFile from "./ImportDeckFromFile.vue";
import DeckCreator from "./DeckCreator.vue";
export default {
name: "AddNewDeck",
components: { ImportDeckFromURL, ImportDeckFromFile, DeckCreator }
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,19 @@
<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>
export default {
name: "DeckCreator"
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,64 @@
<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>
export default {
name: "ImportDeckFromFile",
data() {
return {
chosenFile: null,
fileContent: ""
};
},
methods: {
importFile() {
const reader = new FileReader();
try {
if (this.chosenFile === null) {
throw new Error("No file chosen.");
}
if (
this.chosenFile.name.substr(this.chosenFile.name.length - 5) !==
".json"
) {
throw new Error("Wrong file format!");
}
reader.readAsText(this.chosenFile);
reader.onload = () => {
this.$eventHub.$emit("addDecksFromFile", reader.result);
};
} catch (e) {
this.$eventHub.$emit("snackbarEvent", e);
}
}
}
};
</script>
<style scoped>
#file-input-wrapper {
padding-bottom: 0;
}
.button-padding {
padding: 16px;
}
</style>

View file

@ -0,0 +1,62 @@
<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"
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>
export default {
name: "ImportDeckFromURL",
data() {
return {
chosenURL: null,
fileContent: "",
urlRules: [
value =>
new RegExp("^https://.*/.*.json$").exec(value) !== null ||
"Please provide a correct URL"
]
};
},
methods: {
async loadFileFromURL() {
try {
const response = await fetch(this.chosenURL);
const fileContent = await response.json();
this.$eventHub.$emit("addDecksFromJSON", fileContent);
} catch (error) {
console.log(error);
// TODO: cors?!
this.$eventHub.$emit("snackbarEvent", "An Error Occurred While Loading The File");
}
},
}
};
</script>
<style scoped>
#file-input-wrapper {
padding-bottom: 0;
}
.button-padding {
padding: 16px;
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<v-dialog v-model="showDialog" max-width="400" persistent>
<v-card color="#2e2e2e">
<v-card-title class="headline">{{ options.title }}</v-card-title>
<v-card-text class="text-left">{{ options.message }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close()">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: "Dialog",
props: {},
data() {
return {
showDialog: false,
options: {
title: "",
message: "",
redirectRouteAfterClose: "/"
}
};
},
methods: {
hide() {
this.showDialog = false;
},
show(options) {
this.showDialog = true;
this.options = options;
},
close() {
this.showDialog = false;
if (this.options.redirectRouteAfterClose) {
this.$router.push(this.options.redirectRouteAfterClose);
}
}
}
};
</script>
<style scoped>
</style>

View file

@ -1,20 +0,0 @@
<template>
<div class="deck">
<input type="checkbox" v-bind:name="deckname" v-bind:value="deckname" />
<label v-bind:for="deckname">{{ deckname }}</label>
<br />
</div>
</template>
<script>
export default {
name: "Deck",
props: {
deckname: String
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View file

@ -1,39 +1,131 @@
<template>
<div class="DeckSelection">
<v-container fluid>
<v-row align="center" justify="center">
<v-col cols="10">
<v-card>
<v-card-text>
<v-btn text>Löschen</v-btn>
<v-btn text>Neues Deck hinzufügen</v-btn>
<Deck v-for="deck in decks" :key="deck.id" v-bind:deckname="deck.deckname"></Deck>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text color="primary">Starten</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
<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 v-bind: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 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>
<DialogDeleteDecks
ref="confirmDelete"
:numberOfSelectedDecks="numberOfSelectedDecks"
@confirmed="deleteSelectedDecks"
/>
<DialogDeckInfo
ref="info"
:deck="selectedDeck"
/>
</div>
</template>
<script>
import Deck from "./Deck.vue";
import DialogDeleteDecks from "./DialogDeleteDecks.vue";
import DialogDeckInfo from "./DialogDeckInfo.vue";
export default {
name: "DeckSelection",
props: {
decks: Array
},
components: {
Deck
}
DialogDeleteDecks,
DialogDeckInfo,
},
props: {
decks: Array,
numberOfSelectedDecks: Number,
},
created() {
this.$eventHub.$on("askForConfirmationToDeleteSelectedDecks", () => {
if (this.$refs.confirmDelete) this.$refs.confirmDelete.show();
});
this.$eventHub.$on("showInfoForSelectedDeck", () => {
if (this.$refs.info) this.$refs.info.show();
});
},
data() {
return {
showDeleteDialog: false,
showInfo: false,
};
},
computed: {
deckModel: {
get () {
return this.decks.map((deck) => deck.selected ? deck.id : undefined).filter((id) => id !== undefined);
},
set (newModel) {
this.decks.forEach((deck) => {
deck.selected = newModel.includes(deck.id);
});
}
},
selectedDeck() {
return this.deckModel.length !== 1 ? null : this.decks.find((deck) => deck.id === this.deckModel[0]);
},
},
methods: {
onButtonClick() {
if (this.numberOfSelectedDecks === 0) {
this.$router.push('add');
} else {
// start learning with selected decks
this.$router.push('learn');
}
},
deleteSelectedDecks() {
this.$refs.confirmDelete.hide();
this.$eventHub.$emit("deleteDecks", this.deckModel);
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.btn-fixed-bottom-right-corner {
position: fixed;
bottom: 20px;
right: 20px;
}
.hidden {
opacity: 0;
transition: 0.2s;
}
.visible {
opacity: 1;
transition: 0.2s;
}
.rotate-90 {
-webkit-transform: rotate(90deg);
-moz-transform: rotate(90deg);
-ms-transform: rotate(90deg);
-o-transform: rotate(90deg);
transform: rotate(90deg);
}
#no-decks-yet-notice {
padding: 0 16px;
}
</style>

View file

@ -0,0 +1,58 @@
<template>
<v-dialog v-model="showInfo" max-width="400">
<v-card color="#2e2e2e">
<v-card-title class="headline">{{deck ? deck.name : ""}}</v-card-title>
<v-list v-if="deck">
<v-list-item v-for="item in fileInfos" :key="item.key">
<v-list-item-content>{{ item.name }}</v-list-item-content>
<v-list-item-content>{{ deck.meta.file[item.key] || "-" }}</v-list-item-content>
</v-list-item>
<v-list-item v-for="item in deckInfos" :key="item.key">
<v-list-item-content>{{ item.name }}</v-list-item-content>
<v-list-item-content>{{ deck.meta.deck[item.key] || "-" }}</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>Number of Cards</v-list-item-content>
<v-list-item-content>{{ deck.cards.length }}</v-list-item-content>
</v-list-item>
</v-list>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="showInfo = false">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: "DialogDeckInfo",
props: {
deck: Object
},
data() {
return {
showInfo: false,
fileInfos: [{ key: "author", name: "Author" }],
deckInfos: [{ key: "description", name: "Description" }]
};
},
methods: {
hide() {
this.showInfo = false;
},
show() {
this.showInfo = true;
}
}
};
</script>
<style scoped>
.v-list,
.v-sheet {
background-color: unset;
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<v-dialog
v-model="showDeleteDialog"
max-width="400"
>
<v-card color="#2e2e2e">
<v-card-title class="headline">Delete Deck{{numberOfSelectedDecks > 1 ? "s" : ""}}?</v-card-title>
<v-card-text class="text-left">
Do you really want to delete the {{numberOfSelectedDecks > 1 ? numberOfSelectedDecks + " " : ""}}selected
deck{{numberOfSelectedDecks > 1 ? "s" : ""}}?</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="grey"
text
@click="showDeleteDialog = false"
>
Cancel
</v-btn>
<v-btn
color="red darken-1"
text
@click="$emit('confirmed')"
>
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: "DialogDeleteDecks",
props: {
numberOfSelectedDecks: Number,
},
data() {
return {
showDeleteDialog: false,
}
},
methods: {
hide() {
this.showDeleteDialog = false;
},
show() {
this.showDeleteDialog = true;
},
},
};
</script>
<style scoped>
</style>

View file

@ -1,12 +1,11 @@
<template>
<div class="footer">
<v-footer :inset="footer.inset" app>
<span class="px-4">&copy; {{ new Date().getFullYear() }} Niko Lockenvitz & Rene-Pascal Fischer</span>
<span class="px-4">&copy; {{ new Date().getFullYear() }} Niko Lockenvitz &amp; Rene-Pascal Fischer</span>
</v-footer>
</div>
</template>
<script>
export default {
name: "Footer",

View file

@ -11,26 +11,57 @@
overflow
>
<v-list>
<v-list-item :to="'/'" link>
<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>Startseite</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item :to="'/about'" link>
<v-list-item-content>
<v-list-item-title>About</v-list-item-title>
<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>
<v-app-bar-nav-icon
v-if="primaryDrawer.type !== 'permanent'"
<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="primaryDrawer.model = !primaryDrawer.model"
></v-app-bar-nav-icon>
<v-toolbar-title>{{ title }}</v-toolbar-title>
<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>
@ -39,17 +70,67 @@
export default {
name: "NavigationBar",
props: {
title: String
title: String,
decks: Array,
numberOfSelectedDecks: Number,
navBarList: Array
},
data: () => ({
primaryDrawer: {
model: null,
type: "default (no property)",
model: false,
type: "temporary",
clipped: true,
floating: false,
mini: false
}
})
}),
computed: {
isInDeckSelection() {
return this.$route.name === "DeckSelection";
},
isInLearning() {
return this.$route.name === "Learn";
},
colorAppBar() {
if (this.isInDeckSelection && this.numberOfSelectedDecks > 0) {
return "indigo";
}
return "";
},
toolbarTitle() {
if (this.isInDeckSelection && this.numberOfSelectedDecks > 0) {
return `${this.numberOfSelectedDecks} deck${this.numberOfSelectedDecks === 1 ? "":"s"} selected`;
}
return this.title;
},
},
methods: {
deselectAll() {
this.decks.forEach(deck => {
deck.selected = false;
});
},
selectAll() {
this.decks.forEach(deck => {
deck.selected = true;
});
},
deleteSelected() {
this.$eventHub.$emit("askForConfirmationToDeleteSelectedDecks");
},
showInfoForSelectedDeck() {
this.$eventHub.$emit("showInfoForSelectedDeck");
},
showDrawer() {
this.primaryDrawer.model = true;
},
hideDrawer() {
this.primaryDrawer.model = false;
},
quitLearning() {
this.$eventHub.$emit("askForConfirmationToQuitLearning");
},
}
};
</script>

View file

@ -0,0 +1,56 @@
<template>
<v-dialog
v-model="showQuitDialog"
max-width="400"
>
<v-card color="#2e2e2e">
<v-card-title class="headline">Quit Learning?</v-card-title>
<v-card-text class="text-left">
Do you really want to quit learning?
Nevertheless, your progress is saved.</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="grey"
text
@click="showQuitDialog = false"
>
Cancel
</v-btn>
<v-btn
color="red darken-1"
text
@click="$emit('confirmed')"
>
Quit
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: "DialogQuit",
data() {
return {
showQuitDialog: false,
}
},
methods: {
hide() {
this.showQuitDialog = false;
},
show() {
this.showQuitDialog = true;
},
},
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,118 @@
<template>
<div class="learn" v-if="numberOfSelectedDecks>0"> <!-- otherwise it will be shortly displayed before it is catched by beforeMount -->
<v-subheader>{{ card.deckName }}<v-spacer />69/420</v-subheader>
<div class="max-height">{{ card.q }}</div>
<div class="max-height">
<span v-if="card.showAnswer">{{ card.a }}</span>
<v-btn v-else @click="card.showAnswer = true">Reveal Answer</v-btn>
</div>
<Rating ref="rating"
v-if="card.showAnswer"
:numberOfStars="numberOfStarsInRating"
@rated="onRating"
/>
<v-card-actions>
<v-btn text color="grey lighten-1">Prev</v-btn>
<v-spacer></v-spacer>
<v-btn text color="grey lighten-1">Next</v-btn>
</v-card-actions>
<DialogQuit
ref="confirmQuit"
@confirmed="confirmedQuit"
/>
</div>
</template>
<script>
import Rating from './Rating.vue';
import DialogQuit from './DialogQuit.vue';
export default {
name: "Learn",
components: {
Rating,
DialogQuit,
},
props: {
decks: Array,
numberOfSelectedDecks: Number,
},
data() {
return {
numberOfStarsInRating: 5,
card: {
q: `Lorem ipsum dolor sit amet,
consectetur adipiscing elit. Quisque id nibh venenatis, ultricies odio et, tristique nulla. Aliquam erat volutpat. Quisque eu sollicitudin tortor. Vestibulum ornare ligula vitae magna suscipit sagittis. In vel mattis quam. Vivamus et tincidunt magna, ac suscipit nisi. Donec semper, dui nec interdum lacinia, arcu nisi fermentum turpis, nec venenatis sem arcu non sem. Phasellus ut ipsum ut ex iaculis elementum nec eu sem.
Vivamus ac congue magna. Praesent mollis lacus nec justo porttitor, quis posuere leo vestibulum. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nunc id efficitur arcu. Pellentesque non erat pulvinar, condimentum leo faucibus, scelerisque nulla. Nam massa erat, vehicula non iaculis eu, aliquam sit amet augue. Nam fringilla faucibus justo, at ultricies tellus viverra vel. Maecenas aliquet dictum ex.
Vestibulum ultricies justo eu mattis tincidunt. Phasellus leo quam, pellentesque hendrerit mauris sit amet, faucibus condimentum lacus. Ut sed erat id risus gravida tempus. Aenean id lorem arcu. Curabitur id ante in velit suscipit sagittis. Etiam molestie pretium sapien, ut cursus diam imperdiet quis. Mauris lectus justo, sodales non magna id, sollicitudin euismod nunc. Nunc laoreet eleifend velit eu pellentesque. Vestibulum fringilla, sapien bibendum vulputate feugiat, nisl nunc hendrerit sapien, quis aliquam mauris felis in orci. Donec ac commodo dolor. In tempor sapien erat, sed semper neque ornare a. In hac habitasse platea dictumst.
Morbi tempor quis justo vitae imperdiet.`,
a: "Answer",
showAnswer: false,
rating: undefined,
deckName: "Test Deck 42",
},
};
},
created() {
this.$eventHub.$on("askForConfirmationToQuitLearning", () => {
if (this.$refs.confirmQuit) this.$refs.confirmQuit.show();
});
},
beforeMount() {
if (this.numberOfSelectedDecks === 0) {
this.$router.replace("/");
}
},
methods: {
onRating(rating, programmatically=false) {
// TODO: store rating
if (programmatically === false) {
// TODO: jump to next card
}
},
updateVerticalCentering() {
for (let el of document.getElementsByClassName("max-height")) {
if (el.scrollHeight > el.clientHeight) {
el.classList.remove("flex-center");
} else {
el.classList.add("flex-center");
}
}
},
confirmedQuit() {
this.$router.replace("/");
},
},
mounted() {
this.updateVerticalCentering();
},
updated() {
this.updateVerticalCentering();
},
};
</script>
<style scoped>
.learn {
height: 100%;
display: flex;
flex-direction: column;
}
.max-height {
display: flex;
flex-direction: column;
flex: 1 1 auto;
overflow-y: auto;
height: 0px;
align-items: center;
padding: 0 16px;
}
.flex-center {
justify-content: center;
}
</style>

View file

@ -0,0 +1,78 @@
<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>
export default {
name: "Rating",
props: {
numberOfStars: Number,
},
data() {
return {
numberOfSelectedStar: 0,
}
},
methods: {
onClickStar(n) {
this.numberOfSelectedStar = n;
this.$emit("rated", n);
},
setRating(n) {
this.numberOfSelectedStar = n;
this.$emit("rated", n, true);
}
},
}
</script>
<style scoped>
.rating {
padding: 0 16px;
display: flex;
justify-content: center;
}
.star-wrapper {
display: flex;
flex-flow: column;
flex: 1 1 0;
max-width: 20vw;
max-height: 16vh;
}
.invisible {
visibility: hidden;
}
svg.star {
stroke-width: 5;
stroke-linejoin: round;
}
svg.star polygon {
cursor: pointer;
}
svg.star.filled polygon {
fill: #fc0;
stroke: #fc0;
transition: 0.2s;
}
svg.star polygon {
fill: #222;
stroke: #fc0;
transition: 0.5s;
}
</style>

View file

@ -0,0 +1,61 @@
export function addDecksFromFile(context, fileContent) {
try {
addDecksFromJSON(context, JSON.parse(fileContent));
} catch (e) {
context.showSnackbar(e);
}
}
export function addDecksFromJSON(context, fileContent) {
// Following decks have been added: d0 (5 cards), d1 (76 cards)...
const addedDecksAndCards = [];
try {
for (const deckShortName in fileContent.decks) {
const cards = [];
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: [],
});
}
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,
deck: {
...fileContent.decks[deckShortName].meta,
short_name: deckShortName,
},
},
cards,
});
addedDecksAndCards.push({name, numberOfCards: cards.length});
}
showAddedDecksConfirmation(context, addedDecksAndCards);
} catch (e) {
context.showSnackbar(e);
}
}
function showAddedDecksConfirmation(context, addedDecksAndCards) {
const numberOfAddedCards = addedDecksAndCards.reduce((total, deck) => total + deck.numberOfCards, 0);
if (numberOfAddedCards === 0) {
throw new Error("No decks have been added");
}
const message = addedDecksAndCards.reduce((message, deck, ix, arr) => {
return `${message} Deck "${deck.name}" (Cards: ${deck.numberOfCards})${ix === arr.length-1 ? "" : ","}`;
}, "Following Decks Have Been Added:");
context.showCustomDialog({
title: "Successfully Imported Decks",
message,
redirectRouteAfterClose: '/',
});
}

View file

@ -0,0 +1,45 @@
const LOCAL_STORAGE_APP_CONTEXT = "ffc_";
function get(key) {
return localStorage.getItem(LOCAL_STORAGE_APP_CONTEXT + key);
}
function set(key, value) {
localStorage.setItem(LOCAL_STORAGE_APP_CONTEXT + key, value);
}
// function remove(key) {
// localStorage.removeItem(LOCAL_STORAGE_APP_CONTEXT + key);
// }
function clearAppData() {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith(LOCAL_STORAGE_APP_CONTEXT)) {
localStorage.removeItem(key);
}
}
}
export function readFromLocalStorage(context) {
context.propertiesToSyncWithLocalStorage.forEach((item) => {
try {
context[item.key] = JSON.parse(get(item.key));
if (context[item.key] === null) {
throw new Error("No item found.");
}
} catch (e) {
context[item.key] = item.defaultValue;
}
});
}
export function saveToLocalStorage(context, item) {
set(item.key, JSON.stringify(context[item.key]));
}
export function clearLocalStorage(context) {
clearAppData();
context.propertiesToSyncWithLocalStorage.forEach((item) => {
context[item.key] = item.defaultValue;
});
context.showSnackbar("Removed All App Data From Local Storage.");
}

View file

@ -2,9 +2,12 @@ import Vue from 'vue'
import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify';
import './registerServiceWorker'
Vue.config.productionTip = false
Vue.prototype.$eventHub = new Vue()
new Vue({
router,
vuetify,

View file

@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready () {
console.log(
'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB'
)
},
registered () {
console.log('Service worker has been registered.')
},
cached () {
console.log('Content has been cached for offline use.')
},
updatefound () {
console.log('New content is downloading.')
},
updated () {
console.log('New content is available; please refresh.')
},
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error (error) {
console.error('Error during service worker registration:', error)
}
})
}

View file

@ -1,22 +1,36 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
Vue.use(VueRouter)
const routes = [
const routes = [
{
path: '/',
name: 'Deckselection',
component: Home
name: 'DeckSelection',
component: () => import('../views/Home.vue'),
props: true,
},
{
path: '/learn',
name: 'Learn',
component: () => import('../views/Learn.vue'),
props: true,
},
{
path: '/add',
name: 'Add New Deck',
component: () => import('../views/AddNewDeck.vue'),
props: true,
},
{
path: '/settings',
name: 'Settings',
component: () => import('../views/Settings.vue'),
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
component: () => import('../views/About.vue'),
}
]

22
src/types/index.ts Normal file
View file

@ -0,0 +1,22 @@
export interface Deck {
id: number,
selected: boolean,
name: string,
meta: {
file: object,
deck: object
},
cards: Card[],
}
export interface Card {
id: number,
q: string,
a: string,
r?: Rating[],
}
export interface Rating {
t: number,
r: number,
}

View file

@ -3,13 +3,19 @@
<v-container fluid>
<v-row align="center" justify="center">
<v-col cols="10">
<v-card>
<v-card-text>
<h1>This is an about page</h1>
</v-card-text>
</v-card>
<span class="title">Fancy Flashcard</span>
<br />
<span>&copy; {{ new Date().getFullYear() }} Niko Lockenvitz &amp; Rene-Pascal Fischer</span>
<br />
<a href="https://github.com/fancy-flashcard/ffc">https://github.com/fancy-flashcard/ffc</a>
</v-col>
</v-row>
</v-container>
</div>
</template>
<style scoped>
.about {
text-align: center;
}
</style>

21
src/views/AddNewDeck.vue Normal file
View file

@ -0,0 +1,21 @@
<template>
<div class="addnewdeck">
<AddNewDeckComponent />
</div>
</template>
<script>
import AddNewDeckComponent from "../components/addnewdeck/AddNewDeck.vue";
export default {
name: "AddNewDeck",
props: {
},
components: {
AddNewDeckComponent
}
};
</script>
<style scoped>
</style>

View file

@ -1,30 +1,23 @@
<template>
<div class="deckselection">
<DeckSelection v-bind:decks="decks"></DeckSelection>
<DeckSelection v-bind:decks="decks" v-bind:numberOfSelectedDecks="numberOfSelectedDecks"></DeckSelection>
</div>
</template>
<script>
import DeckSelection from '../components/deckselection/DeckSelection.vue'
export default {
name: 'App',
name: "Home",
props: {
decks: Array,
numberOfSelectedDecks: Number
},
computed: {
},
components: {
DeckSelection
},
data() {
return {
decks: [
{
id: 1,
deckname: "Test1"
},
{
id: 2,
deckname: "Test2"
}
]
};
},
}
</script>

29
src/views/Learn.vue Normal file
View file

@ -0,0 +1,29 @@
<template>
<div class="learn">
<LearnComponent
:decks="decks"
:numberOfSelectedDecks="numberOfSelectedDecks"
/>
</div>
</template>
<script>
import LearnComponent from '../components/learn/Learn.vue';
export default {
name: "Learn",
props: {
decks: Array,
numberOfSelectedDecks: Number,
},
components: {
LearnComponent
}
}
</script>
<style scoped>
.learn {
height: 100%;
}
</style>

34
src/views/Settings.vue Normal file
View file

@ -0,0 +1,34 @@
<template>
<div class="settings">
<v-container fluid>
<v-row align="center" justify="center">
<v-col cols="10">
<span class="title">Fancy Flashcard</span>
<br />
<span>You will be able to change your settings here.</span>
<br />
<a href="https://github.com/fancy-flashcard/ffc">https://github.com/fancy-flashcard/ffc</a>
<br />
<v-btn color="red" @click="clearLocalStorage" class="my-4">Clear Local Storage</v-btn>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script>
export default {
name: "Settings",
methods: {
clearLocalStorage() {
this.$eventHub.$emit("clearLocalStorage");
}
}
}
</script>
<style scoped>
.settings {
text-align: center;
}
</style>