Merge pull request #2 from fancy-flashcard/first-design-adjustments
First design adjustments
2
.github/workflows/deploy-gh-pages.yml
vendored
|
@ -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
|
||||
|
||||
|
|
32
README.md
|
@ -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
|
@ -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
|
@ -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",
|
||||
|
|
BIN
public/img/icons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 9.2 KiB |
BIN
public/img/icons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
public/img/icons/android-chrome-maskable-192x192.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
public/img/icons/android-chrome-maskable-512x512.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
public/img/icons/apple-touch-icon-120x120.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
public/img/icons/apple-touch-icon-152x152.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
public/img/icons/apple-touch-icon-180x180.png
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
public/img/icons/apple-touch-icon-60x60.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
public/img/icons/apple-touch-icon-76x76.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
public/img/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
public/img/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 799 B |
BIN
public/img/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
public/img/icons/msapplication-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
public/img/icons/mstile-150x150.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
3
public/img/icons/safari-pinned-tab.svg
Normal 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
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
157
src/App.vue
|
@ -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>
|
||||
|
|
24
src/components/addnewdeck/AddNewDeck.vue
Normal 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>
|
19
src/components/addnewdeck/DeckCreator.vue
Normal 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>
|
64
src/components/addnewdeck/ImportDeckFromFile.vue
Normal 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>
|
62
src/components/addnewdeck/ImportDeckFromURL.vue
Normal 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>
|
48
src/components/customdialog/CustomDialog.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
58
src/components/deckselection/DialogDeckInfo.vue
Normal 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>
|
59
src/components/deckselection/DialogDeleteDecks.vue
Normal 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>
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<div class="footer">
|
||||
<v-footer :inset="footer.inset" app>
|
||||
<span class="px-4">© {{ new Date().getFullYear() }} Niko Lockenvitz & Rene-Pascal Fischer</span>
|
||||
<span class="px-4">© {{ new Date().getFullYear() }} Niko Lockenvitz & Rene-Pascal Fischer</span>
|
||||
</v-footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Footer",
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
56
src/components/learn/DialogQuit.vue
Normal 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>
|
118
src/components/learn/Learn.vue
Normal 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>
|
78
src/components/learn/Rating.vue
Normal 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>
|
61
src/helpers/addDecksHelper.js
Normal 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: '/',
|
||||
});
|
||||
}
|
45
src/helpers/localStorageHelper.js
Normal 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.");
|
||||
}
|
|
@ -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,
|
||||
|
|
32
src/registerServiceWorker.js
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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
|
@ -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,
|
||||
}
|
|
@ -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>© {{ new Date().getFullYear() }} Niko Lockenvitz & 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
|
@ -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>
|
|
@ -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
|
@ -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
|
@ -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>
|