init
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# top level
|
||||
.vscode
|
||||
.DS_Store
|
||||
.git
|
||||
|
||||
# shared backend and frontend
|
||||
*/.DS_Store
|
||||
*/node_modules
|
||||
*/package-lock.json
|
||||
*/.env
|
||||
|
||||
# backend
|
||||
backend/data/anki/decks/*
|
||||
43
backend/app.js
Normal file
@@ -0,0 +1,43 @@
|
||||
var createError = require('http-errors');
|
||||
var express = require('express');
|
||||
var path = require('path');
|
||||
var cookieParser = require('cookie-parser');
|
||||
var logger = require('morgan');
|
||||
|
||||
var indexRouter = require('./routes/index');
|
||||
var usersRouter = require('./routes/users');
|
||||
var photosearch = require('./routes/photosearch');
|
||||
|
||||
var app = express();
|
||||
|
||||
// view engine setup
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'jade');
|
||||
|
||||
app.use(logger('dev'));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.use('/', indexRouter);
|
||||
app.use('/users', usersRouter);
|
||||
app.use('/search/photos', photosearch)
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use(function(req, res, next) {
|
||||
next(createError(404));
|
||||
});
|
||||
|
||||
// error handler
|
||||
app.use(function(err, req, res, next) {
|
||||
// set locals, only providing error in development
|
||||
res.locals.message = err.message;
|
||||
res.locals.error = req.app.get('env') === 'development' ? err : {};
|
||||
|
||||
// render the error page
|
||||
res.status(err.status || 500);
|
||||
res.render('error');
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
90
backend/bin/www
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
var app = require('../app');
|
||||
var debug = require('debug')('backend:server');
|
||||
var http = require('http');
|
||||
|
||||
/**
|
||||
* Get port from environment and store in Express.
|
||||
*/
|
||||
|
||||
var port = normalizePort(process.env.PORT || '3000');
|
||||
app.set('port', port);
|
||||
|
||||
/**
|
||||
* Create HTTP server.
|
||||
*/
|
||||
|
||||
var server = http.createServer(app);
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
|
||||
/**
|
||||
* Normalize a port into a number, string, or false.
|
||||
*/
|
||||
|
||||
function normalizePort(val) {
|
||||
var port = parseInt(val, 10);
|
||||
|
||||
if (isNaN(port)) {
|
||||
// named pipe
|
||||
return val;
|
||||
}
|
||||
|
||||
if (port >= 0) {
|
||||
// port number
|
||||
return port;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "error" event.
|
||||
*/
|
||||
|
||||
function onError(error) {
|
||||
if (error.syscall !== 'listen') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
var bind = typeof port === 'string'
|
||||
? 'Pipe ' + port
|
||||
: 'Port ' + port;
|
||||
|
||||
// handle specific listen errors with friendly messages
|
||||
switch (error.code) {
|
||||
case 'EACCES':
|
||||
console.error(bind + ' requires elevated privileges');
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'EADDRINUSE':
|
||||
console.error(bind + ' is already in use');
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "listening" event.
|
||||
*/
|
||||
|
||||
function onListening() {
|
||||
var addr = server.address();
|
||||
var bind = typeof addr === 'string'
|
||||
? 'pipe ' + addr
|
||||
: 'port ' + addr.port;
|
||||
debug('Listening on ' + bind);
|
||||
}
|
||||
89
backend/lib/anki.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { open } from 'sqlite'
|
||||
import sqlite3 from 'sqlite3'
|
||||
import AdmZip from 'adm-zip'
|
||||
|
||||
// database structure: https://github.com/ankidroid/Anki-Android/wiki/Database-Structure
|
||||
// python example: https://github.com/patarapolw/ankisync2
|
||||
// old js example: https://github.com/nornagon/mkanki/blob/master/index.js
|
||||
|
||||
// to create a zip file
|
||||
// const zip = new AdmZip()
|
||||
// zip.addLocalFolder('./folder_to_zip')
|
||||
// zip.writeZip('./output.zip')
|
||||
|
||||
class Deck {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param info ID of internal deck, or filename or anki export
|
||||
* @param process whether or not this is an external anki export to process
|
||||
*/
|
||||
constructor(info, process) {
|
||||
// pull out variables
|
||||
this.notes = null // this is what is edited; may create multiple cards
|
||||
this.media = null // image and audio files attached to cards/notes
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an internal deck
|
||||
*/
|
||||
async load(id) {
|
||||
// open database
|
||||
// open media files
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an anki export for internal use. Currently only supports
|
||||
* .apkg files. In the future, may support:
|
||||
* colpkg
|
||||
* txt (notes)
|
||||
* txt (cards)
|
||||
*
|
||||
* @param filename path of file to process
|
||||
*/
|
||||
async process(filename) {
|
||||
return await this.processApkg(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a .apkg file into a database
|
||||
*
|
||||
* @param filename name of file to open and process
|
||||
*/
|
||||
async processApkg(filename) {
|
||||
// error handling
|
||||
if (!filename) throw new Error('Must specify a .apkg filename')
|
||||
|
||||
// setup
|
||||
let uuid = crypto.randomUUID()
|
||||
let location = `../data/anki/decks/${uuid}/`
|
||||
|
||||
// make the resource folder if it does not exist already
|
||||
// let resource = ''
|
||||
// if (!fs.existsSync(resource)) fs.mkdirSync(resource, { recursive: true })
|
||||
|
||||
// extract zip
|
||||
// https://stuk.github.io/jszip/documentation/howto/read_zip.html#in-nodejs
|
||||
let zip = new AdmZip(filename)
|
||||
zip.extractAllTo(location, true)
|
||||
|
||||
// extract the notes database
|
||||
let db = await open({
|
||||
filename: location + 'collection.anki2',
|
||||
driver: sqlite3.cached.Database
|
||||
})
|
||||
|
||||
let result = await db.get('SELECT sfld FROM notes WHERE id = :n', 1486785585103)
|
||||
|
||||
console.log(result);
|
||||
|
||||
// extract media files
|
||||
// ? process/rename media files and update in db?
|
||||
// load data into this obj
|
||||
|
||||
// return { notes, media }
|
||||
}
|
||||
}
|
||||
|
||||
export default Deck
|
||||
BIN
backend/lib/test.apkg
Normal file
4
backend/lib/test.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import Deck from './anki.js'
|
||||
|
||||
let deck = new Deck()
|
||||
await deck.process('test.apkg');
|
||||
BIN
backend/lib/test/0
Normal file
BIN
backend/lib/test/1
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backend/lib/test/10
Normal file
BIN
backend/lib/test/11
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backend/lib/test/12
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/lib/test/13
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/lib/test/14
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
backend/lib/test/15
Normal file
BIN
backend/lib/test/16
Normal file
BIN
backend/lib/test/17
Normal file
BIN
backend/lib/test/18
Normal file
BIN
backend/lib/test/19
Normal file
BIN
backend/lib/test/2
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
backend/lib/test/20
Normal file
BIN
backend/lib/test/21
Normal file
BIN
backend/lib/test/22
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
backend/lib/test/23
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
backend/lib/test/24
Normal file
BIN
backend/lib/test/25
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
backend/lib/test/26
Normal file
BIN
backend/lib/test/27
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backend/lib/test/28
Normal file
BIN
backend/lib/test/29
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backend/lib/test/3
Normal file
BIN
backend/lib/test/30
Normal file
BIN
backend/lib/test/31
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
backend/lib/test/32
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
backend/lib/test/33
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/lib/test/34
Normal file
BIN
backend/lib/test/35
Normal file
BIN
backend/lib/test/36
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
backend/lib/test/37
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
backend/lib/test/38
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backend/lib/test/39
Normal file
BIN
backend/lib/test/4
Normal file
BIN
backend/lib/test/40
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
backend/lib/test/41
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
backend/lib/test/42
Normal file
BIN
backend/lib/test/43
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
backend/lib/test/44
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/lib/test/45
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
backend/lib/test/46
Normal file
BIN
backend/lib/test/47
Normal file
BIN
backend/lib/test/48
Normal file
BIN
backend/lib/test/49
Normal file
BIN
backend/lib/test/5
Normal file
BIN
backend/lib/test/50
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/lib/test/51
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/lib/test/52
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
backend/lib/test/53
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
backend/lib/test/54
Normal file
BIN
backend/lib/test/55
Normal file
BIN
backend/lib/test/56
Normal file
BIN
backend/lib/test/57
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
backend/lib/test/58
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/lib/test/59
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
backend/lib/test/6
Normal file
BIN
backend/lib/test/60
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
backend/lib/test/61
Normal file
BIN
backend/lib/test/62
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
backend/lib/test/63
Normal file
BIN
backend/lib/test/64
Normal file
BIN
backend/lib/test/65
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
backend/lib/test/66
Normal file
BIN
backend/lib/test/67
Normal file
BIN
backend/lib/test/68
Normal file
BIN
backend/lib/test/69
Normal file
BIN
backend/lib/test/7
Normal file
BIN
backend/lib/test/70
Normal file
BIN
backend/lib/test/71
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
backend/lib/test/72
Normal file
BIN
backend/lib/test/73
Normal file
BIN
backend/lib/test/74
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backend/lib/test/75
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/lib/test/8
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
backend/lib/test/9
Normal file
BIN
backend/lib/test/collection.anki2
Normal file
1
backend/lib/test/media
Normal file
@@ -0,0 +1 @@
|
||||
{"66": "rec1486791173.mp3", "12": "paste-5909874999552.jpg", "49": "rec1486790484.mp3", "54": "rec1486789453.mp3", "59": "paste-6051608920308.jpg", "75": "paste-7176890351876.jpg", "50": "paste-5952824672506.jpg", "11": "paste-5342939316470.jpg", "3": "rec1486790515.mp3", "73": "rec1486791444.mp3", "58": "paste-6743098654966.jpg", "26": "rec1486791092.mp3", "60": "paste-5596342386925.jpg", "68": "rec1486787151.mp3", "69": "rec1486790743.mp3", "0": "rec1486791250.mp3", "8": "paste-6219112644848.jpg", "18": "rec1486790648.mp3", "57": "paste-6880537608452.jpg", "61": "rec1486789557.mp3", "33": "paste-6343666696438.jpg", "7": "rec1486791037.mp3", "21": "rec1486786900.mp3", "44": "paste-5476083302645.jpg", "17": "rec1486790321.mp3", "9": "rec1486790979.mp3", "55": "rec1486790707.mp3", "64": "rec1486790888.mp3", "71": "paste-7563437408511.jpg", "53": "paste-6532645257453.jpg", "36": "paste-6322191859961.jpg", "31": "paste-7687991460090.jpg", "28": "rec1486790193.mp3", "47": "rec1486791121.mp3", "39": "rec1486791213.mp3", "14": "paste-7022271529203.jpg", "37": "paste-7288559501563.jpg", "15": "rec1486789738.mp3", "51": "paste-2929167696123.jpg", "25": "paste-7597797146867.jpg", "20": "rec1486790550.mp3", "19": "rec1486790446.mp3", "48": "rec1486788047.mp3", "74": "paste-5849745457397.jpg", "30": "rec1486791007.mp3", "10": "rec1486788946.mp3", "29": "paste-6846177870075.jpg", "4": "rec1486790287.mp3", "56": "rec1486789680.mp3", "62": "paste-5755256176881.jpg", "38": "paste-2143188680961.jpg", "70": "rec1486790804.mp3", "42": "rec1486790858.mp3", "43": "paste-7430293422343.jpg", "23": "paste-6158983102706.jpg", "72": "rec1486789502.mp3", "34": "rec1486790589.mp3", "16": "rec1486791310.mp3", "52": "paste-6266357285108.jpg", "32": "paste-687194767612.jpg", "65": "paste-6708738916592.jpg", "2": "paste-7142530613495.jpg", "40": "paste-816043786497.jpg", "1": "paste-1090921693442.jpg", "67": "rec1486791391.mp3", "5": "rec1486790677.mp3", "6": "rec1486791362.mp3", "46": "rec1486790948.mp3", "22": "paste-7073811136753.jpg", "63": "rec1486790918.mp3", "24": "rec1486790620.mp3", "27": "paste-4939212390648.jpg", "45": "paste-6644314407145.jpg", "41": "paste-5811090751727.jpg", "35": "rec1486789528.mp3", "13": "paste-6442450944247.jpg"}
|
||||
1
backend/lib/utils.js
Normal file
@@ -0,0 +1 @@
|
||||
export {}
|
||||
23
backend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
"reinstall": "rm -rf node_modules/ package-lock.json && npm install"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.13.5",
|
||||
"cookie-parser": "~1.4.4",
|
||||
"debug": "~2.6.9",
|
||||
"express": "~4.16.1",
|
||||
"fs": "^0.0.1-security",
|
||||
"http-errors": "~1.6.3",
|
||||
"jade": "~1.11.0",
|
||||
"morgan": "~1.9.1",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7"
|
||||
}
|
||||
}
|
||||
9
backend/routes/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
|
||||
/* GET home page. */
|
||||
router.get('/', function(req, res, next) {
|
||||
res.render('index', { title: 'Express' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
123
backend/routes/photosearch.js
Normal file
@@ -0,0 +1,123 @@
|
||||
var express = require('express')
|
||||
var router = express.Router()
|
||||
|
||||
const axios = require('axios')
|
||||
|
||||
/**
|
||||
* Services to add:
|
||||
* - https://unsplash.com/developers
|
||||
* - https://pixabay.com/api/docs/
|
||||
*
|
||||
* Pronounciation will require $2/mo sub:
|
||||
* https://api.forvo.com/plans-and-pricing/
|
||||
*/
|
||||
|
||||
async function pexelsSearch(query) {
|
||||
// https://help.pexels.com/hc/en-us/articles/47678194141337-Can-I-change-the-search-language-when-using-the-Pexels-API
|
||||
// pexels has a monthly rate limit and an hourly rate limit, only the monthly is returned in the headers
|
||||
// limit is also 200 per hour, will need to manually track that
|
||||
|
||||
const resp = await axios.get(
|
||||
`https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=5`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: process.env.PEXELS_API,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const { data, headers } = resp
|
||||
const photos = data.photos
|
||||
|
||||
let filtered = photos.map((p) => (
|
||||
{
|
||||
url: p.src.medium,
|
||||
desc: p.alt,
|
||||
credit: p.photographer,
|
||||
id: p.id
|
||||
}
|
||||
))
|
||||
|
||||
let response = {
|
||||
photos: filtered,
|
||||
ratelimit: {
|
||||
limit: headers['x-ratelimit-limit'],
|
||||
remaining: headers['x-ratelimit-remaining'],
|
||||
reset: new Date(Number(headers['x-ratelimit-reset']) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function shutterstockSearch(query) {
|
||||
|
||||
// shutterstock has an hourly rate limit and no monthly limit
|
||||
// https://api-reference.shutterstock.com/?shell#assets-api
|
||||
// higher quality is available with shutterstock watermarks, look into [photo].assets objects
|
||||
|
||||
const SHUTTERSTOCK_API_TOKEN = process.env.SHUTTERSTOCK_API
|
||||
|
||||
try {
|
||||
const resp = await axios.get(
|
||||
'https://api.shutterstock.com/v2/images/search',
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${SHUTTERSTOCK_API_TOKEN}`
|
||||
},
|
||||
params: {
|
||||
query,
|
||||
per_page: 5,
|
||||
sort: 'popular',
|
||||
safe: false,
|
||||
image_type: 'photo'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let { data, headers } = resp
|
||||
|
||||
let filtered = data.data.map((p) => (
|
||||
{
|
||||
url: p.assets.mosaic.url,
|
||||
desc: p.description,
|
||||
credit: p.contributor.id,
|
||||
id: Number(p.id)
|
||||
}
|
||||
))
|
||||
|
||||
let response = {
|
||||
photos: filtered,
|
||||
ratelimit: {
|
||||
limit: headers['ratelimit-limit'],
|
||||
remaining: headers['ratelimit-remaining'],
|
||||
reset: new Date(Number(headers['ratelimit-reset']))
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}``
|
||||
}
|
||||
|
||||
router.get('/', async function(req, res) {
|
||||
try {
|
||||
|
||||
const queryL1 = req.query.queries[0]
|
||||
// const queryL2 = req.query.queries[1]
|
||||
|
||||
// const pexels = await pexelsSearch(queryL1)
|
||||
const shutterstock = await shutterstockSearch(queryL1)
|
||||
|
||||
response = shutterstock;
|
||||
|
||||
res.status(200).send(response)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router
|
||||
9
backend/routes/users.js
Normal file
@@ -0,0 +1,9 @@
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
|
||||
/* GET users listing. */
|
||||
router.get('/', function(req, res, next) {
|
||||
res.status(200).send('respond with a resource');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
8
frontend/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
10
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
6
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
44
frontend/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# .
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
30
frontend/eslint.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,js,mjs,jsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
])
|
||||
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
43
frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "linguamate",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"reinstall": "rm -rf node_modules/ package-lock.json && npm install",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"axios": "^1.13.5",
|
||||
"pinia": "^3.0.4",
|
||||
"primeflex": "^4.0.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.42.0",
|
||||
"eslint-plugin-vue": "~10.7.0",
|
||||
"globals": "^17.3.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.42.0",
|
||||
"prettier": "3.8.1",
|
||||
"sass-embedded": "^1.97.3",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-vue-devtools": "^8.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
9
frontend/src/App.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
<template>
|
||||
<TheMenu />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TheMenu from '@/components/app/TheMenu.vue'
|
||||
</script>
|
||||
62
frontend/src/components/app/TheMenu.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<Menubar :model="items" class="px-8">
|
||||
|
||||
<!-- Menu links -->
|
||||
<template #item="{ item, props, hasSubmenu }">
|
||||
<router-link v-if="item.route" v-slot="{ href, navigate }" :to="item.route" custom>
|
||||
<a :href="href" v-bind="props.action" @click="navigate">
|
||||
<span :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</router-link>
|
||||
<a v-else :href="item.url" :target="item.target" v-bind="props.action">
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="hasSubmenu" class="mdi mdi-chevron-down" />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- User profile icon -->
|
||||
<template #end>
|
||||
<i class="mdi mdi-account-outline" />
|
||||
</template>
|
||||
</Menubar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const items = ref([
|
||||
{
|
||||
label: 'Home',
|
||||
icon: 'mdi mdi-home-outline',
|
||||
route: '/'
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
icon: 'mdi mdi-wrench-outline',
|
||||
route: '/toolkit'
|
||||
},
|
||||
{
|
||||
label: 'Learn',
|
||||
icon: 'mdi mdi-lightbulb-outline',
|
||||
items: [
|
||||
{
|
||||
label: 'Pronunciation',
|
||||
icon: 'mdi mdi-waveform',
|
||||
route: '/speak'
|
||||
},
|
||||
{
|
||||
label: 'Anki Decks',
|
||||
icon: 'mdi mdi-card-multiple-outline',
|
||||
route: '/decks'
|
||||
},
|
||||
{
|
||||
label: 'Self Testing',
|
||||
icon: 'mdi mdi-send-variant-outline',
|
||||
route: '/test'
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
</script>
|
||||