├── cypress.json ├── .dockerignore ├── .prettierrc ├── app ├── utils │ ├── client │ │ ├── domain.client.ts │ │ └── pwa-utils.client.ts │ └── server │ │ ├── db-utils.server.ts │ │ ├── db.server.ts │ │ ├── pwa-utils.server.ts │ │ └── session.server.ts ├── routes │ ├── logout.tsx │ ├── resources │ │ ├── subscribe.ts │ │ └── manifest[.]webmanifest.ts │ ├── preview.$preview.tsx │ ├── notes │ │ ├── index.tsx │ │ ├── new.tsx │ │ └── $noteId.tsx │ ├── notes.tsx │ ├── index.tsx │ ├── login.tsx │ └── join.tsx ├── entry.server.tsx ├── models │ ├── user.server.ts │ └── notes.server.ts ├── styles │ ├── notes.css │ └── paper.css ├── entry.client.tsx ├── root.tsx └── entry.worker.ts ├── public ├── favicon.ico ├── icons │ ├── favicon.ico │ ├── apple-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── ms-icon-70x70.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── ms-icon-144x144.png │ ├── ms-icon-150x150.png │ ├── ms-icon-310x310.png │ ├── android-icon-36x36.png │ ├── android-icon-48x48.png │ ├── android-icon-72x72.png │ ├── android-icon-96x96.png │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── android-icon-144x144.png │ ├── android-icon-192x192.png │ └── apple-icon-precomposed.png ├── images │ ├── hero.jpg │ ├── preview-image.jpg │ └── rockspec-image.png ├── fonts │ ├── Anxiety │ │ └── Anxiety.ttf │ ├── Mokoto │ │ ├── Mokoto_Glitch.ttf │ │ ├── Mokoto_Glitch_Mark.ttf │ │ ├── Mokoto_Glitch_Mark_0.ttf │ │ └── Mokoto_Glitch_Mark_2.ttf │ ├── ModerneSans │ │ └── MODERNE_SANS.ttf │ └── ProductSans │ │ └── Product_Sans.ttf └── svgs │ ├── pwa-svgrepo-com.svg │ ├── remix-black.svg │ └── remix-white.svg ├── .eslintrc ├── prisma ├── seed.ts ├── migrations │ ├── migration_lock.toml │ └── 20220430184056_mock │ │ └── migration.sql └── schema.prisma ├── start_with_migrations.sh ├── .prettierignore ├── remix.env.d.ts ├── postcss.config.js ├── cypress ├── .eslintrc.js ├── fixtures │ └── example.json ├── integration │ ├── notes-security.spec.js │ ├── join.spec.js │ └── login.spec.js ├── support │ ├── index.js │ └── commands.js └── plugins │ └── index.js ├── .env.example ├── remix.init ├── package.json ├── gitignore └── index.js ├── remix.config.js ├── docker-compose.yml ├── .gitignore ├── tsconfig.json ├── tailwind.config.js ├── fly.toml ├── Dockerfile ├── styles └── app.css ├── package.json └── README.md /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /app/utils/client/domain.client.ts: -------------------------------------------------------------------------------- 1 | export const domain = "localhost:3000"; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"] 3 | } 4 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | const db = new PrismaClient(); 3 | -------------------------------------------------------------------------------- /start_with_migrations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | npx prisma migrate deploy 5 | npm run start 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /app/styles/tailwind.css -------------------------------------------------------------------------------- /public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/favicon.ico -------------------------------------------------------------------------------- /public/images/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/images/hero.jpg -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/favicon-96x96.png -------------------------------------------------------------------------------- /public/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/images/preview-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/images/preview-image.jpg -------------------------------------------------------------------------------- /public/fonts/Anxiety/Anxiety.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/fonts/Anxiety/Anxiety.ttf -------------------------------------------------------------------------------- /public/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/images/rockspec-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/images/rockspec-image.png -------------------------------------------------------------------------------- /public/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /public/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /public/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /public/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /public/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/fonts/Mokoto/Mokoto_Glitch.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/fonts/Mokoto/Mokoto_Glitch.ttf -------------------------------------------------------------------------------- /public/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /public/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /public/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /public/fonts/ModerneSans/MODERNE_SANS.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/fonts/ModerneSans/MODERNE_SANS.ttf -------------------------------------------------------------------------------- /public/fonts/ProductSans/Product_Sans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/fonts/ProductSans/Product_Sans.ttf -------------------------------------------------------------------------------- /public/fonts/Mokoto/Mokoto_Glitch_Mark.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/fonts/Mokoto/Mokoto_Glitch_Mark.ttf -------------------------------------------------------------------------------- /public/fonts/Mokoto/Mokoto_Glitch_Mark_0.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/fonts/Mokoto/Mokoto_Glitch_Mark_0.ttf -------------------------------------------------------------------------------- /public/fonts/Mokoto/Mokoto_Glitch_Mark_2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-pwa/rockspec-stack/HEAD/public/fonts/Mokoto/Mokoto_Glitch_Mark_2.ttf -------------------------------------------------------------------------------- /cypress/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | project: "./tsconfig.json", 5 | }, 6 | }; -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /app/utils/server/db-utils.server.ts: -------------------------------------------------------------------------------- 1 | export function validateEmail(email: unknown): email is string { 2 | return typeof email === "string" && email.length > 3 && email.includes("@"); 3 | } 4 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" 2 | SESSION_SECRET="XXXX-XXXX-XXXX-XXXX-XXXX" 3 | VAPID_PUBLIC_KEY="XXXXXXXXXX-XXXXX_XXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXX" 4 | VAPID_PRIVATE_KEY="XXXX_XXXXXXXX" -------------------------------------------------------------------------------- /remix.init/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix.init", 3 | "private": true, 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@iarna/toml": "^2.2.5", 8 | "sort-package-json": "1.54.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /remix.init/gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | 6 | .env 7 | 8 | /cypress/screenshots 9 | /cypress/videos 10 | /prisma/data.db 11 | /prisma/data.db-journal 12 | 13 | /app/styles/tailwind.css 14 | /.node-persist 15 | /node-persist 16 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | ignoredRouteFiles: [".*"], 6 | // appDirectory: "app", 7 | // assetsBuildDirectory: "public/build", 8 | // serverBuildPath: "build/index.js", 9 | // publicPath: "/build/", 10 | }; 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | postgres: 4 | image: postgres:latest 5 | restart: always 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | - POSTGRES_DB=postgres 10 | ports: 11 | - '5432:5432' 12 | volumes: 13 | - ./postgres-data:/var/lib/postgresql/data 14 | -------------------------------------------------------------------------------- /cypress/integration/notes-security.spec.js: -------------------------------------------------------------------------------- 1 | // import { mount } from "@cypress/react"; 2 | // import Notes from "../../app/routes/notes"; 3 | 4 | describe("Notes route security", () => { 5 | it("Is a private route, so we should be redirected to login route", () => { 6 | cy.visit("http://localhost:3000/notes"); 7 | 8 | cy.url().should("include", "/login"); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "@remix-run/node"; 2 | import { logout } from "~/utils/server/session.server"; 3 | 4 | import type { ActionFunction, LoaderFunction } from "@remix-run/node"; 5 | 6 | export const action: ActionFunction = async ({ request }) => { 7 | return logout(request); 8 | }; 9 | 10 | export const loader: LoaderFunction = async () => { 11 | return redirect("/"); 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node Modules 2 | node_modules 3 | 4 | # Lockfiles 5 | package-lock.json 6 | yarn.lock 7 | pnpm-lock.yaml 8 | pnpm-lock.yml 9 | 10 | # Cache & Build files 11 | /.cache 12 | /build 13 | /public/build 14 | /public/entry.worker.js 15 | 16 | # Environment Variable files 17 | .env 18 | 19 | # Generated Tailwind stylesheets 20 | app/styles/app.css 21 | 22 | # Server-side LocalStorage file 23 | /.node-persist 24 | /node-persist 25 | 26 | # Database generated data 27 | /postgres-data/ 28 | -------------------------------------------------------------------------------- /cypress/integration/join.spec.js: -------------------------------------------------------------------------------- 1 | describe("Test the Joining function", () => { 2 | it("Should ensure a clean signing up workflow. Create a new account and confirm registration", () => { 3 | cy.visit("http://localhost:3000"); 4 | 5 | cy.contains("Sign up").click(); 6 | 7 | cy.get("#email-address").type("example-join@gmail.com"); 8 | cy.get("#password").type("12345678"); 9 | 10 | cy.contains("Create account").click(); 11 | 12 | cy.url().should("include", "/"); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "~/*": ["./app/*"] 15 | }, 16 | "noEmit": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "allowJs": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/node"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | import "dotenv/config"; 5 | 6 | export default function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | remixContext: EntryContext 11 | ) { 12 | let markup = renderToString( 13 | 14 | ); 15 | 16 | responseHeaders.set("Content-Type", "text/html"); 17 | 18 | return new Response("" + markup, { 19 | status: responseStatusCode, 20 | headers: responseHeaders, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /app/utils/server/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let prisma: PrismaClient; 4 | 5 | declare global { 6 | var __db__: PrismaClient; 7 | } 8 | 9 | // this is needed because in development we don't want to restart 10 | // the server with every change, but we want to make sure we don't 11 | // create a new connection to the DB with every change either. 12 | // in production we'll have a single connection to the DB. 13 | if (process.env.NODE_ENV === "production") { 14 | prisma = new PrismaClient(); 15 | } else { 16 | if (!global.__db__) { 17 | global.__db__ = new PrismaClient(); 18 | } 19 | prisma = global.__db__; 20 | prisma.$connect(); 21 | } 22 | 23 | export { prisma }; 24 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./app/**/*.{js,ts,jsx,tsx}", 4 | ], 5 | theme: { 6 | extend: { 7 | aspectRatio: { 8 | "128/85": "128 / 85", 9 | "128/70": "128 / 70", 10 | "128/55": "128 / 55", 11 | } 12 | }, 13 | fontFamily: { 14 | "mokoto-0": ['mokoto-0', 'sans-serif'], 15 | "mokoto": ['mokoto-1', 'sans-serif'], 16 | "mokoto-2": ['mokoto-2', 'sans-serif'], 17 | "mokoto-3": ['mokoto-3', 'sans-serif'], 18 | "anxiety": ['Anxiety', 'sans-serif'], 19 | "modern-sans": ['Moderne Sans', 'sans-serif'], 20 | } 21 | }, 22 | plugins: [ 23 | require('@tailwindcss/typography'), 24 | require('@tailwindcss/forms'), 25 | require('@tailwindcss/line-clamp'), 26 | require('@tailwindcss/aspect-ratio') 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for rockspec-stack on 2022-04-16T16:38:14+01:00 2 | 3 | app = "rockspec-stack" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [env] 10 | PORT = "8080" 11 | 12 | [experimental] 13 | allowed_public_ports = [] 14 | auto_rollback = true 15 | 16 | [[services]] 17 | http_checks = [] 18 | internal_port = 8080 19 | processes = ["app"] 20 | protocol = "tcp" 21 | script_checks = [] 22 | 23 | [services.concurrency] 24 | hard_limit = 25 25 | soft_limit = 20 26 | type = "connections" 27 | 28 | [[services.ports]] 29 | force_https = true 30 | handlers = ["http"] 31 | port = 80 32 | 33 | [[services.ports]] 34 | handlers = ["tls", "http"] 35 | port = 443 36 | 37 | [[services.tcp_checks]] 38 | grace_period = "1s" 39 | interval = "15s" 40 | restart_limit = 0 41 | timeout = "2s" 42 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id String @id @default(cuid()) 12 | email String @unique 13 | createdAt DateTime @default(now()) 14 | updatedAt DateTime @updatedAt 15 | notes Note[] 16 | password Password? 17 | } 18 | 19 | model Password { 20 | hash String 21 | userId String @unique 22 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 23 | } 24 | 25 | model Note { 26 | id String @id @default(cuid()) 27 | title String 28 | body String 29 | createdAt DateTime @default(now()) 30 | updatedAt DateTime @updatedAt 31 | userId String 32 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 33 | } 34 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/integration/login.spec.js: -------------------------------------------------------------------------------- 1 | describe("Test Login Authentication workflow", () => { 2 | it("Tries to login with dummy details", () => { 3 | cy.visit("http://localhost:3000"); 4 | 5 | cy.contains("Log In").click(); 6 | 7 | cy.get("#email-address").type("example@gmail.com"); 8 | cy.get("#password").type("12345678"); 9 | 10 | cy.contains("Sign In").click(); 11 | 12 | cy.url().should("include", "/notes"); 13 | }); 14 | 15 | it('Should redirect logged in users to "Notes" route.', () => { 16 | cy.visit("http://localhost:3000/login"); 17 | 18 | cy.get("#email-address").type("example@gmail.com"); 19 | cy.get("#password").type("12345678"); 20 | 21 | cy.contains("Sign In").click(); 22 | 23 | cy.visit("http://localhost:3000/login", { 24 | auth: { 25 | username: "example@gmail.com", 26 | password: "12345678", 27 | }, 28 | }); 29 | // cy.url().should("include", "/notes"); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /app/routes/resources/subscribe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PushNotification, 3 | SaveSubscription, 4 | } from "../../utils/server/pwa-utils.server"; 5 | import type { LoaderFunction, ActionFunction } from "@remix-run/node"; 6 | 7 | const webPush = require("web-push"); 8 | 9 | export const action: ActionFunction = async ({ request }) => { 10 | const data = await request.json(); 11 | const subscription = data.subscription; 12 | 13 | SaveSubscription(subscription); 14 | 15 | return { message: "Done" }; 16 | }; 17 | 18 | export const loader: LoaderFunction = async () => { 19 | if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) { 20 | console.log( 21 | "You must set the VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY " + 22 | "environment variables. You can use the following ones:" 23 | ); 24 | console.log(webPush.generateVAPIDKeys()); 25 | return; 26 | } 27 | 28 | const publicKey = process.env.VAPID_PUBLIC_KEY; 29 | 30 | return new Response(publicKey, { 31 | status: 202, 32 | statusText: "Successful Operation", 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /prisma/migrations/20220430184056_mock/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" TIMESTAMP(3) NOT NULL, 7 | 8 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "Password" ( 13 | "hash" TEXT NOT NULL, 14 | "userId" TEXT NOT NULL 15 | ); 16 | 17 | -- CreateTable 18 | CREATE TABLE "Note" ( 19 | "id" TEXT NOT NULL, 20 | "title" TEXT NOT NULL, 21 | "body" TEXT NOT NULL, 22 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 23 | "updatedAt" TIMESTAMP(3) NOT NULL, 24 | "userId" TEXT NOT NULL, 25 | 26 | CONSTRAINT "Note_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateIndex 30 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 31 | 32 | -- CreateIndex 33 | CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId"); 34 | 35 | -- AddForeignKey 36 | ALTER TABLE "Password" ADD CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 37 | 38 | -- AddForeignKey 39 | ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:16-bullseye-slim as base 3 | 4 | # Install openssl for Prisma 5 | RUN apt-get update && apt-get install -y openssl 6 | 7 | # Install all node_modules, including dev dependencies 8 | FROM base as deps 9 | 10 | RUN mkdir /app 11 | WORKDIR /app 12 | 13 | ADD package.json package-lock.json ./ 14 | RUN npm install --production=false 15 | 16 | # Setup production node_modules 17 | FROM base as production-deps 18 | 19 | RUN mkdir /app 20 | WORKDIR /app 21 | 22 | COPY --from=deps /app/node_modules /app/node_modules 23 | ADD package.json package-lock.json ./ 24 | RUN npm prune --production 25 | 26 | # Build the app 27 | FROM base as build 28 | 29 | ENV NODE_ENV=production 30 | 31 | RUN mkdir /app 32 | WORKDIR /app 33 | 34 | COPY --from=deps /app/node_modules /app/node_modules 35 | 36 | # If we're using Prisma, uncomment to cache the prisma schema 37 | ADD prisma . 38 | RUN npx prisma generate 39 | 40 | ADD . . 41 | RUN npm run build 42 | 43 | # Finally, build the production image with minimal footprint 44 | FROM base 45 | 46 | ENV NODE_ENV=production 47 | 48 | RUN mkdir /app 49 | WORKDIR /app 50 | 51 | COPY --from=production-deps /app/node_modules /app/node_modules 52 | 53 | # Uncomment if using Prisma 54 | COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma 55 | 56 | COPY --from=build /app/build /app/build 57 | COPY --from=build /app/public /app/public 58 | ADD . . 59 | 60 | CMD ["npm", "run", "start"] 61 | -------------------------------------------------------------------------------- /public/svgs/pwa-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/utils/server/pwa-utils.server.ts: -------------------------------------------------------------------------------- 1 | const storage = require("node-persist"); 2 | const webPush = require("web-push"); 3 | 4 | interface PushObject { 5 | title: string; 6 | body: string; 7 | icon?: string; 8 | badge?: string; 9 | dir?: string; 10 | image?: string; 11 | silent?: boolean; 12 | } 13 | 14 | export async function SaveSubscription(sub: PushSubscription): Promise { 15 | await storage.init(); 16 | await storage.setItem("subscription", sub); 17 | } 18 | 19 | export async function PushNotification(content: PushObject, delay: number = 0) { 20 | if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) { 21 | console.log( 22 | "You must set the VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY " + 23 | "environment variables. You can use the following ones:" 24 | ); 25 | console.log(webPush.generateVAPIDKeys()); 26 | return; 27 | } 28 | 29 | webPush.setVapidDetails( 30 | "https://serviceworke.rs/", 31 | process.env.VAPID_PUBLIC_KEY, 32 | process.env.VAPID_PRIVATE_KEY 33 | ); 34 | 35 | await storage.init(); 36 | const subscription = await storage.getItem("subscription"); 37 | 38 | setTimeout(() => { 39 | webPush 40 | .sendNotification(subscription, JSON.stringify(content)) 41 | .then(() => { 42 | return new Response("success", { 43 | status: 200, 44 | }); 45 | }) 46 | .catch((e: Error) => { 47 | console.log(e); 48 | return new Response("Failed!", { 49 | status: 500, 50 | }); 51 | }); 52 | }, delay * 1000); 53 | } 54 | -------------------------------------------------------------------------------- /app/models/user.server.ts: -------------------------------------------------------------------------------- 1 | import type { Password, User } from "@prisma/client"; 2 | import bcrypt from "bcryptjs"; 3 | 4 | import { prisma } from "~/utils/server/db.server"; 5 | 6 | export type { User } from "@prisma/client"; 7 | 8 | export async function getUserById(id: User["id"]) { 9 | return prisma.user.findUnique({ where: { id } }); 10 | } 11 | 12 | export async function getUserByEmail(email: User["email"]) { 13 | return prisma.user.findUnique({ where: { email } }); 14 | } 15 | 16 | export async function createUser(email: User["email"], password: string) { 17 | const hashedPassword = await bcrypt.hash(password, 10); 18 | 19 | return prisma.user.create({ 20 | data: { 21 | email, 22 | password: { 23 | create: { 24 | hash: hashedPassword, 25 | }, 26 | }, 27 | }, 28 | }); 29 | } 30 | 31 | export async function deleteUserByEmail(email: User["email"]) { 32 | return prisma.user.delete({ where: { email } }); 33 | } 34 | 35 | export async function verifyLogin( 36 | email: User["email"], 37 | password: Password["hash"] 38 | ) { 39 | const userWithPassword = await prisma.user.findUnique({ 40 | where: { email }, 41 | include: { 42 | password: true, 43 | }, 44 | }); 45 | 46 | if (!userWithPassword || !userWithPassword.password) { 47 | return null; 48 | } 49 | 50 | const isValid = await bcrypt.compare( 51 | password, 52 | userWithPassword.password.hash 53 | ); 54 | 55 | if (!isValid) { 56 | return null; 57 | } 58 | 59 | const { password: _password, ...userWithoutPassword } = userWithPassword; 60 | 61 | return userWithoutPassword; 62 | } 63 | -------------------------------------------------------------------------------- /app/models/notes.server.ts: -------------------------------------------------------------------------------- 1 | import type { User, Note } from "@prisma/client"; 2 | 3 | import { prisma } from "~/utils/server/db.server"; 4 | 5 | export type { Note } from "@prisma/client"; 6 | 7 | export function getNote({ 8 | id, 9 | userId, 10 | }: Pick & { 11 | userId: User["id"]; 12 | }) { 13 | return prisma.note.findFirst({ 14 | where: { id, userId }, 15 | }); 16 | } 17 | 18 | export function getNoteNonUser({ id }: any) { 19 | return prisma.note.findFirst({ 20 | where: { id }, 21 | }); 22 | } 23 | 24 | export function getNoteListItems({ userId }: { userId: User["id"] }) { 25 | return prisma.note.findMany({ 26 | where: { userId }, 27 | select: { id: true, title: true, body: true }, 28 | orderBy: { updatedAt: "desc" }, 29 | }); 30 | } 31 | 32 | export function createNote({ 33 | body, 34 | title, 35 | userId, 36 | }: Pick & { 37 | userId: User["id"]; 38 | }) { 39 | return prisma.note.create({ 40 | data: { 41 | title, 42 | body, 43 | user: { 44 | connect: { 45 | id: userId, 46 | }, 47 | }, 48 | }, 49 | }); 50 | } 51 | 52 | export function updateNote({ 53 | id, 54 | body, 55 | title, 56 | }: Pick & { 57 | id: Note["id"]; 58 | }) { 59 | return prisma.note.update({ 60 | where: { 61 | id, 62 | }, 63 | data: { 64 | title, 65 | body, 66 | }, 67 | }); 68 | } 69 | 70 | export function deleteNote({ 71 | id, 72 | userId, 73 | }: Pick & { userId: User["id"] }) { 74 | return prisma.note.deleteMany({ 75 | where: { id, userId }, 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /remix.init/index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs/promises"); 2 | const path = require("path"); 3 | const crypto = require("crypto"); 4 | const toml = require("@iarna/toml"); 5 | const sort = require("sort-package-json"); 6 | 7 | function getRandomString(length) { 8 | return crypto.randomBytes(length).toString("hex"); 9 | } 10 | 11 | async function main({ rootDirectory }) { 12 | const FLY_TOML_PATH = path.join(rootDirectory, "fly.toml"); 13 | const EXAMPLE_ENV_PATH = path.join(rootDirectory, ".env.example"); 14 | const ENV_PATH = path.join(rootDirectory, ".env"); 15 | const PACKAGE_JSON_PATH = path.join(rootDirectory, "package.json"); 16 | 17 | const REPLACER = "rockspec-stack"; 18 | 19 | const DIR_NAME = path.basename(rootDirectory); 20 | const SUFFIX = getRandomString(2); 21 | 22 | const APP_NAME = (DIR_NAME + "-" + SUFFIX) 23 | // get rid of anything that's not allowed in an app name 24 | .replace(/[^a-zA-Z0-9-_]/g, "-"); 25 | 26 | const [prodContent, env, packageJson] = await Promise.all([ 27 | fs.readFile(FLY_TOML_PATH, "utf-8"), 28 | fs.readFile(EXAMPLE_ENV_PATH, "utf-8"), 29 | fs.readFile(PACKAGE_JSON_PATH, "utf-8"), 30 | ]); 31 | 32 | const prodToml = toml.parse(prodContent); 33 | prodToml.app = prodToml.app.replace(REPLACER, APP_NAME); 34 | 35 | const newPackageJson = 36 | JSON.stringify( 37 | sort({ ...JSON.parse(packageJson), name: APP_NAME }), 38 | null, 39 | 2 40 | ) + "\n"; 41 | 42 | await Promise.all([ 43 | fs.writeFile(FLY_TOML_PATH, toml.stringify(prodToml)), 44 | fs.writeFile(ENV_PATH, env), 45 | fs.writeFile(PACKAGE_JSON_PATH, newPackageJson), 46 | ]); 47 | } 48 | 49 | module.exports = main; 50 | -------------------------------------------------------------------------------- /styles/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | @font-face { 7 | font-family: "Moderne Sans"; 8 | src: url("/fonts/ModerneSans/MODERNE_SANS.ttf") format("truetype"); 9 | font-display: swap; 10 | font-weight: 400; 11 | } 12 | 13 | @font-face { 14 | font-family: "Product Sans"; 15 | src: url("/fonts/ProductSans/Product_Sans.ttf") format("truetype"); 16 | font-display: swap; 17 | font-weight: 400; 18 | } 19 | 20 | @font-face { 21 | font-family: "Anxiety"; 22 | src: url("/fonts/Anxiety/Anxiety.ttf") format("truetype"); 23 | font-display: swap; 24 | font-weight: 400; 25 | } 26 | 27 | @font-face { 28 | font-family: "mokoto-1"; 29 | src: url("/fonts/Mokoto/Mokoto_Glitch.ttf") format("truetype"); 30 | font-display: swap; 31 | font-weight: 400; 32 | } 33 | 34 | @font-face { 35 | font-family: "mokoto-2"; 36 | src: url("/fonts/Mokoto/Mokoto_Glitch_Mark.ttf") format("truetype"); 37 | font-display: swap; 38 | font-weight: 500; 39 | } 40 | 41 | @font-face { 42 | font-family: "mokoto-3"; 43 | src: url("/fonts/Mokoto/Mokoto_Glitch_Mark_2.ttf") format("truetype"); 44 | font-display: swap; 45 | font-weight: 600; 46 | } 47 | 48 | @font-face { 49 | font-family: "mokoto-0"; 50 | src: url("/fonts/Mokoto/Mokoto_Glitch_Mark_0.ttf") format("truetype"); 51 | font-display: swap; 52 | font-weight: 400; 53 | } 54 | } 55 | 56 | #glass-body::-webkit-scrollbar { 57 | width: 8px; 58 | } 59 | 60 | #glass-body::-webkit-scrollbar-track { 61 | background: #acacac; 62 | } 63 | 64 | #glass-body::-webkit-scrollbar-thumb { 65 | background: #757575; 66 | } 67 | 68 | #glass-body::-webkit-scrollbar-thumb:hover { 69 | background: #444; 70 | } 71 | -------------------------------------------------------------------------------- /app/styles/notes.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .note-list, 7 | li { 8 | list-style: none; 9 | } 10 | 11 | .note-list { 12 | display: flex; 13 | flex-wrap: wrap; 14 | justify-content: center; 15 | } 16 | 17 | .note-list li a { 18 | text-decoration: none; 19 | color: #000; 20 | background: #ffc; 21 | display: block; 22 | padding: 1em; 23 | box-shadow: 5px 5px 7px rgba(33, 33, 33, 0.7); 24 | transform: rotate(-6deg); 25 | transition: transform 0.15s linear; 26 | position: relative; 27 | width: 18rem; 28 | } 29 | 30 | .note-list p { 31 | overflow: hidden; 32 | text-overflow: ellipsis; 33 | max-height: calc(line-height * 6); 34 | -webkit-box-orient: vertical; 35 | display: block; 36 | display: -webkit-box; 37 | overflow: hidden !important; 38 | -webkit-line-clamp: 6; 39 | } 40 | 41 | /* .note-list li a::before { 42 | content: ""; 43 | position: absolute; 44 | top: 0; 45 | right: 0; 46 | border-style: solid; 47 | border-width: 0 4rem 4rem 0; 48 | border-color: #ddd #ffffff00; 49 | } */ 50 | 51 | .note-list li:nth-child(even) a { 52 | transform: rotate(4deg); 53 | position: relative; 54 | top: 5px; 55 | background: #cfc; 56 | } 57 | 58 | .note-list li:nth-child(3n) a { 59 | transform: rotate(-3deg); 60 | position: relative; 61 | top: -5px; 62 | background: #ccf; 63 | } 64 | 65 | .note-list li:nth-child(5n) a { 66 | transform: rotate(5deg); 67 | position: relative; 68 | top: -10px; 69 | } 70 | 71 | .note-list li a:hover, 72 | .note-list li a:focus { 73 | box-shadow: 10px 10px 7px rgba(0, 0, 0, 0.7); 74 | transform: scale(1.25); 75 | position: relative; 76 | z-index: 5; 77 | } 78 | 79 | #menu { 80 | background-image: linear-gradient(to top, #fff 35%, #ffffff00 100%); 81 | } 82 | 83 | .note-list li a:hover #menu { 84 | display: flex; 85 | align-items: center; 86 | justify-content: center; 87 | } 88 | -------------------------------------------------------------------------------- /app/routes/resources/manifest[.]webmanifest.ts: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/node"; 2 | import type { LoaderFunction } from "@remix-run/node"; 3 | 4 | export let loader: LoaderFunction = () => { 5 | return json( 6 | { 7 | short_name: "RockSpec", 8 | name: "RockSpec Stack", 9 | start_url: "/", 10 | display: "standalone", 11 | background_color: "#d3d7dd", 12 | theme_color: "#3B82F6", 13 | shortcuts: [ 14 | { 15 | name: "Homepage", 16 | url: "/", 17 | icons: [ 18 | { 19 | src: "/icons/android-icon-96x96.png", 20 | sizes: "96x96", 21 | type: "image/png", 22 | purpose: "any monochrome", 23 | }, 24 | ], 25 | }, 26 | { 27 | name: "View Notes", 28 | url: "/notes", 29 | icons: [ 30 | { 31 | src: "/icons/android-icon-96x96.png", 32 | sizes: "96x96", 33 | type: "image/png", 34 | purpose: "any monochrome", 35 | }, 36 | ], 37 | }, 38 | ], 39 | icons: [ 40 | { 41 | src: "/icons/android-icon-36x36.png", 42 | sizes: "36x36", 43 | type: "image/png", 44 | density: "0.75", 45 | }, 46 | { 47 | src: "/icons/android-icon-48x48.png", 48 | sizes: "48x48", 49 | type: "image/png", 50 | density: "1.0", 51 | }, 52 | { 53 | src: "/icons/android-icon-72x72.png", 54 | sizes: "72x72", 55 | type: "image/png", 56 | density: "1.5", 57 | }, 58 | { 59 | src: "/icons/android-icon-96x96.png", 60 | sizes: "96x96", 61 | type: "image/png", 62 | density: "2.0", 63 | }, 64 | { 65 | src: "/icons/android-icon-144x144.png", 66 | sizes: "144x144", 67 | type: "image/png", 68 | density: "3.0", 69 | }, 70 | { 71 | src: "/icons/android-icon-192x192.png", 72 | sizes: "192x192", 73 | type: "image/png", 74 | density: "4.0", 75 | }, 76 | ], 77 | }, 78 | { 79 | headers: { 80 | "Cache-Control": "public, max-age=600", 81 | }, 82 | } 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrate } from "react-dom"; 3 | 4 | hydrate(, document); 5 | 6 | function urlBase64ToUint8Array(base64String: string): Uint8Array { 7 | const padding = "=".repeat((4 - (base64String.length % 4)) % 4); 8 | const base64 = (base64String + padding) 9 | .replace(/\-/g, "+") 10 | .replace(/_/g, "/"); 11 | 12 | const rawData = window.atob(base64); 13 | const outputArray = new Uint8Array(rawData.length); 14 | 15 | for (var i = 0; i < rawData.length; ++i) { 16 | outputArray[i] = rawData.charCodeAt(i); 17 | } 18 | return outputArray; 19 | } 20 | 21 | if ("serviceWorker" in navigator) { 22 | // Use the window load event to keep the page load performant 23 | window.addEventListener("load", () => { 24 | navigator.serviceWorker 25 | .register("/entry.worker.js") 26 | .then(() => navigator.serviceWorker.ready) 27 | .then(() => { 28 | if (navigator.serviceWorker.controller) { 29 | navigator.serviceWorker.controller.postMessage({ 30 | type: "SYNC_REMIX_MANIFEST", 31 | manifest: window.__remixManifest, 32 | }); 33 | } else { 34 | navigator.serviceWorker.addEventListener("controllerchange", () => { 35 | navigator.serviceWorker.controller?.postMessage({ 36 | type: "SYNC_REMIX_MANIFEST", 37 | manifest: window.__remixManifest, 38 | }); 39 | }); 40 | } 41 | }) 42 | .catch((error) => { 43 | console.error("Service worker registration failed", error); 44 | }); 45 | }); 46 | } 47 | 48 | navigator.serviceWorker.ready 49 | .then((registration) => { 50 | const subscription = registration.pushManager.getSubscription(); 51 | return { subscription, registration }; 52 | }) 53 | .then(async (sub) => { 54 | if (await sub.subscription) { 55 | return sub.subscription; 56 | } 57 | 58 | const subInfo = await fetch("/resources/subscribe"); 59 | const returnedSubscription = await subInfo.text(); 60 | 61 | const convertedVapidKey = urlBase64ToUint8Array(returnedSubscription); 62 | return sub.registration.pushManager.subscribe({ 63 | userVisibleOnly: true, 64 | applicationServerKey: convertedVapidKey, 65 | }); 66 | }) 67 | .then(async (subscription) => { 68 | await fetch("./resources/subscribe", { 69 | method: "POST", 70 | body: JSON.stringify({ 71 | subscription: subscription, 72 | type: "POST_SUBSCRIPTION", 73 | }), 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /app/styles/paper.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Handlee); 2 | 3 | .paper { 4 | position: relative; 5 | width: 90%; 6 | max-width: 1024px; 7 | min-width: 300px; 8 | margin: 0 auto; 9 | background: #fafafa; 10 | border-radius: 10px; 11 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 12 | overflow: hidden; 13 | z-index: 10; 14 | } 15 | 16 | .paper:before { 17 | content: ""; 18 | position: absolute; 19 | top: 0; 20 | bottom: 0; 21 | left: 0; 22 | width: 60px; 23 | background: radial-gradient(#575450 6px, transparent 7px) repeat-y; 24 | background-size: 30px 30px; 25 | border-right: 3px solid #d44147; 26 | box-sizing: border-box; 27 | } 28 | 29 | .paper-content { 30 | position: absolute; 31 | top: 30px; 32 | right: 0; 33 | bottom: 30px; 34 | left: 60px; 35 | background: linear-gradient(transparent, transparent 28px, #91d1d3 28px); 36 | background-size: 30px 30px; 37 | scroll-snap-points-y: repeat(30px); 38 | scroll-snap-type: y mandatory; 39 | scroll-snap-align: start; 40 | } 41 | 42 | .paper-content textarea { 43 | width: 100%; 44 | max-width: 100%; 45 | height: 100%; 46 | max-height: 100%; 47 | line-height: 30px; 48 | padding: 0 10px; 49 | border: 0; 50 | outline: 0; 51 | background: transparent; 52 | color: rgb(37, 37, 37); 53 | font-family: "Handlee", cursive, sans-serif; 54 | font-weight: bold; 55 | font-size: 18px; 56 | box-sizing: border-box; 57 | z-index: 1; 58 | resize: none; 59 | -webkit-box-shadow: none; 60 | -moz-box-shadow: none; 61 | box-shadow: none; 62 | scroll-snap-align: start; 63 | } 64 | 65 | textarea::-webkit-scrollbar { 66 | width: 10px; 67 | cursor: pointer; 68 | } 69 | 70 | textarea::-webkit-scrollbar-track { 71 | background: #f1f1f1; 72 | } 73 | 74 | textarea::-webkit-scrollbar-thumb { 75 | background: #888; 76 | border-radius: 8px; 77 | } 78 | 79 | textarea::-webkit-scrollbar-thumb:hover { 80 | background: #555; 81 | } 82 | 83 | @media screen and (max-width: 540px) { 84 | .paper:before { 85 | content: ""; 86 | position: absolute; 87 | top: 0; 88 | bottom: 0; 89 | left: 0; 90 | width: 0; 91 | background: radial-gradient(#575450 6px, transparent 7px) repeat-y; 92 | background-size: 30px 30px; 93 | border-right: 3px solid #d44147; 94 | box-sizing: border-box; 95 | } 96 | 97 | .paper-content { 98 | position: absolute; 99 | top: 30px; 100 | right: 0; 101 | bottom: 30px; 102 | left: 3px; 103 | background: linear-gradient(transparent, transparent 28px, #91d1d3 28px); 104 | background-size: 30px 30px; 105 | } 106 | 107 | textarea::-webkit-scrollbar { 108 | width: 8px; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /app/utils/server/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, redirect } from "@remix-run/node"; 2 | import invariant from "tiny-invariant"; 3 | 4 | import type { User } from "~/models/user.server"; 5 | import { getUserById } from "~/models/user.server"; 6 | 7 | invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); 8 | 9 | export const sessionStorage = createCookieSessionStorage({ 10 | cookie: { 11 | name: "__session", 12 | httpOnly: true, 13 | maxAge: 0, 14 | path: "/", 15 | sameSite: "lax", 16 | secrets: [process.env.SESSION_SECRET], 17 | secure: process.env.NODE_ENV === "production", 18 | }, 19 | }); 20 | 21 | const USER_SESSION_KEY = "userId"; 22 | 23 | export async function getSession(request: Request) { 24 | const cookie = request.headers.get("Cookie"); 25 | return sessionStorage.getSession(cookie); 26 | } 27 | 28 | export async function getUserId(request: Request): Promise { 29 | const session = await getSession(request); 30 | const userId = session.get(USER_SESSION_KEY); 31 | return userId; 32 | } 33 | 34 | export async function getUser(request: Request): Promise { 35 | const userId = await getUserId(request); 36 | if (userId === undefined) return null; 37 | 38 | const user = await getUserById(userId); 39 | if (user) return user; 40 | 41 | throw await logout(request); 42 | } 43 | 44 | export async function requireUserId( 45 | request: Request, 46 | redirectTo: string = new URL(request.url).pathname 47 | ): Promise { 48 | const userId = await getUserId(request); 49 | if (!userId) { 50 | const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); 51 | throw redirect(`/login?${searchParams}`); 52 | } 53 | return userId; 54 | } 55 | 56 | export async function requireUser(request: Request) { 57 | const userId = await requireUserId(request); 58 | 59 | const user = await getUserById(userId); 60 | if (user) return user; 61 | 62 | throw await logout(request); 63 | } 64 | 65 | export async function createUserSession({ 66 | request, 67 | userId, 68 | remember, 69 | redirectTo, 70 | }: { 71 | request: Request; 72 | userId: string; 73 | remember: boolean; 74 | redirectTo: string; 75 | }) { 76 | const session = await getSession(request); 77 | session.set(USER_SESSION_KEY, userId); 78 | return redirect(redirectTo, { 79 | headers: { 80 | "Set-Cookie": await sessionStorage.commitSession(session, { 81 | maxAge: remember 82 | ? 60 * 60 * 24 * 7 // 7 days 83 | : undefined, 84 | }), 85 | }, 86 | }); 87 | } 88 | 89 | export async function logout(request: Request) { 90 | const session = await getSession(request); 91 | return redirect("/", { 92 | headers: { 93 | "Set-Cookie": await sessionStorage.destroySession(session), 94 | }, 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rockspec-stack", 3 | "private": true, 4 | "description": "", 5 | "license": "", 6 | "sideEffects": false, 7 | "scripts": { 8 | "build:css": "tailwindcss -m -i ./styles/app.css -o app/styles/app.css", 9 | "build:remix": "cross-env NODE_ENV=production remix build", 10 | "build:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'", 11 | "build": "npm-run-all -s build:*", 12 | "deploy": "fly deploy --remote-only", 13 | "dev:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css", 14 | "dev:remix": "cross-env NODE_ENV=development remix dev", 15 | "dev:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch", 16 | "dev": "npm run docker && npm-run-all -p dev:*", 17 | "docker": "docker-compose up -d", 18 | "format": "prettier --write .", 19 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", 20 | "prisma": "prisma migrate dev && prisma generate", 21 | "push-keys": "web-push generate-vapid-keys [--json]", 22 | "seed": "npx prisma db seed", 23 | "start": "remix-serve build" 24 | }, 25 | "prettier": {}, 26 | "eslintIgnore": [ 27 | "/node_modules", 28 | "/build", 29 | "/public/build" 30 | ], 31 | "dependencies": { 32 | "@headlessui/react": "^1.5.0", 33 | "@heroicons/react": "^1.0.6", 34 | "@prisma/client": "^3.13.0", 35 | "@remix-run/node": "*", 36 | "@remix-run/react": "*", 37 | "@remix-run/serve": "*", 38 | "@tailwindcss/aspect-ratio": "^0.4.0", 39 | "@tailwindcss/forms": "^0.5.0", 40 | "@tailwindcss/line-clamp": "^0.3.1", 41 | "@tailwindcss/typography": "^0.5.2", 42 | "bcryptjs": "^2.4.3", 43 | "cross-env": "^7.0.3", 44 | "dotenv": "^16.0.0", 45 | "node-persist": "^3.1.0", 46 | "npm-run-all": "^4.1.5", 47 | "prettier": "^2.6.2", 48 | "react": "^17.0.2", 49 | "react-dom": "^17.0.2", 50 | "tiny-invariant": "^1.2.0", 51 | "web-push": "^3.4.5" 52 | }, 53 | "devDependencies": { 54 | "@remix-run/dev": "*", 55 | "@remix-run/eslint-config": "*", 56 | "@remix-run/server-runtime": "*", 57 | "@testing-library/cypress": "^8.0.2", 58 | "@types/bcryptjs": "^2.4.2", 59 | "@types/eslint": "^8.4.1", 60 | "@types/node-persist": "^3.1.2", 61 | "@types/prettier": "^2.6.0", 62 | "@types/react": "^17.0.24", 63 | "@types/react-dom": "^17.0.9", 64 | "autoprefixer": "^10.4.4", 65 | "cypress": "^9.6.0", 66 | "esbuild-register": "^3.3.2", 67 | "eslint": "^8.11.0", 68 | "postcss": "^8.4.12", 69 | "prisma": "^3.13.0", 70 | "tailwindcss": "^3.0.24", 71 | "typescript": "^4.5.5" 72 | }, 73 | "engines": { 74 | "node": ">=14" 75 | }, 76 | "prisma": { 77 | "seed": "node -r esbuild-register prisma/seed.ts" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/routes/preview.$preview.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowSmLeftIcon } from "@heroicons/react/outline"; 2 | import { Link, useLoaderData } from "@remix-run/react"; 3 | import { json } from "@remix-run/node"; 4 | import { getUserId } from "~/utils/server/session.server"; 5 | import { getNote, getNoteNonUser } from "~/models/notes.server"; 6 | 7 | import type { LoaderFunction } from "@remix-run/node"; 8 | 9 | type LoaderData = { 10 | user?: string | null; 11 | note: { 12 | id: string; 13 | title: string; 14 | body: string; 15 | userId: string; 16 | createdAt: Date; 17 | updatedAt: Date; 18 | }; 19 | }; 20 | 21 | export const loader: LoaderFunction = async ({ request, params }) => { 22 | const userId = (await getUserId(request)) as string; 23 | const { preview } = params; 24 | //@ts-ignore 25 | const note = await getNoteNonUser({ id: params.preview }); 26 | console.log(note); 27 | 28 | if (!note) { 29 | throw new Response("Not Found", { status: 404 }); 30 | } 31 | 32 | if (!userId) { 33 | return json( 34 | { 35 | user: null, 36 | note, 37 | }, 38 | { 39 | status: 200, 40 | } 41 | ); 42 | } else { 43 | return json( 44 | { 45 | user: userId, 46 | note, 47 | }, 48 | { 49 | status: 200, 50 | } 51 | ); 52 | } 53 | }; 54 | 55 | export default function Preview() { 56 | const data = useLoaderData(); 57 | console.log(data) 58 | 59 | return ( 60 |
61 | preview-background 66 |
67 | 68 | 69 | 70 | Go Back 71 | 72 | 73 | {data.user && ( 74 |
75 | | 76 | 77 | Edit Note 78 | 79 |
80 | )} 81 |
82 |
86 |
87 | {data.note.body} 88 |
89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /public/svgs/remix-black.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/svgs/remix-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | useLocation, 10 | useMatches, 11 | } from "@remix-run/react"; 12 | 13 | import type { LinksFunction, MetaFunction } from "@remix-run/node"; 14 | 15 | import tailwind from "./styles/app.css"; 16 | 17 | export const meta: MetaFunction = () => ({ 18 | charset: "utf-8", 19 | title: "RockSpec Stack", 20 | viewport: "width=device-width,initial-scale=1", 21 | }); 22 | 23 | export const links: LinksFunction = () => { 24 | return [{ rel: "stylesheet", href: tailwind }]; 25 | }; 26 | 27 | let isMount = true; 28 | 29 | export default function App() { 30 | let location = useLocation(); 31 | let matches = useMatches(); 32 | 33 | React.useEffect(() => { 34 | let mounted = isMount; 35 | isMount = false; 36 | if ("serviceWorker" in navigator) { 37 | if (navigator.serviceWorker.controller) { 38 | navigator.serviceWorker.controller?.postMessage({ 39 | type: "REMIX_NAVIGATION", 40 | isMount: mounted, 41 | location, 42 | matches, 43 | manifest: window.__remixManifest, 44 | }); 45 | } else { 46 | let listener = async () => { 47 | await navigator.serviceWorker.ready; 48 | navigator.serviceWorker.controller?.postMessage({ 49 | type: "REMIX_NAVIGATION", 50 | isMount: mounted, 51 | location, 52 | matches, 53 | manifest: window.__remixManifest, 54 | }); 55 | }; 56 | navigator.serviceWorker.addEventListener("controllerchange", listener); 57 | return () => { 58 | navigator.serviceWorker.removeEventListener( 59 | "controllerchange", 60 | listener 61 | ); 62 | }; 63 | } 64 | } 65 | }, [location]); 66 | 67 | return ( 68 | 69 | 70 | 71 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 111 | 116 | 122 | 128 | 134 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /app/routes/notes/index.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "@remix-run/node"; 2 | import { Link, useFetcher, useLoaderData } from "@remix-run/react"; 3 | import invariant from "tiny-invariant"; 4 | import { PencilIcon, XCircleIcon, ShareIcon } from "@heroicons/react/solid"; 5 | import { BookOpenIcon } from "@heroicons/react/outline"; 6 | import { requireUserId } from "~/utils/server/session.server"; 7 | import { deleteNote, getNoteListItems } from "~/models/notes.server"; 8 | 9 | import type { 10 | LinksFunction, 11 | LoaderFunction, 12 | ActionFunction, 13 | } from "@remix-run/node"; 14 | 15 | import notes from "../../styles/notes.css"; 16 | 17 | type LoaderData = { 18 | noteListItems: Awaited>; 19 | }; 20 | 21 | export const links: LinksFunction = () => { 22 | return [{ rel: "stylesheet", href: notes }]; 23 | }; 24 | 25 | export const action: ActionFunction = async ({ request, params }) => { 26 | const userId = await requireUserId(request); 27 | const formData = await request.formData(); 28 | const id = formData.get("id") as string; 29 | invariant(id, "noteId not found"); 30 | 31 | await deleteNote({ userId, id }); 32 | return redirect("/notes"); 33 | }; 34 | 35 | export const loader: LoaderFunction = async ({ request }) => { 36 | const userId = await requireUserId(request); 37 | const noteListItems = await getNoteListItems({ userId }); 38 | return json({ noteListItems }); 39 | }; 40 | 41 | function NoNote() { 42 | return ( 43 |
44 | 45 |

46 | No notes yet 47 |

48 | 49 | 52 | 53 |
54 | ); 55 | } 56 | 57 | function Note({ title, content, link }: any) { 58 | const fetcher = useFetcher(); 59 | 60 | const deleteNote = (id: string): boolean => { 61 | fetcher.submit({ mode: "DELETE_NOTE", id: link }, { method: "post" }); 62 | return false; 63 | }; 64 | 65 | return ( 66 |
  • 67 | 71 |

    72 | {title} 73 |

    74 |

    {content}

    75 | 87 | 88 |
  • 89 | ); 90 | } 91 | 92 | export default function IndexNotes() { 93 | const data = useLoaderData() as LoaderData; 94 | 95 | return ( 96 |
    97 | {data.noteListItems.length > 0 && ( 98 |
    99 | 100 | 103 | 104 |
    105 | )} 106 |
    107 | {data.noteListItems.length == 0 || !data ? ( 108 | 109 | ) : ( 110 |
      111 | {data.noteListItems.map((note) => ( 112 | 118 | ))} 119 |
    120 | )} 121 |
    122 |
    123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /app/routes/notes/new.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Form, 4 | useActionData, 5 | useFetcher, 6 | useTransition, 7 | } from "@remix-run/react"; 8 | import { json, redirect } from "@remix-run/node"; 9 | import { createNote } from "~/models/notes.server"; 10 | import { requireUserId } from "~/utils/server/session.server"; 11 | 12 | import type { LinksFunction, ActionFunction } from "@remix-run/node"; 13 | 14 | import paper from "../../styles/paper.css"; 15 | 16 | type ActionData = { 17 | errors?: { 18 | title?: string; 19 | body?: string; 20 | }; 21 | }; 22 | 23 | export const links: LinksFunction = () => { 24 | return [{ rel: "stylesheet", href: paper }]; 25 | }; 26 | 27 | export const action: ActionFunction = async ({ request }) => { 28 | const userId = await requireUserId(request); 29 | 30 | const formData = await request.formData(); 31 | const title = formData.get("title"); 32 | const body = formData.get("body"); 33 | 34 | if (typeof title !== "string" || title.length === 0) { 35 | return json( 36 | { errors: { title: "Title is required" } }, 37 | { status: 400 } 38 | ); 39 | } 40 | 41 | if (typeof body !== "string" || body.length === 0) { 42 | return json( 43 | { errors: { body: "Body is required" } }, 44 | { status: 400 } 45 | ); 46 | } 47 | 48 | const note = await createNote({ title, body, userId }); 49 | 50 | return redirect(`/notes/${note.id}`); 51 | }; 52 | 53 | export default function New() { 54 | const actionData = useActionData() as ActionData; 55 | const fetcher = useFetcher(); 56 | const transition = useTransition(); 57 | 58 | const titleRef = React.useRef(null); 59 | const bodyRef = React.useRef(null); 60 | 61 | React.useEffect(() => { 62 | if (actionData?.errors?.title) { 63 | titleRef.current?.focus(); 64 | } else if (actionData?.errors?.body) { 65 | bodyRef.current?.focus(); 66 | } 67 | }, [actionData]); 68 | 69 | return ( 70 |
    71 | {actionData?.errors?.body && ( 72 |
    73 |
    74 | {actionData.errors.body} 75 |
    76 |
    77 | )} 78 |
    79 |
    80 |