├── .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 | 
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 |
--------------------------------------------------------------------------------