From 09ccde7eed4b3ed406db780b8929534778e01dd8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 29 Apr 2022 14:17:38 +0900 Subject: [PATCH 1/3] refactor: move "exec" to "cli.ts" --- app/misc/cli.ts | 6 +++++- app/utils/node.server.ts | 3 --- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/misc/cli.ts b/app/misc/cli.ts index 11e4cde9a6..7f3f573371 100644 --- a/app/misc/cli.ts +++ b/app/misc/cli.ts @@ -1,4 +1,6 @@ import * as assert from "assert"; +import * as childProcess from "child_process"; +import { promisify } from "util"; import { installGlobals } from "@remix-run/node"; import { cac } from "cac"; import { range, zip } from "lodash"; @@ -6,9 +8,11 @@ import { z } from "zod"; import { client } from "../db/client.server"; import { Q, filterNewVideo, insertVideoAndCaptionEntries } from "../db/models"; import { createUserCookie, register, verifySignin } from "../utils/auth"; -import { exec, streamToString } from "../utils/node.server"; +import { streamToString } from "../utils/node.server"; import { NewVideo, fetchCaptionEntries } from "../utils/youtube"; +const exec = promisify(childProcess.exec); + const cli = cac("cli").help(); cli diff --git a/app/utils/node.server.ts b/app/utils/node.server.ts index 2e434020d0..086d081bc2 100644 --- a/app/utils/node.server.ts +++ b/app/utils/node.server.ts @@ -1,10 +1,7 @@ -import * as childProcess from "child_process"; import * as crypto from "crypto"; import type { Readable } from "stream"; -import { promisify } from "util"; export { crypto as crypto }; -export const exec = promisify(childProcess.exec); // cf. https://nodejs.org/docs/latest-v14.x/api/stream.html#stream_readable_symbol_asynciterator export async function streamToString(readable: Readable): Promise { From 30abf10a8d3abeb0aa76d8dc76d2451b394e8b4d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 29 Apr 2022 16:05:44 +0900 Subject: [PATCH 2/3] chore: experiment with netlify-edge --- .eslintrc.js | 2 +- .gitignore | 1 - .netlify/edge-functions/manifest.json | 4 +++ .patch/patches/@remix-run+dev+1.4.1.patch | 22 +++++++++++++++- .prettierignore | 1 + app/misc/netlify-edge.ts | 31 +++++++++++++++++++++++ app/utils/session.server.ts | 4 ++- netlify.toml | 15 +++-------- package.json | 1 + pnpm-lock.yaml | 31 ++++++++++++++++++++--- remix.config.js | 24 +++++++++++++++--- 11 files changed, 112 insertions(+), 24 deletions(-) create mode 100644 .netlify/edge-functions/manifest.json create mode 100644 app/misc/netlify-edge.ts diff --git a/.eslintrc.js b/.eslintrc.js index eeccb21ecb..4e87301539 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,5 +7,5 @@ module.exports = { "import/order": ["error", { alphabetize: { order: "asc" } }], "sort-imports": ["error", { ignoreDeclarationSort: true }], }, - ignorePatterns: [".cache", "build", "node_modules", "coverage"], + ignorePatterns: [".cache", "build", "node_modules", "coverage", ".netlify"], }; diff --git a/.gitignore b/.gitignore index ac2a2f54c3..12ff1bf091 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ /node_modules /build /.cache -/.netlify /.env.staging.sh /.env.production.sh /coverage diff --git a/.netlify/edge-functions/manifest.json b/.netlify/edge-functions/manifest.json new file mode 100644 index 0000000000..1d890b3b4f --- /dev/null +++ b/.netlify/edge-functions/manifest.json @@ -0,0 +1,4 @@ +{ + "functions": [{ "function": "index", "path": "/*" }], + "version": 1 +} diff --git a/.patch/patches/@remix-run+dev+1.4.1.patch b/.patch/patches/@remix-run+dev+1.4.1.patch index d8e69098d1..7a58012be3 100644 --- a/.patch/patches/@remix-run+dev+1.4.1.patch +++ b/.patch/patches/@remix-run+dev+1.4.1.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@remix-run/dev/compiler.js b/node_modules/@remix-run/dev/compiler.js -index 0de2694..91caea0 100644 +index 0de2694..e8adbd8 100644 --- a/node_modules/@remix-run/dev/compiler.js +++ b/node_modules/@remix-run/dev/compiler.js @@ -331,7 +331,7 @@ async function createBrowserBuild(config, options) { @@ -11,3 +11,23 @@ index 0de2694..91caea0 100644 metafile: true, incremental: options.incremental, mainFields: ["browser", "module", "main"], +@@ -375,6 +375,7 @@ function createServerBuild(config, options, assetsManifestPromiseRef) { + absWorkingDir: config.rootDirectory, + stdin, + entryPoints, ++ external: ["mysql"], + outfile: config.serverBuildPath, + write: false, + conditions: isCloudflareRuntime ? ["worker"] : undefined, +diff --git a/node_modules/@remix-run/dev/compiler/plugins/serverBareModulesPlugin.js b/node_modules/@remix-run/dev/compiler/plugins/serverBareModulesPlugin.js +index dede8a5..1cbcbe4 100644 +--- a/node_modules/@remix-run/dev/compiler/plugins/serverBareModulesPlugin.js ++++ b/node_modules/@remix-run/dev/compiler/plugins/serverBareModulesPlugin.js +@@ -82,6 +82,7 @@ function serverBareModulesPlugin(remixConfig, dependencies, onWarning) { + // Always bundle everything for cloudflare. + case "cloudflare-pages": + case "cloudflare-workers": ++ case "deno": + return undefined; + } + diff --git a/.prettierignore b/.prettierignore index 43bddb791c..ec3f339900 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ /misc/demo/*.yml /misc/ytsub-v2/data-v2.json /coverage +/.netlify/edge-functions/index.js diff --git a/app/misc/netlify-edge.ts b/app/misc/netlify-edge.ts new file mode 100644 index 0000000000..339744c1ac --- /dev/null +++ b/app/misc/netlify-edge.ts @@ -0,0 +1,31 @@ +import * as build from "@remix-run/dev/server-build"; +import { createRequestHandler as createRemixRequestHandler } from "@remix-run/server-runtime"; + +// cf. https://github.com/remix-run/remix/blob/fd9fa7f4b5abaa6a0c8204c66b92033815ba7d0e/packages/remix-netlify-edge/server.ts + +// e.g. `context.next` +interface NetlifyEdgeContext {} + +async function netlifyEdgeHandler( + request: Request, + _context: NetlifyEdgeContext +) { + const url = new URL(request.url); + // Hard-code root assets for CDN delegation + if ( + url.pathname.startsWith("/build") || + url.pathname.startsWith("/ui-dev") || + url.pathname.startsWith("/_copy") || + url.pathname.startsWith("/service-worker.js") + ) { + return; + } + // { + // const res = { url: request.url, pathname: url.pathname }; + // return new Response(JSON.stringify(res, null, 2)); + // } + const remixHandler = createRemixRequestHandler(build); + return await remixHandler(request); +} + +export default netlifyEdgeHandler; diff --git a/app/utils/session.server.ts b/app/utils/session.server.ts index f9673ea69f..696357388e 100644 --- a/app/utils/session.server.ts +++ b/app/utils/session.server.ts @@ -1,4 +1,6 @@ -import { createCookieSessionStorage } from "@remix-run/node"; +// TODO: how to switch at build time? (resolution?) +// import { createCookieSessionStorage } from "@remix-run/node"; +import { createCookieSessionStorage } from "@remix-run/netlify-edge"; import { env } from "../misc/env"; const { getSession, commitSession, destroySession } = diff --git a/netlify.toml b/netlify.toml index d0d0ea7f63..4a06d7ce04 100644 --- a/netlify.toml +++ b/netlify.toml @@ -4,15 +4,6 @@ publish = "build/remix/production/public" [build.processing] skip_processing = true -[functions] -directory = "build/remix/production/server-bundle-zip" - -[[redirects]] -from = "/*" -to = "/.netlify/functions/index" -status = 200 -force = false # serve static assets matching request paths without calling lambda - -[[headers]] -for = "/build/*" -values = { cache-control = "max-age=31536000, immutable" } +# [[headers]] +# for = "/build/*" +# values = { cache-control = "max-age=31536000, immutable" } diff --git a/package.json b/package.json index 236e826005..f48eecb27c 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "devDependencies": { "@playwright/test": "^1.19.2", "@remix-run/dev": "file:./.patch/node_modules/@remix-run/dev", + "@remix-run/netlify-edge": "0.0.0-experimental-c6bf743d", "@remix-run/serve": "file:./.patch/node_modules/@remix-run/serve", "@tailwindcss/line-clamp": "^0.3.1", "@types/fs-extra": "^9.0.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9f8f29c8c..6bab440638 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ specifiers: '@playwright/test': ^1.19.2 '@remix-run/dev': file:./.patch/node_modules/@remix-run/dev '@remix-run/netlify': ^1.4.0 + '@remix-run/netlify-edge': 0.0.0-experimental-c6bf743d '@remix-run/node': ^1.4.0 '@remix-run/react': ^1.4.0 '@remix-run/serve': file:./.patch/node_modules/@remix-run/serve @@ -87,6 +88,7 @@ dependencies: devDependencies: '@playwright/test': 1.19.2 '@remix-run/dev': link:.patch/node_modules/@remix-run/dev + '@remix-run/netlify-edge': 0.0.0-experimental-c6bf743d_react-dom@17.0.2+react@17.0.2 '@remix-run/serve': link:.patch/node_modules/@remix-run/serve '@tailwindcss/line-clamp': 0.3.1_tailwindcss@3.0.23 '@types/fs-extra': 9.0.13 @@ -1004,6 +1006,15 @@ packages: - utf-8-validate dev: true + /@remix-run/netlify-edge/0.0.0-experimental-c6bf743d_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-7ao3G0H/J2IGwqudzK4fGQMAD0rjzzxpJLc2KNpmjmXdwR303J+W1FaRCMWalOFRcNRSlolizfTgbjkPNggYVQ==} + dependencies: + '@remix-run/server-runtime': 0.0.0-experimental-c6bf743d_react-dom@17.0.2+react@17.0.2 + transitivePeerDependencies: + - react + - react-dom + dev: true + /@remix-run/netlify/1.4.1_1d1ce03cfafdba27bba741ad8440c333: resolution: {integrity: sha512-KW3fCYHwMEtjCIsC6CjOGFnezuGvqTjHUEHe8BtOYKz/y1Nzf8h6owweoVGiJImGtZ2eyKxLoDEZIpSjwuEUlQ==} peerDependencies: @@ -1049,6 +1060,22 @@ packages: react-router-dom: 6.2.2_react-dom@17.0.2+react@17.0.2 dev: false + /@remix-run/server-runtime/0.0.0-experimental-c6bf743d_react-dom@17.0.2+react@17.0.2: + resolution: {integrity: sha512-yeJIBOoKAB049b2e3q09+NM5Ghji0WZ2ND0zdVWMUbnaxOyLe9uyheilhjxeRBkMLIVA+CQ+ZkCaZs96F9sVvQ==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + dependencies: + '@types/cookie': 0.4.1 + cookie: 0.4.2 + jsesc: 3.0.2 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + react-router-dom: 6.2.2_react-dom@17.0.2+react@17.0.2 + set-cookie-parser: 2.4.8 + source-map: 0.7.3 + dev: true + /@remix-run/server-runtime/1.4.1_react-dom@17.0.2+react@17.0.2: resolution: {integrity: sha512-vC5+7IZSNbAQEHC0436O3bYl6DpPAguJT0sSoemHE1jwJ1x+hUej3/k1QjIy1T/bioa6/7eHPggnm0hUlIFX1g==} peerDependencies: @@ -1109,7 +1136,6 @@ packages: /@types/cookie/0.4.1: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} - dev: false /@types/form-data/0.0.33: resolution: {integrity: sha1-yayFsqX9GENbjIXZ7LUObWyJP/g=} @@ -3506,7 +3532,6 @@ packages: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} hasBin: true - dev: false /json-parse-even-better-errors/2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -4653,7 +4678,6 @@ packages: /set-cookie-parser/2.4.8: resolution: {integrity: sha512-edRH8mBKEWNVIVMKejNnuJxleqYE/ZSdcT8/Nem9/mmosx12pctd80s2Oy00KNZzrogMZS5mauK2/ymL1bvlvg==} - dev: false /set-harmonic-interval/1.0.1: resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} @@ -4758,7 +4782,6 @@ packages: /source-map/0.7.3: resolution: {integrity: sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==} engines: {node: '>= 8'} - dev: false /sourcemap-codec/1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} diff --git a/remix.config.js b/remix.config.js index 5317c69c8d..37c1ebeae0 100644 --- a/remix.config.js +++ b/remix.config.js @@ -1,9 +1,25 @@ -const env = process.env.NODE_ENV ?? "development"; +const node_env = process.env.NODE_ENV ?? "development"; /** @type {import('@remix-run/dev').AppConfig} */ module.exports = { - serverBuildPath: `build/remix/${env}/server/index.js`, - assetsBuildDirectory: `build/remix/${env}/public/build`, // @remix-run/serve is patched to serve this directory - server: process.env.BUILD_NETLIFY ? "./app/misc/netlify.ts" : undefined, + serverBuildTarget: process.env.BUILD_NETLIFY_EDGE ? "deno" : "node-cjs", + serverBuildPath: process.env.BUILD_NETLIFY_EDGE + ? ".netlify/edge-functions/index.js" + : `build/remix/${node_env}/server/index.js`, + assetsBuildDirectory: `build/remix/${node_env}/public/build`, // see @remix-run+serve+1.4.1.patch + server: process.env.BUILD_NETLIFY_EDGE + ? "./app/misc/netlify-edge.ts" + : process.env.BUILD_NETLIFY + ? "./app/misc/netlify.ts" + : undefined, ignoredRouteFiles: ["**/__tests__/**/*"], }; + +// 1. remix build with "deno" backend (see @remix-run+dev+1.4.1.patch) +// NODE_ENV=production BUILD_NETLIFY_EDGE=1 npx remix build +// +// 2. copy to netlify's "internal" directory and run netlify build +// netlify build + +// 3. deploy +// netlify deploy From fda686e043bd125a510efd683bf07b83609de9a8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 29 Apr 2022 18:28:40 +0900 Subject: [PATCH 3/3] tmp --- .patch/package-lock.json | 94 +++ .patch/package.json | 3 +- .patch/patches/knex+1.0.7.patch | 28 + app/misc/env.ts | 4 +- app/root.tsx | 539 +++++++-------- app/routes/__tests__/bookmarks-index.test.ts | 36 -- app/routes/__tests__/bookmarks-new.test.ts | 47 -- app/routes/__tests__/helper.ts | 167 ----- app/routes/__tests__/index.test.ts | 30 - app/routes/__tests__/videos-id.test.ts | 84 --- app/routes/__tests__/videos-index.test.ts | 29 - app/routes/__tests__/videos-new.test.ts | 162 ----- app/routes/bookmarks/index.tsx | 404 ------------ app/routes/bookmarks/new.tsx | 46 -- app/routes/decks/$id/index.tsx | 278 -------- app/routes/decks/$id/new-practice-action.tsx | 37 -- app/routes/decks/$id/new-practice-entry.tsx | 67 -- app/routes/decks/$id/practice.tsx | 216 ------- app/routes/decks/index.tsx | 115 ---- app/routes/decks/new.tsx | 95 --- app/routes/health-check.tsx | 5 - app/routes/index.tsx | 32 +- app/routes/kill.tsx | 10 - app/routes/share-target.tsx | 26 - app/routes/users/__tests__/register.test.ts | 108 ---- app/routes/users/__tests__/signin.test.ts | 80 --- app/routes/users/__tests__/signout.test.ts | 57 -- app/routes/users/me.tsx | 172 ----- app/routes/users/register.tsx | 139 ---- app/routes/users/signin.tsx | 113 ---- app/routes/users/signout.tsx | 22 - app/routes/videos/$id.tsx | 648 ------------------- app/routes/videos/index.tsx | 271 -------- app/routes/videos/new.tsx | 236 ------- knexfile.js | 4 +- package.json | 4 +- pnpm-lock.yaml | 110 +--- 37 files changed, 447 insertions(+), 4071 deletions(-) create mode 100644 .patch/patches/knex+1.0.7.patch delete mode 100644 app/routes/__tests__/bookmarks-index.test.ts delete mode 100644 app/routes/__tests__/bookmarks-new.test.ts delete mode 100644 app/routes/__tests__/helper.ts delete mode 100644 app/routes/__tests__/index.test.ts delete mode 100644 app/routes/__tests__/videos-id.test.ts delete mode 100644 app/routes/__tests__/videos-index.test.ts delete mode 100644 app/routes/__tests__/videos-new.test.ts delete mode 100644 app/routes/bookmarks/index.tsx delete mode 100644 app/routes/bookmarks/new.tsx delete mode 100644 app/routes/decks/$id/index.tsx delete mode 100644 app/routes/decks/$id/new-practice-action.tsx delete mode 100644 app/routes/decks/$id/new-practice-entry.tsx delete mode 100644 app/routes/decks/$id/practice.tsx delete mode 100644 app/routes/decks/index.tsx delete mode 100644 app/routes/decks/new.tsx delete mode 100644 app/routes/health-check.tsx delete mode 100644 app/routes/kill.tsx delete mode 100644 app/routes/share-target.tsx delete mode 100644 app/routes/users/__tests__/register.test.ts delete mode 100644 app/routes/users/__tests__/signin.test.ts delete mode 100644 app/routes/users/__tests__/signout.test.ts delete mode 100644 app/routes/users/me.tsx delete mode 100644 app/routes/users/register.tsx delete mode 100644 app/routes/users/signin.tsx delete mode 100644 app/routes/users/signout.tsx delete mode 100644 app/routes/videos/$id.tsx delete mode 100644 app/routes/videos/index.tsx delete mode 100644 app/routes/videos/new.tsx diff --git a/.patch/package-lock.json b/.patch/package-lock.json index 9ec5cf16c0..e2b6871381 100644 --- a/.patch/package-lock.json +++ b/.patch/package-lock.json @@ -1612,6 +1612,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "colorette": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz", + "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==" + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1625,6 +1630,11 @@ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.2.tgz", "integrity": "sha512-G5yTt3KQN4Yn7Yk4ed73hlZ1evrFKXeUW3086p3PRFNp7m2vIjI6Pg+Kgb+oyzhd9F2qdcoj67+y3SdxL5XWsg==" }, + "commander": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.2.0.tgz", + "integrity": "sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w==" + }, "commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -2206,6 +2216,11 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, + "esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==" + }, "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -2714,6 +2729,11 @@ "has-symbols": "^1.0.1" } }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==" + }, "get-port": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", @@ -2738,6 +2758,11 @@ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=" }, + "getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==" + }, "git-hooks-list": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-1.0.3.tgz", @@ -3130,6 +3155,11 @@ "side-channel": "^1.0.4" } }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==" + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3705,6 +3735,42 @@ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.4.tgz", "integrity": "sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==" }, + "knex": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/knex/-/knex-1.0.7.tgz", + "integrity": "sha512-89jxuRATt4qJMb9ZyyaKBy0pQ4d5h7eOFRqiNFnUvsgU+9WZ2eIaZKrAPG1+F3mgu5UloPUnkVE5Yo2sKZUs6Q==", + "requires": { + "colorette": "2.0.16", + "commander": "^9.1.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.5.0", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4983,6 +5049,11 @@ } } }, + "pg-connection-string": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", + "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -5239,6 +5310,14 @@ } } }, + "rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "requires": { + "resolve": "^1.20.0" + } + }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -5330,6 +5409,11 @@ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==" + }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -5978,6 +6062,11 @@ } } }, + "tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==" + }, "temp": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.8.4.tgz", @@ -6010,6 +6099,11 @@ "xtend": "~4.0.1" } }, + "tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", diff --git a/.patch/package.json b/.patch/package.json index 8e9a869d85..441ad26cd6 100644 --- a/.patch/package.json +++ b/.patch/package.json @@ -4,7 +4,8 @@ }, "dependencies": { "@remix-run/dev": "^1.4.0", - "@remix-run/serve": "^1.4.0" + "@remix-run/serve": "^1.4.0", + "knex": "^1.0.4" }, "devDependencies": { "patch-package": "^6.4.7" diff --git a/.patch/patches/knex+1.0.7.patch b/.patch/patches/knex+1.0.7.patch new file mode 100644 index 0000000000..0b6be3bc7f --- /dev/null +++ b/.patch/patches/knex+1.0.7.patch @@ -0,0 +1,28 @@ +diff --git a/node_modules/knex/lib/knex-builder/make-knex.js b/node_modules/knex/lib/knex-builder/make-knex.js +index 55f9acd..51be552 100644 +--- a/node_modules/knex/lib/knex-builder/make-knex.js ++++ b/node_modules/knex/lib/knex-builder/make-knex.js +@@ -1,7 +1,5 @@ + const { EventEmitter } = require('events'); + +-const { Migrator } = require('../migrations/migrate/Migrator'); +-const Seeder = require('../migrations/seed/Seeder'); + const FunctionHelper = require('./FunctionHelper'); + const QueryInterface = require('../query/method-constants'); + const merge = require('lodash/merge'); +@@ -47,6 +45,7 @@ const KNEX_PROPERTY_DEFINITIONS = { + + migrate: { + get() { ++ const { Migrator } = require('../migrations/migrate/Migrator'); + return new Migrator(this); + }, + configurable: true, +@@ -54,6 +53,7 @@ const KNEX_PROPERTY_DEFINITIONS = { + + seed: { + get() { ++ const Seeder = require('../migrations/seed/Seeder'); + return new Seeder(this); + }, + configurable: true, diff --git a/app/misc/env.ts b/app/misc/env.ts index 01c4ea836e..c5ee5f503c 100644 --- a/app/misc/env.ts +++ b/app/misc/env.ts @@ -1,3 +1,5 @@ export const env = { - APP_SESSION_SECRET: process.env.APP_SESSION_SECRET ?? "__secret__", + // APP_SESSION_SECRET: process.env.APP_SESSION_SECRET ?? "__secret__", + // @ts-ignore + APP_SESSION_SECRET: Deno.env.get("APP_SESSION_SECRET") ?? "__secret__", }; diff --git a/app/root.tsx b/app/root.tsx index 37cf7d8c8f..0bb6f93e4f 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -8,10 +8,15 @@ import { Outlet, Scripts, ShouldReloadFunction, + useLoaderData, useMatches, useTransition, } from "@remix-run/react"; -import { LinksFunction, MetaFunction } from "@remix-run/server-runtime"; +import { + LinksFunction, + LoaderFunction, + MetaFunction, +} from "@remix-run/server-runtime"; import { last } from "lodash"; import * as React from "react"; import { @@ -43,6 +48,8 @@ import { Controller, makeLoader } from "./utils/controller-utils"; import { getFlashMessages } from "./utils/flash-message"; import { RootLoaderData, useRootLoaderData } from "./utils/loader-utils"; import { Match } from "./utils/page-handle"; +import * as superjson from "superjson"; +import { client } from "./db/client.server" const ASSETS = { "index.css": require("../build/tailwind/" + @@ -71,14 +78,22 @@ export const meta: MetaFunction = () => { // loader // -export const loader = makeLoader(Controller, async function () { - this.session; - const data: RootLoaderData = { - currentUser: await this.currentUser(), - flashMessages: getFlashMessages(this.session), - }; - return this.serialize(data); -}); +// export const loader = makeLoader(Controller, async function () { +// this.session; +// const data: RootLoaderData = { +// currentUser: await this.currentUser(), +// flashMessages: getFlashMessages(this.session), +// }; +// return this.serialize(data); +// }); + +export const loader: LoaderFunction = async function ({ request }) { + const tables = await client.raw("SHOW TABLES"); + // const knexfile = ""; + // const knexfile = require("../knexfile"); + const knexfile = require("../knexfile").migrations; + return superjson.serialize({ data: request.url, knexfile, tables }); +}; export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { if (submission?.action === R["/bookmarks/new"]) { @@ -109,42 +124,56 @@ export default function DefaultComponent() { } function Root() { - const data = useRootLoaderData(); - - const { enqueueSnackbar } = useSnackbar(); - React.useEffect(() => { - for (const message of data.flashMessages) { - enqueueSnackbar(message.content, { variant: message.variant }); - } - }, [data]); - - // `PageHandle` of the leaf compoment - const matches: Match[] = useMatches(); - const { navBarTitle, navBarMenu } = last(matches)?.handle ?? {}; + const data = superjson.deserialize(useLoaderData()); return ( - <> - - -
- -
-
- -
-
-
-
- - - +
+
DATA
+
{JSON.stringify(data, null, 2)}
+
OUTLET START
+ +
OUTLET END
+
); } +// function Root() { +// const data = useRootLoaderData(); + +// const { enqueueSnackbar } = useSnackbar(); +// React.useEffect(() => { +// for (const message of data.flashMessages) { +// enqueueSnackbar(message.content, { variant: message.variant }); +// } +// }, [data]); + +// // `PageHandle` of the leaf compoment +// const matches: Match[] = useMatches(); +// const { navBarTitle, navBarMenu } = last(matches)?.handle ?? {}; + +// return ( +// <> +// +// +//
+// +//
+//
+// +//
+//
+//
+//
+// +// +// +// ); +// } + function RootProviders({ children }: React.PropsWithChildren<{}>) { return ( @@ -174,225 +203,225 @@ const queryClient = new QueryClient({ }, }); -function GlobalProgress() { - const transition = useTransition(); - return ; -} +// function GlobalProgress() { +// const transition = useTransition(); +// return ; +// } -const DRAWER_TOGGLE_INPUT_ID = "--drawer-toggle-input--"; +// const DRAWER_TOGGLE_INPUT_ID = "--drawer-toggle-input--"; -function toggleDrawer(open?: boolean): void { - const element = document.querySelector( - "#" + DRAWER_TOGGLE_INPUT_ID - ); - if (!element) return; - if (open === undefined) { - element.checked = !element.checked; - } else { - element.checked = open; - } -} +// function toggleDrawer(open?: boolean): void { +// const element = document.querySelector( +// "#" + DRAWER_TOGGLE_INPUT_ID +// ); +// if (!element) return; +// if (open === undefined) { +// element.checked = !element.checked; +// } else { +// element.checked = open; +// } +// } -function Navbar({ - title, - user, - menu, -}: { - title?: React.ReactNode; - user?: UserTable; - menu?: React.ReactNode; -}) { - return ( -
-
-
-
{title}
- {menu} -
- ( - - )} - floating={({ open, setOpen, props }) => ( - -
    - {user ? ( - <> -
  • - setOpen(false)}> - - Account - -
  • -
    setOpen(false)} - > -
  • - -
  • -
    - - ) : ( - <> -
  • - setOpen(false)} - > - - Sign in - -
  • - - )} -
-
- )} - /> -
-
- ); -} +// function Navbar({ +// title, +// user, +// menu, +// }: { +// title?: React.ReactNode; +// user?: UserTable; +// menu?: React.ReactNode; +// }) { +// return ( +//
+//
+//
+//
{title}
+// {menu} +//
+// ( +// +// )} +// floating={({ open, setOpen, props }) => ( +// +//
    +// {user ? ( +// <> +//
  • +// setOpen(false)}> +// +// Account +// +//
  • +//
    setOpen(false)} +// > +//
  • +// +//
  • +//
    +// +// ) : ( +// <> +//
  • +// setOpen(false)} +// > +// +// Sign in +// +//
  • +// +// )} +//
+//
+// )} +// /> +//
+//
+// ); +// } -interface SideMenuEntry { - to: string; - icon: any; - title: string; - requireSignin: boolean; -} +// interface SideMenuEntry { +// to: string; +// icon: any; +// title: string; +// requireSignin: boolean; +// } -const SIDE_MENU_ENTRIES: SideMenuEntry[] = [ - { - to: R["/"], - icon: Home, - title: "Examples", - requireSignin: false, - }, - { - to: R["/videos"], - icon: Video, - title: "Your Videos", - requireSignin: true, - }, - { - to: R["/bookmarks"], - icon: Bookmark, - title: "Bookmarks", - requireSignin: true, - }, - { - to: R["/decks"], - icon: BookOpen, - title: "Practice", - requireSignin: true, - }, -]; +// const SIDE_MENU_ENTRIES: SideMenuEntry[] = [ +// { +// to: R["/"], +// icon: Home, +// title: "Examples", +// requireSignin: false, +// }, +// { +// to: R["/videos"], +// icon: Video, +// title: "Your Videos", +// requireSignin: true, +// }, +// { +// to: R["/bookmarks"], +// icon: Bookmark, +// title: "Bookmarks", +// requireSignin: true, +// }, +// { +// to: R["/decks"], +// icon: BookOpen, +// title: "Practice", +// requireSignin: true, +// }, +// ]; -function SideMenuDrawerWrapper({ - isSignedIn, - children, -}: React.PropsWithChildren<{ isSignedIn: boolean }>) { - // TODO: initial render shows open drawer? - return ( -
- -
{children}
-
-
-
- ); -} +// function SideMenuDrawerWrapper({ +// isSignedIn, +// children, +// }: React.PropsWithChildren<{ isSignedIn: boolean }>) { +// // TODO: initial render shows open drawer? +// return ( +//
+// +//
{children}
+//
+//
+//
+// ); +// } -function SearchComponent() { - return ( -
toggleDrawer(false)} - data-test="search-form" - > - -
- ); -} +// function SearchComponent() { +// return ( +//
toggleDrawer(false)} +// data-test="search-form" +// > +// +//
+// ); +// } diff --git a/app/routes/__tests__/bookmarks-index.test.ts b/app/routes/__tests__/bookmarks-index.test.ts deleted file mode 100644 index 1cad488755..0000000000 --- a/app/routes/__tests__/bookmarks-index.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { assert } from "../../misc/assert"; -import { loader } from "../bookmarks/index"; -import { testLoader, useUser } from "./helper"; - -// TODO: use data -describe("bookmarks/index.loader", () => { - const { signin } = useUser({ - seed: __filename, - }); - - it("basic", async () => { - const res = await testLoader(loader, { transform: signin }); - assert(res instanceof Response); - expect(await res.json()).toMatchInlineSnapshot(` - { - "json": { - "captionEntries": [], - "pagination": { - "data": [], - "page": 1, - "perPage": 20, - "total": 0, - "totalPage": 0, - }, - "request": { - "order": "createdAt", - "page": 1, - "perPage": 20, - }, - "videos": [], - }, - } - `); - }); -}); diff --git a/app/routes/__tests__/bookmarks-new.test.ts b/app/routes/__tests__/bookmarks-new.test.ts deleted file mode 100644 index f98f211ce0..0000000000 --- a/app/routes/__tests__/bookmarks-new.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Q } from "../../db/models"; -import { assert } from "../../misc/assert"; -import { AppError } from "../../utils/errors"; -import { action } from "../bookmarks/new"; -import { testLoader, useUserVideo } from "./helper"; - -describe("bookmarks/new.action", () => { - const { signin, video, captionEntries } = useUserVideo(2, { - seed: __filename, - }); - - it("basic", async () => { - const data = { - videoId: video().id, - captionEntryId: captionEntries()[0].id, - text: "Bonjour à tous", - side: 0, - offset: 8, - }; - const res = await testLoader(action, { form: data, transform: signin }); - assert(res instanceof Response); - - const resJson = await res.json(); - expect(resJson.success).toBe(true); - - const id = resJson.data.id; - assert(typeof id === "number"); - - const found = await Q.bookmarkEntries().where("id", id).first(); - assert(found); - expect(found.text).toBe(data.text); - }); - - it("error", async () => { - const data = { - videoId: -1, // video not found - captionEntryId: captionEntries()[0].id, - text: "Bonjour à tous", - side: 0, - offset: 8, - }; - await expect( - testLoader(action, { form: data, transform: signin }) - ).rejects.toBeInstanceOf(AppError); - }); -}); diff --git a/app/routes/__tests__/helper.ts b/app/routes/__tests__/helper.ts deleted file mode 100644 index fe2df1f6bd..0000000000 --- a/app/routes/__tests__/helper.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { LoaderFunction } from "@remix-run/server-runtime"; -import { afterAll, beforeAll } from "vitest"; -import { - CaptionEntryTable, - UserTable, - VideoTable, - filterNewVideo, - getVideoAndCaptionEntries, - insertVideoAndCaptionEntries, -} from "../../db/models"; -import { assert } from "../../misc/assert"; -import { useUserImpl } from "../../misc/helper"; -import { createUserCookie } from "../../utils/auth"; -import { toQuery } from "../../utils/url-data"; -import { NewVideo, fetchCaptionEntries } from "../../utils/youtube"; - -const DUMMY_URL = "http://localhost:3000"; - -export function testLoader( - loader: LoaderFunction, - { - method, - query, - form, - params = {}, - transform, - }: { - method?: "GET" | "POST" | "DELETE"; - query?: any; - form?: any; - params?: Record; - transform?: (request: Request) => Request; - } = {} -) { - let url = DUMMY_URL; - if (query) { - url += "/?" + toQuery(query); - } - let request = new Request(url, { method: method ?? "GET" }); - if (form) { - request = new Request(url, { - method: method ?? "POST", - body: toQuery(form), - headers: { - "content-type": "application/x-www-form-urlencoded", - }, - }); - } - if (transform) { - request = transform(request); - } - return loader({ - request, - context: {}, - params, - }); -} - -export function useUser(...args: Parameters) { - const { before, after } = useUserImpl(...args); - - let user: UserTable; - let cookie: string; - - beforeAll(async () => { - user = await before(); - cookie = await createUserCookie(user); - }); - - afterAll(async () => { - await after(); - }); - - function signin(request: Request): Request { - request.headers.set("cookie", cookie); - return request; - } - - return { user: () => user, signin }; -} - -// TODO: use pre-downloaded fixture -const NEW_VIDEOS: NewVideo[] = [ - { - videoId: "_2FF6O6Z8Hc", - language1: { id: ".fr-FR" }, - language2: { id: ".en" }, - }, - { - videoId: "MoH8Fk2K9bc", - language1: { id: ".fr-FR" }, - language2: { id: ".en" }, - }, - { - videoId: "EnPYXckiUVg", - language1: { id: ".fr" }, - language2: { id: ".en" }, - }, -]; - -export function useVideo(type: 0 | 1 | 2 = 2, userId?: () => number) { - const newVideo = NEW_VIDEOS[type]; - let result: { video: VideoTable; captionEntries: CaptionEntryTable[] }; - - beforeAll(async () => { - const id = userId?.(); - await filterNewVideo(newVideo, id).delete(); - - const data = await fetchCaptionEntries(newVideo); - const videoId = await insertVideoAndCaptionEntries(newVideo, data, id); - const resultOption = await getVideoAndCaptionEntries(videoId); - assert(resultOption); - result = resultOption; - }); - - afterAll(async () => { - await filterNewVideo(newVideo, userId?.()).delete(); - }); - - return { - video: () => result.video, - captionEntries: () => result.captionEntries, - }; -} - -// TODO: jest/vitest doesn't serialize `before` hooks, so we cannot simply combine `useUser` and `useVideo` -export function useUserVideo( - type: 0 | 1 | 2 = 2, - ...args: Parameters -) { - const { before, after } = useUserImpl(...args); - - let user: UserTable; - let cookie: string; - let video: VideoTable; - let captionEntries: CaptionEntryTable[]; - const newVideo = NEW_VIDEOS[type]; - - beforeAll(async () => { - user = await before(); - cookie = await createUserCookie(user); - - const data = await fetchCaptionEntries(newVideo); - const videoId = await insertVideoAndCaptionEntries(newVideo, data, user.id); - const result = await getVideoAndCaptionEntries(videoId); - assert(result); - video = result.video; - captionEntries = result.captionEntries; - }); - - afterAll(async () => { - await after(); - await filterNewVideo(newVideo, user.id).delete(); - }); - - function signin(request: Request): Request { - request.headers.set("cookie", cookie); - return request; - } - - return { - user: () => user, - video: () => video, - captionEntries: () => captionEntries, - signin, - }; -} diff --git a/app/routes/__tests__/index.test.ts b/app/routes/__tests__/index.test.ts deleted file mode 100644 index b12abada71..0000000000 --- a/app/routes/__tests__/index.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { Q } from "../../db/models"; -import { assert } from "../../misc/assert"; -import { loader } from "../index"; -import { testLoader } from "./helper"; - -// TODO: use data -describe("index.loader", () => { - beforeAll(async () => { - await Q.videos().delete(); - }); - - it("basic", async () => { - const res = await testLoader(loader); - assert(res instanceof Response); - expect(await res.json()).toMatchInlineSnapshot(` - { - "json": { - "pagination": { - "data": [], - "page": 1, - "perPage": 20, - "total": 0, - "totalPage": 0, - }, - }, - } - `); - }); -}); diff --git a/app/routes/__tests__/videos-id.test.ts b/app/routes/__tests__/videos-id.test.ts deleted file mode 100644 index a64b5b19d4..0000000000 --- a/app/routes/__tests__/videos-id.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { omit } from "lodash"; -import { describe, expect, it } from "vitest"; -import { CaptionEntryTable, Q, VideoTable } from "../../db/models"; -import { assert } from "../../misc/assert"; -import { deserialize } from "../../utils/controller-utils"; -import { action, loader } from "../videos/$id"; -import { useUserVideo, useVideo } from "./helper"; -import { testLoader } from "./helper"; - -describe("videos/id.loader", () => { - const { video } = useVideo(); - - it("basic", async () => { - const res = await testLoader(loader, { - params: { id: String(video().id) }, - }); - assert(res instanceof Response); - const resJson: { video: VideoTable; captionEntries: CaptionEntryTable[] } = - deserialize(await res.json()); - expect(resJson.video.videoId).toBe("EnPYXckiUVg"); - expect(resJson.video.title).toMatchInlineSnapshot( - '"Are French People Really That Mean?! // French Girls React to Emily In Paris (in FR w/ FR & EN subs)"' - ); - expect(resJson.captionEntries.length).toMatchInlineSnapshot("182"); - expect( - resJson.captionEntries - .slice(0, 3) - .map((e) => omit(e, ["id", "videoId", "createdAt", "updatedAt"])) - ).toMatchInlineSnapshot(` - [ - { - "begin": 0.08, - "end": 4.19, - "index": 0, - "text1": "Salut ! Bonjour à tous, j'espère que vous allez bien.", - "text2": "Hello ! Hello everyone, I hope you are well.", - }, - { - "begin": 4.19, - "end": 9.93, - "index": 1, - "text1": "Aujourd'hui, je vous retrouve pour une nouvelle vidéo avec ma sœur Inès que vous avez déjà", - "text2": "Today, I am meeting you for a new video with my sister Inès that you have already", - }, - { - "begin": 9.93, - "end": 11.67, - "index": 2, - "text1": "vu dans d'autres vidéos.", - "text2": "seen in other videos.", - }, - ] - `); - }); -}); - -describe("videos/id.action", () => { - const { signin, user, video } = useUserVideo(2, { - seed: __filename + "videos/id.action", - }); - - it("delete", async () => { - const where = { - id: video().id, - userId: user().id, - }; - expect(Q.videos().where(where).first()).resolves.toBeDefined(); - - const res = await testLoader(action, { - method: "DELETE", - params: { id: String(video().id) }, - transform: signin, - }); - const resJson = await res.json(); - expect(resJson).toMatchInlineSnapshot(` - { - "success": true, - "type": "DELETE /videos/\$id", - } - `); - - expect(Q.videos().where(where).first()).resolves.toBe(undefined); - }); -}); diff --git a/app/routes/__tests__/videos-index.test.ts b/app/routes/__tests__/videos-index.test.ts deleted file mode 100644 index d38fdd7170..0000000000 --- a/app/routes/__tests__/videos-index.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { assert } from "../../misc/assert"; -import { loader } from "../videos/index"; -import { testLoader, useUser } from "./helper"; - -// TODO: use data -describe("videos/index.loader", () => { - const { signin } = useUser({ - seed: __filename, - }); - - it("basic", async () => { - const res = await testLoader(loader, { transform: signin }); - assert(res instanceof Response); - expect(await res.json()).toMatchInlineSnapshot(` - { - "json": { - "pagination": { - "data": [], - "page": 1, - "perPage": 20, - "total": 0, - "totalPage": 0, - }, - }, - } - `); - }); -}); diff --git a/app/routes/__tests__/videos-new.test.ts b/app/routes/__tests__/videos-new.test.ts deleted file mode 100644 index 6ef2405bc3..0000000000 --- a/app/routes/__tests__/videos-new.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { last, omit } from "lodash"; -import { beforeAll, describe, expect, it } from "vitest"; -import { Q } from "../../db/models"; -import { assert } from "../../misc/assert"; -import { getResponseSession } from "../../utils/session-utils"; -import { action, loader } from "../videos/new"; -import { testLoader, useUser } from "./helper"; - -describe("videos/new.loader", () => { - it("basic", async () => { - const data = { videoId: "MoH8Fk2K9bc" }; - const res = await testLoader(loader, { - query: data, - }); - const resJson = await res.json(); - expect(resJson.videoDetails?.title).toBe( - "LEARN FRENCH IN 2 MINUTES – French idiom : Noyer le poisson" - ); - }); - - it("url", async () => { - const data = { videoId: "https://www.youtube.com/watch?v=MoH8Fk2K9bc" }; - const res = await testLoader(loader, { query: data }); - const resJson = await res.json(); - expect(resJson.videoDetails?.title).toBe( - "LEARN FRENCH IN 2 MINUTES – French idiom : Noyer le poisson" - ); - }); - - it("invalid-input", async () => { - const data = { videoId: "xxx" }; - const res = await testLoader(loader, { query: data }); - - assert(res instanceof Response); - expect(res.status).toBe(302); - expect(res.headers.get("location")).toBe("/"); - - const resSession = await getResponseSession(res); - expect(resSession.data).toMatchInlineSnapshot(` - { - "__flash_flash-messages__": [ - { - "content": "Invalid input", - "variant": "error", - }, - ], - } - `); - }); -}); - -describe("videos/new.action", () => { - const { user, signin } = useUser({ seed: __filename }); - - beforeAll(async () => { - // cleanup anonymous data - await Q.videos().delete().where("userId", null); - }); - - it("basic", async () => { - const data = { - videoId: "EnPYXckiUVg", - language1: { - id: ".fr", - translation: "", - }, - language2: { - id: ".en", - translation: "", - }, - }; - const res = await testLoader(action, { form: data, transform: signin }); - - // persist video and caption entries - const video = await Q.videos().where("userId", user().id).first(); - assert(video); - - const captionEntries = await Q.captionEntries().where("videoId", video.id); - - expect(omit(video, ["id", "userId", "createdAt", "updatedAt"])) - .toMatchInlineSnapshot(` - { - "author": "Piece of French", - "channelId": "UCVzyfpNuFF4ENY8zNTIW7ug", - "language1_id": ".fr", - "language1_translation": null, - "language2_id": ".en", - "language2_translation": null, - "title": "Are French People Really That Mean?! // French Girls React to Emily In Paris (in FR w/ FR & EN subs)", - "videoId": "EnPYXckiUVg", - } - `); - expect(captionEntries.length).toMatchInlineSnapshot("182"); - expect( - captionEntries - .slice(0, 3) - .map((e) => omit(e, ["id", "videoId", "createdAt", "updatedAt"])) - ).toMatchInlineSnapshot(` - [ - { - "begin": 0.08, - "end": 4.19, - "index": 0, - "text1": "Salut ! Bonjour à tous, j'espère que vous allez bien.", - "text2": "Hello ! Hello everyone, I hope you are well.", - }, - { - "begin": 4.19, - "end": 9.93, - "index": 1, - "text1": "Aujourd'hui, je vous retrouve pour une nouvelle vidéo avec ma sœur Inès que vous avez déjà", - "text2": "Today, I am meeting you for a new video with my sister Inès that you have already", - }, - { - "begin": 9.93, - "end": 11.67, - "index": 2, - "text1": "vu dans d'autres vidéos.", - "text2": "seen in other videos.", - }, - ] - `); - - // redirect - assert(res instanceof Response); - expect(res.headers.get("location")).toBe(`/videos/${video.id}`); - - // calling with the same parameters doesn't create new video - const res2 = await testLoader(action, { form: data, transform: signin }); - assert(res2 instanceof Response); - expect(res2.headers.get("location")).toBe(`/videos/${video.id}`); - }); - - it("anonymous", async () => { - const data = { - videoId: "EnPYXckiUVg", - language1: { - id: ".fr", - translation: "", - }, - language2: { - id: ".en", - translation: "", - }, - }; - const res = await testLoader(action, { form: data }); - assert(res instanceof Response); - - const location = res.headers.get("location"); - assert(location); - - const id = last(location.split("/")); - const video = await Q.videos().where("id", id).first(); - assert(video); - expect(video.userId).toBe(null); - - // calling with the same parameters doesn't create new video - const res2 = await testLoader(action, { form: data }); - assert(res2 instanceof Response); - expect(res2.headers.get("location")).toBe(`/videos/${video.id}`); - }); -}); diff --git a/app/routes/bookmarks/index.tsx b/app/routes/bookmarks/index.tsx deleted file mode 100644 index 937dc3571b..0000000000 --- a/app/routes/bookmarks/index.tsx +++ /dev/null @@ -1,404 +0,0 @@ -import { Transition } from "@headlessui/react"; -import { Link, useLoaderData } from "@remix-run/react"; -import { redirect } from "@remix-run/server-runtime"; -import { omit } from "lodash"; -import * as React from "react"; -import { Book, ChevronDown, ChevronUp, Filter, Video, X } from "react-feather"; -import { z } from "zod"; -import { PaginationComponent } from "../../components/misc"; -import { useModal } from "../../components/modal"; -import { Popover } from "../../components/popover"; -import { - BookmarkEntryTable, - CaptionEntryTable, - PaginationResult, - Q, - VideoTable, - toPaginationResult, -} from "../../db/models"; -import { R } from "../../misc/routes"; -import { useToById } from "../../utils/by-id"; -import { Controller, makeLoader } from "../../utils/controller-utils"; -import { useDeserialize, useRafLoop } from "../../utils/hooks"; -import { useLeafLoaderData } from "../../utils/loader-utils"; -import { isNonNullable } from "../../utils/misc"; -import { PageHandle } from "../../utils/page-handle"; -import { PAGINATION_PARAMS_SCHEMA } from "../../utils/pagination"; -import { CaptionEntry } from "../../utils/types"; -import { toQuery } from "../../utils/url-data"; -import { YoutubePlayer } from "../../utils/youtube"; -import { zStringToInteger } from "../../utils/zod-utils"; -import { CaptionEntryComponent, usePlayer } from "../videos/$id"; - -export const handle: PageHandle = { - navBarTitle: () => "Bookmarks", - navBarMenu: () => , -}; - -const BOOKMARKS_REQUEST = z - .object({ - videoId: zStringToInteger.optional(), - deckId: zStringToInteger.optional(), - order: z.enum(["createdAt", "caption"]).default("createdAt"), - }) - .merge(PAGINATION_PARAMS_SCHEMA); - -type BookmarksRequest = z.infer; - -interface LoaderData { - pagination: PaginationResult; - videos: VideoTable[]; - captionEntries: CaptionEntryTable[]; - request: BookmarksRequest; -} - -export const loader = makeLoader(Controller, async function () { - const user = await this.currentUser(); - if (!user) { - this.flash({ - content: "Signin required.", - variant: "error", - }); - return redirect(R["/users/signin"]); - } - - const parsed = BOOKMARKS_REQUEST.safeParse(this.query()); - if (!parsed.success) { - this.flash({ content: "invalid parameters", variant: "error" }); - return redirect(R["/bookmarks"]); - } - - const request = parsed.data; - const userId = user.id; - let sql = Q.bookmarkEntries() - .select("bookmarkEntries.*") - .where("bookmarkEntries.userId", userId); - - if (request.videoId) { - sql = sql.where("bookmarkEntries.videoId", request.videoId); - } - - // TODO: test - if (request.deckId) { - sql = sql - .join( - "practiceEntries", - "practiceEntries.bookmarkEntryId", - "bookmarkEntries.id" - ) - .join("decks", "decks.id", "practiceEntries.deckId") - .where("decks.id", request.deckId); - } - - if (request.order === "createdAt") { - sql = sql.orderBy("bookmarkEntries.createdAt", "desc"); - } - - if (request.order === "caption") { - sql = sql.join( - "captionEntries", - "captionEntries.id", - "bookmarkEntries.captionEntryId" - ); - sql = sql.orderBy([ - { - column: "captionEntries.index", - order: "asc", - }, - { - column: "bookmarkEntries.offset", - order: "asc", - }, - ]); - } - - const pagination = await toPaginationResult(sql, parsed.data); - const bookmarkEntries = pagination.data; - const videos = await Q.videos().whereIn( - "id", - bookmarkEntries.map((x) => x.videoId) - ); - const captionEntries = await Q.captionEntries().whereIn( - "id", - bookmarkEntries.map((x) => x.captionEntryId) - ); - const res: LoaderData = { - videos, - captionEntries, - pagination, - request, - }; - return this.serialize(res); -}); - -export default function DefaultComponent() { - const data: LoaderData = useDeserialize(useLoaderData()); - return ; -} - -function ComponentImpl(props: LoaderData) { - const videos = useToById(props.videos); - const captionEntries = useToById(props.captionEntries); - const bookmarkEntries = props.pagination.data; - - return ( - <> -
-
-
- {/* TODO: CTA when empty */} - {bookmarkEntries.length === 0 &&
Empty
} - {bookmarkEntries.map((bookmarkEntry) => ( - - ))} -
-
-
-
{/* fake padding to allow scrool more */} -
- -
- - ); -} - -export function BookmarkEntryComponent({ - video, - captionEntry, - bookmarkEntry, -}: { - video: VideoTable; - captionEntry: CaptionEntryTable; - bookmarkEntry: BookmarkEntryTable; -}) { - let [open, setOpen] = React.useState(false); - - return ( -
-
- -
setOpen(!open)} - data-test="bookmark-entry-text" - > - {bookmarkEntry.text} -
- {/* TODO */} - {false && ( - - )} -
- {open && } -
- ); -} - -function MiniPlayer({ - video, - captionEntry, -}: { - video: VideoTable; - captionEntry: CaptionEntryTable; -}) { - const [player, setPlayer] = React.useState(); - const [isPlaying, setIsPlaying] = React.useState(false); - const [isRepeating, setIsRepeating] = React.useState(false); - const { begin, end } = captionEntry; - - // - // handlers - // - - function onClickEntryPlay(entry: CaptionEntry, toggle: boolean) { - if (!player) return; - - // No-op if some text is selected (e.g. for google translate extension) - if (document.getSelection()?.toString()) return; - - if (toggle) { - if (isPlaying) { - player.pauseVideo(); - } else { - player.playVideo(); - } - } else { - player.seekTo(entry.begin); - player.playVideo(); - } - } - - function onClickEntryRepeat() { - setIsRepeating(!isRepeating); - } - - // - // effects - // - - const [playerRef, playerLoading] = usePlayer({ - defaultOptions: { - videoId: video.videoId, - playerVars: { start: Math.max(0, Math.floor(begin) - 1) }, - }, - onLoad: setPlayer, - }); - - useRafLoop( - React.useCallback(() => { - if (!player) return; - - // update `isPlaying` - setIsPlaying(player.getPlayerState() === 1); - - // handle `isRepeating` - if (isRepeating) { - const currentTime = player.getCurrentTime(); - if (currentTime < begin || end < currentTime) { - player.seekTo(begin); - } - } - }, [player, isRepeating]) - ); - - return ( -
-
-
-
-
- {playerLoading && ( -
-
-
- )} -
- {/* TODO: highlight bookmark text? */} - -
- ); -} - -// -// NavBarMenuComponent -// - -function NavBarMenuComponent() { - const { request }: LoaderData = useDeserialize(useLeafLoaderData()); - const { openModal } = useModal(); - - function onClickVideoFilter() { - openModal(); - } - - function onClickDeckFilter() { - openModal(); - } - - const isFilterActive = isNonNullable(request.videoId ?? request.deckId); - - return ( - <> -
- ( - - )} - floating={({ open, setOpen, props }) => ( - -
    -
  • - -
  • -
  • - -
  • -
  • - setOpen(false)}> - - Clear - -
  • -
-
- )} - /> -
- - ); -} - -function VideoSelectComponent() { - return ( -
-
TODO
-
- ); -} - -function DeckSelectComponent() { - return ( -
-
TODO
-
- ); -} diff --git a/app/routes/bookmarks/new.tsx b/app/routes/bookmarks/new.tsx deleted file mode 100644 index bbe11a92d5..0000000000 --- a/app/routes/bookmarks/new.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { z } from "zod"; -import { Q } from "../../db/models"; -import { Controller, makeLoader } from "../../utils/controller-utils"; -import { AppError } from "../../utils/errors"; -import { zStringToInteger } from "../../utils/zod-utils"; - -// -// action -// - -const NEW_BOOKMARK_SCHEMA = z.object({ - videoId: zStringToInteger, - captionEntryId: zStringToInteger, - text: z.string().nonempty(), - side: zStringToInteger.refine((x) => x === 0 || x === 1), - offset: zStringToInteger, -}); - -export type NewBookmark = z.infer; - -// TODO: error handling -export const action = makeLoader(Controller, async function () { - const parsed = NEW_BOOKMARK_SCHEMA.safeParse(await this.form()); - if (!parsed.success) throw new AppError("Invalid parameters"); - - const user = await this.currentUser(); - if (!user) throw new AppError("Authenticaton failure"); - - const { videoId, captionEntryId } = parsed.data; - - const video = await Q.videos() - .where("userId", user.id) - .where("id", videoId) - .first(); - const captionEntry = await Q.captionEntries() - .where("videoId", videoId) - .where("id", captionEntryId) - .first(); - if (!video || !captionEntry) throw new AppError("Resource not found"); - - const [id] = await Q.bookmarkEntries().insert({ - ...parsed.data, - userId: user.id, - }); - return { success: true, data: { id } }; -}); diff --git a/app/routes/decks/$id/index.tsx b/app/routes/decks/$id/index.tsx deleted file mode 100644 index 0dd705d2f8..0000000000 --- a/app/routes/decks/$id/index.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { Transition } from "@headlessui/react"; -import { Form, Link, useLoaderData } from "@remix-run/react"; -import { redirect } from "@remix-run/server-runtime"; -import * as React from "react"; -import { Bookmark, MoreVertical, Play, Trash2 } from "react-feather"; -import { z } from "zod"; -import { Popover } from "../../../components/popover"; -import { - BookmarkEntryTable, - DeckTable, - PracticeEntryTable, - Q, - UserTable, -} from "../../../db/models"; -import { assert } from "../../../misc/assert"; -import { R } from "../../../misc/routes"; -import { useToById } from "../../../utils/by-id"; -import { Controller, makeLoader } from "../../../utils/controller-utils"; -import { useDeserialize } from "../../../utils/hooks"; -import { useLeafLoaderData } from "../../../utils/loader-utils"; -import { PageHandle } from "../../../utils/page-handle"; -import { - DeckPracticeStatistics, - PracticeSystem, -} from "../../../utils/practice-system"; -import { Timedelta } from "../../../utils/timedelta"; -import { zStringToInteger } from "../../../utils/zod-utils"; - -export const handle: PageHandle = { - navBarTitle: () => , - navBarMenu: () => , -}; - -// TODO -// - delete -// - show statistics - -const PARAMS_SCHEMA = z.object({ - id: zStringToInteger, -}); - -export async function requireUserAndDeck( - this: Controller -): Promise<[UserTable, DeckTable]> { - const user = await this.requireUser(); - const parsed = PARAMS_SCHEMA.safeParse(this.args.params); - if (parsed.success) { - const { id } = parsed.data; - const deck = await Q.decks().where({ id, userId: user.id }).first(); - if (deck) { - return [user, deck]; - } - } - this.flash({ content: "Deck not found", variant: "error" }); - throw redirect(R["/decks"]); -} - -// -// loader -// - -interface LoaderData { - deck: DeckTable; - statistics: DeckPracticeStatistics; - practiceEntries: PracticeEntryTable[]; // TODO: paginate - bookmarkEntries: BookmarkEntryTable[]; -} - -export const loader = makeLoader(Controller, async function () { - const [user, deck] = await requireUserAndDeck.apply(this); - const system = new PracticeSystem(user, deck); - const now = new Date(); - const statistics = await system.getStatistics(now); - const practiceEntries = await Q.practiceEntries() - .where("deckId", deck.id) - .orderBy("createdAt", "asc"); - const bookmarkEntries = await Q.bookmarkEntries().whereIn( - "id", - practiceEntries.map((e) => e.bookmarkEntryId) - ); - const res: LoaderData = { - deck, - statistics, - practiceEntries, - bookmarkEntries, - }; - return this.serialize(res); -}); - -// -// action -// - -export const action = makeLoader(Controller, async function () { - assert(this.request.method === "DELETE"); - const [, deck] = await requireUserAndDeck.apply(this); - await Q.decks().delete().where("id", deck.id); - this.flash({ content: `Deck '${deck.name}' is deleted`, variant: "info" }); - return redirect(R["/decks"]); -}); - -// -// component -// - -export default function DefaultComponent() { - const { statistics, practiceEntries, bookmarkEntries }: LoaderData = - useDeserialize(useLoaderData()); - const bookmarkEntriesById = useToById(bookmarkEntries); - - return ( -
-
-
- {/* TODO(refactor): copied from `practice.tsx` */} -
-
- Progress -
-
-
-
- {statistics.NEW.daily} / {statistics.NEW.total} -
-
-
-
- {statistics.LEARN.daily} / {statistics.LEARN.total} -
-
-
-
- {statistics.REVIEW.daily} / {statistics.REVIEW.total} -
-
-
-
- {practiceEntries.length === 0 &&
Empty
} - {practiceEntries.map((practiceEntry) => ( - - ))} -
-
-
- ); -} - -function PracticeEntryComponent({ - practiceEntry, - bookmarkEntry, -}: { - practiceEntry: PracticeEntryTable; - bookmarkEntry: BookmarkEntryTable; -}) { - return ( -
-
- {bookmarkEntry.text} -
-
- {formatScheduledAt(practiceEntry.scheduledAt, new Date())} -
-
- ); -} - -const IntlRtf = new Intl.RelativeTimeFormat("en"); - -function formatScheduledAt(date: Date, now: Date): string { - const delta = Timedelta.difference(date, now); - if (delta.value <= 0) { - return ""; - } - const n = delta.normalize(); - for (const unit of ["days", "hours", "minutes"] as const) { - if (n[unit] > 0) { - return IntlRtf.format(n[unit], unit); - } - } - return IntlRtf.format(n.seconds, "seconds"); -} - -// -// NavBarTitleComponent -// - -function NavBarTitleComponent() { - const { deck }: LoaderData = useDeserialize(useLeafLoaderData()); - return <>{deck.name}; -} - -// -// NavBarMenuComponent -// - -function NavBarMenuComponent() { - const { deck }: LoaderData = useDeserialize(useLeafLoaderData()); - - return ( - <> -
- ( - - )} - floating={({ open, setOpen, props }) => ( - -
    -
  • - setOpen(false)} - > - - Practice - -
  • -
  • - setOpen(false)} - > - - Bookmarks - -
  • -
    { - if (!window.confirm("Are you sure?")) { - e.preventDefault(); - } - }} - > -
  • - -
  • -
    -
-
- )} - /> -
- - ); -} diff --git a/app/routes/decks/$id/new-practice-action.tsx b/app/routes/decks/$id/new-practice-action.tsx deleted file mode 100644 index f2777a8819..0000000000 --- a/app/routes/decks/$id/new-practice-action.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { redirect } from "@remix-run/server-runtime"; -import { z } from "zod"; -import { PRACTICE_ACTION_TYPES, Q } from "../../../db/models"; -import { assert } from "../../../misc/assert"; -import { R } from "../../../misc/routes"; -import { Controller, makeLoader } from "../../../utils/controller-utils"; -import { PracticeSystem } from "../../../utils/practice-system"; -import { zStringToDate, zStringToInteger } from "../../../utils/zod-utils"; -import { requireUserAndDeck } from "."; - -// -// action -// - -const ACTION_REQUEST_SCHEMA = z.object({ - practiceEntryId: zStringToInteger, - actionType: z.enum(PRACTICE_ACTION_TYPES), - now: zStringToDate, -}); - -export type NewPracticeActionRequest = z.infer; - -export const action = makeLoader(Controller, async function () { - const [user, deck] = await requireUserAndDeck.apply(this); - const parsed = ACTION_REQUEST_SCHEMA.safeParse(await this.form()); - assert(parsed.success); - - const { practiceEntryId, actionType, now } = parsed.data; - const practiceEntry = await Q.practiceEntries() - .where({ id: practiceEntryId }) - .first(); - assert(practiceEntry); - - const system = new PracticeSystem(user, deck); - await system.createPracticeAction(practiceEntry, actionType, now); - return redirect(R["/decks/$id/practice"](deck.id)); -}); diff --git a/app/routes/decks/$id/new-practice-entry.tsx b/app/routes/decks/$id/new-practice-entry.tsx deleted file mode 100644 index 5b4c4e09ac..0000000000 --- a/app/routes/decks/$id/new-practice-entry.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { z } from "zod"; -import { Q } from "../../../db/models"; -import { assert } from "../../../misc/assert"; -import { Controller, makeLoader } from "../../../utils/controller-utils"; -import { Result, isNonNullable } from "../../../utils/misc"; -import { PracticeSystem } from "../../../utils/practice-system"; -import { zStringToDate, zStringToInteger } from "../../../utils/zod-utils"; -import { requireUserAndDeck } from "."; - -// -// action -// - -// TODO: support `bookmarkEntryId` -const ACTION_REQUEST_SCHEMA = z - .object({ - videoId: zStringToInteger.optional(), - bookmarkEntryId: zStringToInteger.optional(), - now: zStringToDate, - }) - .refine( - (data) => - [data.videoId, data.bookmarkEntryId].filter(isNonNullable).length === 1 - ); - -export type NewPracticeEntryRequest = z.infer; - -export type NewPracticeEntryResponse = Result< - { ids: number[] }, - { message: string } ->; - -export const action = makeLoader(Controller, actionImpl); - -async function actionImpl(this: Controller): Promise { - const [user, deck] = await requireUserAndDeck.apply(this); - const parsed = ACTION_REQUEST_SCHEMA.safeParse(await this.form()); - if (!parsed.success) { - return { ok: false, data: { message: "Invalid request" } }; - } - - const { videoId, now } = parsed.data; - assert(videoId); - - const bookmarkEntries = await Q.bookmarkEntries() - .select("bookmarkEntries.*") - .orWhere("bookmarkEntries.videoId", videoId) - .leftJoin( - "captionEntries", - "captionEntries.id", - "bookmarkEntries.captionEntryId" - ) - .orderBy([ - { - column: "captionEntries.index", - order: "asc", - }, - { - column: "bookmarkEntries.offset", - order: "asc", - }, - ]); - - const system = new PracticeSystem(user, deck); - const ids = await system.createPracticeEntries(bookmarkEntries, now); - return { ok: true, data: { ids } }; -} diff --git a/app/routes/decks/$id/practice.tsx b/app/routes/decks/$id/practice.tsx deleted file mode 100644 index 069ab9b1c3..0000000000 --- a/app/routes/decks/$id/practice.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useFetcher, useLoaderData, useTransition } from "@remix-run/react"; -import * as React from "react"; -import { Spinner } from "../../../components/misc"; -import { - BookmarkEntryTable, - CaptionEntryTable, - DeckTable, - PRACTICE_ACTION_TYPES, - PracticeActionType, - PracticeEntryTable, - Q, - VideoTable, -} from "../../../db/models"; -import { assert } from "../../../misc/assert"; -import { R } from "../../../misc/routes"; -import { Controller, makeLoader } from "../../../utils/controller-utils"; -import { useDeserialize } from "../../../utils/hooks"; -import { useLeafLoaderData } from "../../../utils/loader-utils"; -import { PageHandle } from "../../../utils/page-handle"; -import { - DeckPracticeStatistics, - PracticeSystem, -} from "../../../utils/practice-system"; -import { toForm } from "../../../utils/url-data"; -import { BookmarkEntryComponent } from "../../bookmarks"; -import { NewPracticeActionRequest } from "./new-practice-action"; -import { requireUserAndDeck } from "./index"; - -export const handle: PageHandle = { - navBarTitle: () => , - navBarMenu: () => , -}; - -// -// loader -// - -interface LoaderData { - deck: DeckTable; - statistics: DeckPracticeStatistics; - // TODO: improve practice status message (e.g. when it shouldn't say "finished" when there's no practice entry to start with) - data: - | { - finished: true; - } - | { - finished: false; - practiceEntry: PracticeEntryTable; - bookmarkEntry: BookmarkEntryTable; - captionEntry: CaptionEntryTable; - video: VideoTable; - }; -} - -export const loader = makeLoader(Controller, async function () { - const [user, deck] = await requireUserAndDeck.apply(this); - const system = new PracticeSystem(user, deck); - const now = new Date(); - const statistics = await system.getStatistics(now); - const practiceEntry = await system.getNextPracticeEntry(now); - let data: LoaderData["data"]; - if (!practiceEntry) { - data = { finished: true }; - } else { - // TODO: optimize query - const bookmarkEntry = await Q.bookmarkEntries() - .where("id", practiceEntry.bookmarkEntryId) - .first(); - assert(bookmarkEntry); - const [captionEntry, video] = await Promise.all([ - Q.captionEntries().where("id", bookmarkEntry.captionEntryId).first(), - Q.videos().where("id", bookmarkEntry.videoId).first(), - ]); - assert(captionEntry); - assert(video); - data = { - finished: false, - practiceEntry, - bookmarkEntry, - captionEntry, - video, - }; - } - const res: LoaderData = { deck, statistics, data }; - return this.serialize(res); -}); - -// -// component -// - -export default function DefaultComponent() { - const { deck, statistics, data }: LoaderData = useDeserialize( - useLoaderData() - ); - - return ( -
-
-
-
-
- Progress -
-
-
-
- {statistics.NEW.daily} / {statistics.NEW.total} -
-
-
-
- {statistics.LEARN.daily} / {statistics.LEARN.total} -
-
-
-
- {statistics.REVIEW.daily} / {statistics.REVIEW.total} -
-
-
-
- {data.finished ? ( -
Practice is completed!
- ) : ( - - )} -
-
-
- ); -} - -function PracticeComponent({ - deck, - practiceEntry, - bookmarkEntry, - captionEntry, - video, -}: { - deck: DeckTable; - practiceEntry: PracticeEntryTable; - bookmarkEntry: BookmarkEntryTable; - captionEntry: CaptionEntryTable; - video: VideoTable; -}) { - const fetcher = useFetcher(); - const transition = useTransition(); - const isLoading = - transition.state !== "idle" && - transition.location?.pathname.startsWith(R["/decks/$id"](deck.id)); - - function onClickAction(actionType: PracticeActionType) { - const data: NewPracticeActionRequest = { - practiceEntryId: practiceEntry.id, - now: new Date(), - actionType, - }; - fetcher.submit(toForm(data), { - action: R["/decks/$id/new-practice-action"](deck.id), - method: "post", - }); - } - - return ( - <> -
- {isLoading ? ( -
- -
- ) : ( - - )} -
-
-
- {PRACTICE_ACTION_TYPES.map((type) => ( - - ))} -
-
- - ); -} - -// -// NavBarTitleComponent -// - -function NavBarTitleComponent() { - const { deck }: LoaderData = useDeserialize(useLeafLoaderData()); - return <>{deck.name} (practice); -} - -// -// NavBarMenuComponent -// - -function NavBarMenuComponent() { - return <>; -} diff --git a/app/routes/decks/index.tsx b/app/routes/decks/index.tsx deleted file mode 100644 index 9932abf872..0000000000 --- a/app/routes/decks/index.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { Transition } from "@headlessui/react"; -import { Link, useLoaderData } from "@remix-run/react"; -import * as React from "react"; -import { MoreVertical, Play, PlusSquare } from "react-feather"; -import { Popover } from "../../components/popover"; -import { DeckTable, Q } from "../../db/models"; -import { R } from "../../misc/routes"; -import { Controller, makeLoader } from "../../utils/controller-utils"; -import { useDeserialize } from "../../utils/hooks"; -import { PageHandle } from "../../utils/page-handle"; - -export const handle: PageHandle = { - navBarTitle: () => "Practice Decks", - navBarMenu: () => , -}; - -// -// loader -// - -// TODO: more data (e.g. deck statistics, etc...) -export interface DecksLoaderData { - decks: DeckTable[]; -} - -export const loader = makeLoader(Controller, async function () { - const user = await this.requireUser(); - const decks = await Q.decks() - .where({ userId: user.id }) - .orderBy("createdAt", "desc"); - return this.serialize({ decks } as DecksLoaderData); -}); - -// -// component -// - -export default function DefaultComponent() { - const data: DecksLoaderData = useDeserialize(useLoaderData()); - const { decks } = data; - return ( -
-
-
- {decks.length === 0 &&
Empty
} - {decks.map((deck) => ( - - ))} -
-
-
- ); -} - -function DeckComponent({ deck }: { deck: DeckTable }) { - return ( -
- - {deck.name} - - - - -
- ); -} - -// -// NavBarMenuComponent -// - -function NavBarMenuComponent() { - return ( - <> -
- ( - - )} - floating={({ open, setOpen, props }) => ( - -
    -
  • - setOpen(false)}> - - New deck - -
  • -
-
- )} - /> -
- - ); -} diff --git a/app/routes/decks/new.tsx b/app/routes/decks/new.tsx deleted file mode 100644 index 283a34c22e..0000000000 --- a/app/routes/decks/new.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Form, useActionData } from "@remix-run/react"; -import { redirect } from "@remix-run/server-runtime"; -import * as React from "react"; -import { z } from "zod"; -import { useSnackbar } from "../../components/snackbar"; -import { Q } from "../../db/models"; -import { R } from "../../misc/routes"; -import { Controller, makeLoader } from "../../utils/controller-utils"; -import { useIsFormValid } from "../../utils/hooks"; -import { PageHandle } from "../../utils/page-handle"; - -export const handle: PageHandle = { - navBarTitle: () => "New Deck", -}; - -// -// loader -// - -export const loader = makeLoader(Controller, async function () { - await this.requireUser(); - return null; -}); - -// -// action -// - -const REQUEST_SCHEMA = z.object({ - name: z.string().nonempty(), -}); - -interface ActionData { - message: string; -} - -export const action = makeLoader(Controller, async function () { - const user = await this.requireUser(); - - const parsed = REQUEST_SCHEMA.safeParse(await this.form()); - if (!parsed.success) { - return { message: "invalid request" } as ActionData; - } - - const { name } = parsed.data; - const [id] = await Q.decks().insert({ - name, - userId: user.id, - }); - return redirect(R["/decks/$id"](id)); -}); - -// -// component -// - -export default function DefaultComponent() { - const actionData = useActionData(); - const { enqueueSnackbar } = useSnackbar(); - const [isValid, formProps] = useIsFormValid(); - - React.useEffect(() => { - if (actionData?.message) { - enqueueSnackbar(actionData?.message, { variant: "error" }); - } - }, [actionData]); - - return ( -
-
-
- - -
-
- -
-
-
- ); -} diff --git a/app/routes/health-check.tsx b/app/routes/health-check.tsx deleted file mode 100644 index 90013a9cf8..0000000000 --- a/app/routes/health-check.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoaderFunction } from "@remix-run/server-runtime"; - -export const loader: LoaderFunction = async () => { - return { success: true }; -}; diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 09fa6fe640..6aa789ec54 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -12,7 +12,6 @@ import { Controller, makeLoader } from "../utils/controller-utils"; import { useDeserialize } from "../utils/hooks"; import { PageHandle } from "../utils/page-handle"; import { PAGINATION_PARAMS_SCHEMA } from "../utils/pagination"; -import { VideoListComponent } from "./videos"; export const handle: PageHandle = { navBarTitle: () => "Examples", @@ -22,22 +21,23 @@ interface LoaderData { pagination: PaginationResult; } -export const loader = makeLoader(Controller, async function () { - const parsed = PAGINATION_PARAMS_SCHEMA.safeParse(this.query()); - if (!parsed.success) { - this.flash({ content: "invalid parameters", variant: "error" }); - return redirect(R["/"]); - } +// export const loader = makeLoader(Controller, async function () { +// const parsed = PAGINATION_PARAMS_SCHEMA.safeParse(this.query()); +// if (!parsed.success) { +// this.flash({ content: "invalid parameters", variant: "error" }); +// return redirect(R["/"]); +// } - const pagination = await toPaginationResult( - Q.videos().where("userId", null).orderBy("updatedAt", "desc"), - parsed.data - ); - const data: LoaderData = { pagination }; - return this.serialize(data); -}); +// const pagination = await toPaginationResult( +// Q.videos().where("userId", null).orderBy("updatedAt", "desc"), +// parsed.data +// ); +// const data: LoaderData = { pagination }; +// return this.serialize(data); +// }); export default function DefaultComponent() { - const data: LoaderData = useDeserialize(useLoaderData()); - return ; + return <>INDEX; + // const data: LoaderData = useDeserialize(useLoaderData()); + // return ; } diff --git a/app/routes/kill.tsx b/app/routes/kill.tsx deleted file mode 100644 index 3cd11f279b..0000000000 --- a/app/routes/kill.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { LoaderFunction } from "@remix-run/server-runtime"; - -export const loader: LoaderFunction = async () => { - if (process.env.NODE_ENV === "production") { - return { success: false }; - } - console.log("🔥🔥🔥 process.exit(0) 🔥🔥🔥"); - setTimeout(() => process.exit(0)); - return { success: true }; -}; diff --git a/app/routes/share-target.tsx b/app/routes/share-target.tsx deleted file mode 100644 index 575fc61fb8..0000000000 --- a/app/routes/share-target.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useCatch } from "@remix-run/react"; -import { LoaderFunction, json, redirect } from "@remix-run/server-runtime"; -import * as React from "react"; -import { R } from "../misc/routes"; -import { parseVideoId } from "../utils/youtube"; - -// see manifest.json -const SHARE_TARGET_TEXT = "share-target-text"; - -export const loader: LoaderFunction = async ({ request }) => { - const shareTargetText = new URL(request.url).searchParams.get( - SHARE_TARGET_TEXT - ); - if (shareTargetText) { - const videoId = parseVideoId(shareTargetText); - if (videoId) { - return redirect(R["/videos/new"] + `?videoId=${videoId}`); - } - } - throw json({ message: "Invalid share data" }); -}; - -export function CatchBoundary() { - const { data } = useCatch(); - return
ERROR: {data.message}
; -} diff --git a/app/routes/users/__tests__/register.test.ts b/app/routes/users/__tests__/register.test.ts deleted file mode 100644 index a45e2abcb7..0000000000 --- a/app/routes/users/__tests__/register.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { Q } from "../../../db/models"; -import { assert } from "../../../misc/assert"; -import { getSessionUser } from "../../../utils/auth"; -import { getSession } from "../../../utils/session.server"; -import { testLoader } from "../../__tests__/helper"; -import { action } from "../register"; - -describe("register.action", () => { - beforeEach(async () => { - await Q.users().delete(); - }); - - describe("success", () => { - it("basic", async () => { - const username = "root"; - const data = { - username, - password: "pass", - passwordConfirmation: "pass", - }; - const res = await testLoader(action, { form: data }); - const found = await Q.users().where("username", data.username).first(); - assert(found); - expect(found.username).toBe(username); - - // redirect to root - assert(res instanceof Response); - expect(res.status).toBe(302); - expect(res.headers.get("location")).toBe("/"); - - // verify session user - const session = await getSession(res.headers.get("set-cookie")); - const sessionUser = await getSessionUser(session); - expect(sessionUser).toEqual(found); - }); - }); - - describe("error", () => { - it("username format", async () => { - const data = { - username: "r@@t", - password: "pass", - passwordConfirmation: "pass", - }; - const res = await testLoader(action, { form: data }); - const resJson = await res.json(); - expect(resJson).toMatchInlineSnapshot(` - { - "errors": { - "fieldErrors": { - "username": [ - "Invalid", - ], - }, - "formErrors": [], - }, - "message": "Invalid registration", - } - `); - }); - - it("password confirmation", async () => { - const data = { - username: "root", - password: "pass", - passwordConfirmation: "ssap", - }; - const res = await testLoader(action, { form: data }); - const resJson = await res.json(); - expect(resJson).toMatchInlineSnapshot(` - { - "errors": { - "fieldErrors": { - "passwordConfirmation": [ - "Invalid", - ], - }, - "formErrors": [], - }, - "message": "Invalid registration", - } - `); - }); - - it("unique username", async () => { - const data = { - username: "root", - password: "pass", - passwordConfirmation: "pass", - }; - { - const res = await testLoader(action, { form: data }); - assert(res instanceof Response); - expect(res.status).toBe(302); - } - { - const res = await testLoader(action, { form: data }); - const resJson = await res.json(); - expect(resJson).toMatchInlineSnapshot(` - { - "message": "Username 'root' is already taken", - } - `); - } - }); - }); -}); diff --git a/app/routes/users/__tests__/signin.test.ts b/app/routes/users/__tests__/signin.test.ts deleted file mode 100644 index e52412629f..0000000000 --- a/app/routes/users/__tests__/signin.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { Q, UserTable } from "../../../db/models"; -import { assert } from "../../../misc/assert"; -import { getSessionUser, register } from "../../../utils/auth"; -import { getSession } from "../../../utils/session.server"; -import { testLoader } from "../../__tests__/helper"; -import { action } from "../signin"; - -describe("signin.action", () => { - let user: UserTable; - const credentials = { username: "root", password: "pass" }; - - beforeAll(async () => { - await Q.users().delete(); - user = await register(credentials); - }); - - describe("success", () => { - it("basic", async () => { - const res = await testLoader(action, { form: credentials }); - - // redirect to root - assert(res instanceof Response); - expect(res.status).toBe(302); - expect(res.headers.get("location")).toBe("/"); - - // verify session user - const session = await getSession(res.headers.get("set-cookie")); - const sessionUser = await getSessionUser(session); - expect(sessionUser).toEqual(user); - }); - }); - - describe("error", () => { - it("format", async () => { - const data = { - username: "r@@t", - password: "pass", - }; - const res = await testLoader(action, { form: data }); - const resJson = await res.json(); - expect(resJson).toMatchInlineSnapshot(` - { - "message": "Invalid sign in", - "success": false, - } - `); - }); - - it("not-found", async () => { - const data = { - ...credentials, - username: "no-such-root", - }; - const res = await testLoader(action, { form: data }); - const resJson = await res.json(); - expect(resJson).toMatchInlineSnapshot(` - { - "message": "Invalid username or password", - "success": false, - } - `); - }); - - it("wonrg-password", async () => { - const data = { - ...credentials, - password: "no-such-pass", - }; - const res = await testLoader(action, { form: data }); - const resJson = await res.json(); - expect(resJson).toMatchInlineSnapshot(` - { - "message": "Invalid username or password", - "success": false, - } - `); - }); - }); -}); diff --git a/app/routes/users/__tests__/signout.test.ts b/app/routes/users/__tests__/signout.test.ts deleted file mode 100644 index a2a9ffb8e0..0000000000 --- a/app/routes/users/__tests__/signout.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { assert } from "../../../misc/assert"; -import { getResponseSession } from "../../../utils/session-utils"; -import { testLoader, useUser } from "../../__tests__/helper"; -import { action } from "../signout"; - -describe("signout.action", () => { - const { signin } = useUser({ seed: __filename }); - - describe("success", () => { - it("basic", async () => { - const res = await testLoader(action, { transform: signin }); - - // redirect to root - assert(res instanceof Response); - expect(res.status).toBe(302); - expect(res.headers.get("location")).toBe("/"); - - // verify empty session user - const resSession = await getResponseSession(res); - expect(resSession.data).toMatchInlineSnapshot(` - { - "__flash_flash-messages__": [ - { - "content": "Signed out successfuly", - "variant": "success", - }, - ], - } - `); - }); - }); - - describe("error", () => { - it("no-session-user", async () => { - const res = await testLoader(action); - - // redirect to root - assert(res instanceof Response); - expect(res.status).toBe(302); - expect(res.headers.get("location")).toBe("/"); - - // verify empty session user - const resSession = await getResponseSession(res); - expect(resSession.data).toMatchInlineSnapshot(` - { - "__flash_flash-messages__": [ - { - "content": "Not signed in", - "variant": "error", - }, - ], - } - `); - }); - }); -}); diff --git a/app/routes/users/me.tsx b/app/routes/users/me.tsx deleted file mode 100644 index d264a52b7b..0000000000 --- a/app/routes/users/me.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { - Form, - useActionData, - useLoaderData, - useTransition, -} from "@remix-run/react"; -import { json, redirect } from "@remix-run/server-runtime"; -import * as React from "react"; -import { z } from "zod"; -import { useSnackbar } from "../../components/snackbar"; -import { Q } from "../../db/models"; -import type { UserTable } from "../../db/models"; -import { R } from "../../misc/routes"; -import { - Controller, - deserialize, - makeLoader, -} from "../../utils/controller-utils"; -import { useIsFormValid } from "../../utils/hooks"; -import { - FILTERED_LANGUAGE_CODES, - LanguageCode, - languageCodeToName, -} from "../../utils/language"; -import { PageHandle } from "../../utils/page-handle"; - -export const handle: PageHandle = { - navBarTitle: () => "Account", -}; - -export const loader = makeLoader(Controller, async function () { - // TODO: here we're loading the same data as root loader for simplicity - const user = await this.currentUser(); - if (user) { - return this.serialize(user); - } - return redirect(R["/users/signin"]); -}); - -const ACTION_SCHEMA = z.object({ - language1: z.string().nonempty(), - language2: z.string().nonempty(), -}); - -export const action = makeLoader(Controller, async function () { - const user = await this.currentUser(); - if (user === undefined) { - return redirect(R["/users/signin"]); - } - const parsed = ACTION_SCHEMA.safeParse(await this.form()); - if (!parsed.success) { - return json({ success: false, message: "Fail to update settings" }); - } - await Q.users().update(parsed.data).where("id", user.id); - return json({ success: true, message: "Settings updated successfuly" }); -}); - -export default function DefaultComponent() { - const currentUser: UserTable = deserialize(useLoaderData()); - const transition = useTransition(); - const actionData = useActionData<{ success: boolean; message: string }>(); - const [changed, setChanged] = React.useState(false); - const [isValid, formProps] = useIsFormValid(); - const { enqueueSnackbar } = useSnackbar(); - - // Reset form on success - React.useEffect(() => { - if (!actionData) return; - const { message, success } = actionData; - enqueueSnackbar(message, { variant: success ? "success" : "error" }); - if (success) { - setChanged(false); - } - }, [actionData]); - - const isLoading = - transition.state !== "idle" && - transition.location?.pathname === R["/users/me"]; - - return ( -
-
{ - formProps.onChange(); - setChanged(true); - }} - > -
-
Account
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
-
- ); -} - -function LanguageSelect({ - languageCodes, - ...props -}: { - languageCodes: LanguageCode[]; -} & JSX.IntrinsicElements["select"]) { - return ( - - ); -} diff --git a/app/routes/users/register.tsx b/app/routes/users/register.tsx deleted file mode 100644 index 57f9568916..0000000000 --- a/app/routes/users/register.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Form, Link, useActionData } from "@remix-run/react"; -import { redirect } from "@remix-run/server-runtime"; -import * as React from "react"; -import { R } from "../../misc/routes"; -import { - PASSWORD_MAX_LENGTH, - REGISTER_SCHEMA, - USERNAME_MAX_LENGTH, - register, - signinSession, -} from "../../utils/auth"; -import { Controller, makeLoader } from "../../utils/controller-utils"; -import { AppError } from "../../utils/errors"; -import { useIsFormValid } from "../../utils/hooks"; -import { mapOption } from "../../utils/misc"; -import { PageHandle } from "../../utils/page-handle"; - -export const handle: PageHandle = { - navBarTitle: () => "Register", -}; - -export const loader = makeLoader(Controller, async function () { - const user = await this.currentUser(); - if (user) { - this.flash({ - content: `Already signed in as '${user.username}'`, - variant: "error", - }); - return redirect(R["/users/me"]); - } - return null; -}); - -interface ActionData { - message: string; - errors?: { - formErrors: string[]; - fieldErrors: Record; - }; -} - -export const action = makeLoader(Controller, async function () { - const parsed = REGISTER_SCHEMA.safeParse(await this.form()); - if (!parsed.success) { - return { - message: "Invalid registration", - errors: parsed.error.flatten(), - }; - } - try { - const user = await register(parsed.data); - signinSession(this.session, user); - return redirect(R["/"]); - } catch (e) { - if (e instanceof AppError) { - return { message: e.message }; - } - throw e; - } -}); - -export default function DefaultComponent() { - const actionData: ActionData | undefined = useActionData(); - const [isValid, formProps] = useIsFormValid(); - - const errors = mapOption(actionData?.errors?.fieldErrors, Object.keys) ?? []; - - return ( -
-
- {actionData?.message ? ( -
-
Error: {actionData.message}
-
- ) : null} -
- - -
-
- - -
-
- - -
-
- - -
-
-
- ); -} diff --git a/app/routes/users/signin.tsx b/app/routes/users/signin.tsx deleted file mode 100644 index ba1b5d0ad6..0000000000 --- a/app/routes/users/signin.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Form, Link, useActionData } from "@remix-run/react"; -import { redirect } from "@remix-run/server-runtime"; -import * as React from "react"; -import { R } from "../../misc/routes"; -import { - PASSWORD_MAX_LENGTH, - SIGNIN_SCHEMA, - USERNAME_MAX_LENGTH, - getSessionUser, - signinSession, - verifySignin, -} from "../../utils/auth"; -import { Controller, makeLoader } from "../../utils/controller-utils"; -import { AppError } from "../../utils/errors"; -import { useIsFormValid } from "../../utils/hooks"; -import { PageHandle } from "../../utils/page-handle"; - -export const handle: PageHandle = { - navBarTitle: () => "Sign in", -}; - -export const loader = makeLoader(Controller, async function () { - const user = await getSessionUser(this.session); - if (user) { - this.flash({ - content: `Already signed in as '${user.username}'`, - variant: "error", - }); - return redirect(R["/users/me"]); - } - return null; -}); - -export const action = makeLoader(Controller, async function () { - const parsed = SIGNIN_SCHEMA.safeParse(await this.form()); - if (!parsed.success) { - return { success: false, message: "Invalid sign in" }; - } - - try { - const user = await verifySignin(parsed.data); - signinSession(this.session, user); - this.flash({ - content: `Succesfully signed in as '${user.username}'`, - variant: "success", - }); - return redirect(R["/"]); - } catch (e) { - if (e instanceof AppError) { - return { success: false, message: e.message }; - } - throw e; - } -}); - -export default function DefaultComponent() { - const actionData: { message: string } | undefined = useActionData(); - const [isValid, formProps] = useIsFormValid(); - - return ( -
-
- {actionData?.message ? ( -
-
Error: {actionData.message}
-
- ) : null} -
- - -
-
- - -
-
- - -
-
-
- ); -} diff --git a/app/routes/users/signout.tsx b/app/routes/users/signout.tsx deleted file mode 100644 index d53073dec2..0000000000 --- a/app/routes/users/signout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { redirect } from "@remix-run/server-runtime"; -import { R } from "../../misc/routes"; -import { signoutSession } from "../../utils/auth"; -import { Controller, makeLoader } from "../../utils/controller-utils"; - -export const loader = () => redirect(R["/"]); - -export const action = makeLoader(Controller, async function () { - if (!(await this.currentUser())) { - this.flash({ - content: "Not signed in", - variant: "error", - }); - return redirect(R["/"]); - } - signoutSession(this.session); - this.flash({ - content: "Signed out successfuly", - variant: "success", - }); - return redirect(R["/"]); -}); diff --git a/app/routes/videos/$id.tsx b/app/routes/videos/$id.tsx deleted file mode 100644 index 683d2cb7cb..0000000000 --- a/app/routes/videos/$id.tsx +++ /dev/null @@ -1,648 +0,0 @@ -import { Transition } from "@headlessui/react"; -import { - Form, - Link, - ShouldReloadFunction, - useFetcher, - useLoaderData, -} from "@remix-run/react"; -import { redirect } from "@remix-run/server-runtime"; -import * as React from "react"; -import { Bookmark, MoreVertical, Play, Repeat, Save, X } from "react-feather"; -import { z } from "zod"; -import { Spinner } from "../../components/misc"; -import { Popover } from "../../components/popover"; -import { useSnackbar } from "../../components/snackbar"; -import { - CaptionEntryTable, - Q, - UserTable, - VideoTable, - getVideoAndCaptionEntries, -} from "../../db/models"; -import { assert } from "../../misc/assert"; -import { R } from "../../misc/routes"; -import { Controller, makeLoader } from "../../utils/controller-utils"; -import { useDeserialize, useSelection } from "../../utils/hooks"; -import { useYoutubeIframeApi } from "../../utils/hooks"; -import { useLeafLoaderData, useRootLoaderData } from "../../utils/loader-utils"; -import { PageHandle } from "../../utils/page-handle"; -import { CaptionEntry } from "../../utils/types"; -import { toForm } from "../../utils/url-data"; -import { - YoutubeIframeApi, - YoutubePlayer, - YoutubePlayerOptions, - stringifyTimestamp, -} from "../../utils/youtube"; -import { zStringToInteger } from "../../utils/zod-utils"; -import { NewBookmark } from "../bookmarks/new"; - -export const handle: PageHandle = { - navBarTitle: () => "Watch", - navBarMenu: () => , -}; - -// -// loader -// - -const SCHEMA = z.object({ - id: zStringToInteger, -}); - -type LoaderData = { video: VideoTable; captionEntries: CaptionEntryTable[] }; - -export const loader = makeLoader(Controller, async function () { - const parsed = SCHEMA.safeParse(this.args.params); - if (parsed.success) { - const { id } = parsed.data; - const data: LoaderData | undefined = await getVideoAndCaptionEntries(id); - if (data) { - return this.serialize(data); - } - } - this.flash({ - content: "Invalid Video ID", - variant: "error", - }); - return redirect(R["/"]); -}); - -export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => { - if (submission?.action === R["/bookmarks/new"]) { - return false; - } - return true; -}; - -// -// action -// - -export const action = makeLoader(Controller, async function () { - if (this.request.method === "DELETE") { - const parsed = SCHEMA.safeParse(this.args.params); - if (parsed.success) { - const { id } = parsed.data; - const user = await this.currentUser(); - if (user) { - const video = await Q.videos().where({ id, userId: user.id }).first(); - if (video) { - await Promise.all([ - Q.videos().delete().where({ id, userId: user.id }), - Q.captionEntries().delete().where("videoId", id), - Q.bookmarkEntries().delete().where("videoId", id), - ]); - // return `type` so that `useFetchers` can identify where the response is from - return { type: "DELETE /videos/$id", success: true }; - } - } - } - } - return { - type: "DELETE /videos/$id", - success: false, - message: "invalid request", - }; -}); - -// -// component -// - -export default function DeafultComponent() { - const { currentUser } = useRootLoaderData(); - const data: LoaderData = useDeserialize(useLoaderData()); - return ; -} - -function findCurrentEntry( - entries: CaptionEntry[], - time: number -): CaptionEntry | undefined { - for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].begin <= time) { - return entries[i]; - } - } - return; -} - -function toggleArrayInclusion(container: T[], element: T): T[] { - if (container.includes(element)) { - return container.filter((other) => other !== element); - } - return [...container, element]; -} - -// adhoc routines to derive `BookmarkState` by probing dom tree -function findSelectionEntryIndex(selection: Selection): number { - const isValid = - selection.toString().trim() && - selection.anchorNode && - selection.anchorNode === selection.focusNode && - selection.anchorNode.nodeType === document.TEXT_NODE && - selection.anchorNode.parentElement?.classList?.contains( - BOOKMARKABLE_CLASSNAME - ); - if (!isValid) return -1; - const textElement = selection.getRangeAt(0).startContainer; - const entryNode = textElement.parentElement?.parentElement?.parentElement!; - const entriesContainer = entryNode.parentElement!; - const index = Array.from(entriesContainer.childNodes).findIndex( - (other) => other === entryNode - ); - return index; -} - -const BOOKMARKABLE_CLASSNAME = "--bookmarkable--"; - -interface BookmarkState { - captionEntry: CaptionEntryTable; - text: string; - side: number; // 0 | 1 - offset: number; -} - -function PageComponent({ - video, - captionEntries, - currentUser, -}: LoaderData & { currentUser?: UserTable }) { - const fetcher = useFetcher(); - const { enqueueSnackbar } = useSnackbar(); - - // - // state - // - - const [player, setPlayer] = React.useState(); - const [isPlaying, setIsPlaying] = React.useState(false); - const [currentEntry, setCurrentEntry] = React.useState(); - const [repeatingEntries, setRepeatingEntries] = React.useState< - CaptionEntry[] - >([]); - const [bookmarkState, setBookmarkState] = React.useState(); - - // - // handlers - // - - // TODO: use `useRafLoop` like in `MiniPlayer` (bookmarks/index.tsx) - function startSynchronizePlayerState(player: YoutubePlayer): () => void { - // Poll state change via RAF - // (this assumes `player` and `captionEntries` don't change during the entire component lifecycle) - let id: number | undefined; - function loop() { - // On development rendering takes more than 100ms depending on the amount of subtitles - setIsPlaying(player.getPlayerState() === 1); - setCurrentEntry( - findCurrentEntry(captionEntries, player.getCurrentTime()) - ); - id = requestAnimationFrame(loop); - } - loop(); - return () => { - if (typeof id === "number") { - cancelAnimationFrame(id); - } - }; - } - - function repeatEntry(player: YoutubePlayer) { - if (repeatingEntries.length === 0) return; - const begin = Math.min(...repeatingEntries.map((entry) => entry.begin)); - const end = Math.max(...repeatingEntries.map((entry) => entry.end)); - const currentTime = player.getCurrentTime(); - if (currentTime < begin || end < currentTime) { - player.seekTo(begin); - } - } - - function onClickEntryPlay(entry: CaptionEntry, toggle: boolean) { - if (!player) return; - - // No-op if some text is selected (e.g. for google translate extension) - if (document.getSelection()?.toString()) return; - - if (toggle && entry === currentEntry) { - if (isPlaying) { - player.pauseVideo(); - } else { - player.playVideo(); - } - } else { - player.seekTo(entry.begin); - player.playVideo(); - } - } - - function onClickEntryRepeat(entry: CaptionEntry) { - setRepeatingEntries(toggleArrayInclusion(repeatingEntries, entry)); - } - - const onSelection = React.useCallback((selection?: Selection): void => { - let newBookmarkState = undefined; - if (selection) { - const index = findSelectionEntryIndex(selection); - if (index >= 0) { - const el = selection.anchorNode!.parentNode!; - const side = Array.from(el.parentNode!.children).findIndex( - (c) => c === el - ); - assert(side === 0 || side === 1); - newBookmarkState = { - captionEntry: captionEntries[index], - text: selection.toString(), - side: side, - offset: selection.anchorOffset, - }; - } - } - setBookmarkState(newBookmarkState); - }, []); - - function onClickBookmark() { - if (!bookmarkState) return; - const typedData: NewBookmark = { - videoId: video.id, - captionEntryId: bookmarkState.captionEntry.id, - text: bookmarkState.text, - side: bookmarkState.side, - offset: bookmarkState.offset, - }; - // use `unstable_shouldReload` to prevent invalidating loaders - fetcher.submit(toForm(typedData), { - method: "post", - action: R["/bookmarks/new"], - }); - document.getSelection()?.removeAllRanges(); - } - - function onCancelBookmark() { - document.getSelection()?.removeAllRanges(); - setBookmarkState(undefined); - } - - // - // effects - // - - React.useEffect(() => { - if (!player) return; - return startSynchronizePlayerState(player); - }, [player, captionEntries]); - - React.useEffect(() => { - if (!player) return; - repeatEntry(player); - }, [player, isPlaying, currentEntry, repeatingEntries, captionEntries]); - - React.useEffect(() => { - if (fetcher.type === "done") { - if (fetcher.data.success) { - enqueueSnackbar("Bookmark success", { variant: "success" }); - } else { - enqueueSnackbar("Bookmark failed", { variant: "error" }); - } - setBookmarkState(undefined); - } - }, [fetcher.type]); - - useSelection(onSelection); - - return ( - - } - subtitles={ - - } - bookmarkActions={ - currentUser && - currentUser.id === video.userId && ( - - - - - ) - } - /> - ); -} - -function LayoutComponent( - props: Record<"player" | "subtitles" | "bookmarkActions", React.ReactNode> -) { - // - // - Mobile layout - // - // +-----------+ - // | PLAYER | fixed aspect ratio 16 / 9 - // +-----------+ - // | SUBTITLES | grow - // +-----------+ - // - // - Desktop layout - // - // +--------------+-----------+ - // | | | - // | PLAYER | SUBTITLES | - // | | | - // +--------------+-----------+ - // grow 1/3 width - // - return ( -
-
{props.player}
-
-
- {props.subtitles} -
- {props.bookmarkActions} -
-
- ); -} - -export function usePlayer({ - defaultOptions, - onLoad = () => {}, - onError = () => {}, -}: { - defaultOptions: YoutubePlayerOptions; - onLoad?: (player: YoutubePlayer) => void; - onError?: (e: Error) => void; -}) { - const [loading, setLoading] = React.useState(true); - const ref = React.useRef(null); - const api = useYoutubeIframeApi(null, { - onError, - }); - - React.useEffect(() => { - if (!api.isSuccess) return; - if (!ref.current) { - setLoading(false); - throw new Error(`"ref" element is not available`); - } - if (!api.data) { - setLoading(false); - throw new Error(); - } - - let callback = () => { - setLoading(false); - onLoad(player); - }; - const player = new api.data.Player(ref.current, { - ...defaultOptions, - events: { onReady: () => callback() }, - }); - // Avoid calling `onLoad` if unmounted before - return () => { - callback = () => {}; - }; - }, [api.isSuccess]); - - return [ref, loading] as const; -} - -function PlayerComponent({ - defaultOptions, - onLoad = () => {}, - onError = () => {}, -}: { - defaultOptions: YoutubePlayerOptions; - onLoad?: (player: YoutubePlayer) => void; - onError?: (e: Error) => void; -}) { - const [ref, loading] = usePlayer({ defaultOptions, onLoad, onError }); - return ( -
-
-
-
-
- {loading && ( -
-
-
- )} -
-
- ); -} - -function CaptionEntriesComponent({ - entries, - ...props -}: { - entries: CaptionEntry[]; - currentEntry?: CaptionEntry; - repeatingEntries: CaptionEntry[]; - onClickEntryPlay: (entry: CaptionEntry, toggle: boolean) => void; - onClickEntryRepeat: (entry: CaptionEntry) => void; - isPlaying: boolean; -}) { - return ( -
- {entries.map((entry) => ( - - ))} -
- ); -} - -export function CaptionEntryComponent({ - entry, - currentEntry, - repeatingEntries = [], - onClickEntryPlay, - onClickEntryRepeat, - isPlaying, - border = true, -}: { - entry: CaptionEntry; - currentEntry?: CaptionEntry; - repeatingEntries?: CaptionEntry[]; - onClickEntryPlay: (entry: CaptionEntry, toggle: boolean) => void; - onClickEntryRepeat: (entry: CaptionEntry) => void; - isPlaying: boolean; - border?: boolean; -}) { - const { begin, end, text1, text2 } = entry; - const timestamp = [begin, end].map(stringifyTimestamp).join(" - "); - - const isCurrentEntry = entry === currentEntry; - const isRepeating = repeatingEntries.includes(entry); - const isEntryPlaying = isCurrentEntry && isPlaying; - - return ( -
-
-
{timestamp}
-
onClickEntryRepeat(entry)} - > - -
-
onClickEntryPlay(entry, false)} - > - -
-
-
onClickEntryPlay(entry, true)} - > -
- {text1} -
-
- {text2} -
-
-
- ); -} - -function toCaptionEntryId({ begin, end }: CaptionEntry): string { - return `${begin}--${end}`; -} - -function NavBarMenuComponent() { - const { currentUser } = useRootLoaderData(); - const { video }: LoaderData = useDeserialize(useLeafLoaderData()); - return ; -} - -function NavBarMenuComponentImpl({ - user, - video, -}: { - user?: UserTable; - video: VideoTable; -}) { - // TODO: refactor too much copy-paste of `Popover` from `NavBar` in `root.tsx` - return ( - <> - {user && user.id !== video.userId && ( -
- {/* prettier-ignore */} - <> - - - - - - - -
- )} -
- ( - - )} - floating={({ open, setOpen, props }) => ( - -
    -
  • - "Your Videos", -}; - -// TODO -// - filter (`` in `navBarMenuComponent`) -// - by language -// - by author -// - order -// - by "lastWatchedAt" -// - better layout for desktop - -interface VideoTableExtra extends VideoTable { - bookmarkEntriesCount?: number; -} - -interface LoaderData { - pagination: PaginationResult; -} - -export const loader = makeLoader(Controller, async function () { - const user = await this.currentUser(); - if (!user) { - this.flash({ - content: "Signin required.", - variant: "error", - }); - return redirect(R["/users/signin"]); - } - - const parsed = PAGINATION_PARAMS_SCHEMA.safeParse(this.query()); - if (!parsed.success) { - this.flash({ content: "invalid parameters", variant: "error" }); - return redirect(R["/bookmarks"]); - } - - // TODO: cache "has-many" counter (https://github.com/rails/rails/blob/de53ba56cab69fb9707785a397a59ac4aaee9d6f/activerecord/lib/active_record/counter_cache.rb#L159) - const pagination = await toPaginationResult( - Q.videos() - .select("videos.*", { - bookmarkEntriesCount: client.raw( - "CAST(SUM(bookmarkEntries.id IS NOT NULL) AS SIGNED)" - ), - }) - .where("videos.userId", user.id) - .orderBy("videos.updatedAt", "desc") - .leftJoin("bookmarkEntries", "bookmarkEntries.videoId", "videos.id") - .groupBy("videos.id"), - parsed.data, - { clearJoin: true } - ); - - const data: LoaderData = { pagination }; - return this.serialize(data); -}); - -export default function DefaultComponent() { - const { currentUser } = useRootLoaderData(); - const data: LoaderData = useDeserialize(useLoaderData()); - return ; -} - -export function VideoListComponent({ - pagination, - currentUser, -}: LoaderData & { - currentUser?: UserTable; -}) { - // cannot run this effect in `VideoComponentExtra` because the component is already gone when action returns response - const fetchers = useFetchers(); - const { enqueueSnackbar } = useSnackbar(); - - React.useEffect(() => { - for (const fetcher of fetchers) { - if ( - fetcher.type === "done" && - fetcher.data.type === "DELETE /videos/$id" - ) { - if (fetcher.data.success) { - enqueueSnackbar("Deleted successfuly", { variant: "success" }); - } else { - enqueueSnackbar("Deletion failed", { variant: "error" }); - } - } - } - }, [fetchers]); - - return ( - <> -
    -
    -
    - {/*
    - -
    */} - {/* TODO: CTA when empty */} - {pagination.data.length === 0 &&
    Empty
    } - {pagination.data.map((video) => ( - - ))} -
    -
    -
    -
    {/* fake padding to allow scrool more */} -
    - -
    - - ); -} - -function VideoComponentExtra({ - video, - currentUser, -}: { - video: VideoTableExtra; - currentUser?: UserTable; -}) { - const fetcher = useFetcher(); - const { openModal } = useModal(); - const addToDeckDisabled = !video.bookmarkEntriesCount; - - function onClickAddToDeck() { - if (addToDeckDisabled) return; - openModal(); - } - - return ( - -
  • - -
  • - { - if (!window.confirm("Are you sure?")) { - e.preventDefault(); - } - }} - > -
  • - -
  • -
    - - ) - } - /> - ); -} - -function AddToDeckComponent({ videoId }: { videoId: number }) { - // get decks - const fetcher1 = useFetcher(); - const data: DecksLoaderData | undefined = React.useMemo( - () => mapOption(fetcher1.data, deserialize), - [fetcher1.data] - ); - React.useEffect(() => fetcher1.load(R["/decks"]), []); - - // create practice entries - const fetcher2 = useFetcher(); - const { enqueueSnackbar } = useSnackbar(); - - React.useEffect(() => { - if (fetcher2.type === "done") { - const data: NewPracticeEntryResponse = fetcher2.data; - if (data.ok) { - enqueueSnackbar(`Added ${data.data.ids.length} to a deck`, { - variant: "success", - }); - } else { - enqueueSnackbar("Failed to add to a deck", { variant: "error" }); - } - } - }, [fetcher2.type]); - - function onClickPlus(deck: DeckTable) { - const data: NewPracticeEntryRequest = { - videoId, - now: new Date(), - }; - fetcher2.submit(toForm(data), { - action: R["/decks/$id/new-practice-entry"](deck.id), - method: "post", - }); - } - - return ( -
    -
    Select a Deck
    - {data ? ( -
      - {data.decks.map((deck) => ( -
    • - -
    • - ))} -
    - ) : ( -
    - -
    - )} -
    - ); -} diff --git a/app/routes/videos/new.tsx b/app/routes/videos/new.tsx deleted file mode 100644 index de2b862965..0000000000 --- a/app/routes/videos/new.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { Form, useLoaderData } from "@remix-run/react"; -import { redirect } from "@remix-run/server-runtime"; -import * as React from "react"; -import { z } from "zod"; -import { filterNewVideo, insertVideoAndCaptionEntries } from "../../db/models"; -import { R } from "../../misc/routes"; -import { Controller, makeLoader } from "../../utils/controller-utils"; -import { AppError } from "../../utils/errors"; -import { useIsFormValid } from "../../utils/hooks"; -import { useRootLoaderData } from "../../utils/loader-utils"; -import { PageHandle } from "../../utils/page-handle"; -import { CaptionConfig, VideoMetadata } from "../../utils/types"; -import { NEW_VIDEO_SCHEMA, fetchCaptionEntries } from "../../utils/youtube"; -import { - fetchVideoMetadata, - findCaptionConfigPair, - parseVideoId, - toCaptionConfigOptions, -} from "../../utils/youtube"; - -// -// loader -// - -const LOADER_SCHEMA = z.object({ - videoId: z.string().nonempty(), -}); - -export const loader = makeLoader(Controller, async function () { - const parsed = LOADER_SCHEMA.safeParse(this.query()); - if (parsed.success) { - const videoId = parseVideoId(parsed.data.videoId); - if (videoId) { - const videoMetadata = await fetchVideoMetadata(videoId); - if (videoMetadata.playabilityStatus.status === "OK") { - return videoMetadata; - } - } - } - this.flash({ - content: "Invalid input", - variant: "error", - }); - return redirect(R["/"]); -}); - -// -// action -// - -export const action = makeLoader(Controller, async function () { - const parsed = NEW_VIDEO_SCHEMA.safeParse(await this.form()); - if (!parsed.success) throw new AppError("Invalid parameters"); - - const user = await this.currentUser(); - const row = await filterNewVideo(parsed.data, user?.id).select("id").first(); - let id = row?.id; - if (id) { - this.flash({ - content: "Loaded existing video", - variant: "info", - }); - } else { - const data = await fetchCaptionEntries(parsed.data); - id = await insertVideoAndCaptionEntries(parsed.data, data, user?.id); - this.flash({ - content: "Created new video", - variant: "success", - }); - } - return redirect(R["/videos/$id"](id)); -}); - -// -// component -// - -export const handle: PageHandle = { - navBarTitle: () => "Select languages", -}; - -export default function DefaultComponent() { - const { currentUser } = useRootLoaderData(); - const videoMetadata: VideoMetadata = useLoaderData(); - const [isValid, formProps] = useIsFormValid(); - - // TODO: more heuristics to set default languages e.g. - // language1 = { id: } (maybe we can infer from `videoDetails.keywords`) - // language2 = { id: language1.id, translation: "en" } - let defaultValues = ["", ""]; - const { language1, language2 } = currentUser ?? {}; - if (language1 && language2) { - const pair = findCaptionConfigPair(videoMetadata, [ - language1, - language2, - ] as any); - pair.forEach((config, i) => { - if (config) { - defaultValues[i] = JSON.stringify(config); - } - }); - } - - return ( -
    -
    -
    -
    Select Languages
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    -
    -
    -
    - ); -} - -function LanguageSelectComponent({ - videoMetadata, - propertyName, - ...props -}: JSX.IntrinsicElements["select"] & { - videoMetadata: VideoMetadata; - propertyName: string; -}) { - const ref0 = React.useRef(); - const ref1 = React.useRef(); - const ref2 = React.useRef(); - React.useEffect(copy, []); - - function copy() { - const value = ref0.current?.value; - if (value) { - const { id, translation } = JSON.parse(value) as CaptionConfig; - ref1.current!.value = id; - ref2.current!.value = translation ?? ""; - } else { - ref1.current!.value = ""; - ref2.current!.value = ""; - } - } - - const { captions, translationGroups } = toCaptionConfigOptions(videoMetadata); - return ( - <> - - - - - ); -} diff --git a/knexfile.js b/knexfile.js index cfe3e113e5..a499668b95 100644 --- a/knexfile.js +++ b/knexfile.js @@ -1,7 +1,9 @@ const NODE_ENV = process.env.NODE_ENV ?? "development"; function env(key) { - return process.env[`APP_MYSQL_${key.toUpperCase()}`]; + // return process.env[`APP_MYSQL_${key.toUpperCase()}`]; + // @ts-ignore + return Deno.env.get(`APP_MYSQL_${key.toUpperCase()}`); } /** @type {import("knex").Knex.Config>} */ diff --git a/package.json b/package.json index f48eecb27c..1c63739ea7 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,14 @@ "@headlessui/react": "^0.0.0-insiders.ab6310c", "@netlify/functions": "^0.10.0", "@remix-run/netlify": "^1.4.0", + "@remix-run/netlify-edge": "0.0.0-experimental-c6bf743d", "@remix-run/node": "^1.4.0", "@remix-run/react": "^1.4.0", "@remix-run/server-runtime": "^1.4.0", "@types/bcryptjs": "^2.4.2", "bcryptjs": "^2.4.3", "fast-xml-parser": "^4.0.7", - "knex": "^1.0.4", + "knex": "file:./.patch/node_modules/knex", "lodash": "^4.17.21", "mysql2": "^2.3.3", "qs": "^6.10.3", @@ -66,7 +67,6 @@ "devDependencies": { "@playwright/test": "^1.19.2", "@remix-run/dev": "file:./.patch/node_modules/@remix-run/dev", - "@remix-run/netlify-edge": "0.0.0-experimental-c6bf743d", "@remix-run/serve": "file:./.patch/node_modules/@remix-run/serve", "@tailwindcss/line-clamp": "^0.3.1", "@types/fs-extra": "^9.0.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bab440638..61dc113f71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ specifiers: fs-extra: ^10.1.0 gh-pages: ^3.2.3 happy-dom: ^2.50.0 - knex: ^1.0.4 + knex: file:./.patch/node_modules/knex lodash: ^4.17.21 mysql2: ^2.3.3 postcss: ^8.4.7 @@ -66,13 +66,14 @@ dependencies: '@headlessui/react': 0.0.0-insiders.ab6310c_react-dom@17.0.2+react@17.0.2 '@netlify/functions': 0.10.0 '@remix-run/netlify': 1.4.1_1d1ce03cfafdba27bba741ad8440c333 + '@remix-run/netlify-edge': 0.0.0-experimental-c6bf743d_react-dom@17.0.2+react@17.0.2 '@remix-run/node': 1.4.1_react-dom@17.0.2+react@17.0.2 '@remix-run/react': 1.4.1_react-dom@17.0.2+react@17.0.2 '@remix-run/server-runtime': 1.4.1_react-dom@17.0.2+react@17.0.2 '@types/bcryptjs': 2.4.2 bcryptjs: 2.4.3 fast-xml-parser: 4.0.7 - knex: 1.0.4_mysql2@2.3.3 + knex: link:.patch/node_modules/knex lodash: 4.17.21 mysql2: 2.3.3 qs: 6.10.3 @@ -88,7 +89,6 @@ dependencies: devDependencies: '@playwright/test': 1.19.2 '@remix-run/dev': link:.patch/node_modules/@remix-run/dev - '@remix-run/netlify-edge': 0.0.0-experimental-c6bf743d_react-dom@17.0.2+react@17.0.2 '@remix-run/serve': link:.patch/node_modules/@remix-run/serve '@tailwindcss/line-clamp': 0.3.1_tailwindcss@3.0.23 '@types/fs-extra': 9.0.13 @@ -1013,7 +1013,7 @@ packages: transitivePeerDependencies: - react - react-dom - dev: true + dev: false /@remix-run/netlify/1.4.1_1d1ce03cfafdba27bba741ad8440c333: resolution: {integrity: sha512-KW3fCYHwMEtjCIsC6CjOGFnezuGvqTjHUEHe8BtOYKz/y1Nzf8h6owweoVGiJImGtZ2eyKxLoDEZIpSjwuEUlQ==} @@ -1074,7 +1074,7 @@ packages: react-router-dom: 6.2.2_react-dom@17.0.2+react@17.0.2 set-cookie-parser: 2.4.8 source-map: 0.7.3 - dev: true + dev: false /@remix-run/server-runtime/1.4.1_react-dom@17.0.2+react@17.0.2: resolution: {integrity: sha512-vC5+7IZSNbAQEHC0436O3bYl6DpPAguJT0sSoemHE1jwJ1x+hUej3/k1QjIy1T/bioa6/7eHPggnm0hUlIFX1g==} @@ -1136,6 +1136,7 @@ packages: /@types/cookie/0.4.1: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + dev: false /@types/form-data/0.0.33: resolution: {integrity: sha1-yayFsqX9GENbjIXZ7LUObWyJP/g=} @@ -1875,10 +1876,6 @@ packages: color-string: 1.9.0 dev: true - /colorette/2.0.16: - resolution: {integrity: sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==} - dev: false - /colors/1.4.0: resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} engines: {node: '>=0.1.90'} @@ -1897,6 +1894,7 @@ packages: /commander/8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + dev: true /commondir/1.0.1: resolution: {integrity: sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=} @@ -2467,6 +2465,7 @@ packages: /escalade/3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} + dev: true /escape-html/1.0.3: resolution: {integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=} @@ -2613,11 +2612,6 @@ packages: - supports-color dev: true - /esm/3.2.25: - resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} - engines: {node: '>=6'} - dev: false - /espree/9.3.1: resolution: {integrity: sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3020,10 +3014,6 @@ packages: call-bind: 1.0.2 get-intrinsic: 1.1.1 - /getopts/2.3.0: - resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} - dev: false - /gh-pages/3.2.3: resolution: {integrity: sha512-jA1PbapQ1jqzacECfjUaO9gV8uBgU6XNMV0oXLtfCX3haGLe5Atq8BxlrADhbD6/UdG9j6tZLWAkAybndOXTJg==} engines: {node: '>=10'} @@ -3258,11 +3248,6 @@ packages: has: 1.0.3 side-channel: 1.0.4 - /interpret/2.2.0: - resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} - engines: {node: '>= 0.10'} - dev: false - /ip/1.1.5: resolution: {integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=} dev: true @@ -3315,6 +3300,7 @@ packages: resolution: {integrity: sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==} dependencies: has: 1.0.3 + dev: true /is-date-object/1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} @@ -3532,6 +3518,7 @@ packages: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} hasBin: true + dev: false /json-parse-even-better-errors/2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -3579,52 +3566,6 @@ packages: graceful-fs: 4.2.9 dev: true - /knex/1.0.4_mysql2@2.3.3: - resolution: {integrity: sha512-cMQ81fpkVmr4ia20BtyrD3oPere/ir/Q6IGLAgcREKOzRVhMsasQ4nx1VQuDRJjqq6oK5kfcxmvWoYkHKrnuMA==} - engines: {node: '>=12'} - hasBin: true - peerDependencies: - '@vscode/sqlite3': '*' - better-sqlite3: '*' - mysql: '*' - mysql2: '*' - pg: '*' - pg-native: '*' - tedious: '*' - peerDependenciesMeta: - '@vscode/sqlite3': - optional: true - better-sqlite3: - optional: true - mysql: - optional: true - mysql2: - optional: true - pg: - optional: true - pg-native: - optional: true - tedious: - optional: true - dependencies: - colorette: 2.0.16 - commander: 8.3.0 - debug: 4.3.3 - escalade: 3.1.1 - esm: 3.2.25 - getopts: 2.3.0 - interpret: 2.2.0 - lodash: 4.17.21 - mysql2: 2.3.3 - pg-connection-string: 2.5.0 - rechoir: 0.8.0 - resolve-from: 5.0.0 - tarn: 3.0.2 - tildify: 2.0.0 - transitivePeerDependencies: - - supports-color - dev: false - /levn/0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4081,6 +4022,7 @@ packages: /path-parse/1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true /path-to-regexp/0.1.7: resolution: {integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=} @@ -4099,10 +4041,6 @@ packages: resolution: {integrity: sha1-elfrVQpng/kRUzH89GY9XI4AelA=} dev: true - /pg-connection-string/2.5.0: - resolution: {integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==} - dev: false - /picocolors/1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true @@ -4515,13 +4453,6 @@ packages: picomatch: 2.3.1 dev: true - /rechoir/0.8.0: - resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} - engines: {node: '>= 10.13.0'} - dependencies: - resolve: 1.22.0 - dev: false - /regenerator-runtime/0.13.9: resolution: {integrity: sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==} @@ -4552,11 +4483,6 @@ packages: engines: {node: '>=4'} dev: true - /resolve-from/5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - dev: false - /resolve/1.22.0: resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} hasBin: true @@ -4564,6 +4490,7 @@ packages: is-core-module: 2.8.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + dev: true /retry/0.12.0: resolution: {integrity: sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=} @@ -4678,6 +4605,7 @@ packages: /set-cookie-parser/2.4.8: resolution: {integrity: sha512-edRH8mBKEWNVIVMKejNnuJxleqYE/ZSdcT8/Nem9/mmosx12pctd80s2Oy00KNZzrogMZS5mauK2/ymL1bvlvg==} + dev: false /set-harmonic-interval/1.0.1: resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} @@ -4782,6 +4710,7 @@ packages: /source-map/0.7.3: resolution: {integrity: sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==} engines: {node: '>= 8'} + dev: false /sourcemap-codec/1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} @@ -4931,6 +4860,7 @@ packages: /supports-preserve-symlinks-flag/1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + dev: true /sync-request/6.1.0: resolution: {integrity: sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw==} @@ -4980,11 +4910,6 @@ packages: - ts-node dev: true - /tarn/3.0.2: - resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} - engines: {node: '>=8.0.0'} - dev: false - /test-exclude/6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -5020,11 +4945,6 @@ packages: engines: {node: '>=10'} dev: false - /tildify/2.0.0: - resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} - engines: {node: '>=8'} - dev: false - /tinypool/0.1.2: resolution: {integrity: sha512-fvtYGXoui2RpeMILfkvGIgOVkzJEGediv8UJt7TxdAOY8pnvUkFg/fkvqTfXG9Acc9S17Cnn1S4osDc2164guA==} engines: {node: '>=14.0.0'}