diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0fe3f53..1919e69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,23 +1,45 @@ -name: Build and Generate Types +name: Build & Generate Types on: [push, pull_request] # Run on Push and Pull Requests jobs: build: + name: Build + timeout-minutes: 15 runs-on: ubuntu-latest - strategy: - matrix: - node-version: [16.x, 18.x] - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + - name: Check out code + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: actions/cache@v3 + with: + # See here for caching with `yarn` https://github.com/actions/cache/blob/main/examples.md#node---yarn or you can leverage caching with actions/setup-node https://github.com/actions/setup-node + path: | + ~/.npm + ${{ github.workspace }}/.next/cache + # Generate a new cache whenever packages or source files change. + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} + # If source files changed but packages didn't, rebuild from a prior cache. + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- + + - name: Setup Node.js environment + uses: actions/setup-node@v3 with: - node-version: ${{ matrix.node-version }} - - name: npm Install - run: npm ci --force + node-version: 16 + cache: "npm" + + - name: Install dependencies + run: npm install + - name: Generate Types - run: npm run generate + run: npm run generate && npx prisma generate --schema apps/graphql/prisma/schema.prisma + + - name: Types + run: npm run types --workspaces + - name: Build run: npm run build + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5c13b0..2cfaa44 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,15 +21,15 @@ jobs: node-version: [16.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - name: npm Install - run: npm ci --force + run: npm install - name: Setup DB - run: npm run db:push + run: npx prisma db push --schema apps/graphql/prisma/schema.prisma env: # The hostname used to communicate with the PostgreSQL service container POSTGRES_HOST: localhost @@ -39,7 +39,7 @@ jobs: POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ci_db_test - name: Test - run: npm run test:ci + run: npm run test env: # The hostname used to communicate with the PostgreSQL service container POSTGRES_HOST: localhost diff --git a/.gitignore b/.gitignore index 5419e89..fcff58d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ node_modules # misc .DS_Store *.pem +tsconfig.tsbuildinfo # debug npm-debug.log* @@ -34,3 +35,7 @@ yarn-error.log* # vercel .vercel .turbo + +dist +.parcel-cache +.husky diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 8ab6149..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npm run format diff --git a/.watchmanconfig b/.watchmanconfig deleted file mode 100644 index 0967ef4..0000000 --- a/.watchmanconfig +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7777bad --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that: + +(1) source code distributions retain the above copyright notice and this +paragraph in its entirety, + +(2) distributions including binary code include the above copyright notice and +this paragraph in its entirety in the documentation or other materials provided +with the distribution, and + +(3) all advertising materials mentioning features or use of this software +display the following acknowledgement: + +"This product includes software developed by the University of California, +Lawrence Berkeley Laboratory and its contributors." + +Neither the name of the University nor the names of its contributors may be used +to endorse or promote products derived from this software without specific prior +written permission. + +THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b1ee92 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +![Reubin Header](.github/reubin-og.png) + +# Reubin + +This is a really simple project that shows the usage of Next.js with TypeScript. + +## Setup + +### Prerequists + +``` +brew install postgres@14 node +``` + +### Node + +Make sure you're using `16.x` because [Vercel](https://vercel.com/docs/runtimes#official-runtimes/node-js/node-js-version) currently lists their default runtime as that version. This project uses [npm workspaces](https://docs.npmjs.com/cli/v8/using-npm/workspaces) & [Turborepo](https://turborepo.org/) and setup is simple: + +``` +npm install +``` + +### Database + +Setup the database by running the following: + +``` +psql postgres +``` + +This should open a session, copy and paste the following. + +``` +CREATE DATABASE reubindb; +CREATE ROLE reubinadmin WITH LOGIN PASSWORD 'password'; +ALTER ROLE reubinadmin WITH SUPERUSER; +ALTER DATABASE reubindb OWNER TO reubinadmin; +\q +``` + +Create an `.env` with the following: + +``` +DATABASE_URL="postgresql://reubinadmin:password@localhost:5432/reubindb?schema=public" +``` + +First push the db to setup the tables: + +``` +npm run db +``` + +Then run the seed comment to populate the db: + +``` +npm run seed +``` + +## Project Structure + +### Application + +- `/apps/browser-extension`: Chrome browser extension, uses Preact and Parcel recipes +- `/apps/graphql`: GraphQL server uses Fastify and Mercurius +- `/apps/ui`: Web application uses Next.js and TailwindCSS + +Each project contains + +- `/apps//src/*`: source code +- `/apps//test/*`: all unit tests + +### Repository + +- `/docs`: Documentation in markdown +- `/scripts`: Project specific scripts diff --git a/Readme.md b/Readme.md deleted file mode 100644 index 049790b..0000000 --- a/Readme.md +++ /dev/null @@ -1,5 +0,0 @@ -![Reubin Header](.github/reubin-og.png) - -# Reubin - -This is a really simple project that shows the usage of Next.js with TypeScript. diff --git a/apps/browser-extension/.parcelrc b/apps/browser-extension/.parcelrc new file mode 100644 index 0000000..65a11f3 --- /dev/null +++ b/apps/browser-extension/.parcelrc @@ -0,0 +1,3 @@ +{ + "extends": "@parcel/config-webextension" +} diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json new file mode 100644 index 0000000..865a620 --- /dev/null +++ b/apps/browser-extension/package.json @@ -0,0 +1,33 @@ +{ + "name": "@reubin/extension", + "version": "1.1.0", + "private": true, + "scripts": { + "dev": "parcel watch src/manifest.json --host localhost", + "build": "parcel build src/manifest.json --no-source-maps", + "test": "echo \"Error: no test specified\" && exit 0", + "types": "tsc --noEmit --pretty" + }, + "dependencies": { + "preact": "^10.11.0" + }, + "devDependencies": { + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/typography": "^0.5.7", + "@parcel/config-webextension": "^2.6.2", + "@types/chrome": "^0.0.197", + "parcel": "^2.6.2", + "tailwindcss": "^3.1.8", + "typescript": "^4.8.4" + }, + "alias": { + "preact/jsx-dev-runtime": "preact/jsx-runtime", + "react/jsx-runtime": "preact/jsx-runtime" + }, + "postcss": { + "plugins": { + "tailwindcss": {} + } + } +} diff --git a/apps/browser-extension/src/content.ts b/apps/browser-extension/src/content.ts new file mode 100644 index 0000000..ce25027 --- /dev/null +++ b/apps/browser-extension/src/content.ts @@ -0,0 +1,46 @@ +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.text === "searchRSS") { + let types = [ + "application/rss+xml", + "application/atom+xml", + "application/rdf+xml", + "application/rss", + "application/atom", + "application/rdf", + "text/rss+xml", + "text/atom+xml", + "text/rdf+xml", + "text/rss", + "text/atom", + "text/rdf", + ]; + let links: NodeListOf = document.querySelectorAll("link[type]"); + let feeds: RSSLink[] = []; + for (let i = 0; i < links.length; i++) { + const link = links[i]; + if (link.hasAttribute("type") && types.indexOf(link.getAttribute("type")!) !== -1) { + let feed_url = link.getAttribute("href"); + + if (feed_url) { + // If feed's url starts with "//" + if (feed_url.indexOf("//") === 0) feed_url = "http:" + feed_url; + // If feed's url starts with "/" + else if (feed_url.startsWith("/")) + feed_url = message.url.split("/")[0] + "//" + message.url.split("/")[2] + feed_url; + else if (!/^(http|https):\/\//i.test(feed_url)) + feed_url = message.url + "/" + feed_url.replace(/^\//g, ""); + + let feed = { + type: link.getAttribute("type") ?? "application/rss+xml", + href: feed_url, + title: link.getAttribute("title") || feed_url, + }; + feeds.push(feed); + } + } + } + sendResponse(feeds); + } +}); + +export {}; diff --git a/apps/browser-extension/src/env.d.ts b/apps/browser-extension/src/env.d.ts new file mode 100644 index 0000000..ff6a4f4 --- /dev/null +++ b/apps/browser-extension/src/env.d.ts @@ -0,0 +1,4 @@ +declare module "*.css" { + const mapping: Record; + export default mapping; +} diff --git a/apps/browser-extension/src/index.css b/apps/browser-extension/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/apps/browser-extension/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/browser-extension/src/index.html b/apps/browser-extension/src/index.html new file mode 100644 index 0000000..bfdd021 --- /dev/null +++ b/apps/browser-extension/src/index.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + +
+
+
+ + + + + +
+
+ +
+
+
+
+ + + diff --git a/apps/browser-extension/src/manifest.json b/apps/browser-extension/src/manifest.json new file mode 100644 index 0000000..f0be2f8 --- /dev/null +++ b/apps/browser-extension/src/manifest.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/chrome-manifest", + "manifest_version": 3, + "name": "Reubin", + "action": { + "default_popup": "index.html", + "default_title": "Open the popup" + }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["content.ts"] + } + ], + "description": "Add feeds to Reubin", + "permissions": ["clipboardWrite", "activeTab", "storage", "tabs"], + "host_permissions": ["http://*/*", "https://*/*"], + "version": "1.0.0" +} diff --git a/apps/browser-extension/src/parse-document.ts b/apps/browser-extension/src/parse-document.ts new file mode 100644 index 0000000..e86e76f --- /dev/null +++ b/apps/browser-extension/src/parse-document.ts @@ -0,0 +1,13 @@ +export function parseDocumentLinks() { + return new Promise((resolve, reject) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0] && tabs[0].id) { + chrome.tabs.sendMessage( + tabs[0].id, + { text: "searchRSS", url: tabs[0].url }, + (feeds: RSSLink[]) => resolve(feeds) + ); + } + }); + }); +} diff --git a/apps/browser-extension/src/popup.ts b/apps/browser-extension/src/popup.ts new file mode 100644 index 0000000..e8465cc --- /dev/null +++ b/apps/browser-extension/src/popup.ts @@ -0,0 +1,172 @@ +import { h, render } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { parseDocumentLinks } from "./parse-document"; + +function IconFeed(props: any) { + return h( + "svg", + Object.assign( + { + stroke: "currentColor", + fill: "currentColor", + strokeWidth: 0, + viewBox: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + }, + props + ), + h("use", { href: "#icon-feed" }) + ); +} + +function IconChevronRight(props: any) { + return h( + "svg", + Object.assign( + { + width: 24, + height: 24, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round", + strokeLinejoin: "round", + }, + props + ), + h("use", { href: "#icon-chevron-right" }) + ); +} + +interface AppState { + hasChecked: boolean; + availableFeeds: RSSLink[]; +} + +function AvailableFeedList() { + let content = null; + const [state, setState] = useState({ hasChecked: false, availableFeeds: [] }); + + useEffect(() => { + if (!state.hasChecked) { + parseDocumentLinks().then((links) => { + setState({ + hasChecked: true, + availableFeeds: links ?? [], + }); + }); + } + }, []); + + if (!state.hasChecked) { + content = h( + "div", + { className: "h-8 w-8 text-sky-500", role: "alert", "aria-busy": "true" }, + h( + "svg", + { height: "100%", viewBox: "0 0 32 32", width: "100%", className: "animate-spin" }, + h("circle", { + cx: "16", + cy: "16", + fill: "none", + r: "14", + "stroke-width": "4", + stroke: "currentColor", + opacity: 0.2, + }), + h("circle", { + cx: "16", + cy: "16", + fill: "none", + r: "14", + "stroke-width": "4", + stroke: "currentColor", + "stroke-dashoffset": 60, + "stroke-dasharray": 80, + }) + ) + ); + } else if (state.availableFeeds.length === 0) { + content = h("p", {}, [ + h("span", { className: "opacity-50" }, "No feeds found. "), + h( + "a", + { className: "text-sky-500 dark:text-sky-600", href: "https://reubin.app" }, + "Learn more here." + ), + ]); + } else { + content = h( + "div", + { className: "overflow-hidden rounded-md bg-zinc-100 shadow dark:bg-zinc-800" }, + h( + "ul", + { role: "list", className: "divide-y dark:divide-zinc-600" }, + state.availableFeeds.map((feed) => + h( + "li", + { key: feed.href, className: "cursor-pointer" }, + h( + "div", + { className: "block px-2 py-4 hover:bg-zinc-200 dark:hover:bg-zinc-500" }, + h( + "div", + { className: "flex items-center gap-2" }, + h( + "div", + { className: "flex flex-shrink-0 items-center" }, + h(IconFeed, { + className: "h-6 w-6 text-sky-500 dark:text-sky-600", + "aria-hidden": "true", + }) + ), + h( + "div", + { className: "flex-1" }, + h( + "div", + { className: "truncate" }, + h( + "div", + null, + h( + "p", + { + className: + "truncate text-base font-bold text-sky-500 dark:text-sky-600", + }, + feed.title + ), + h("p", { className: "font-mono text-xs opacity-50" }, feed.href) + ), + h("div", { className: "mt-2 flex" }) + ) + ), + h( + "div", + { className: "flex-shrink-0" }, + h(IconChevronRight, { + className: "h-5 w-5 text-zinc-400", + "aria-hidden": "true", + }) + ) + ) + ) + ) + ) + ) + ); + } + + return h("div", {}, [ + h("h2", { className: "mb-4 flex-1 text-xl font-semibold" }, "Available Feeds"), + content, + ]); +} + +const root = document.getElementById("app"); + +if (root !== null) { + render(h(AvailableFeedList, {}), root); +} diff --git a/apps/browser-extension/src/rss.d.ts b/apps/browser-extension/src/rss.d.ts new file mode 100644 index 0000000..63b85f5 --- /dev/null +++ b/apps/browser-extension/src/rss.d.ts @@ -0,0 +1,5 @@ +interface RSSLink { + href: string; + type: string; + title: string; +} diff --git a/apps/browser-extension/tailwind.config.cjs b/apps/browser-extension/tailwind.config.cjs new file mode 100644 index 0000000..6464f84 --- /dev/null +++ b/apps/browser-extension/tailwind.config.cjs @@ -0,0 +1,19 @@ +const defaultTheme = require("tailwindcss/defaultTheme"); + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{js,ts,jsx,tsx}", "./src/*.html"], + theme: { + extend: { + fontFamily: { + sans: ["Inter var", ...defaultTheme.fontFamily.sans], + display: ["DM Serif Display", ...defaultTheme.fontFamily.serif], + }, + }, + }, + plugins: [ + require("@tailwindcss/forms"), + require("@tailwindcss/typography"), + require("@tailwindcss/aspect-ratio"), + ], +}; diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json new file mode 100644 index 0000000..75c9bf5 --- /dev/null +++ b/apps/browser-extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "esModuleInterop": true, + "jsx": "react", + "jsxFactory": "h", + "lib": ["dom", "esnext"], + "module": "esnext", + "moduleResolution": "node", + "strict": true, + "target": "es5" + }, + "include": ["src/**/*", "parse-document.ts"] +} diff --git a/apps/graphql/graphql-transformer.js b/apps/graphql/graphql-transformer.js new file mode 100644 index 0000000..30c13d5 --- /dev/null +++ b/apps/graphql/graphql-transformer.js @@ -0,0 +1,16 @@ +const fileTransfomer = { + process(sourceText, sourcePath, options) { + return { + code: ` + const { gql } = require("graphql-tag"); + + module.exports = gql\` + ${sourceText} + \`; + + `, + }; + }, +}; + +module.exports = fileTransfomer; diff --git a/apps/graphql/jest.config.js b/apps/graphql/jest.config.js new file mode 100644 index 0000000..014c0cd --- /dev/null +++ b/apps/graphql/jest.config.js @@ -0,0 +1,13 @@ +// @ts-check + +/** @type {import('jest').Config} */ +const config = { + preset: "ts-jest", + testEnvironment: "node", + verbose: true, + transform: { + "\\.(gql|graphql)$": "/graphql-transformer.js", + }, +}; + +module.exports = config; diff --git a/apps/graphql/package.json b/apps/graphql/package.json new file mode 100644 index 0000000..4c4cc4c --- /dev/null +++ b/apps/graphql/package.json @@ -0,0 +1,49 @@ +{ + "name": "@reubin/graphql", + "version": "1.1.0", + "private": true, + "scripts": { + "start": "node dist/server", + "build": "npx prisma generate && rollup -c", + "dev": "npx prisma generate && rollup -c -w", + "generate": "npx prisma generate", + "types": "tsc --noEmit --pretty", + "test": "jest" + }, + "devDependencies": { + "@rollup/plugin-graphql": "^1.1.0", + "@rollup/plugin-run": "^2.1.0", + "@rollup/plugin-typescript": "^8.5.0", + "@types/jest": "^29.1.1", + "@types/node": "^18.0.0", + "babel-jest": "^29.1.2", + "jest": "^29.1.2", + "mercurius-integration-testing": "^6.0.0", + "prisma": "^4.4.0", + "rollup": "^2.79.1", + "rollup-plugin-esbuild": "^4.10.1", + "ts-jest": "^29.0.2", + "typescript": "^4.8.4" + }, + "dependencies": { + "@graphql-tools/load": "^7.7.7", + "@graphql-tools/schema": "^9.0.4", + "@prisma/client": "^4.4.0", + "@types/bcryptjs": "^2.4.2", + "@types/sanitize-html": "^2.3.1", + "@types/xml2js": "^0.4.11", + "axios": "^0.27.2", + "bcryptjs": "^2.4.3", + "cheerio": "^1.0.0-rc.11", + "entities": "^4.4.0", + "fastify": "^4.7.0", + "graphql": "^16.6.0", + "graphql-scalars": "^1.18.0", + "graphql-tag": "^2.12.6", + "html-entities": "^2.3.2", + "iconv-lite": "^0.6.3", + "mercurius": "^11.0.0", + "sanitize-html": "^2.7.0", + "xml2js": "^0.4.23" + } +} diff --git a/prisma/schema.prisma b/apps/graphql/prisma/schema.prisma similarity index 58% rename from prisma/schema.prisma rename to apps/graphql/prisma/schema.prisma index f616d17..29a7410 100644 --- a/prisma/schema.prisma +++ b/apps/graphql/prisma/schema.prisma @@ -15,30 +15,16 @@ model Entry { favorite Boolean @default(false) unread Boolean @default(true) feedId String? - feed Feed? @relation(fields: [feedId], references: [id]) readAt DateTime? @map("read_at") + feed Feed? @relation(fields: [feedId], references: [id]) } model Tag { - id String @id @default(cuid()) + id String @id @default(cuid()) title String userId String? - // Reference: https://www.prisma.io/docs/concepts/components/prisma-schema/relations/many-to-many-relations - feeds TagsOnFeeds[] - user User? @relation(fields: [userId], references: [id]) - - Feed Feed[] -} - -model TagsOnFeeds { - post Feed @relation(fields: [feedId], references: [id]) - feedId String // relation scalar field (used in the `@relation` attribute above) - tag Tag @relation(fields: [tagId], references: [id]) - tagId String // relation scalar field (used in the `@relation` attribute above) - assignedAt DateTime @default(now()) - assignedBy String - - @@id([feedId, tagId]) + user User? @relation(fields: [userId], references: [id]) + feeds Feed[] } model Feed { @@ -48,13 +34,10 @@ model Feed { link String feedURL String lastFetched DateTime @default(now()) + tagId String? + tag Tag? @relation(fields: [tagId], references: [id]) user User? @relation(fields: [userId], references: [id]) entries Entry[] - - - TagsOnFeeds TagsOnFeeds[] - Tag Tag? @relation(fields: [tagId], references: [id]) - tagId String? } model User { @@ -66,7 +49,7 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") feeds Feed[] + tags Tag[] - tags Tag[] @@map("users") } diff --git a/apps/graphql/rollup.config.js b/apps/graphql/rollup.config.js new file mode 100644 index 0000000..8b8774d --- /dev/null +++ b/apps/graphql/rollup.config.js @@ -0,0 +1,22 @@ +// @ts-check +import run from "@rollup/plugin-run"; +import ts from "@rollup/plugin-typescript"; +import graphql from "@rollup/plugin-graphql"; +import { defineConfig } from "rollup"; + +const isDev = process.env.ROLLUP_WATCH === "true"; + +export default defineConfig([ + { + input: "./src/server.ts", + external: (id) => !/^[./]/.test(id), + output: [ + { + file: "./dist/server.js", + format: "cjs", + sourcemap: true, + }, + ], + plugins: [graphql(), ts(), isDev && run()], + }, +]); diff --git a/apps/graphql/src/__generated__.ts b/apps/graphql/src/__generated__.ts new file mode 100644 index 0000000..8a7354e --- /dev/null +++ b/apps/graphql/src/__generated__.ts @@ -0,0 +1,383 @@ +import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from "graphql"; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { + [SubKey in K]?: Maybe; +}; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type RequireFields = Omit & { + [P in K]-?: NonNullable; +}; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + Date: any; +}; + +export type Activity = { + __typename?: "Activity"; + starred: Array>; + unread: Array>; +}; + +export type Entry = { + __typename?: "Entry"; + author?: Maybe; + /** HTML String */ + content?: Maybe; + created_at?: Maybe; + favorite: Scalars["Boolean"]; + feed_id: Scalars["ID"]; + id: Scalars["ID"]; + published?: Maybe; + title: Scalars["String"]; + unread: Scalars["Boolean"]; + url?: Maybe; +}; + +export enum EntryFilter { + All = "ALL", + Favorited = "FAVORITED", + Unread = "UNREAD", +} + +export type Feed = { + __typename?: "Feed"; + feedURL: Scalars["String"]; + id: Scalars["ID"]; + lastFetched: Scalars["Date"]; + link: Scalars["String"]; + tag?: Maybe; + title: Scalars["String"]; +}; + +export type Mutation = { + __typename?: "Mutation"; + addFeed: Feed; + addTag: Tag; + markAsFavorite: Entry; + markAsRead: Entry; + refreshFeed: Array; + removeFeed: Feed; + updateFeed: Feed; +}; + +export type MutationAddFeedArgs = { + url: Scalars["String"]; +}; + +export type MutationAddTagArgs = { + name: Scalars["String"]; +}; + +export type MutationMarkAsFavoriteArgs = { + favorite: Scalars["Boolean"]; + id: Scalars["ID"]; +}; + +export type MutationMarkAsReadArgs = { + id: Scalars["ID"]; +}; + +export type MutationRefreshFeedArgs = { + id: Scalars["ID"]; +}; + +export type MutationRemoveFeedArgs = { + id: Scalars["ID"]; +}; + +export type MutationUpdateFeedArgs = { + fields?: InputMaybe; + id: Scalars["ID"]; +}; + +export type Query = { + __typename?: "Query"; + entries: Array; + entry: Entry; + feed: Feed; + feeds: Array>; + tags: Array>; +}; + +export type QueryEntriesArgs = { + feed_id: Scalars["ID"]; + filter?: InputMaybe; +}; + +export type QueryEntryArgs = { + id: Scalars["ID"]; +}; + +export type QueryFeedArgs = { + id: Scalars["ID"]; +}; + +export type Tag = { + __typename?: "Tag"; + id: Scalars["ID"]; + title: Scalars["String"]; +}; + +export type UpdateFeedInput = { + tagID?: InputMaybe; + title?: InputMaybe; +}; + +export type ResolverTypeWrapper = Promise | T; + +export type ResolverWithResolve = { + resolve: ResolverFn; +}; +export type Resolver = + | ResolverFn + | ResolverWithResolve; + +export type ResolverFn = ( + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => Promise | TResult; + +export type SubscriptionSubscribeFn = ( + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => AsyncIterable | Promise>; + +export type SubscriptionResolveFn = ( + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => TResult | Promise; + +export interface SubscriptionSubscriberObject< + TResult, + TKey extends string, + TParent, + TContext, + TArgs +> { + subscribe: SubscriptionSubscribeFn<{ [key in TKey]: TResult }, TParent, TContext, TArgs>; + resolve?: SubscriptionResolveFn; +} + +export interface SubscriptionResolverObject { + subscribe: SubscriptionSubscribeFn; + resolve: SubscriptionResolveFn; +} + +export type SubscriptionObject = + | SubscriptionSubscriberObject + | SubscriptionResolverObject; + +export type SubscriptionResolver< + TResult, + TKey extends string, + TParent = {}, + TContext = {}, + TArgs = {} +> = + | ((...args: any[]) => SubscriptionObject) + | SubscriptionObject; + +export type TypeResolveFn = ( + parent: TParent, + context: TContext, + info: GraphQLResolveInfo +) => Maybe | Promise>; + +export type IsTypeOfResolverFn = ( + obj: T, + context: TContext, + info: GraphQLResolveInfo +) => boolean | Promise; + +export type NextResolverFn = () => Promise; + +export type DirectiveResolverFn = ( + next: NextResolverFn, + parent: TParent, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo +) => TResult | Promise; + +/** Mapping between all available schema types and the resolvers types */ +export type ResolversTypes = { + Activity: ResolverTypeWrapper; + Boolean: ResolverTypeWrapper; + Date: ResolverTypeWrapper; + Entry: ResolverTypeWrapper; + EntryFilter: EntryFilter; + Feed: ResolverTypeWrapper; + ID: ResolverTypeWrapper; + Int: ResolverTypeWrapper; + Mutation: ResolverTypeWrapper<{}>; + Query: ResolverTypeWrapper<{}>; + String: ResolverTypeWrapper; + Tag: ResolverTypeWrapper; + UpdateFeedInput: UpdateFeedInput; +}; + +/** Mapping between all available schema types and the resolvers parents */ +export type ResolversParentTypes = { + Activity: Activity; + Boolean: Scalars["Boolean"]; + Date: Scalars["Date"]; + Entry: Entry; + Feed: Feed; + ID: Scalars["ID"]; + Int: Scalars["Int"]; + Mutation: {}; + Query: {}; + String: Scalars["String"]; + Tag: Tag; + UpdateFeedInput: UpdateFeedInput; +}; + +export type ActivityResolvers< + ContextType = any, + ParentType extends ResolversParentTypes["Activity"] = ResolversParentTypes["Activity"] +> = { + starred?: Resolver>, ParentType, ContextType>; + unread?: Resolver>, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export interface DateScalarConfig + extends GraphQLScalarTypeConfig { + name: "Date"; +} + +export type EntryResolvers< + ContextType = any, + ParentType extends ResolversParentTypes["Entry"] = ResolversParentTypes["Entry"] +> = { + author?: Resolver, ParentType, ContextType>; + content?: Resolver, ParentType, ContextType>; + created_at?: Resolver, ParentType, ContextType>; + favorite?: Resolver; + feed_id?: Resolver; + id?: Resolver; + published?: Resolver, ParentType, ContextType>; + title?: Resolver; + unread?: Resolver; + url?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type FeedResolvers< + ContextType = any, + ParentType extends ResolversParentTypes["Feed"] = ResolversParentTypes["Feed"] +> = { + feedURL?: Resolver; + id?: Resolver; + lastFetched?: Resolver; + link?: Resolver; + tag?: Resolver, ParentType, ContextType>; + title?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type MutationResolvers< + ContextType = any, + ParentType extends ResolversParentTypes["Mutation"] = ResolversParentTypes["Mutation"] +> = { + addFeed?: Resolver< + ResolversTypes["Feed"], + ParentType, + ContextType, + RequireFields + >; + addTag?: Resolver< + ResolversTypes["Tag"], + ParentType, + ContextType, + RequireFields + >; + markAsFavorite?: Resolver< + ResolversTypes["Entry"], + ParentType, + ContextType, + RequireFields + >; + markAsRead?: Resolver< + ResolversTypes["Entry"], + ParentType, + ContextType, + RequireFields + >; + refreshFeed?: Resolver< + Array, + ParentType, + ContextType, + RequireFields + >; + removeFeed?: Resolver< + ResolversTypes["Feed"], + ParentType, + ContextType, + RequireFields + >; + updateFeed?: Resolver< + ResolversTypes["Feed"], + ParentType, + ContextType, + RequireFields + >; +}; + +export type QueryResolvers< + ContextType = any, + ParentType extends ResolversParentTypes["Query"] = ResolversParentTypes["Query"] +> = { + entries?: Resolver< + Array, + ParentType, + ContextType, + RequireFields + >; + entry?: Resolver< + ResolversTypes["Entry"], + ParentType, + ContextType, + RequireFields + >; + feed?: Resolver< + ResolversTypes["Feed"], + ParentType, + ContextType, + RequireFields + >; + feeds?: Resolver>, ParentType, ContextType>; + tags?: Resolver>, ParentType, ContextType>; +}; + +export type TagResolvers< + ContextType = any, + ParentType extends ResolversParentTypes["Tag"] = ResolversParentTypes["Tag"] +> = { + id?: Resolver; + title?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type Resolvers = { + Activity?: ActivityResolvers; + Date?: GraphQLScalarType; + Entry?: EntryResolvers; + Feed?: FeedResolvers; + Mutation?: MutationResolvers; + Query?: QueryResolvers; + Tag?: TagResolvers; +}; diff --git a/apps/graphql/src/app.ts b/apps/graphql/src/app.ts new file mode 100644 index 0000000..5fe47c5 --- /dev/null +++ b/apps/graphql/src/app.ts @@ -0,0 +1,25 @@ +import Fastify from "fastify"; +import mercurius, { type MercuriusOptions } from "mercurius"; +import { schema } from "./schema"; +import { context } from "./context"; +import { RecommendedKeyArray } from "./recommendations"; + +export const createApp = () => { + const app = Fastify({ logger: false }); + + app.get("/recommendations", (_req, res) => { + res.send({ + data: RecommendedKeyArray, + }); + }); + + const options: MercuriusOptions = { + schema, + graphiql: true, + context: () => context, + }; + + app.register(mercurius, options); + + return app; +}; diff --git a/server/context.ts b/apps/graphql/src/context.ts similarity index 54% rename from server/context.ts rename to apps/graphql/src/context.ts index 6edb81e..36d4b32 100644 --- a/server/context.ts +++ b/apps/graphql/src/context.ts @@ -4,28 +4,28 @@ import { RSSKit } from "./rss"; let prisma: PrismaClient; declare global { - var prisma: PrismaClient | undefined; + var prisma: PrismaClient | undefined; } if (process.env.NODE_ENV === "production") { - prisma = new PrismaClient(); + prisma = new PrismaClient(); } else { - if (!global.prisma) { - global.prisma = new PrismaClient(); - } - prisma = global.prisma; + if (!global.prisma) { + global.prisma = new PrismaClient(); + } + prisma = global.prisma; } export { prisma }; export interface Context { - prisma: PrismaClient; - rss: RSSKit; + prisma: PrismaClient; + rss: RSSKit; } const rss = new RSSKit(); export const context: Context = { - prisma, - rss, + prisma: prisma, + rss, }; diff --git a/apps/graphql/src/errors.ts b/apps/graphql/src/errors.ts new file mode 100644 index 0000000..c33effa --- /dev/null +++ b/apps/graphql/src/errors.ts @@ -0,0 +1,43 @@ +export interface ErrorJSON { + name: string; + message: string; + stack?: string; + statusCode?: number; +} + +export class SDKError extends Error { + public readonly statusCode?: number; + constructor(message: string, code?: number) { + super(message); + this.name = "SDKError"; + this.statusCode = code; + } + public toJSON(): { error: ErrorJSON } { + const error: ErrorJSON = { + name: this.name, + message: this.message, + stack: this.stack, + }; + + if (this.statusCode) { + error.statusCode = this.statusCode; + } + return { + error, + }; + } +} + +export class AuthenticationError extends SDKError { + constructor(message: string) { + super(message, 401); + this.name = "AuthenticationError"; + } +} + +export class ForbiddenError extends SDKError { + constructor(message: string) { + super(message, 403); + this.name = "ForbiddenError"; + } +} diff --git a/apps/graphql/src/feeds.ts b/apps/graphql/src/feeds.ts new file mode 100644 index 0000000..b1ca58e --- /dev/null +++ b/apps/graphql/src/feeds.ts @@ -0,0 +1,9 @@ +import axios from "axios"; + +export const getFeedFromDirectURL = async (url: string) => { + return axios.get(url, { + headers: { + "Content-Type": "text/plain", + }, + }); +}; diff --git a/apps/graphql/src/graphql.d.ts b/apps/graphql/src/graphql.d.ts new file mode 100644 index 0000000..954a379 --- /dev/null +++ b/apps/graphql/src/graphql.d.ts @@ -0,0 +1,6 @@ +declare module "*.graphql" { + import { DocumentNode } from "graphql"; + const Schema: DocumentNode; + + export = Schema; +} diff --git a/apps/graphql/src/meta/html.ts b/apps/graphql/src/meta/html.ts new file mode 100644 index 0000000..29d2267 --- /dev/null +++ b/apps/graphql/src/meta/html.ts @@ -0,0 +1,29 @@ +import sanitize from "sanitize-html"; +import { encode } from "html-entities"; +import cheerio, { load } from "cheerio"; + +export const getContent = (content: string) => { + const sanitized = sanitize(content, { + allowedTags: sanitize.defaults.allowedTags.concat(["img", "iframe", "pre", "code"]), + allowedAttributes: { + a: ["href", "name", "target"], + img: ["src", "alt"], + iframe: ["src"], + }, + }); + + const $ = load(sanitized); + + $("pre").each((_i, t) => { + const current = $(t); + const text = current.text(); + + const content = cheerio("pre", `
${encode(text)}
`); + + current.replaceWith(content); + }); + + return $.html(); +}; + +export const getTime = (d: string) => new Date(d).getTime(); diff --git a/server/meta/index.ts b/apps/graphql/src/meta/index.ts similarity index 53% rename from server/meta/index.ts rename to apps/graphql/src/meta/index.ts index fd0d6b0..a2ce0ff 100644 --- a/server/meta/index.ts +++ b/apps/graphql/src/meta/index.ts @@ -1,7 +1,7 @@ import { load } from "cheerio"; export class MetaKit { - constructor(html: string) { - load(html); - } + constructor(html: string) { + load(html); + } } diff --git a/apps/graphql/src/recommendations.ts b/apps/graphql/src/recommendations.ts new file mode 100644 index 0000000..3949e98 --- /dev/null +++ b/apps/graphql/src/recommendations.ts @@ -0,0 +1,82 @@ +export type RecommendedField = { + link: string; + displayName: string; +}; + +const NEWS: RecommendedField[] = [ + { + link: "https://www.vox.com/rss/index.xml", + displayName: "Vox", + }, + { + link: "https://www.out.com/rss.xml", + displayName: "Out.com", + }, + { + link: "https://www.buzzfeed.com/world.xml", + displayName: "BuzzFeed World News", + }, + { + link: "https://nautil.us/rss/all", + displayName: "Nautilus", + }, + { + link: "https://thedailywhat.cheezburger.com/rss", + displayName: "Daily What", + }, +]; + +const TECH: RecommendedField[] = [ + { + link: "https://xkcd.com/atom.xml", + displayName: "xkcd", + }, + { + link: "https://www.theverge.com/web/rss/index.xml", + displayName: "The Verge", + }, + { + link: "https://future.a16z.com/feed/", + displayName: "Future", + }, + { + link: "https://cointelegraph.com/rss", + displayName: "Coin Telegraph", + }, +]; + +const LIFESTYLE: RecommendedField[] = [ + { + link: "https://www.apartmenttherapy.com/main.rss", + displayName: "Apartment Therapy", + }, + { + link: "https://feedpress.me/FIJ", + displayName: "Food in Jars", + }, +]; + +const RELEVANT: RecommendedField[] = [ + { + link: "https://charliewil.co/rss", + displayName: "Charlie's Blog", + }, + { + link: "https://typescript.wtf/rss.xml", + displayName: "TypeScript & React for Everyone", + }, +]; + +export const RecommendedKeyArray: [string, RecommendedField[]][] = [ + ["News", NEWS], + ["Tech", TECH], + ["Lifestyle", LIFESTYLE], + ["Relevant", RELEVANT], +]; + +export const RecommendationMap = new Map([ + ["News", NEWS], + ["Tech", TECH], + ["Lifestyle", LIFESTYLE], + ["Relevant", RELEVANT], +]); diff --git a/apps/graphql/src/resolvers.ts b/apps/graphql/src/resolvers.ts new file mode 100644 index 0000000..e361f61 --- /dev/null +++ b/apps/graphql/src/resolvers.ts @@ -0,0 +1,251 @@ +import { + type MutationResolvers, + type QueryResolvers, + type Resolvers, + EntryFilter, +} from "./__generated__"; +import type { Context } from "./context"; +import { mapFeedtoAPIFeed, mapORMEntryToAPIEntry, mapRSStoEntry } from "./structs"; +import { getFeedFromDirectURL } from "./feeds"; +import { Feed } from "@prisma/client"; + +// TODO: Create tag object +// TODO: User sign up, registration +// TODO: Stripe integration + +/** + * TODO: + * - recently read + * - tags + * - me + **/ +const query: QueryResolvers = { + async tags(_, __, { prisma }) { + const tags = await prisma.tag.findMany(); + + return tags.map((tag) => ({ id: tag.id, title: tag.title })); + }, + async feeds(_parent, _args, { prisma }) { + const feeds = await prisma.feed.findMany(); + + return feeds.map((f) => mapFeedtoAPIFeed(f)); + }, + async feed(_parent, { id }, { prisma }) { + const feed = await prisma.feed.findUnique({ + where: { + id, + }, + }); + + if (feed === null) { + throw new Error("Feed not found"); + } + + return feed; + }, + async entry(_parent, { id }, { prisma }) { + const entry = await prisma.entry.findUnique({ + where: { + id, + }, + }); + + if (entry === null) { + throw new Error("Entry not found."); + } + + return mapORMEntryToAPIEntry(entry); + }, + async entries(_parent, { feed_id, filter }, { prisma }) { + let args: any = { feedId: feed_id }; + + if (filter === EntryFilter.Favorited) { + args.favorite = true; + } + + if (filter === EntryFilter.Unread) { + args.unread = true; + } + + const entries = await prisma.entry.findMany({ + where: args, + }); + + return entries.map((value) => mapORMEntryToAPIEntry(value)); + }, +}; + +/** + * TODO: + * - create, read, update, delete tags + * - login + * - register user + **/ +const mutation: MutationResolvers = { + async addFeed(_parent, { url }, { prisma, rss }) { + try { + const { data } = await getFeedFromDirectURL(url); + const parsed = await rss.parse(data); + const feed = await prisma.feed.create({ + data: { + title: parsed.title ?? "Untitled Feed", + link: parsed.link ?? url, + feedURL: parsed.feedUrl ?? url, + lastFetched: new Date(Date.now()), + }, + }); + + await prisma.entry.createMany({ + data: parsed.items.map((value) => mapRSStoEntry(value, feed.id)), + }); + + return feed; + } catch (err: any) { + throw new Error(err); + } + }, + async removeFeed(_parent, { id }, { prisma }) { + await prisma.entry.deleteMany({ + where: { + feedId: id, + }, + }); + const feed = await prisma.feed.delete({ + where: { + id, + }, + }); + return feed; + }, + async refreshFeed(_parent, { id }, { prisma, rss }) { + const feed = await prisma.feed.findUnique({ + where: { + id, + }, + }); + + if (feed === null) { + throw new Error("Couldn't find feed"); + } + + const { data: rssText } = await getFeedFromDirectURL(feed.feedURL); + const { items } = await rss.parse(rssText); + + const lastFetchedISO = feed.lastFetched.toISOString(); + + const entries: ReturnType[] = []; + + for (const item of items) { + if (item) { + if (lastFetchedISO < item.isoDate!) { + entries.push(mapRSStoEntry(item, feed.id)); + } + } + } + + await prisma.entry.createMany({ + data: entries, + }); + + const _ = await prisma.entry.findMany({ + where: { + feedId: id, + pubDate: { + gte: feed.lastFetched, + }, + }, + }); + + await prisma.feed.update({ + where: { + id, + }, + data: { + lastFetched: new Date(Date.now()), + }, + }); + + return _.map((value) => mapORMEntryToAPIEntry(value)); + }, + async markAsRead(_parent, { id }, { prisma }) { + const entry = await prisma.entry.update({ + where: { + id, + }, + data: { + unread: false, + }, + }); + if (entry === null) { + throw new Error("Entry not updated"); + } + + return mapORMEntryToAPIEntry(entry); + }, + + async markAsFavorite(_parent, { id, favorite }, { prisma }) { + const entry = await prisma.entry.update({ + where: { + id, + }, + data: { + favorite, + }, + }); + if (entry === null) { + throw new Error("Entry not updated"); + } + + return mapORMEntryToAPIEntry(entry); + }, + async updateFeed(_parent, { id, fields }, { prisma }) { + try { + const data: Partial = {}; + + if (fields?.title) { + data.title = fields.title; + } + + if (fields?.tagID) { + data.tagId = fields.tagID; + } + + const feed = await prisma.feed.update({ + where: { + id, + }, + data, + }); + + if (feed === null) { + throw new Error("Feed not updated"); + } + + return mapFeedtoAPIFeed(feed); + } catch (error: any) { + throw new Error(error); + } + }, + + async addTag(_parent, { name }, { prisma }) { + try { + const tag = await prisma.tag.create({ + data: { + title: name, + }, + }); + + return { + id: tag.id, + title: tag.title, + }; + } catch (error: any) { + throw new Error(error); + } + }, +}; + +export const resolvers: Resolvers = { + Query: query, + Mutation: mutation, +}; diff --git a/apps/graphql/src/rss/fields.ts b/apps/graphql/src/rss/fields.ts new file mode 100644 index 0000000..12a8db6 --- /dev/null +++ b/apps/graphql/src/rss/fields.ts @@ -0,0 +1,82 @@ +type Field = string[] | string | [string, string, { includeSnippet: true }]; + +export interface IAllFields { + feed: Array; + item: Array; + podcastFeed: Array; + podcastItem: Array; +} + +const fields: IAllFields = { + feed: [], + item: [], + podcastFeed: [], + podcastItem: [], +}; + +fields.feed = [ + ["author", "creator"], + ["dc:publisher", "publisher"], + ["dc:creator", "creator"], + ["dc:source", "source"], + ["dc:title", "title"], + ["dc:type", "type"], + "title", + "description", + "author", + "pubDate", + "webMaster", + "managingEditor", + "generator", + "link", + "language", + "copyright", + "lastBuildDate", + "docs", + "generator", + "ttl", + "rating", + "skipHours", + "skipDays", +]; + +fields.item = [ + ["author", "creator"], + ["dc:creator", "creator"], + ["dc:date", "date"], + ["dc:language", "language"], + ["dc:rights", "rights"], + ["dc:source", "source"], + ["dc:title", "title"], + "title", + "link", + "pubDate", + "author", + "summary", + ["content:encoded", "content:encoded", { includeSnippet: true }], + "enclosure", + "dc:creator", + "dc:date", + "comments", +]; + +var mapItunesField = function (f: string) { + return ["itunes:" + f, f]; +}; + +fields.podcastFeed = ["author", "subtitle", "summary", "explicit"].map(mapItunesField); + +fields.podcastItem = [ + "author", + "subtitle", + "summary", + "explicit", + "duration", + "image", + "episode", + "image", + "season", + "keywords", +].map(mapItunesField); + +export { fields }; diff --git a/apps/graphql/src/rss/index.ts b/apps/graphql/src/rss/index.ts new file mode 100644 index 0000000..0cb6550 --- /dev/null +++ b/apps/graphql/src/rss/index.ts @@ -0,0 +1,383 @@ +import { Parser, Options } from "xml2js"; +import { copyFromXML, getLink, isJSON, getSnippet, getContent } from "./utils"; +import { fields } from "./fields"; + +export const DEFAULT_HEADERS = { + "User-Agent": "rss-parser", + Accept: "application/rss+xml", +}; +type CustomFieldItem = keyof U | { keepArray: boolean }; + +export interface CustomFields { + readonly feed?: Array; + readonly item?: CustomFieldItem[] | CustomFieldItem[][]; +} + +export interface ParserOptions { + xml2js?: Options; + defaultRSS?: number; +} + +export interface Enclosure { + url: string; + length?: number; + type?: string; +} + +export interface RSSItem { + link?: string; + guid?: string; + title?: string; + pubDate?: string; + creator?: string; + summary?: string; + content?: string; + isoDate?: string; + categories?: string[]; + contentSnippet?: string; + enclosure?: Enclosure; +} + +export interface PaginationLinks { + self?: string; + first?: string; + next?: string; + last?: string; + prev?: string; +} + +export interface RSSOutput { + image?: { + link?: string; + url: string; + title?: string; + }; + paginationLinks?: PaginationLinks; + link?: string; + title?: string; + items: (U & RSSItem)[]; + feedUrl?: string; + description?: string; + itunes?: { + [key: string]: any; + image?: string; + owner?: { + name?: string; + email?: string; + }; + author?: string; + summary?: string; + explicit?: string; + categories?: string[]; + keywords?: string[]; + }; +} + +export class RSSKit { + public options: ParserOptions; + public xmlParser: Parser; + constructor(options: ParserOptions = {}) { + options.xml2js = options.xml2js || {}; + + this.options = options; + this.xmlParser = new Parser(this.options.xml2js); + } + + private xmlParseToAsync(xml: string) { + return new Promise((resolve, reject) => { + this.xmlParser.parseString(xml, (err: any, result: any) => { + if (err) return reject(err); + if (!result) { + return reject(new Error("Unable to parse XML.")); + } + + return resolve(result); + }); + }); + } + + public async parse(xml: string): Promise> { + if (isJSON(xml)) { + return JSON.parse(xml); + } + + const result = await this.xmlParseToAsync(xml); + + let feed = null; + if (result.feed) { + feed = this.buildAtomFeed(result); + } else if ( + result.rss && + result.rss.$ && + result.rss.$.version && + result.rss.$.version.match(/^2/) + ) { + feed = this.buildRSS2(result); + } else if (result["rdf:RDF"]) { + feed = this.buildRSS1(result); + } else if ( + result.rss && + result.rss.$ && + result.rss.$.version && + result.rss.$.version.match(/0\.9/) + ) { + feed = this.buildRSS0_9(result); + } else if (result.rss && this.options.defaultRSS) { + switch (this.options.defaultRSS) { + case 0.9: + feed = this.buildRSS0_9(result); + break; + case 1: + feed = this.buildRSS1(result); + break; + case 2: + feed = this.buildRSS2(result); + break; + default: + throw new Error("default RSS version not recognized."); + } + } else { + throw new Error("Feed not recognized as RSS 1 or 2."); + } + return feed; + } + + private buildAtomFeed(xmlObj: any) { + let feed: any = { items: [] }; + copyFromXML(xmlObj.feed, feed); + if (xmlObj.feed.link) { + feed.link = getLink(xmlObj.feed.link, "alternate", 0); + feed.feedUrl = getLink(xmlObj.feed.link, "self", 1); + } + if (xmlObj.feed.title) { + let title = xmlObj.feed.title[0] || ""; + if (title._) title = title._; + if (title) feed.title = title; + } + if (xmlObj.feed.updated) { + feed.lastBuildDate = xmlObj.feed.updated[0]; + } + feed.items = (xmlObj.feed.entry || []).map((entry: any) => this.parseItemAtom(entry)); + return feed; + } + + private parseItemAtom(entry?: any) { + let item: any = {}; + copyFromXML(entry, item); + if (entry.title) { + let title = entry.title[0] || ""; + if (title._) title = title._; + if (title) item.title = title; + } + if (entry.link && entry.link.length) { + item.link = getLink(entry.link, "alternate", 0); + } + if (entry.published && entry.published.length && entry.published[0].length) + item.pubDate = new Date(entry.published[0]).toISOString(); + if (!item.pubDate && entry.updated && entry.updated.length && entry.updated[0].length) + item.pubDate = new Date(entry.updated[0]).toISOString(); + if ( + entry.author && + entry.author.length && + entry.author[0].name && + entry.author[0].name.length + ) + item.author = entry.author[0].name[0]; + if (entry.content && entry.content.length) { + item.content = getContent(entry.content[0]); + item.contentSnippet = getSnippet(item.content); + } + if (entry.summary && entry.summary.length) { + item.summary = getContent(entry.summary[0]); + } + if (entry.id) { + item.id = entry.id[0]; + } + this.setISODate(item); + return item; + } + + private buildRSS0_9(xmlObj: any) { + var channel = xmlObj.rss.channel[0]; + var items = channel.item; + return this.buildRSS(channel, items); + } + + private buildRSS1(xmlObj: any) { + xmlObj = xmlObj["rdf:RDF"]; + let channel = xmlObj.channel[0]; + let items = xmlObj.item; + return this.buildRSS(channel, items); + } + + private buildRSS2(xmlObj: any) { + let channel = xmlObj.rss.channel[0]; + let items = channel.item; + let feed = this.buildRSS(channel, items); + if (xmlObj.rss.$ && xmlObj.rss.$["xmlns:itunes"]) { + this.decorateItunes(feed, channel); + } + return feed; + } + + private buildRSS(channel: any, items: any[]) { + items = items || []; + let feed: any = { items: [] }; + let feedFields = fields.feed; + let itemFields = fields.item; + if (channel["atom:link"] && channel["atom:link"][0] && channel["atom:link"][0].$) { + feed.feedUrl = channel["atom:link"][0].$.href; + } + if (channel.image && channel.image[0] && channel.image[0].url) { + feed.image = {}; + let image = channel.image[0]; + if (image.link) feed.image.link = image.link[0]; + if (image.url) feed.image.url = image.url[0]; + if (image.title) feed.image.title = image.title[0]; + if (image.width) feed.image.width = image.width[0]; + if (image.height) feed.image.height = image.height[0]; + } + const paginationLinks = this.generatePaginationLinks(channel); + if (Object.keys(paginationLinks).length) { + feed.paginationLinks = paginationLinks; + } + copyFromXML(channel, feed, feedFields); + feed.items = items.map((xmlItem: string) => this.parseItemRss(xmlItem, itemFields)); + return feed; + } + + private parseItemRss(xmlItem: any, itemFields: any[]) { + let item: any = {}; + copyFromXML(xmlItem, item, itemFields); + if (xmlItem.enclosure) { + item.enclosure = xmlItem.enclosure[0].$; + } + if (xmlItem.description) { + item.content = getContent(xmlItem.description[0]); + item.contentSnippet = getSnippet(item.content); + } + if (xmlItem.guid) { + item.guid = xmlItem.guid[0]; + if (item.guid._) item.guid = item.guid._; + } + if (xmlItem.category) item.categories = xmlItem.category; + this.setISODate(item); + return item; + } + + /** + * Add iTunes specific fields from XML to extracted JSON + * + * @access public + * @param {object} feed extracted + * @param {object} channel parsed XML + */ + private decorateItunes(feed: any, channel: any) { + let items = channel.item || []; + feed.itunes = {}; + + if (channel["itunes:owner"]) { + let owner: any = {}; + + if (channel["itunes:owner"][0]["itunes:name"]) { + owner.name = channel["itunes:owner"][0]["itunes:name"][0]; + } + if (channel["itunes:owner"][0]["itunes:email"]) { + owner.email = channel["itunes:owner"][0]["itunes:email"][0]; + } + feed.itunes.owner = owner; + } + + if (channel["itunes:image"]) { + let image; + let hasImageHref = + channel["itunes:image"][0] && + channel["itunes:image"][0].$ && + channel["itunes:image"][0].$.href; + image = hasImageHref ? channel["itunes:image"][0].$.href : null; + if (image) { + feed.itunes.image = image; + } + } + + if (channel["itunes:category"]) { + const categoriesWithSubs = channel["itunes:category"].map((category: any) => { + return { + name: category && category.$ && category.$.text, + subs: category["itunes:category"] + ? category["itunes:category"].map((subcategory: any) => ({ + name: subcategory && subcategory.$ && subcategory.$.text, + })) + : null, + }; + }); + + feed.itunes.categories = categoriesWithSubs.map((category: any) => category.name); + feed.itunes.categoriesWithSubs = categoriesWithSubs; + } + + if (channel["itunes:keywords"]) { + if (channel["itunes:keywords"].length > 1) { + feed.itunes.keywords = channel["itunes:keywords"].map( + (keyword: any) => keyword && keyword.$ && keyword.$.text + ); + } else { + let keywords = channel["itunes:keywords"][0]; + if (keywords && typeof keywords._ === "string") { + keywords = keywords._; + } + + if (keywords && keywords.$ && keywords.$.text) { + feed.itunes.keywords = keywords.$.text.split(","); + } else if (typeof keywords === "string") { + feed.itunes.keywords = keywords.split(","); + } + } + } + + copyFromXML(channel, feed.itunes, fields.podcastFeed); + items.forEach((item: any, index: number) => { + let entry = feed.items[index]; + entry.itunes = {}; + copyFromXML(item, entry.itunes, fields.podcastItem); + let image = item["itunes:image"]; + if (image && image[0] && image[0].$ && image[0].$.href) { + entry.itunes.image = image[0].$.href; + } + }); + } + + private setISODate(item: any) { + let date = item.pubDate || item.date; + if (date) { + try { + item.isoDate = new Date(date.trim()).toISOString(); + } catch (e) { + // Ignore bad date format + } + } + } + + /** + * Generates a pagination object where the rel attribute is the key and href attribute is the value + * { self: 'self-url', first: 'first-url', ... } + * + * @access private + * @param {Object} channel parsed XML + * @returns {Object} + */ + private generatePaginationLinks(channel: any) { + if (!channel["atom:link"]) { + return {}; + } + const paginationRelAttributes = ["self", "first", "next", "prev", "last"]; + + return channel["atom:link"].reduce((paginationLinks: any, link: any) => { + if (!link.$ || !paginationRelAttributes.includes(link.$.rel)) { + return paginationLinks; + } + paginationLinks[link.$.rel] = link.$.href; + return paginationLinks; + }, {}); + } +} diff --git a/apps/graphql/src/rss/utils.ts b/apps/graphql/src/rss/utils.ts new file mode 100644 index 0000000..016ef56 --- /dev/null +++ b/apps/graphql/src/rss/utils.ts @@ -0,0 +1,116 @@ +import { decodeHTML } from "entities"; +import { Builder } from "xml2js"; + +export const stripHtml = function (str: string) { + str = str.replace( + /([^\n])<\/?(h|br|p|ul|ol|li|blockquote|section|table|tr|div)(?:.|\n)*?>([^\n])/gm, + "$1\n$3" + ); + str = str.replace(/<(?:.|\n)*?>/gm, ""); + return str; +}; + +export const getSnippet = function (str: string) { + return decodeHTML(stripHtml(str)).trim(); +}; + +export const getLink = function (links: any[], rel: string, fallbackIdx: number) { + if (!links) return; + for (let i = 0; i < links.length; ++i) { + if (links[i].$.rel === rel) return links[i].$.href; + } + if (links[fallbackIdx]) return links[fallbackIdx].$.href; +}; + +export const getContent = function (content: any) { + if (typeof content._ === "string") { + return content._; + } else if (typeof content === "object") { + let builder = new Builder({ + headless: true, + // explicitRoot: true, + rootName: "div", + renderOpts: { pretty: false }, + }); + return builder.buildObject(content); + } else { + return content; + } +}; + +export const copyFromXML = function (xml: string, dest: any, fields: any[] = []) { + fields.forEach(function (f) { + let from = f; + let to = f; + let options = { + keepArray: false, + includeSnippet: false, + }; + if (Array.isArray(f)) { + from = f[0]; + to = f[1]; + if (f.length > 2) { + options = f[2]; + } + } + const { keepArray, includeSnippet } = options; + if (xml[from] !== undefined) { + dest[to] = keepArray ? xml[from] : xml[from][0]; + } + if (dest[to] && typeof dest[to]._ === "string") { + dest[to] = dest[to]._; + } + if (includeSnippet && dest[to] && typeof dest[to] === "string") { + dest[to + "Snippet"] = getSnippet(dest[to]); + } + }); +}; + +export const maybePromisify = function ( + callback: (...args: any) => any, + promise: Promise +) { + if (!callback) return promise; + return promise.then( + (data) => setTimeout(() => callback(null, data)), + (err) => setTimeout(() => callback(err)) + ); +}; + +const DEFAULT_ENCODING = "utf8"; +const ENCODING_REGEX = /(encoding|charset)\s*=\s*(\S+)/; +const SUPPORTED_ENCODINGS = [ + "ascii", + "utf8", + "utf16le", + "ucs2", + "base64", + "latin1", + "binary", + "hex", +]; +const ENCODING_ALIASES = { + "utf-8": "utf8", + "iso-8859-1": "latin1", +}; + +export const getEncodingFromContentType = function (contentType: string) { + contentType = contentType || ""; + let match = contentType.match(ENCODING_REGEX); + let encoding: keyof typeof ENCODING_ALIASES | string = (match || [])[2] || ""; + encoding = encoding.toLowerCase(); + encoding = ENCODING_ALIASES[encoding as keyof typeof ENCODING_ALIASES] || encoding; + if (!encoding || SUPPORTED_ENCODINGS.indexOf(encoding) === -1) { + encoding = DEFAULT_ENCODING; + } + return encoding; +}; + +export const isJSON = (str: any): str is T => { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; +}; diff --git a/apps/graphql/src/schema.graphql b/apps/graphql/src/schema.graphql new file mode 100644 index 0000000..d53541f --- /dev/null +++ b/apps/graphql/src/schema.graphql @@ -0,0 +1,70 @@ +scalar Date + +enum EntryFilter { + UNREAD + ALL + FAVORITED +} + +type Feed { + id: ID! + title: String! + link: String! + lastFetched: Date! + tag: ID + feedURL: String! +} + +type Tag { + id: ID! + title: String! +} + +type Activity { + unread: [Int]! + starred: [Int]! +} + +type Entry { + id: ID! + feed_id: ID! + title: String! + url: String + """ + HTML String + """ + content: String + author: String + published: Date + created_at: Date + unread: Boolean! + favorite: Boolean! +} + +type Query { + tags: [Tag]! + feeds: [Feed]! + entry(id: ID!): Entry! + feed(id: ID!): Feed! + entries(feed_id: ID!, filter: EntryFilter): [Entry!]! +} + +input UpdateFeedInput { + title: String + tagID: ID +} + +type Mutation { + addFeed(url: String!): Feed! + addTag(name: String!): Tag! + removeFeed(id: ID!): Feed! + refreshFeed(id: ID!): [Entry!]! + markAsFavorite(id: ID!, favorite: Boolean!): Entry! + markAsRead(id: ID!): Entry! + updateFeed(id: ID!, fields: UpdateFeedInput): Feed! +} + +schema { + query: Query + mutation: Mutation +} diff --git a/apps/graphql/src/schema.ts b/apps/graphql/src/schema.ts new file mode 100644 index 0000000..f34fb32 --- /dev/null +++ b/apps/graphql/src/schema.ts @@ -0,0 +1,9 @@ +import { makeExecutableSchema } from "@graphql-tools/schema"; +import { resolvers } from "./resolvers"; + +import typeDefs from "./schema.graphql"; + +export const schema = makeExecutableSchema({ + typeDefs, + resolvers, +}); diff --git a/apps/graphql/src/server.ts b/apps/graphql/src/server.ts new file mode 100644 index 0000000..45c591e --- /dev/null +++ b/apps/graphql/src/server.ts @@ -0,0 +1,19 @@ +import { createApp } from "./app"; + +const app = createApp(); + +const start = async () => { + try { + app.listen({ port: 5300 }).then(() => { + console.log(`\ + 🚀 Server ready at: http://localhost:5300/graphiql + ⭐️ See sample queries: http://pris.ly/e/ts/graphql-fastify#using-the-graphql-api + `); + }); + } catch (err) { + app.log.error(err); + process.exit(1); + } +}; + +start(); diff --git a/apps/graphql/src/structs.ts b/apps/graphql/src/structs.ts new file mode 100644 index 0000000..8f39f32 --- /dev/null +++ b/apps/graphql/src/structs.ts @@ -0,0 +1,34 @@ +import type { Entry, Feed } from "@prisma/client"; +import type { RSSItem } from "./rss"; +import type { Entry as EntryType, Feed as FeedType } from "./__generated__"; + +export const mapFeedtoAPIFeed = (feed: Feed): FeedType => { + return { + ...feed, + tag: feed.tagId, + }; +}; + +export const mapRSStoEntry = ( + rssItem: RSSItem, + feedId: string +): Pick => { + return { + title: rssItem.title ?? "Untitled Entry", + content: rssItem.content ?? "", + feedId, + pubDate: new Date(rssItem.pubDate ?? Date.now()), + }; +}; + +export const mapORMEntryToAPIEntry = (entry: Entry): EntryType => { + return { + title: entry.title, + id: entry.id, + content: entry.content, + feed_id: entry.feedId!, + published: entry.pubDate, + unread: entry.unread, + favorite: entry.favorite, + }; +}; diff --git a/apps/graphql/test/errors.test.ts b/apps/graphql/test/errors.test.ts new file mode 100644 index 0000000..492d3d2 --- /dev/null +++ b/apps/graphql/test/errors.test.ts @@ -0,0 +1,9 @@ +import { SDKError } from "../src/errors"; + +describe("Errors", () => { + it("error contains message", () => { + const error = new SDKError("Service unavailable", 503); + expect(error).toBeInstanceOf(SDKError); + expect(error.name).toBe("SDKError"); + }); +}); diff --git a/test/fixtures/guardian.rss b/apps/graphql/test/fixtures/guardian.rss similarity index 100% rename from test/fixtures/guardian.rss rename to apps/graphql/test/fixtures/guardian.rss diff --git a/test/fixtures/serial.rss b/apps/graphql/test/fixtures/serial.rss similarity index 100% rename from test/fixtures/serial.rss rename to apps/graphql/test/fixtures/serial.rss diff --git a/apps/graphql/test/integration.test.ts b/apps/graphql/test/integration.test.ts new file mode 100644 index 0000000..789aa7e --- /dev/null +++ b/apps/graphql/test/integration.test.ts @@ -0,0 +1,53 @@ +/** + * @jest-environment node + */ + +import { createApp } from "../src/app"; +import gql from "graphql-tag"; + +import { createMercuriusTestClient } from "mercurius-integration-testing"; + +describe("Integration", () => { + const client = createMercuriusTestClient(createApp()); + + it("can create feeds", async () => { + const createFeed = await client.query( + gql` + mutation CreateFeed($url: String!) { + addFeed(url: $url) { + id + title + } + } + `, + { + variables: { + url: "https://www.inputmag.com/rss", + }, + } + ); + + expect(createFeed.data.addFeed.title).toEqual("Input"); + + const entryList = await client.query( + gql` + query EntriesByFeed($id: ID!) { + entries(feed_id: $id) { + title + content + id + unread + published + } + } + `, + { + variables: { + id: createFeed.data.addFeed.id, + }, + } + ); + + expect(entryList.data.entries.length).toBeGreaterThan(1); + }); +}); diff --git a/apps/graphql/test/rss.test.ts b/apps/graphql/test/rss.test.ts new file mode 100644 index 0000000..2e92de6 --- /dev/null +++ b/apps/graphql/test/rss.test.ts @@ -0,0 +1,31 @@ +import { RSSKit } from "../src/rss"; + +import { readFile } from "fs/promises"; +import path from "path"; + +export const getFixtureAsString = async (filePath: string) => { + const buffer = await readFile(path.join("test/fixtures", filePath), { + encoding: "utf-8", + }); + + return buffer.toString(); +}; + +const parser = new RSSKit(); + +describe("RSS", () => { + it("can parse a string", async () => { + const feed = await getFixtureAsString("guardian.rss"); + const output = await parser.parse(feed); + + expect(output.title).toBe("The Guardian"); + expect(output.items.length).toEqual(90); + }); + + it("can parse a podcast RSS feed", async () => { + const feed = await getFixtureAsString("serial.rss"); + const output = await parser.parse(feed); + expect(output.title).toBe("Serial"); + expect(output.items.length).toEqual(46); + }); +}); diff --git a/apps/graphql/test/smoke.test.ts b/apps/graphql/test/smoke.test.ts new file mode 100644 index 0000000..ba7418b --- /dev/null +++ b/apps/graphql/test/smoke.test.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from "@prisma/client"; + +describe("example test with Prisma Client", () => { + let prisma = new PrismaClient(); + + beforeAll(async () => { + await prisma.$connect(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + test("test query", async () => { + await prisma.entry.findMany(); + }); +}); diff --git a/apps/graphql/tsconfig.json b/apps/graphql/tsconfig.json new file mode 100644 index 0000000..12f4586 --- /dev/null +++ b/apps/graphql/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "rootDir": "src", + "target": "es6", + "module": "esnext", + "allowJs": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "removeComments": false, + "resolveJsonModule": true, + "preserveConstEnums": true, + "sourceMap": true, + "skipLibCheck": true, + "noImplicitAny": true, + "baseUrl": ".", + "lib": ["es2016", "es2017.object"], + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "isolatedModules": true, + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/apps/ui/jest.config.js b/apps/ui/jest.config.js new file mode 100644 index 0000000..d166d20 --- /dev/null +++ b/apps/ui/jest.config.js @@ -0,0 +1,21 @@ +// @type-check +const nextJest = require("next/jest"); + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: "./", +}); + +// Add any custom config to be passed to Jest +const customJestConfig = { + setupFilesAfterEnv: ["/jest.setup.ts"], + + // Add more setup options before each test is run + // setupFilesAfterEnv: ['/jest.setup.js'], + // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work + moduleDirectories: ["node_modules", "/"], + testEnvironment: "jest-environment-jsdom", +}; + +// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async +module.exports = createJestConfig(customJestConfig); diff --git a/apps/ui/jest.setup.ts b/apps/ui/jest.setup.ts new file mode 100644 index 0000000..6a69409 --- /dev/null +++ b/apps/ui/jest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/extend-expect"; diff --git a/next-env.d.ts b/apps/ui/next-env.d.ts similarity index 100% rename from next-env.d.ts rename to apps/ui/next-env.d.ts diff --git a/apps/ui/next.config.js b/apps/ui/next.config.js new file mode 100644 index 0000000..10eb46b --- /dev/null +++ b/apps/ui/next.config.js @@ -0,0 +1,22 @@ +// @ts-check + +/** @type {import('next').NextConfig} */ +const config = { + reactStrictMode: true, + optimizeFonts: true, + cleanDistDir: true, + swcMinify: true, + experimental: { + gzipSize: true, + }, + async rewrites() { + return [ + { + source: "/v1/:path*", + destination: "http://localhost:5300/:path*", + }, + ]; + }, +}; + +module.exports = config; diff --git a/apps/ui/package.json b/apps/ui/package.json new file mode 100644 index 0000000..4ee3592 --- /dev/null +++ b/apps/ui/package.json @@ -0,0 +1,59 @@ +{ + "name": "@reubin/ui", + "repository": "charliewilco/reubin.app", + "author": "Charlie ⚡ ", + "version": "1.1.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "jest --verbose", + "test:ci": "jest --verbose --ci --runInBand --no-cache", + "types": "tsc --noEmit --pretty" + }, + "dependencies": { + "@headlessui/react": "^1.7.2", + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.3", + "@tailwindcss/typography": "^0.5.7", + "autoprefixer": "^10.4.7", + "date-fns": "^2.29.2", + "fathom-client": "^3.5.0", + "graphql-request": "^5.0.0", + "graphql-tag": "^2.12.6", + "next": "^12.3.1", + "postcss": "^8.4.14", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-fast-compare": "^3.2.0", + "react-icons": "^4.4.0", + "sharp": "^0.31.0", + "swr": "^1.3.0", + "tailwindcss": "^3.1.8", + "typescript": "^4.8.4", + "zod": "^3.19.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.0", + "@types/jest": "^29.1.1", + "@types/node": "^18.7.20", + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.5", + "eslint": "^8.23.0", + "eslint-config-next": "^12.3.0", + "jest": "^29.1.2", + "jest-environment-jsdom": "^29.1.2", + "msw": "^0.47.3" + }, + "eslintConfig": { + "extends": "next/core-web-vitals" + }, + "postcss": { + "plugins": { + "tailwindcss": {}, + "autoprefixer": {} + } + } +} diff --git a/public/app-icon-play-store.png b/apps/ui/public/app-icon-play-store.png similarity index 100% rename from public/app-icon-play-store.png rename to apps/ui/public/app-icon-play-store.png diff --git a/public/app-icon-updated.png b/apps/ui/public/app-icon-updated.png similarity index 100% rename from public/app-icon-updated.png rename to apps/ui/public/app-icon-updated.png diff --git a/public/app-icon.png b/apps/ui/public/app-icon.png similarity index 100% rename from public/app-icon.png rename to apps/ui/public/app-icon.png diff --git a/public/favicon.png b/apps/ui/public/favicon.png similarity index 100% rename from public/favicon.png rename to apps/ui/public/favicon.png diff --git a/apps/ui/src/components/add-feed.tsx b/apps/ui/src/components/add-feed.tsx new file mode 100644 index 0000000..3705b5f --- /dev/null +++ b/apps/ui/src/components/add-feed.tsx @@ -0,0 +1,81 @@ +import { useCallback, useState } from "react"; +import { FiPlus } from "react-icons/fi"; + +import { addFeed } from "../lib/graphql"; + +import { SuperButton, Button } from "./ui/button"; +import { Label, Input, TextLabel } from "./ui/input"; +import { Dialog } from "./ui/dialog"; + +interface AddFeedFormProps { + onSubmit(url: string): void | Promise; + onSelect?(selectedFeed: unknown): void | Promise; +} + +export const AddFeedForm = (props: AddFeedFormProps) => { + const [url, setUrl] = useState(""); + + const handleSubmit: React.FormEventHandler = useCallback( + (event) => { + if (event) { + event.preventDefault(); + } + + if (url === "") { + return; + } + + props.onSubmit(url); + }, + [url, props] + ); + + const handleChange: React.ChangeEventHandler = useCallback( + (event) => { + setUrl(event.target.value); + }, + [setUrl] + ); + + return ( +
+
+ + +
+ Submit +
+
+
+ ); +}; + +export const AddFeed = () => { + const [isOpen, setOpen] = useState(false); + const handleSubmit = useCallback((url: string) => { + addFeed(url); + }, []); + + return ( + <> + + setOpen(false)} title="Add Feed"> +
+ Make changes to your profile here. Click save when you're done. +
+ +
+ + ); +}; diff --git a/apps/ui/src/components/app-header.tsx b/apps/ui/src/components/app-header.tsx new file mode 100644 index 0000000..bf42160 --- /dev/null +++ b/apps/ui/src/components/app-header.tsx @@ -0,0 +1,27 @@ +import { Logo } from "./logo"; + +interface AppHeaderProps { + title: string; + children?: React.ReactNode; +} + +export const AppHeader = (props: AppHeaderProps) => { + return ( +
+
+
+ +

{props.title}

+
+ +
{props.children}
+
+ + +
+ ); +}; diff --git a/apps/ui/src/components/create-tag.tsx b/apps/ui/src/components/create-tag.tsx new file mode 100644 index 0000000..f1c6489 --- /dev/null +++ b/apps/ui/src/components/create-tag.tsx @@ -0,0 +1,63 @@ +import { useCallback, useState } from "react"; +import { addTag } from "../lib/graphql"; +import { SuperButton } from "./ui/button"; +import { Label, Input, TextLabel } from "./ui/input"; + +interface CreateTagFormProps { + onSubmit(tag: string): void | Promise; +} + +export const CreateTagForm = () => { + const handleSubmitTag = useCallback(async (tagName: string) => { + await addTag(tagName); + }, []); + + return ; +}; + +export const CreateTag = (props: CreateTagFormProps) => { + const [tagName, setTagName] = useState(""); + const handleSubmit: React.FormEventHandler = useCallback( + (event) => { + if (event) { + event.preventDefault(); + } + + if (tagName === "") { + return; + } + + props.onSubmit(tagName); + setTagName(""); + }, + [tagName, props] + ); + + const handleChange: React.ChangeEventHandler = useCallback( + (event) => { + setTagName(event.target.value); + }, + [setTagName] + ); + + return ( +
+
+ + +
+ Submit +
+
+
+ ); +}; diff --git a/apps/ui/src/components/entries-list.tsx b/apps/ui/src/components/entries-list.tsx new file mode 100644 index 0000000..8fe53b6 --- /dev/null +++ b/apps/ui/src/components/entries-list.tsx @@ -0,0 +1,101 @@ +import { memo, useCallback } from "react"; +import useSWR from "swr"; +import type { EntryDetailsFragment, EntryFilter } from "../lib/__generated__"; +import { classNames } from "./ui/class-names"; +import { getEntriesFromFeed, refreshFeed } from "../lib/graphql"; +import { FeedToolbar } from "./feed-toolbar"; +import format from "date-fns/format"; + +interface EntryListProps { + filter?: EntryFilter; + selectedEntry: null | string; + id: string; + onSelect(id: string): void; +} + +interface EntryListItemProps { + isSelected: boolean; + onSelect(id: string): void; + title: string; + id: string; + isUnread: boolean; + published: string; +} + +function sortByNearest( + { published: a }: EntryDetailsFragment, + { published: b }: EntryDetailsFragment +) { + const now = Date.now(); + return Math.abs(Date.parse(a) - now) - Math.abs(Date.parse(b) - now); +} + +export const EntryListItem = memo((props: EntryListItemProps) => { + const handleSelect = () => { + props.onSelect(props.id); + }; + + const date = format(new Date(props.published), "MMM d, yyyy"); + + return ( +
  • +
    +

    {props.title}

    +

    {date}

    +
    +
  • + ); +}); + +EntryListItem.displayName = "EntryListItem"; + +export const EntryList = (props: EntryListProps) => { + const { data, mutate } = useSWR([props.id, props.filter], getEntriesFromFeed); + + // const isLoading = !error && !data; + + const handleRefresh = useCallback(async () => { + const result = await refreshFeed(props.id); + + mutate( + (prev) => { + if (prev && result.refreshFeed) { + // TODO: write mutation to call refresh on feed. + const entries: EntryDetailsFragment[] = [...result.refreshFeed, ...prev.entries]; + return { ...prev, entries }; + } + }, + { rollbackOnError: true } + ); + }, [mutate, props.id]); + + return ( +
    + + +
      + {data?.entries.sort(sortByNearest).map((entry) => { + const isUnread = !!entry?.unread; + + return ( + + ); + })} +
    +
    + ); +}; diff --git a/apps/ui/src/components/entry-full.tsx b/apps/ui/src/components/entry-full.tsx new file mode 100644 index 0000000..85b6ec4 --- /dev/null +++ b/apps/ui/src/components/entry-full.tsx @@ -0,0 +1,64 @@ +import Head from "next/head"; +import { useEffect } from "react"; +import useSWR from "swr"; +import { LoadingIndicator } from "./ui/activity-indicator"; +import { getEntry, markAsRead } from "../lib/graphql"; + +interface EntryFullProps { + id: string; +} + +export const EntryFull = (props: EntryFullProps) => { + const { error, data, mutate } = useSWR(props.id, getEntry); + + const isLoading = !error && !data; + + useEffect(() => { + async function mutator() { + await markAsRead(props.id); + + mutate( + async (data) => { + if (data) { + return { + ...data, + unread: false, + }; + } + }, + { rollbackOnError: true } + ); + } + if (data?.entry.unread) { + mutator(); + } + }, [data?.entry, mutate, props.id]); + + if (isLoading) { + return ; + } + + if (error) { + return
    {error.toString()}
    ; + } + + if (data) { + return ( +
    + + {data.entry?.title} | Reubin + +
    +
    +

    {data.entry?.title}

    +
    +
    +
    +
    +
    +
    + ); + } + + return null; +}; diff --git a/apps/ui/src/components/feed-list.tsx b/apps/ui/src/components/feed-list.tsx new file mode 100644 index 0000000..467b330 --- /dev/null +++ b/apps/ui/src/components/feed-list.tsx @@ -0,0 +1,135 @@ +import { memo, useCallback, useMemo } from "react"; +import isEqual from "react-fast-compare"; +import useSWR from "swr"; +import { useDashboardContext } from "../hooks/useDashboard"; +import { getFeeds } from "../lib/graphql"; +import { mapTagsToFeed } from "../lib/map-tags-feed"; +import type { GetFeedsQuery } from "../lib/__generated__"; +import { LoadingIndicator } from "./ui/activity-indicator"; +import { classNames } from "./ui/class-names"; +import { TagWithFeeds } from "./ui/tag-with-feeds"; + +interface FeedItemProps { + id: string; + title: string; + selected: null | string; + onSelect(id: string, title: string): void; +} + +export const FeedItem = (props: FeedItemProps) => { + const handleSelect = () => { + if (props.id) { + props.onSelect(props.id, props.title); + } + }; + + const isSelected = useMemo(() => props.id === props.selected, [props]); + + const listProps = isSelected ? { "data-testid": "selected" } : {}; + return ( +
  • +
    +
    +

    {props.title}

    +
    +
    +
  • + ); +}; + +interface FeedsSortedByTagProps extends GetFeedsQuery {} + +const FeedListSortedByTag = memo((props: FeedsSortedByTagProps) => { + const [{ feed: selectedFeed }, { selectFeed }] = useDashboardContext(); + + const sorted = useMemo(() => mapTagsToFeed(props), [props]); + + const getTitle = useCallback( + (tagID: string) => { + const tag = props.tags.find((t) => t?.id === tagID); + return tag?.title; + }, + [props] + ); + + return ( +
    + {sorted.map(([tagID, feeds]) => { + if (tagID == null) { + return ( +
      + {feeds.map((feed) => + feed === null ? null : ( + + ) + )} +
    + ); + } else { + return ( +
    + +
      + {feeds.map((feed) => + feed === null ? null : ( + + ) + )} +
    +
    +
    + ); + } + })} +
    + ); +}, isEqual); + +FeedListSortedByTag.displayName = "MemoFeedsSortedByTag"; + +export const FeedList = () => { + const { data, error } = useSWR("feeds", getFeeds); + + const isLoading = !error && !data; + + if (isLoading) { + return ; + } + + if (error) { + return
    {error.toString()}
    ; + } + + if (data) { + if (data.feeds.length === 0) { + return ( +
    +

    Looks like you have no feeds.

    +
    + ); + } + + return ( +
    + +
    + ); + } + + return null; +}; diff --git a/apps/ui/src/components/feed-settings.tsx b/apps/ui/src/components/feed-settings.tsx new file mode 100644 index 0000000..c59094c --- /dev/null +++ b/apps/ui/src/components/feed-settings.tsx @@ -0,0 +1,151 @@ +import useSWR, { mutate } from "swr"; +import { useCallback, useState } from "react"; +import { FiSettings, FiTrash2 } from "react-icons/fi"; + +import { Button, SuperButton } from "./ui/button"; +import { Dialog } from "./ui/dialog"; +import { removeFeed, updateFeedTitle, getAllTags, getFeed } from "../lib/graphql"; +import { Input, Label, TextLabel } from "./ui/input"; +import { useDashboardContext } from "../hooks/useDashboard"; +import { TagSelectionList } from "./ui/tag-selection-list"; +import type { FeedDetailsFragment, TagInfoFragment } from "../lib/__generated__"; + +interface FeedSettingsFormProps { + onSubmit(title: string, tagID?: string | null): void | Promise; + onDelete(): void | Promise; + initialFeed: FeedDetailsFragment; +} + +export const UpdateFeedForm = (props: FeedSettingsFormProps) => { + const [fields, setFields] = useState({ + title: props.initialFeed.title, + tag: props.initialFeed.tag, + }); + const { data } = useSWR("tags", getAllTags); + + const handleSubmit: React.FormEventHandler = useCallback( + (event) => { + if (event) { + event.preventDefault(); + } + + props.onSubmit(fields.title, fields.tag); + }, + [fields, props] + ); + + const handleTitleChange: React.ChangeEventHandler = useCallback( + (event) => + setFields((prev) => { + return { + ...prev, + title: event.target.value, + }; + }), + [setFields] + ); + + const handleTagChange = useCallback((tag: TagInfoFragment) => { + setFields((prev) => { + return { + ...prev, + tag: tag.id, + }; + }); + }, []); + + const selected: TagInfoFragment | null | undefined = data?.tags.find( + (t) => t?.id === fields.tag + ); + + return ( +
    + + +
    +
    + +
    + + + Save + +
    +
    + ); +}; + +export const FeedSettings = () => { + const [isOpen, setOpen] = useState(false); + + const [{ feed }, { unselectFeed }] = useDashboardContext(); + const { data } = useSWR(feed, getFeed); + + const handleRemove = useCallback(() => { + if (feed) { + try { + removeFeed(feed).then(() => { + setOpen(false); + unselectFeed(); + mutate("feeds"); + }); + } catch (error) {} + } + }, [feed, unselectFeed]); + + const handleSubmit = useCallback( + (title: string, tagID?: string | null) => { + if (feed) { + try { + updateFeedTitle(feed, { + title, + tagID, + }).then(() => { + setOpen(false); + mutate("feeds"); + }); + } catch (error) {} + } + }, + [feed] + ); + + return ( + <> + + {data && data.feed && ( + setOpen(false)} + title={`Update feed "${data.feed.title}"`}> + + + )} + + ); +}; diff --git a/apps/ui/src/components/feed-toolbar.tsx b/apps/ui/src/components/feed-toolbar.tsx new file mode 100644 index 0000000..c157b18 --- /dev/null +++ b/apps/ui/src/components/feed-toolbar.tsx @@ -0,0 +1,21 @@ +import { FiRotateCw } from "react-icons/fi"; +import { FeedSettings } from "./feed-settings"; +import { Button } from "./ui/button"; + +interface FeedToolbarProps { + onRefresh(): void; +} + +export const FeedToolbar = (props: FeedToolbarProps) => { + return ( +
    + + + +
    + ); +}; diff --git a/apps/ui/src/components/layout.tsx b/apps/ui/src/components/layout.tsx new file mode 100644 index 0000000..201e787 --- /dev/null +++ b/apps/ui/src/components/layout.tsx @@ -0,0 +1,40 @@ +import Image from "next/image"; +import Head from "next/head"; +import Link from "next/link"; + +import { SiteFooter } from "./site-footer"; + +interface LayoutProps { + addressbar: string; + title?: string; + children?: React.ReactNode; +} + +export const MarketingLayout = ({ title, addressbar, children }: LayoutProps) => { + return ( +
    + + {addressbar} + + + + +
    {children}
    + +
    + ); +}; diff --git a/apps/ui/src/components/logo.tsx b/apps/ui/src/components/logo.tsx new file mode 100644 index 0000000..d6361df --- /dev/null +++ b/apps/ui/src/components/logo.tsx @@ -0,0 +1,26 @@ +export const Logo = () => { + return ( + + + + + + ); +}; diff --git a/apps/ui/src/components/promo/call-to-action.tsx b/apps/ui/src/components/promo/call-to-action.tsx new file mode 100644 index 0000000..dc3ce4c --- /dev/null +++ b/apps/ui/src/components/promo/call-to-action.tsx @@ -0,0 +1,38 @@ +import Image from "next/image"; +import Newspaper from "./roman-kraft-unsplash.jpg"; + +export const CTA = () => { + return ( +
    +
    +
    +
    +

    + Ready to dive in? + Start your free trial today. +

    +

    + Ac euismod vel sit maecenas id pellentesque eu sed consectetur. Malesuada + adipiscing sagittis vel nulla nec. +

    + + Sign up for free + +
    +
    + +
    + App screenshot +
    +
    +
    + ); +}; diff --git a/apps/ui/src/components/promo/features.tsx b/apps/ui/src/components/promo/features.tsx new file mode 100644 index 0000000..762e15a --- /dev/null +++ b/apps/ui/src/components/promo/features.tsx @@ -0,0 +1,50 @@ +import { FiGlobe, FiActivity, FiMail } from "react-icons/fi"; + +const features = [ + { + name: "Competitive rates", + description: + "Consequuntur omnis dicta cumque, inventore atque ab dolores aspernatur tempora ab doloremque.", + icon: FiGlobe, + }, + + { + name: "Instant transfers", + description: + "Omnis, illo delectus? Libero, possimus nulla nemo tenetur adipisci repellat dolore eligendi velit doloribus mollitia.", + icon: FiActivity, + }, + { + name: "Reminder emails", + description: + "Veniam necessitatibus reiciendis fugit explicabo dolorem nihil et omnis assumenda odit? Quisquam unde accusantium.", + icon: FiMail, + }, +]; + +export const FeatureList = () => ( +
    +
    +
    +

    + Take doom out of your evening scroll. +

    +
    +
    + {features.map((feature) => ( +
    +
    +
    +
    +

    + {feature.name} +

    +
    +
    {feature.description}
    +
    + ))} +
    +
    +
    +); diff --git a/apps/ui/src/components/promo/hero.tsx b/apps/ui/src/components/promo/hero.tsx new file mode 100644 index 0000000..999dd73 --- /dev/null +++ b/apps/ui/src/components/promo/hero.tsx @@ -0,0 +1,36 @@ +import Link from "next/link"; + +export const Hero = () => ( +
    +
    + + + + + + + + + +

    + Reubin +

    +

    RSS for the next generation.

    +
    +
    +); diff --git a/apps/ui/src/components/promo/pricing.tsx b/apps/ui/src/components/promo/pricing.tsx new file mode 100644 index 0000000..f947951 --- /dev/null +++ b/apps/ui/src/components/promo/pricing.tsx @@ -0,0 +1,98 @@ +import { FiCheck } from "react-icons/fi"; + +const tiers = [ + { + name: "Free", + href: "#", + priceMonthly: 0, + description: "All the basics for starting a new business", + includedFeatures: [ + "Potenti felis, in cras at at ligula nunc.", + "Orci neque eget pellentesque.", + ], + }, + { + name: "Premium", + href: "#", + priceMonthly: 24, + description: "All the basics for starting a new business", + includedFeatures: [ + "Potenti felis, in cras at at ligula nunc. ", + "Orci neque eget pellentesque.", + ], + }, + { + name: "Lifetime", + href: "#", + priceMonthly: 32, + description: "All the basics for starting a new business", + includedFeatures: [ + "Potenti felis, in cras at at ligula nunc. ", + "Orci neque eget pellentesque.", + "Donec mauris sit in eu tincidunt etiam.", + ], + }, +]; + +export const Pricing = () => { + return ( +
    +
    +

    Pricing Plans

    +

    + Start building for free, then add a site plan to go live. Account plans unlock + additional features. +

    +
    + + +
    +
    +
    + {tiers.map((tier) => ( +
    +
    +

    {tier.name}

    +

    {tier.description}

    +

    + + ${tier.priceMonthly} + {" "} + /mo +

    + + Buy {tier.name} + +
    +
    +

    What's included

    +
      + {tier.includedFeatures.map((feature) => ( +
    • +
    • + ))} +
    +
    +
    + ))} +
    +
    + ); +}; diff --git a/components/promo/roman-kraft-unsplash.jpg b/apps/ui/src/components/promo/roman-kraft-unsplash.jpg similarity index 100% rename from components/promo/roman-kraft-unsplash.jpg rename to apps/ui/src/components/promo/roman-kraft-unsplash.jpg diff --git a/apps/ui/src/components/promo/services.tsx b/apps/ui/src/components/promo/services.tsx new file mode 100644 index 0000000..441cbbe --- /dev/null +++ b/apps/ui/src/components/promo/services.tsx @@ -0,0 +1,24 @@ +import { SiMedium, SiReddit, SiSubstack, SiTwitter, SiWordpress } from "react-icons/si"; + +export const Services = () => { + return ( +
    +
    +
    +
    + + + + + +
    +
    +

    + Track feeds from all your favorite services +

    +
    +
    +
    +
    + ); +}; diff --git a/apps/ui/src/components/recommendation-list.tsx b/apps/ui/src/components/recommendation-list.tsx new file mode 100644 index 0000000..ff464ca --- /dev/null +++ b/apps/ui/src/components/recommendation-list.tsx @@ -0,0 +1,57 @@ +import type { GetFeedsQuery } from "../lib/__generated__"; +import { RecommendationCard } from "./ui/recommendation-card"; + +export type RecommendedField = { link: string; displayName: string }; + +interface RecommendationListItemProps { + feeds: RecommendedField[]; + title: string; + data?: GetFeedsQuery; + error?: any; + onClick: (link: string) => void; +} + +export const NEWS = [ + { + link: "https://www.vox.com/rss/index.xml", + displayName: "Vox", + }, + { + link: "https://www.out.com/rss.xml", + displayName: "Out.com", + }, + { + link: "https://www.buzzfeed.com/world.xml", + displayName: "BuzzFeed World News", + }, + { + link: "https://nautil.us/rss/all", + displayName: "Nautilus", + }, + { + link: "https://thedailywhat.cheezburger.com/rss", + displayName: "Daily What", + }, +]; + +export const RecommendationList = (props: RecommendationListItemProps) => { + return ( +
    +

    {props.title}

    + +
      + {props.feeds.map((r) => ( +
    • + +
    • + ))} +
    +
    + ); +}; diff --git a/apps/ui/src/components/side-navigation.tsx b/apps/ui/src/components/side-navigation.tsx new file mode 100644 index 0000000..667366b --- /dev/null +++ b/apps/ui/src/components/side-navigation.tsx @@ -0,0 +1,61 @@ +import Link from "next/link"; +import isEqual from "react-fast-compare"; +import { memo } from "react"; +import { + MdHomeFilled, + MdOutlineFeed, + MdBookmarks, + MdOutlineSettings, + MdCoffee, + MdCardGiftcard, +} from "react-icons/md"; + +interface LinkItemProps { + href: string; + name: string; + children: React.ReactNode; +} + +export const LinkItem = (props: LinkItemProps) => { + return ( + + + {props.children} + {props.name} + + + ); +}; + +// unread / bookmarked / all / recommendations / appearance / settings + +const _SideNavigation = () => { + return ( + + ); +}; + +const SideNavigation = memo(_SideNavigation, isEqual); + +SideNavigation.displayName = "SideNavigation"; + +export { SideNavigation }; diff --git a/apps/ui/src/components/site-footer.tsx b/apps/ui/src/components/site-footer.tsx new file mode 100644 index 0000000..6d46967 --- /dev/null +++ b/apps/ui/src/components/site-footer.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; + +import { FiGlobe } from "react-icons/fi"; +import { SiGithub, SiTwitter } from "react-icons/si"; + +export const SiteFooter = () => ( + +); diff --git a/apps/ui/src/components/styles.css b/apps/ui/src/components/styles.css new file mode 100644 index 0000000..7e3b2c1 --- /dev/null +++ b/apps/ui/src/components/styles.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +article { + font-feature-settings: "rlig" 1, "calt" 1, "ss01" 1, "ss06" 1; +} diff --git a/apps/ui/src/components/ui/activity-indicator.tsx b/apps/ui/src/components/ui/activity-indicator.tsx new file mode 100644 index 0000000..f675715 --- /dev/null +++ b/apps/ui/src/components/ui/activity-indicator.tsx @@ -0,0 +1,29 @@ +interface ActivityIndicatorProps {} + +export const LoadingIndicator = ({}: ActivityIndicatorProps) => { + return ( +
    + + + + +
    + ); +}; diff --git a/apps/ui/src/components/ui/button.tsx b/apps/ui/src/components/ui/button.tsx new file mode 100644 index 0000000..e701413 --- /dev/null +++ b/apps/ui/src/components/ui/button.tsx @@ -0,0 +1,46 @@ +import { forwardRef } from "react"; +import { classNames } from "./class-names"; + +type BaseButtonProps = React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement +>; + +const Button = forwardRef((props, ref) => { + return + + + ); +}; diff --git a/apps/ui/src/components/ui/forms/core.ts b/apps/ui/src/components/ui/forms/core.ts new file mode 100644 index 0000000..ee0d5ad --- /dev/null +++ b/apps/ui/src/components/ui/forms/core.ts @@ -0,0 +1,75 @@ +import { useCallback, useReducer } from "react"; + +type DefaultValues = Record; + +type FormActions = + | { + type: "input"; + payload: Record; + } + | { + type: "submit"; + } + | { + type: "reset"; + }; + +interface FormState { + values: T; + errors: Partial; +} + +function reducer( + state: FormState, + _action: FormActions +): FormState { + return { + ...state, + }; +} + +interface FormProps { + initialValues: T; + onSubmit(values: T): void; +} + +const initializer = (props: FormProps): FormState => { + return { + values: props.initialValues, + errors: {}, + }; +}; + +export const useForm = (props: FormProps) => { + const [state, dispatch] = useReducer< + React.Reducer, FormActions>, + FormProps + >(reducer, props, initializer); + + const getFieldProps = useCallback( + ( + key: keyof T + ): React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + > => { + return { + value: state.values[key] as string | number | string[], + onChange(event) { + const payload = {} as any; + payload[key] = event.target.value; + dispatch({ + type: "input", + payload, + }); + }, + }; + }, + [state, dispatch] + ); + + return { + getFieldProps, + values: state.values, + }; +}; diff --git a/apps/ui/src/components/ui/input.tsx b/apps/ui/src/components/ui/input.tsx new file mode 100644 index 0000000..4638c15 --- /dev/null +++ b/apps/ui/src/components/ui/input.tsx @@ -0,0 +1,71 @@ +import { forwardRef } from "react"; +import { classNames } from "./class-names"; + +type InputProps = React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement +>; + +// className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + +const Input = forwardRef(({ className, ...props }, ref) => { + return ( + + ); +}); + +Input.displayName = "Input"; + +type FieldsetProps = React.DetailedHTMLProps< + React.FieldsetHTMLAttributes, + HTMLFieldSetElement +>; + +const Fieldset = forwardRef( + ({ className, ...props }, ref) => { + return ( +
    + ); + } +); + +Fieldset.displayName = "Fieldset"; + +type TextLabelProps = React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLSpanElement +>; + +const TextLabel = forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); + +TextLabel.displayName = "TextLabel"; + +type LabelProps = React.DetailedHTMLProps< + React.LabelHTMLAttributes, + HTMLLabelElement +>; + +const Label = forwardRef(({ className, ...props }, ref) => { + return