├── .env.example ├── .gitignore ├── docs └── cover.jpg ├── assets ├── avocato.gif └── sad_pika.gif ├── src ├── views │ ├── info.js │ ├── loading.js │ ├── footer.js │ ├── dashboard.js │ ├── review.js │ ├── settings.js │ ├── login.js │ ├── help.js │ ├── navbar.js │ ├── cards.js │ └── card.js ├── supabase.js ├── effects.js ├── router.js ├── services.js ├── index.js └── actions.js ├── static └── favicon.svg ├── shell.nix ├── postcss.config.js ├── .babelrc ├── README.md ├── index.html ├── package.json ├── webpack.config.js └── custom.css /.env.example: -------------------------------------------------------------------------------- 1 | SUPABASE_URL= 2 | SUPABASE_KEY= 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /node_modules/ 3 | /.env 4 | -------------------------------------------------------------------------------- /docs/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jethrokuan/srsly/HEAD/docs/cover.jpg -------------------------------------------------------------------------------- /assets/avocato.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jethrokuan/srsly/HEAD/assets/avocato.gif -------------------------------------------------------------------------------- /assets/sad_pika.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jethrokuan/srsly/HEAD/assets/sad_pika.gif -------------------------------------------------------------------------------- /src/views/info.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | 3 | const Help = { 4 | view: () => { 5 | return m("h1", "HELP"); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/views/loading.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | 3 | export const Loading = { 4 | view: () => { 5 | return m("div.center", m("i.nes-kirby.rotate"), m("p.loading", "Loading")); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | pkgs.mkShell { 4 | buildInputs = [ 5 | pkgs.nodejs_latest 6 | 7 | # keep this line if you use bash 8 | pkgs.bashInteractive 9 | ]; 10 | } 11 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Add you postcss configuration here 3 | // Learn more about it at https://github.com/webpack-contrib/postcss-loader#config-files 4 | plugins: [['autoprefixer']], 5 | }; 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["syntax-dynamic-import"], 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/supabase.js: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | 3 | const supabaseUrl = process.env.SUPABASE_URL; 4 | const supabaseKey = process.env.SUPABASE_KEY; 5 | export const supabase = createClient(supabaseUrl, supabaseKey); 6 | -------------------------------------------------------------------------------- /src/views/footer.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | 3 | export const Footer = { 4 | view: () => { 5 | return m( 6 | "footer", 7 | m( 8 | "p", 9 | "Made with ", 10 | m("i.nes-icon.heart.is-small"), 11 | " by ", 12 | m("a", { href: "https://jethro.dev" }, "Jethro Kuan") 13 | ) 14 | ); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/views/dashboard.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | import { Cards } from "./cards"; 3 | import { Settings } from "./settings"; 4 | 5 | export const Dashboard = { 6 | view: ({ attrs: { state, actions } }) => 7 | m( 8 | "div", 9 | !state.profile?.id && m(Settings, { state, actions }), 10 | state.profile && m(Cards, { state, actions }) 11 | ), 12 | }; 13 | -------------------------------------------------------------------------------- /src/effects.js: -------------------------------------------------------------------------------- 1 | const DashboardEffect = (actions) => (state) => { 2 | if (state.dashboard?.status === "loading") { 3 | actions.loadCards(); 4 | } 5 | }; 6 | 7 | const ReviewEffect = (actions) => (state) => { 8 | if (state.review?.status === "loading") { 9 | actions.loadReviewCards(); 10 | } 11 | }; 12 | 13 | export const Effects = (actions) => { 14 | return [DashboardEffect(actions), ReviewEffect(actions)]; 15 | }; 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Srsly 2 | 3 | Srsly is spaced-repetition using the Hypothes.is annotation platform. See [this blog post](https://blog.jethro.dev/posts/taking_srs_seriously/) for more details. 4 | 5 | ![cover](docs/cover.jpg) 6 | 7 | ## Using Srsly 8 | 9 | Srsly is hosted at https://srsly.netlify.app/, you are free to use it. 10 | 11 | Disclaimer: note that using the service means me (the service owner) is able to 12 | see things, such as your Hypothesis token and your flashcards. If that bothers 13 | you then don't use it. 14 | 15 | ## Credits 16 | 17 | - Theme: https://nostalgic-css.github.io/NES.css/ 18 | - Icons: https://pixelarticons.com/ 19 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | import { createMithrilRouter } from "meiosis-routing/router-helper"; 3 | import { createRouteSegments, routeTransition } from "meiosis-routing/state"; 4 | 5 | export const Route = createRouteSegments([ 6 | "Dashboard", 7 | "Login", 8 | "Help", 9 | "Review", 10 | "Settings", 11 | "NotFound", 12 | ]); 13 | 14 | const routeConfig = { 15 | Review: "/", 16 | Login: "/login", 17 | Help: "/help", 18 | Dashboard: "/dashboard", 19 | Settings: "/settings", 20 | NotFound: "/:404...", 21 | }; 22 | 23 | export const navigateTo = (route) => ({ 24 | nextRoute: () => (Array.isArray(route) ? route : [route]), 25 | }); 26 | 27 | export const router = createMithrilRouter({ 28 | m, 29 | routeConfig, 30 | }); 31 | -------------------------------------------------------------------------------- /src/views/review.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | import { Card } from "./card"; 3 | import avocato from "../../assets/avocato.gif"; 4 | 5 | export const Review = { 6 | view: ({ attrs: { state, actions } }) => { 7 | return m( 8 | "div", 9 | state.review?.cards?.length > 0 && 10 | m( 11 | "div", 12 | m("progress.nes-progress.is-pattern", { 13 | value: state.review.index, 14 | max: state.review.cards.length, 15 | }), 16 | m(Card, { 17 | actions, 18 | state, 19 | }) 20 | ), 21 | state.review?.status === "done" && 22 | m( 23 | "div.center", 24 | m("img", { 25 | src: avocato, 26 | style: { 27 | margin: "3rem 0", 28 | }, 29 | }), 30 | m("p", "All done for now! Come back again later.") 31 | ) 32 | ); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Srs.ly 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "srsly", 3 | "version": "1.0.0", 4 | "description": "srsly", 5 | "private": true, 6 | "main": "src/index.js", 7 | "scripts": { 8 | "build": "webpack --mode=production", 9 | "build:dev": "webpack --mode=development", 10 | "build:prod": "webpack --mode=production", 11 | "serve": "webpack serve", 12 | "watch": "webpack --watch" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "@supabase/supabase-js": "^1.11.9", 19 | "marked": "^2.0.3", 20 | "meiosis-routing": "^3.0.0", 21 | "meiosis-setup": "^5.2.0-beta.1", 22 | "mergerino": "^0.4.0", 23 | "mithril": "^2.0.4", 24 | "nes.css": "^2.3.0" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.14.0", 28 | "@babel/preset-env": "^7.14.1", 29 | "@webpack-cli/generators": "^2.0.0", 30 | "autoprefixer": "^10.2.5", 31 | "babel-loader": "^8.2.2", 32 | "copy-webpack-plugin": "^8.1.1", 33 | "css-loader": "^5.2.4", 34 | "dotenv-webpack": "^7.0.2", 35 | "html-webpack-plugin": "^5.3.1", 36 | "image-minimizer-webpack-plugin": "^2.2.0", 37 | "imagemin-gifsicle": "^7.0.0", 38 | "imagemin-jpegtran": "^7.0.0", 39 | "imagemin-optipng": "^8.0.0", 40 | "imagemin-svgo": "^9.0.0", 41 | "meiosis-tracer": "^4.0.0", 42 | "postcss": "^8.2.13", 43 | "postcss-loader": "^5.2.0", 44 | "style-loader": "^2.0.0", 45 | "webpack": "^5.36.2", 46 | "webpack-cli": "^4.6.0", 47 | "webpack-dev-server": "^3.11.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/views/settings.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | 3 | export const Settings = { 4 | view: ({ attrs: { state, actions } }) => { 5 | return m( 6 | "section.nes-container.with-title", 7 | m("h3.title", "Update Hypothes.is Settings"), 8 | m( 9 | "p", 10 | "Get your token from ", 11 | m("a", { href: "https://hypothes.is/account/developer" }, "here"), 12 | "." 13 | ), 14 | m( 15 | "form.item", 16 | m( 17 | "div.nes-field.is-inline", 18 | m("label", { for: "user" }, "User"), 19 | m("input.nes-input", { 20 | type: "text", 21 | placeholder: "user", 22 | value: state.profile.hypothesis_user, 23 | oninput: (evt) => actions.hypothesisUser(evt.target.value), 24 | }) 25 | ), 26 | m( 27 | "div.nes-field.is-inline", 28 | m("label", { for: "token" }, "Token"), 29 | m("input.nes-input", { 30 | type: "password", 31 | placeholder: "token", 32 | value: state.profile.hypothesis_token, 33 | oninput: (evt) => actions.hypothesisToken(evt.target.value), 34 | }) 35 | ), 36 | m( 37 | "div.btn-grp", 38 | m( 39 | "button.nes-btn.is-primary", 40 | { 41 | type: "button", 42 | onclick: () => 43 | actions.profileSubmit( 44 | state.profile.hypothesis_user, 45 | state.profile.hypothesis_token 46 | ), 47 | }, 48 | "Update" 49 | ) 50 | ) 51 | ) 52 | ); 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // Generated using webpack-cli http://github.com/webpack-cli 2 | const path = require("path"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); 5 | const Dotenv = require("dotenv-webpack"); 6 | const CopyPlugin = require("copy-webpack-plugin"); 7 | 8 | module.exports = { 9 | mode: "development", 10 | entry: "./src/index.js", 11 | output: { 12 | path: path.resolve(__dirname, "dist"), 13 | }, 14 | devServer: { 15 | open: true, 16 | host: "localhost", 17 | }, 18 | plugins: [ 19 | new Dotenv({ 20 | systemvars: true, 21 | }), 22 | new CopyPlugin({ 23 | patterns: [ 24 | { from: "static", to: "." }, 25 | ], 26 | }), 27 | new HtmlWebpackPlugin({ 28 | template: "index.html", 29 | }), 30 | new ImageMinimizerPlugin({ 31 | minimizerOptions: { 32 | // Lossless optimization with custom option 33 | // Feel free to experiment with options for better result for you 34 | plugins: [ 35 | ["gifsicle", { interlaced: true }], 36 | ["jpegtran", { progressive: true }], 37 | ["optipng", { optimizationLevel: 5 }], 38 | ], 39 | }, 40 | }), 41 | ], 42 | module: { 43 | rules: [ 44 | { 45 | test: /\\.(js|jsx)$/, 46 | loader: "babel-loader", 47 | }, 48 | { 49 | test: /\.css$/i, 50 | use: ["style-loader", "css-loader", "postcss-loader"], 51 | }, 52 | { 53 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/, 54 | type: "asset", 55 | }, 56 | 57 | // Add your rules for custom modules here 58 | // Learn more about loaders from https://webpack.js.org/loaders/ 59 | ], 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /src/services.js: -------------------------------------------------------------------------------- 1 | import { Route } from "./router"; 2 | import { routeTransition } from "meiosis-routing/state"; 3 | 4 | const routeService = (state) => ({ 5 | routeTransition: () => routeTransition(state.route, state.nextRoute), 6 | route: state.nextRoute, 7 | }); 8 | 9 | const protectedService = (state) => { 10 | const unprotectedRoutes = ["Login", "Help"]; 11 | if (!unprotectedRoutes.includes(state.route[0]?.id) && !state.user) { 12 | const route = [ 13 | Route.Login({ 14 | message: "Please login.", 15 | returnTo: Route.Dashboard(), 16 | }), 17 | ]; 18 | return { 19 | nextRoute: route, 20 | route, 21 | routeTransition: { 22 | arrive: () => ({ Login: Route.Login() }), 23 | leave: () => ({}), 24 | }, 25 | }; 26 | } 27 | }; 28 | 29 | const dashboardService = (state) => { 30 | if (state.routeTransition.arrive.Dashboard) { 31 | return { 32 | dashboard: { 33 | status: "loading", 34 | }, 35 | }; 36 | } else if (state.routeTransition.leave.Dashboard) { 37 | return { dashboard: undefined }; 38 | } 39 | }; 40 | 41 | const reviewService = (state) => { 42 | if (state.routeTransition.arrive.Review) { 43 | return { 44 | review: { 45 | status: "loading", 46 | }, 47 | }; 48 | } 49 | }; 50 | 51 | const loginService = (state) => { 52 | if (state.routeTransition.arrive.Login) { 53 | return { 54 | login: { 55 | email: "", 56 | password: "", 57 | }, 58 | }; 59 | } else if (state.routeTransition.leave.Login) { 60 | return { 61 | login: undefined, 62 | }; 63 | } 64 | }; 65 | 66 | export const Services = [ 67 | routeService, 68 | protectedService, 69 | dashboardService, 70 | loginService, 71 | reviewService, 72 | ]; 73 | -------------------------------------------------------------------------------- /src/views/login.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | 3 | export const Login = { 4 | view: ({ attrs: { state, actions, routing } }) => { 5 | const { message, returnTo } = routing.localSegment.params; 6 | 7 | return m( 8 | "section.nes-container.with-title", 9 | m("h3.title", "Login"), 10 | m( 11 | "form.item", 12 | m( 13 | "div.nes-field.is-inline", 14 | m("label", { for: "email" }, "Email"), 15 | m("input.nes-input", { 16 | type: "text", 17 | placeholder: "email", 18 | value: state.login?.email, 19 | oninput: (evt) => actions.email(evt.target.value), 20 | }) 21 | ), 22 | m( 23 | "div.nes-field.is-inline", 24 | m("label", { for: "password" }, "Password"), 25 | m("input.nes-input", { 26 | type: "password", 27 | placeholder: "password", 28 | value: state.login?.password, 29 | oninput: (evt) => actions.password(evt.target.value), 30 | }) 31 | ), 32 | m( 33 | "div.btn-grp", 34 | m( 35 | "button.nes-btn.is-primary", 36 | { 37 | type: "button", 38 | onclick: () => 39 | actions.login( 40 | state.login.email, 41 | state.login.password, 42 | returnTo 43 | ), 44 | }, 45 | "Login" 46 | ), 47 | m( 48 | "button.nes-btn", 49 | { 50 | type: "button", 51 | onclick: () => 52 | actions.signup( 53 | state.login.email, 54 | state.login.password, 55 | returnTo 56 | ), 57 | }, 58 | "Sign Up" 59 | ) 60 | ) 61 | ) 62 | ); 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /src/views/help.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | import { SyncButton } from "./navbar"; 3 | 4 | export const Help = { 5 | view: ({ attrs: { state, actions } }) => { 6 | return m( 7 | "div.content", 8 | m("h1", "Srs.ly"), 9 | m( 10 | "p", 11 | "Srs.ly uses hypothes.is to build flashcards for anything on the web." 12 | ), 13 | m("h2", "Adding cards"), 14 | m("p", "Srs.ly supports 2 kinds of cards:"), 15 | m( 16 | "section.cards.m3", 17 | m( 18 | "div.nes-container.with-title.is-centered.is-card", 19 | m("h3.title", "Basic Card"), 20 | m("p", "Basic cards have their question and answer"), 21 | m("br"), 22 | m("br"), 23 | m("p", "Separated with 2 new lines") 24 | ), 25 | m( 26 | "div.nes-container.with-title.is-centered.is-card", 27 | m("h3.title", "Cloze Card"), 28 | m("p", "[Cloze cards] have their cloze [in square brackets]") 29 | ) 30 | ), 31 | m( 32 | "p", 33 | "To indicate that the annotations are flashcards, tag them: #srsly for all cards, #srsly-basic for basic cards, #srsly-cloze for cloze cards." 34 | ), 35 | m("p", "Add cards in 3 simple steps ", m("i.nes-icon.coin.is-medium")), 36 | m( 37 | "ol", 38 | m("li", "Use hypothes.is to create these cards (with tags)."), 39 | m( 40 | "li", 41 | "Click the Sync button ", 42 | m("img.nes-avatar.is-medium", { 43 | src: "https://unpkg.com/pixelarticons@1.4.0/svg/sync.svg", 44 | style: { 45 | "image-rendering": "pixelated", 46 | }, 47 | }) 48 | ), 49 | m( 50 | "li", 51 | "Review your cards ", 52 | m("img.nes-avatar.is-medium", { 53 | src: "https://unpkg.com/pixelarticons@1.4.0/svg/human-run.svg", 54 | style: { 55 | "image-rendering": "pixelated", 56 | }, 57 | }), 58 | " or see them in the dashboard ", 59 | m("img.nes-avatar.is-medium", { 60 | src: "https://unpkg.com/pixelarticons@1.4.0/svg/home.svg", 61 | style: { 62 | "image-rendering": "pixelated", 63 | }, 64 | }) 65 | ) 66 | ) 67 | ); 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /src/views/navbar.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | import { router, Route, navigateTo } from "../router"; 3 | 4 | export const NavButton = ({ href, onclick, url, css }) => { 5 | return m( 6 | "li" + css, 7 | m( 8 | "a", 9 | { 10 | href, 11 | onclick, 12 | }, 13 | m("img.nes-avatar.is-medium", { 14 | src: url, 15 | style: { 16 | "image-rendering": "pixelated", 17 | }, 18 | }) 19 | ) 20 | ); 21 | }; 22 | 23 | export const Navbar = { 24 | view: ({ attrs: { state, actions } }) => { 25 | if (state.user) { 26 | return m( 27 | "nav", 28 | m( 29 | "ul", 30 | NavButton({ 31 | href: router.toPath(Route.Review()), 32 | url: "https://unpkg.com/pixelarticons@1.4.0/svg/human-run.svg", 33 | }), 34 | NavButton({ 35 | href: router.toPath(Route.Dashboard()), 36 | url: "https://unpkg.com/pixelarticons@1.4.0/svg/home.svg", 37 | }), 38 | NavButton({ 39 | href: router.toPath(Route.Settings()), 40 | url: "https://unpkg.com/pixelarticons@1.4.0/svg/user.svg", 41 | }), 42 | NavButton({ 43 | css: state.syncing ? ".bounce" : "", 44 | href: "#", 45 | onclick: (e) => { 46 | e.preventDefault(); 47 | actions.syncCards(state.profile); 48 | }, 49 | url: "https://unpkg.com/pixelarticons@1.4.0/svg/sync.svg", 50 | }), 51 | NavButton({ 52 | href: router.toPath(Route.Help()), 53 | url: "https://unpkg.com/pixelarticons@1.4.0/svg/info-box.svg", 54 | }), 55 | NavButton({ 56 | href: "#", 57 | onclick: (e) => { 58 | e.preventDefault(); 59 | actions.logout(); 60 | }, 61 | url: "https://unpkg.com/pixelarticons@1.4.0/svg/logout.svg", 62 | }) 63 | ) 64 | ); 65 | } else { 66 | return m( 67 | "nav", 68 | m( 69 | "ul", 70 | NavButton({ 71 | href: router.toPath(Route.Login()), 72 | url: "https://unpkg.com/pixelarticons@1.4.0/svg/login.svg", 73 | }), 74 | NavButton({ 75 | href: router.toPath(Route.Help()), 76 | url: "https://unpkg.com/pixelarticons@1.4.0/svg/info-box.svg", 77 | }) 78 | ) 79 | ); 80 | } 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | import Stream from "mithril/stream"; 3 | import merge from "mergerino"; 4 | import meiosis from "meiosis-setup/mergerino"; 5 | import "nes.css/css/nes.min.css"; 6 | import "../custom.css"; 7 | 8 | import { Routing } from "meiosis-routing/state"; 9 | import meiosisTracer from "meiosis-tracer"; 10 | 11 | import { Route, Router, router, routes, navigateTo } from "./router"; 12 | import { supabase } from "./supabase"; 13 | 14 | import { Navbar } from "./views/navbar"; 15 | import { Footer } from "./views/footer"; 16 | import { Dashboard } from "./views/dashboard"; 17 | import { Review } from "./views/review"; 18 | import { Login } from "./views/login"; 19 | import { Help } from "./views/help"; 20 | import { Settings } from "./views/settings"; 21 | 22 | import { Actions } from "./actions"; 23 | import { Services } from "./services"; 24 | import { Effects } from "./effects"; 25 | 26 | const componentMap = { 27 | Dashboard, 28 | Help, 29 | Login, 30 | Review, 31 | Settings, 32 | NotFound: { view: () => m("div", "Page Not Found") }, 33 | }; 34 | 35 | const Root = { 36 | view: ({ attrs: { state, actions } }) => { 37 | const routing = Routing(state.route); 38 | const Component = componentMap[routing.localSegment.id]; 39 | const { message } = routing.localSegment.params; 40 | 41 | return m( 42 | "div.container", 43 | m(Navbar, { state, actions }), 44 | message ? m("div.nes-container.m3", message) : null, 45 | m(Component, { state, actions, routing }), 46 | m(Footer) 47 | ); 48 | }, 49 | }; 50 | 51 | const App = { 52 | view: ({ attrs: { state, actions } }) => m(Root, { state, actions }), 53 | }; 54 | 55 | const getSupabaseSession = async () => { 56 | const session = await supabase.auth.session(); 57 | if (session?.user) { 58 | let { data: profile } = await supabase.from("users"); 59 | return { user: session?.user, profile: profile[0] }; 60 | } 61 | }; 62 | 63 | const buildInitialState = async (initialRoute) => { 64 | const routeDict = navigateTo(initialRoute || Route.Review()); 65 | const session = await getSupabaseSession(); 66 | return { 67 | ...session, 68 | ...routeDict, 69 | }; 70 | }; 71 | 72 | const createApp = async (initialRoute) => ({ 73 | initial: await buildInitialState(initialRoute), 74 | Actions: (update) => Actions(update), 75 | services: Services, 76 | Effects: (update, actions) => Effects(actions), 77 | }); 78 | 79 | createApp(router.initialRoute).then((app) => { 80 | const { states, actions } = meiosis({ stream: Stream, merge, app }); 81 | 82 | m.route( 83 | document.getElementById("app"), 84 | "/", 85 | router.MithrilRoutes({ states, actions, App }) 86 | ); 87 | 88 | meiosisTracer({ 89 | streams: [{ stream: states, label: "states" }], 90 | }); 91 | 92 | states.map(() => m.redraw()); 93 | states.map((state) => router.locationBarSync(state.route)); 94 | }); 95 | -------------------------------------------------------------------------------- /custom.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap"); 2 | 3 | html { 4 | font-family: "Press Start 2P", cursive; 5 | } 6 | 7 | .container { 8 | padding: 0 16px; 9 | max-width: 980px; 10 | margin: auto; 11 | padding-top: 16px; 12 | } 13 | 14 | .item > * { 15 | margin-bottom: 1.5rem !important; 16 | } 17 | 18 | .m3 { 19 | margin: 3rem 0; 20 | } 21 | 22 | .nes-btn { 23 | margin: 0 0.5rem; 24 | } 25 | 26 | .btn-grp { 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | } 31 | 32 | nav { 33 | margin: 1rem 0; 34 | } 35 | 36 | nav ul { 37 | padding: 0; 38 | margin: 0; 39 | display: flex; 40 | list-style-type: none; 41 | justify-content: right; 42 | } 43 | 44 | .font-small { 45 | font-size: 0.8rem; 46 | } 47 | 48 | .center { 49 | text-align: center; 50 | } 51 | 52 | .loading::after { 53 | display: inline-block; 54 | animation: dotty steps(1, end) 1s infinite; 55 | content: ""; 56 | } 57 | 58 | @keyframes dotty { 59 | 0% { 60 | content: ""; 61 | } 62 | 25% { 63 | content: "."; 64 | } 65 | 50% { 66 | content: ".."; 67 | } 68 | 75% { 69 | content: "..."; 70 | } 71 | 100% { 72 | content: ""; 73 | } 74 | } 75 | 76 | .rotate { 77 | animation-name: spin; 78 | animation-duration: 2000ms; 79 | animation-iteration-count: infinite; 80 | animation-timing-function: linear; 81 | } 82 | 83 | @-ms-keyframes spin { 84 | from { 85 | -ms-transform: rotate(0deg); 86 | } 87 | to { 88 | -ms-transform: rotate(360deg); 89 | } 90 | } 91 | @-moz-keyframes spin { 92 | from { 93 | -moz-transform: rotate(0deg); 94 | } 95 | to { 96 | -moz-transform: rotate(360deg); 97 | } 98 | } 99 | @-webkit-keyframes spin { 100 | from { 101 | -webkit-transform: rotate(0deg); 102 | } 103 | to { 104 | -webkit-transform: rotate(360deg); 105 | } 106 | } 107 | @keyframes spin { 108 | from { 109 | transform: rotate(0deg); 110 | } 111 | to { 112 | transform: rotate(360deg); 113 | } 114 | } 115 | 116 | .bounce { 117 | animation-name: bounce; 118 | animation-duration: 1s; 119 | animation-fill-mode: both; 120 | } 121 | 122 | @keyframes bounce { 123 | 0%, 124 | 20%, 125 | 50%, 126 | 80%, 127 | 100% { 128 | transform: translateY(0); 129 | } 130 | 40% { 131 | transform: translateY(-30px); 132 | } 133 | 60% { 134 | transform: translateY(-15px); 135 | } 136 | } 137 | 138 | .is-card { 139 | width: 45%; 140 | } 141 | 142 | section.cards { 143 | display: flex; 144 | justify-content: space-between; 145 | } 146 | 147 | footer { 148 | margin-top: 1rem; 149 | font-size: 0.7rem; 150 | text-align: center; 151 | color: #ccc; 152 | } 153 | 154 | footer a { 155 | color: #f45d4c; 156 | } 157 | 158 | @media(max-width: 600px) { 159 | body { 160 | font-size: 12px; 161 | } 162 | section.cards { 163 | margin: 0; 164 | flex-direction: column; 165 | } 166 | 167 | .is-card { 168 | margin: 1rem 0; 169 | width: 100%; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/views/cards.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | import sadpika from "../../assets/sad_pika.gif"; 3 | import { Loading } from "./loading"; 4 | import { router, Route } from "../router"; 5 | 6 | const CardsTable = { 7 | view: ({ attrs: { state, actions } }) => { 8 | return m("table.nes-table.is-bordered.font-small", [ 9 | m( 10 | "thead", 11 | m("tr", [ 12 | m("th", "Card Type"), 13 | m("th", "Text"), 14 | m("th", "Parameters"), 15 | m("th", "Actions"), 16 | ]) 17 | ), 18 | m( 19 | "tbody", 20 | state.dashboard.cards.map((card) => 21 | m("tr", [ 22 | m("td", card.card_type), 23 | m("td", card.text), 24 | m( 25 | "td", 26 | m("ul", [ 27 | m("li", `Correct: ${card.correct}`), 28 | m("li", `Difficulty: ${card.difficulty}`), 29 | m("li", `Days between: ${card.days_between}`), 30 | m("li", `Date last reviewed: ${card.date_last_reviewed}`), 31 | ]) 32 | ), 33 | m( 34 | "td", 35 | m( 36 | "div", 37 | m( 38 | "button.nes-btn.is-warning", 39 | { 40 | onclick: (e) => { 41 | e.preventDefault(); 42 | actions.resetCard(card); 43 | }, 44 | style: { 45 | "margin-bottom": "10px", 46 | }, 47 | }, 48 | "Reset" 49 | ), 50 | m( 51 | "button.nes-btn.is-error", 52 | { 53 | onclick: (e) => { 54 | e.preventDefault(); 55 | actions.deleteCard(state, card); 56 | }, 57 | }, 58 | "Delete" 59 | ) 60 | ) 61 | ), 62 | ]) 63 | ) 64 | ), 65 | ]); 66 | }, 67 | }; 68 | export const Cards = { 69 | view: ({ attrs: { state, actions } }) => { 70 | const loaded = state.dashboard?.status === "loaded"; 71 | const noCards = 72 | state.dashboard?.status === "loaded" && 73 | state.dashboard?.cards.length === 0; 74 | if (loaded) { 75 | if (noCards) { 76 | return m( 77 | "div.center", 78 | m("img", { 79 | src: sadpika, 80 | style: { 81 | margin: "3rem 0", 82 | }, 83 | }), 84 | m( 85 | "p", 86 | "No cards yet. Try ", 87 | m( 88 | "a", 89 | { 90 | href: "#", 91 | onclick: (e) => { 92 | e.preventDefault(); 93 | actions.syncCards(); 94 | }, 95 | }, 96 | "syncing" 97 | ), 98 | " or ", 99 | m( 100 | "a", 101 | { 102 | href: router.toPath(Route.Help()), 103 | }, 104 | "making some cards" 105 | ), 106 | "?" 107 | ) 108 | ); 109 | } else { 110 | return m(CardsTable, { state, actions }); 111 | } 112 | } else { 113 | return m(Loading); 114 | } 115 | }, 116 | }; 117 | -------------------------------------------------------------------------------- /src/views/card.js: -------------------------------------------------------------------------------- 1 | import m from "mithril"; 2 | import marked from "marked"; 3 | 4 | const RevealButton = { 5 | view: ({ attrs: { actions } }) => { 6 | return m( 7 | "div.btn-grp", 8 | m( 9 | "button.nes-btn", 10 | { 11 | type: "button", 12 | onclick: () => { 13 | actions.revealCard(); 14 | }, 15 | }, 16 | "Reveal" 17 | ) 18 | ); 19 | }, 20 | }; 21 | 22 | const ReviewButtons = { 23 | view: ({ attrs: { state, actions } }) => { 24 | return m( 25 | "div.btn-grp", 26 | m( 27 | "button.nes-btn.is-error", 28 | { 29 | type: "button", 30 | onclick: () => { 31 | actions.reviewCard(0.2, state); 32 | }, 33 | }, 34 | "Again" 35 | ), 36 | m( 37 | "button.nes-btn.is-warning", 38 | { 39 | type: "button", 40 | onclick: () => { 41 | actions.reviewCard(0.4, state); 42 | }, 43 | }, 44 | "Hard" 45 | ), 46 | m( 47 | "button.nes-btn.is-primary", 48 | { 49 | type: "button", 50 | onclick: () => { 51 | actions.reviewCard(0.6, state); 52 | }, 53 | }, 54 | "Good" 55 | ), 56 | m( 57 | "button.nes-btn.is-success", 58 | { 59 | type: "button", 60 | onclick: () => { 61 | actions.reviewCard(0.8, state); 62 | }, 63 | }, 64 | "Easy" 65 | ) 66 | ); 67 | }, 68 | }; 69 | 70 | const BasicCard = { 71 | view: ({ attrs: { actions, state } }) => { 72 | const card = state.review.cards[state.review.index]; 73 | const [question, answer] = card.text.split("\n\n"); 74 | return m( 75 | "div", 76 | m.trust(marked(question)), 77 | state.review.status === "question" && m(RevealButton, { actions }), 78 | state.review.status === "answer" && m.trust(marked(answer)), 79 | state.review.status === "answer" && m(ReviewButtons, { actions, state }) 80 | ); 81 | }, 82 | }; 83 | const ClozeCard = { 84 | view: ({ attrs: { actions, state } }) => { 85 | const card = state.review.cards[state.review.index]; 86 | let question = card.text.slice(); 87 | const cloze_regex = /\[(.*?)\]/g; 88 | let deletions = card.text.match(cloze_regex); 89 | const random_index = Math.floor(Math.random() * deletions.length); 90 | const random_deletion = deletions[random_index]; 91 | deletions.splice(random_index, 1); 92 | for (let i = 0; i < deletions.length; i++) { 93 | question = question.replace( 94 | deletions[i], 95 | deletions[i].substring(1, deletions[i].length - 1) 96 | ); 97 | } 98 | question = question.replace(random_deletion, "[...]"); 99 | let answer = question.slice(); 100 | answer = answer.replace( 101 | "[...]", 102 | random_deletion.substring(1, random_deletion.length - 1) 103 | ); 104 | return m( 105 | "div", 106 | m.trust( 107 | state.review.status === "question" ? marked(question) : marked(answer) 108 | ), 109 | state.review.status === "question" && m(RevealButton, { actions }), 110 | state.review.status === "answer" && m(ReviewButtons, { actions, state }) 111 | ); 112 | }, 113 | }; 114 | 115 | export const Card = { 116 | view: ({ attrs: { actions, state } }) => { 117 | const card = state.review.cards[state.review.index]; 118 | return m( 119 | "div.nes-container.with-title", 120 | m( 121 | "h3.title", 122 | `Card ${state.review.index + 1} of ${state.review.cards.length}` 123 | ), 124 | card.card_type === "basic" 125 | ? m(BasicCard, { actions, state }) 126 | : m(ClozeCard, { actions, state }) 127 | ); 128 | }, 129 | }; 130 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { Route, navigateTo } from "./router"; 2 | import { supabase } from "./supabase"; 3 | import m from "mithril"; 4 | 5 | export const Actions = (update) => ({ 6 | navigateTo: (route) => update(navigateTo(route)), 7 | // Dashboard 8 | loadCards: async () => { 9 | let { data: cards, error } = await supabase.from("cards"); 10 | return update({ 11 | dashboard: { 12 | status: "loaded", 13 | cards, 14 | }, 15 | }); 16 | }, 17 | resetCard: async (card) => { 18 | const { data, error } = await supabase 19 | .from("cards") 20 | .update({ 21 | difficulty: 0.3, 22 | date_last_reviewed: new Date().toISOString(), 23 | correct: false, 24 | days_between: 3.0, 25 | }) 26 | .eq("id", card.id); 27 | // TODO: Handle errors 28 | return update({ 29 | dashboard: { status: "loading" }, 30 | }); 31 | }, 32 | deleteCard: async (state, card) => { 33 | const resp = await m.request({ 34 | url: `https://api.hypothes.is/api/annotations/${card.id}`, 35 | method: "DELETE", 36 | headers: { 37 | Authorization: `Bearer ${state.profile["hypothesis_token"]}`, 38 | }, 39 | }); 40 | const { supabaseData, supabaseError } = await supabase 41 | .from("cards") 42 | .delete() 43 | .eq("id", card.id); 44 | return update({ 45 | dashboard: { status: "loading" }, 46 | }); 47 | }, 48 | // Review 49 | loadReviewCards: async () => { 50 | let { data: cards, error } = await supabase.from("cards"); 51 | const now = new Date().getTime(); 52 | let filteredCards = []; 53 | cards.forEach((card) => { 54 | const percentOverdue = card["correct"] 55 | ? Math.min( 56 | 2, 57 | (now - new Date(card["date_last_reviewed"] + "Z")) / 58 | 86400000 / 59 | card["days_between"] 60 | ) 61 | : 1; 62 | if ( 63 | (now - new Date(card["date_last_reviewed"] + "Z")) / 3600000 > 8 || 64 | !card["correct"] || 65 | percentOverdue >= 1 66 | ) { 67 | filteredCards.push({ ...card, percent_overdue: percentOverdue }); 68 | } 69 | }); 70 | filteredCards.sort((c1, c2) => { 71 | return c1["percent_overdue"] > c2["percent_overdue"]; 72 | }); 73 | if (filteredCards.length === 0) { 74 | return update({ 75 | review: { 76 | status: "done", 77 | }, 78 | }); 79 | } else { 80 | return update({ 81 | review: { 82 | status: "question", 83 | index: 0, 84 | cards: filteredCards, 85 | }, 86 | }); 87 | } 88 | }, 89 | syncCards: async (profile) => { 90 | update({ 91 | syncing: true, 92 | }); 93 | const result = await m.request({ 94 | method: "GET", 95 | url: "https://api.hypothes.is/api/search", 96 | params: { 97 | limit: 200, 98 | user: `acct:${profile["hypothesis_user"]}@hypothes.is`, 99 | tags: "srsly", 100 | }, 101 | headers: { 102 | Authorization: `Bearer ${profile["hypothesis_token"]}`, 103 | }, 104 | }); 105 | const rows = result["rows"].map((r) => { 106 | return { 107 | id: r["id"], 108 | user_id: profile.id, 109 | uri: r["uri"], 110 | text: r["text"], 111 | created: r["created"], 112 | updated: r["updated"], 113 | card_type: r["tags"].includes("srsly-cloze") ? "cloze" : "basic", 114 | }; 115 | }); 116 | const { supabaseResult, supabaseError } = await supabase 117 | .from("cards") 118 | .upsert(rows); 119 | update({ 120 | syncing: false, 121 | dashboard: { 122 | status: "loading", 123 | }, 124 | }); 125 | }, 126 | revealCard: () => { 127 | return update({ 128 | review: { 129 | status: "answer", 130 | }, 131 | }); 132 | }, 133 | reviewCard: async (rating, state) => { 134 | const card = state.review.cards[state.review.index]; 135 | const correct = rating >= 0.6; 136 | const now = new Date(); 137 | const date_last_reviewed = now.toISOString(); 138 | const difficulty = Math.max( 139 | 0, 140 | Math.min( 141 | card.difficulty + (card.percent_overdue / 17) * (8 - 9 * rating), 142 | 1 143 | ) 144 | ); 145 | const difficulty_weight = 3 - 1.7 * difficulty; 146 | const days_between = correct 147 | ? 1 + (difficulty_weight - 1) * card.percent_overdue 148 | : Math.max(1, 1 / difficulty_weight ** 2); 149 | const { data, error } = await supabase 150 | .from("cards") 151 | .update({ difficulty, date_last_reviewed, correct, days_between }) 152 | .eq("id", card.id); 153 | 154 | // TODO: Handle errors 155 | if (state.review.index === state.review.cards.length - 1) { 156 | return update({ 157 | review: { 158 | status: "loading", 159 | index: 0, 160 | cards: [], 161 | }, 162 | }); 163 | } else { 164 | return update({ 165 | review: { 166 | index: (x) => { 167 | return x + 1; 168 | }, 169 | status: "question", 170 | }, 171 | }); 172 | } 173 | }, 174 | email: (value) => update({ login: { email: value } }), 175 | password: (value) => update({ login: { password: value } }), 176 | login: async (email, password, returnTo) => { 177 | const { user, session, error } = await supabase.auth.signIn({ 178 | email, 179 | password, 180 | }); 181 | if (error) { 182 | const route = [ 183 | Route.Login({ 184 | message: error["message"], 185 | returnTo, 186 | }), 187 | ]; 188 | return update({ 189 | nextRoute: route, 190 | route, 191 | returnTo, 192 | }); 193 | } else { 194 | let { data: profile } = await supabase.from("users"); 195 | return update([ 196 | { user, profile: profile[0] }, 197 | navigateTo(Route.Dashboard()), 198 | ]); 199 | } 200 | }, 201 | signup: async (email, password, returnTo) => { 202 | const { user, session, error } = await supabase.auth.signUp({ 203 | email, 204 | password, 205 | }); 206 | if (error) { 207 | const route = [ 208 | Route.Login({ 209 | message: error["message"], 210 | returnTo, 211 | }), 212 | ]; 213 | return update({ 214 | nextRoute: route, 215 | route, 216 | returnTo, 217 | }); 218 | } else { 219 | let { data: profile } = await supabase.from("users"); 220 | return update([ 221 | { user, profile: profile[0] }, 222 | navigateTo(Route.Dashboard()), 223 | ]); 224 | } 225 | }, 226 | logout: async () => { 227 | let { error } = await supabase.auth.signOut(); 228 | if (error) { 229 | console.log(error); 230 | } 231 | return update([ 232 | { user: undefined, profile: undefined }, 233 | navigateTo(Route.Login()), 234 | ]); 235 | }, 236 | hypothesisUser: (value) => update({ profile: { hypothesis_user: value } }), 237 | hypothesisToken: (value) => update({ profile: { hypothesis_token: value } }), 238 | profileSubmit: async (user, token) => { 239 | await supabase.from("users").update([ 240 | { 241 | hypothesis_user: user, 242 | hypothesis_token: token, 243 | }, 244 | ]); 245 | return update({ message: "Successfully updated!" }); 246 | }, 247 | }); 248 | --------------------------------------------------------------------------------