├── .env.example
├── .eslintignore
├── .eslintrc.json
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── config.yml
└── workflows
│ └── development.yml
├── .gitignore
├── .husky
├── .gitignore
└── pre-commit
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── app
├── components
│ ├── About.tsx
│ ├── BackupForm.tsx
│ ├── BookmarkDetails.tsx
│ ├── Card.tsx
│ ├── CategoryIcon.tsx
│ ├── Feed.tsx
│ ├── FlashMessage.tsx
│ ├── IconLink.tsx
│ ├── InputOption.tsx
│ ├── Progress.tsx
│ ├── ResourcesDetails.tsx
│ ├── ResourcesList.tsx
│ ├── SearchList.tsx
│ ├── ShowMoreButton.tsx
│ ├── SidebarNavigation.tsx
│ ├── SuggestedResources.tsx
│ └── SvgIcon.tsx
├── config.ts
├── entry.client.tsx
├── entry.server.tsx
├── helpers.ts
├── hooks.ts
├── icons
│ ├── back.svg
│ ├── badge-check.svg
│ ├── bookmark.svg
│ ├── box-open.svg
│ ├── bullhorn.svg
│ ├── calendar-alt.svg
│ ├── chalkboard-teacher.svg
│ ├── check-circle.svg
│ ├── circle.svg
│ ├── code.svg
│ ├── collapse.svg
│ ├── discord.svg
│ ├── exclamation-circle.svg
│ ├── expand.svg
│ ├── fire-alt.svg
│ ├── github.svg
│ ├── hexagon.svg
│ ├── history.svg
│ ├── home.svg
│ ├── info-circle.svg
│ ├── layer-group.svg
│ ├── link.svg
│ ├── logo.svg
│ ├── mail-bulk.svg
│ ├── map-pin.svg
│ ├── map-signs.svg
│ ├── meetup.svg
│ ├── menu.svg
│ ├── pencil.svg
│ ├── plus.svg
│ ├── remix.svg
│ ├── rss-square.svg
│ ├── satellite-dish.svg
│ ├── search.svg
│ ├── square.svg
│ ├── template.svg
│ ├── times-circle.svg
│ ├── times.svg
│ ├── trending.svg
│ ├── user.svg
│ └── video.svg
├── layout.tsx
├── resources.tsx
├── root.tsx
├── routes
│ ├── [rss.xml].tsx
│ ├── [sitemap.xml].tsx
│ ├── _layout.$list.tsx
│ ├── _layout.admin.index.tsx
│ ├── _layout.admin.pages.tsx
│ ├── _layout.admin.resources.tsx
│ ├── _layout.admin.statistics.tsx
│ ├── _layout.admin.tsx
│ ├── _layout.admin.users.$userId.backup.tsx
│ ├── _layout.admin.users.tsx
│ ├── _layout.index.tsx
│ ├── _layout.resources.$resourceId.tsx
│ ├── _layout.resources.index.tsx
│ ├── _layout.resources.tsx
│ ├── _layout.submit.tsx
│ ├── _layout.tsx
│ ├── auth.tsx
│ ├── discover.$list.tsx
│ ├── discover.tsx
│ ├── login.tsx
│ └── logout.tsx
├── scroll.tsx
├── search.ts
└── types.ts
├── package-lock.json
├── package.json
├── playwright.config.ts
├── public
├── favicon.ico
├── favicon.svg
└── robots.txt
├── remix.config.js
├── remix.env.d.ts
├── scripts
├── backup.mjs
└── build.mjs
├── tailwind.config.js
├── tests
├── authentication.spec.ts
├── index.spec.ts
├── setup.ts
├── submission.spec.ts
└── utils.ts
├── tsconfig.json
├── worker
├── adapter.ts
├── cache.ts
├── index.ts
├── logging.ts
├── scraping.ts
├── session.ts
├── store
│ ├── PageStore.ts
│ ├── ResourcesStore.ts
│ ├── UserStore.ts
│ └── index.ts
├── types.ts
└── utils.ts
└── wrangler.toml
/.env.example:
--------------------------------------------------------------------------------
1 | # Example: Please rename this file to `.env` for local development
2 |
3 | # [Required] GitHub Authentication (OAuth Apps)
4 | # Please note that a real client credentials is needed if you wanna login locally (You can create one with your own GitHub account)
5 | GITHUB_CLIENT_ID=foobar
6 | GITHUB_CLIENT_SECRET=1234567890
7 | GITHUB_CALLBACK_URL=http://localhost:8787/auth
8 | SESSION_SECRETS=s3cr3ts
9 |
10 | # [Optional] GitHub token for scrapping GitHub Repository
11 | GITHUB_TOKEN=
12 |
13 | # [Optional] Google API Key for the Safe Browsing API
14 | GOOGLE_API_KEY=
15 |
16 | # [Optional] UserAgent used for scrapping webpage (Some requests might fail without a proper user-agent)
17 | USER_AGENT="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36"
18 |
19 | # [Optional] Sentry DSN for error logging, fallback to console log if missing
20 | SENTRY_DSN=
21 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /.cache
2 | /.github
3 | /.husky
4 | /build
5 | /public
6 | /node_modules
7 | /dist
8 | /app/styles/tailwind.css
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@remix-run", "prettier"]
3 | }
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: '🐛 Bug report'
2 | description: Create a report to help us improve
3 | body:
4 | - type: markdown
5 | attributes:
6 | value: |
7 | Thank you for reporting an issue :pray:.
8 |
9 | This issue tracker is for reporting bugs found in `remix-guide` (https://github.com/edmundhung/remix-guide).
10 | If you have a question about how to achieve something and are struggling, please post a question
11 | inside of `remix-guide` Discussions tab: https://github.com/edmundhung/remix-guide/discussions
12 |
13 | Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already:
14 | - `remix-guide` Issues tab: https://github.com/edmundhung/remix-guide/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc
15 | - `remix-guide` closed issues tab: https://github.com/edmundhung/remix-guide/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed
16 | - `remix-guide` Discussions tab: https://github.com/edmundhung/remix-guide/discussions
17 |
18 | The more information you fill in, the better the community can help you.
19 | - type: textarea
20 | id: description
21 | attributes:
22 | label: Describe the bug
23 | description: Provide a clear and concise description of the challenge you are running into.
24 | validations:
25 | required: true
26 | - type: textarea
27 | id: steps
28 | attributes:
29 | label: Steps to Reproduce the Bug or Issue
30 | description: Describe the steps we have to take to reproduce the behavior.
31 | placeholder: |
32 | 1. Go to '...'
33 | 2. Click on '....'
34 | 3. Scroll down to '....'
35 | 4. See error
36 | validations:
37 | required: true
38 | - type: textarea
39 | id: expected
40 | attributes:
41 | label: Expected behavior
42 | description: Provide a clear and concise description of what you expected to happen.
43 | placeholder: |
44 | As a user, I expected ___ behavior but i am seeing ___
45 | validations:
46 | required: true
47 | - type: textarea
48 | id: screenshots_or_videos
49 | attributes:
50 | label: Screenshots or Videos
51 | description: |
52 | If applicable, add screenshots or a video to help explain your problem.
53 | For more information on the supported file image/file types and the file size limits, please refer
54 | to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files
55 | placeholder: |
56 | You can drag your video or image files inside of this editor ↓
57 | - type: textarea
58 | id: platform
59 | attributes:
60 | label: Platform
61 | value: |
62 | - OS: [e.g. macOS, Windows, Linux]
63 | - Browser: [e.g. Chrome, Safari, Firefox]
64 | - Version: [e.g. 91.1]
65 | validations:
66 | required: true
67 | - type: textarea
68 | id: additional
69 | attributes:
70 | label: Additional context
71 | description: Add any other context about the problem here.
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 🤔 Feature Requests & Questions
4 | url: https://github.com/edmundhung/remix-guide/discussions
5 | about: Please ask and answer questions here.
6 | - name: 💬 Remix Discord Channel
7 | url: https://rmx.as/discord
8 | about: Interact with other people using Remix 📀
9 | - name: 💬 New Updates (Twitter)
10 | url: https://twitter.com/remix_run
11 | about: Stay up to date with Remix news on twitter
12 | - name: 🍿 Remix YouTube Channel
13 | url: https://www.youtube.com/channel/UC_9cztXyAZCli9Cky6NWWwQ
14 | about: Are you a techlead or wanting to learn more about Remix in depth? Checkout the Remix YouTube Channel
--------------------------------------------------------------------------------
/.github/workflows/development.yml:
--------------------------------------------------------------------------------
1 | name: 📝 Development Workflow
2 | on:
3 | - push
4 | jobs:
5 | test:
6 | name: 🔍 Testing
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: ⬇️ Checkout repo
10 | uses: actions/checkout@v3
11 | - name: ⎔ Setup node
12 | uses: actions/setup-node@v3
13 | with:
14 | node-version: 16
15 | - name: 📥 Download deps
16 | uses: bahmutov/npm-install@v1
17 | - name: 🎭 Install Playwright
18 | run: npx playwright install --with-deps
19 | - name: 📦 Build the worker
20 | run: npm run build
21 | - name: 💣 Run some tests
22 | run: npm run test
23 |
24 | lint:
25 | name: ⬣ Linting
26 | runs-on: ubuntu-latest
27 | steps:
28 | - name: ⬇️ Checkout repo
29 | uses: actions/checkout@v3
30 | - name: ⎔ Setup node
31 | uses: actions/setup-node@v3
32 | with:
33 | node-version: 16
34 | - name: 📥 Download deps
35 | uses: bahmutov/npm-install@v1
36 | - name: ✨ Code format check
37 | run: npx prettier --check .
38 | - name: ✅ Code linting
39 | run: npx eslint . --ext .js,.mjs,.ts,.tsx
40 |
41 | deploy:
42 | name: 🛳 Deploying
43 | needs: [test, lint]
44 | if: github.ref == 'refs/heads/main'
45 | runs-on: ubuntu-latest
46 | steps:
47 | - name: 🛑 Cancel Previous Runs
48 | uses: styfle/cancel-workflow-action@0.9.1
49 | - name: ⬇️ Checkout repo
50 | uses: actions/checkout@v3
51 | - name: ⎔ Setup node
52 | uses: actions/setup-node@v3
53 | with:
54 | node-version: 16
55 | - name: 📥 Download deps
56 | uses: bahmutov/npm-install@v1
57 | - name: 🔥 Publish
58 | run: npx wrangler publish
59 | env:
60 | CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | # Remix
4 | /.cache
5 | /build
6 | /public/build
7 |
8 | # Custom Build
9 | /dist
10 |
11 | # Tailwind
12 | /app/styles/tailwind.css
13 |
14 | # Playwright
15 | /test-results
16 |
17 | # Miniflare
18 | /.mf
19 | /.env
20 |
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 16
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /.cache
2 | /.github
3 | /.husky
4 | /build
5 | /public
6 | /node_modules
7 | /dist
8 | /app/styles/tailwind.css
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | # Prettier Configuration
2 | # All supported options are listed here: https://prettier.io/docs/en/options.html
3 | tabWidth: 2
4 | useTabs: true
5 | semi: true
6 | singleQuote: true
7 | trailingComma: all
8 | bracketSpacing: true
9 | bracketSameLine: false
10 | arrowParens: always
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Edmund Hung
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # remix-guide
2 |
3 | Remix Guide is a platform for sharing everything about Remix. It is built with [Remix](https://remix.run) and is deployed to [Cloudflare Workers](https://workers.cloudflare.com/). All contents are saved in Durable Objects and cached with Worker KV.
4 |
5 | ## Roadmap
6 |
7 | The idea behind Remix Guide is to make resources from the community more accessible and making the process as automatic as possible at the same time. Future plans include:
8 |
9 | - Make and share your own list
10 | - Better search ranking / recommendations
11 | - Support searching by language and version (remix and packages)
12 |
13 | ## Submission
14 |
15 | As of v1.0, new resources can only be submitted online. There are some basic validations in place to ensure the submitted URL is relevant to remix. However, in order to minimize the risk of spamming or phishing, additional measures have to be added before it is generally available.
16 |
17 | If you would like to submit new content, feel free to share them on the `#showcase` channel of the [Remix Discord](https://discord.com/invite/remix). We are watching the channel and will publish anything shared there as soon as possible.
18 |
19 | ## Development
20 |
21 | To run Remix Guide locally, please make a `.env` file based on `.env.example` first. You can then start the app in development mode using:
22 |
23 | ```sh
24 | npm run dev
25 | ```
26 |
27 | This should kickstart a dev server and open the webpage on the browser automatically.
28 |
29 | ## Node Version
30 |
31 | Please make sure the node version is **>= 16.7**. If you are using `nvm`, simply run:
32 |
33 | ```sh
34 | nvm use
35 | ```
36 |
37 | This allows [miniflare](https://github.com/cloudflare/miniflare) to serve a development environment as close to the actual worker runtime as possibile.
38 |
--------------------------------------------------------------------------------
/app/components/About.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react';
2 | import { useMatches } from '@remix-run/react';
3 | import SvgIcon from '~/components/SvgIcon';
4 | import logo from '~/icons/logo.svg';
5 |
6 | function About(): ReactElement {
7 | const matches = useMatches();
8 | const { version } = matches[0]?.data ?? {};
9 |
10 | return (
11 |
12 |
13 |
14 |
Remix Guide
15 |
Sharing everything about Remix
16 |
17 | {version ? (
18 |
27 | ) : null}
28 |
29 | );
30 | }
31 |
32 | export default About;
33 |
--------------------------------------------------------------------------------
/app/components/BackupForm.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react';
2 | import { Form } from '@remix-run/react';
3 |
4 | interface BackupFormProps {
5 | data: any;
6 | }
7 |
8 | function BackupForm({ data }: BackupFormProps): ReactElement {
9 | const dataSortedByKeys = !data
10 | ? null
11 | : Object.keys(data)
12 | .sort()
13 | .reduce(
14 | (result, key) => Object.assign(result, { [key]: data[key] }),
15 | {} as Record,
16 | );
17 |
18 | const handleConfirm = (
19 | e: React.MouseEvent,
20 | ) => {
21 | if (!confirm('Are you sure?')) {
22 | e.preventDefault();
23 | }
24 | };
25 |
26 | return (
27 |
55 | );
56 | }
57 |
58 | export default BackupForm;
59 |
--------------------------------------------------------------------------------
/app/components/BookmarkDetails.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react';
2 | import { useMemo } from 'react';
3 | import { useLocation } from '@remix-run/react';
4 | import { PaneContainer, PaneHeader, PaneContent, PaneFooter } from '~/layout';
5 | import IconLink from '~/components/IconLink';
6 | import InputOption from '~/components/InputOption';
7 | import timesIcon from '~/icons/times.svg';
8 | import type { Resource } from '~/types';
9 | import { toggleSearchParams } from '~/search';
10 | import { useLists } from '~/hooks';
11 |
12 | interface BookmarkDetailsProps {
13 | resource: Resource;
14 | }
15 |
16 | function BookmarkDetails({ resource }: BookmarkDetailsProps): ReactElement {
17 | const location = useLocation();
18 | const lists = useLists();
19 | const closeURL = useMemo(
20 | () => `?${toggleSearchParams(location.search, 'bookmark')}`,
21 | [location.search],
22 | );
23 |
24 | return (
25 |
26 |
27 | Bookmark
28 |
29 |
30 |
31 |
32 |
33 |
34 | Title
35 |
36 |
{resource.title}
37 |
38 |
39 |
40 | Description
41 |
42 |
43 |
49 |
50 |
51 |
52 |
List
53 |
54 | {lists.map((list) => (
55 |
63 | ))}
64 |
65 |
66 |
67 |
68 |
69 |
77 |
85 |
86 |
87 |
88 | );
89 | }
90 |
91 | export default BookmarkDetails;
92 |
--------------------------------------------------------------------------------
/app/components/Card.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@remix-run/react';
2 | import type { ReactElement } from 'react';
3 | import type { Resource } from '~/types';
4 | import { getSite, getResourceURL } from '~/search';
5 | import type { SearchOptions } from '~/types';
6 |
7 | interface CardProps {
8 | entry: Resource;
9 | searchOptions: SearchOptions;
10 | selected?: boolean;
11 | }
12 |
13 | function Card({ entry, searchOptions, selected }: CardProps): ReactElement {
14 | return (
15 |
16 |
27 |
28 |
29 |
30 | {entry.category} /{' '}
31 | {getSite(entry.url)}
32 |
33 | {entry.createdAt.substring(0, 10)}
34 |
35 |
36 | {entry.title ?? entry.url}
37 |
38 | {!entry.description ? null : (
39 | {entry.description}
40 | )}
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | export default Card;
48 |
--------------------------------------------------------------------------------
/app/components/CategoryIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react';
2 | import SvgIcon from '~/components/SvgIcon';
3 | import packageIcon from '~/icons/box-open.svg';
4 | import repositoryIcon from '~/icons/map-signs.svg';
5 | import othersIcon from '~/icons/mail-bulk.svg';
6 |
7 | interface CategoryIconProps {
8 | category: string;
9 | }
10 |
11 | function CategoryIcon({ category }: CategoryIconProps): ReactElement | null {
12 | let iconUrl: string | null = null;
13 |
14 | switch (category) {
15 | case 'package':
16 | iconUrl = packageIcon;
17 | break;
18 | case 'repository':
19 | iconUrl = repositoryIcon;
20 | break;
21 | case 'others':
22 | iconUrl = othersIcon;
23 | break;
24 | }
25 |
26 | if (!iconUrl) {
27 | return ;
28 | }
29 |
30 | return ;
31 | }
32 |
33 | export default CategoryIcon;
34 |
--------------------------------------------------------------------------------
/app/components/Feed.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 | import { useMemo } from 'react';
3 | import { useLocation } from '@remix-run/react';
4 | import clsx from 'clsx';
5 | import SearchList from '~/components/SearchList';
6 | import ResourcesList from '~/components/ResourcesList';
7 | import { getSearchOptions } from '~/search';
8 | import type { Resource } from '~/types';
9 |
10 | interface ListProps {
11 | entries: Resource[];
12 | count: number;
13 | selectedId?: string | null;
14 | children: ReactNode;
15 | }
16 |
17 | function Feed({ entries, count, selectedId, children }: ListProps) {
18 | const location = useLocation();
19 | const searchParams = useMemo(
20 | () => new URLSearchParams(location.search),
21 | [location.search],
22 | );
23 | const searchOptions = getSearchOptions(
24 | `${location.pathname}${location.search}`,
25 | );
26 | const isSearching = searchParams.get('open') === 'search';
27 | const selected = typeof selectedId !== 'undefined' && selectedId !== null;
28 |
29 | return (
30 |
31 |
36 | {isSearching ? (
37 |
41 | ) : (
42 |
48 | )}
49 |
50 |
51 | {children}
52 |
53 |
54 | );
55 | }
56 |
57 | export default Feed;
58 |
--------------------------------------------------------------------------------
/app/components/FlashMessage.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react';
2 | import type { MessageType } from '~/types';
3 | import { useEffect, useState } from 'react';
4 | import SvgIcon from '~/components/SvgIcon';
5 | import timesIcon from '~/icons/times.svg';
6 | import checkCircleIcon from '~/icons/check-circle.svg';
7 | import timesCircleIcon from '~/icons/times-circle.svg';
8 | import exclamationCircleIcon from '~/icons/exclamation-circle.svg';
9 | import infoCircleIcon from '~/icons/info-circle.svg';
10 |
11 | function formatMessage(message: string): ReactElement {
12 | const [type, content] = message.split(':');
13 | let icon: string | null = null;
14 |
15 | switch (type.trim() as MessageType) {
16 | case 'success':
17 | icon = checkCircleIcon;
18 | break;
19 | case 'error':
20 | icon = timesCircleIcon;
21 | break;
22 | case 'info':
23 | icon = infoCircleIcon;
24 | break;
25 | case 'warning':
26 | icon = exclamationCircleIcon;
27 | break;
28 | }
29 |
30 | return (
31 | <>
32 | {!icon ? null : }
33 | {content.trim()}
34 | >
35 | );
36 | }
37 |
38 | interface FlashMessageProps {
39 | message: string | null;
40 | }
41 |
42 | function FlashMessage({ message }: FlashMessageProps): ReactElement | null {
43 | const [dismissed, setDismissed] = useState(false);
44 |
45 | useEffect(() => {
46 | setDismissed(false);
47 | }, [message]);
48 |
49 | if (!message || dismissed) {
50 | return null;
51 | }
52 |
53 | return (
54 |
55 |
56 | {formatMessage(message)}
57 |
58 |
61 |
62 | );
63 | }
64 |
65 | export default FlashMessage;
66 |
--------------------------------------------------------------------------------
/app/components/IconLink.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import type { ComponentProps, ReactElement } from 'react';
3 | import { Link } from '@remix-run/react';
4 | import SvgIcon from '~/components/SvgIcon';
5 |
6 | interface IconLinkProps {
7 | to: ComponentProps['to'];
8 | icon: ComponentProps['href'];
9 | prefetch?: ComponentProps['prefetch'];
10 | mobileOnly?: boolean;
11 | rotateIcon?: boolean;
12 | }
13 |
14 | function IconLink({
15 | to,
16 | icon,
17 | prefetch,
18 | mobileOnly,
19 | rotateIcon,
20 | }: IconLinkProps): ReactElement {
21 | return (
22 |
31 |
35 |
36 | );
37 | }
38 |
39 | export default IconLink;
40 |
--------------------------------------------------------------------------------
/app/components/InputOption.tsx:
--------------------------------------------------------------------------------
1 | interface InputOptionProps {
2 | type: 'radio' | 'checkbox';
3 | label?: string;
4 | name: string;
5 | value?: string | null;
6 | checked: boolean;
7 | }
8 |
9 | function InputOption({ type, label, name, value, checked }: InputOptionProps) {
10 | const id = `${name}-${value ?? 'any'}`;
11 |
12 | return (
13 |
27 | );
28 | }
29 |
30 | export default InputOption;
31 |
--------------------------------------------------------------------------------
/app/components/Progress.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement, RefObject } from 'react';
2 | import { useEffect, useRef } from 'react';
3 | import { useTransition } from '@remix-run/react';
4 |
5 | export function useProgress(): RefObject {
6 | const el = useRef(null);
7 | const timeout = useRef();
8 | const { location } = useTransition();
9 |
10 | useEffect(() => {
11 | if (!location || !el.current) {
12 | return;
13 | }
14 |
15 | if (timeout.current) {
16 | clearTimeout(timeout.current);
17 | }
18 |
19 | const target = el.current;
20 |
21 | target.style.width = `0%`;
22 |
23 | let updateWidth = (ms: number) => {
24 | timeout.current = setTimeout(() => {
25 | if (!target) {
26 | return;
27 | }
28 |
29 | let width = parseFloat(target.style.width);
30 | let percent = !isNaN(width) ? 15 + 0.85 * width : 0;
31 |
32 | target.style.width = `${percent}%`;
33 |
34 | updateWidth(100);
35 | }, ms);
36 | };
37 |
38 | updateWidth(300);
39 |
40 | return () => {
41 | if (timeout.current) {
42 | clearTimeout(timeout.current);
43 | }
44 |
45 | if (!target || target.style.width === `0%`) {
46 | return;
47 | }
48 |
49 | target.style.width = `100%`;
50 |
51 | timeout.current = setTimeout(() => {
52 | if (target.style.width !== '100%') {
53 | return;
54 | }
55 |
56 | target.style.width = ``;
57 | }, 200);
58 | };
59 | }, [location]);
60 |
61 | return el;
62 | }
63 |
64 | function Progress(): ReactElement {
65 | const progress = useProgress();
66 |
67 | return (
68 |
74 | );
75 | }
76 |
77 | export default Progress;
78 |
--------------------------------------------------------------------------------
/app/components/ResourcesDetails.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useLocation, useFetcher } from '@remix-run/react';
2 | import type { ReactElement, ReactNode } from 'react';
3 | import { useEffect, useMemo } from 'react';
4 | import type { Resource } from '~/types';
5 | import SvgIcon from '~/components/SvgIcon';
6 | import linkIcon from '~/icons/link.svg';
7 | import backIcon from '~/icons/back.svg';
8 | import bookmarkIcon from '~/icons/bookmark.svg';
9 | import {
10 | getSite,
11 | toggleSearchParams,
12 | getResourceURL,
13 | getSearchOptions,
14 | } from '~/search';
15 | import { PaneContainer, PaneHeader, PaneFooter, PaneContent } from '~/layout';
16 | import FlashMessage from '~/components/FlashMessage';
17 | import type { User } from '~/types';
18 | import IconLink from '~/components/IconLink';
19 | import { platforms } from '~/config';
20 | import { isMaintainer } from '~/helpers';
21 | import { useLists } from '~/hooks';
22 |
23 | interface ResourcesDetailsProps {
24 | resource: Resource;
25 | user: User | null;
26 | message?: string | null;
27 | children: ReactNode;
28 | }
29 |
30 | function ResourcesDetails({
31 | resource,
32 | user,
33 | message,
34 | children,
35 | }: ResourcesDetailsProps): ReactElement {
36 | const { submit } = useFetcher();
37 | const bookmark = useFetcher();
38 | const lists = useLists();
39 | const location = useLocation();
40 | const [backURL, bookmarkURL] = useMemo(() => {
41 | const searchParams = new URLSearchParams(location.search);
42 | const searchOptions = getSearchOptions(
43 | `${location.pathname}${location.search}`,
44 | );
45 | const backURL = getResourceURL(searchOptions);
46 | const bookmarkURL = `?${toggleSearchParams(
47 | searchParams.toString(),
48 | 'bookmark',
49 | )}`;
50 |
51 | return [backURL, bookmarkURL];
52 | }, [location.pathname, location.search]);
53 |
54 | useEffect(() => {
55 | submit(
56 | { type: 'view', resourceId: resource.id, url: resource.url },
57 | { method: 'post', action: `${location.pathname}?index` },
58 | );
59 | }, [submit, resource.id, resource.url, location.pathname]);
60 |
61 | const authenticated = user !== null;
62 |
63 | let bookmarked = user?.bookmarked.includes(resource.id) ?? false;
64 | let bookmarkCount = resource.bookmarkUsers?.length ?? 0;
65 |
66 | if (bookmark.submission) {
67 | const pendingBookmarkType = bookmark.submission?.formData.get('type');
68 |
69 | if (pendingBookmarkType === 'bookmark') {
70 | bookmarked = true;
71 | bookmarkCount = bookmarkCount + 1;
72 | } else if (pendingBookmarkType === 'unbookmark') {
73 | bookmarked = false;
74 | bookmarkCount = bookmarkCount - 1;
75 | }
76 | }
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
84 |
89 |
94 |
95 |
96 |
109 |
112 |
113 | {isMaintainer(user?.profile.name) ? (
114 |
115 |
116 |
117 | ) : null}
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 | {resource.category}
127 | {resource.createdAt.substring(0, 10)}
128 |
129 |
136 |
140 |
144 | {getSite(resource.url)}
145 |
146 | {resource.integrations || resource.lists ? (
147 |
148 | {resource.lists
149 | ?.flatMap(
150 | (slug) =>
151 | lists.find((list) => list.slug === slug) ?? [],
152 | )
153 | .map((list) => (
154 |
162 | {list.title}
163 |
164 | ))}
165 | {resource.integrations?.map((integration) => (
166 |
181 | {integration}
182 |
183 | ))}
184 |
185 | ) : null}
186 | {!resource.description ? null : (
187 |
188 | {resource.description}
189 |
190 | )}
191 |
192 | {resource.video || resource.image ? (
193 |
194 | {resource.video ? (
195 |
208 | ) : resource.image ? (
209 |
220 | ) : null}
221 |
222 | ) : null}
223 |
224 |
225 | {children}
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 | );
234 | }
235 |
236 | export default ResourcesDetails;
237 |
--------------------------------------------------------------------------------
/app/components/ResourcesList.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useLocation } from '@remix-run/react';
2 | import Card from '~/components/Card';
3 | import SvgIcon from '~/components/SvgIcon';
4 | import searchIcon from '~/icons/search.svg';
5 | import pencilIcon from '~/icons/pencil.svg';
6 | import timesIcon from '~/icons/times.svg';
7 | import newIcon from '~/icons/satellite-dish.svg';
8 | import hotIcon from '~/icons/fire-alt.svg';
9 | import topIcon from '~/icons/badge-check.svg';
10 | import menuIcon from '~/icons/menu.svg';
11 | import type { Resource } from '~/types';
12 | import { PaneContainer, PaneHeader, PaneContent, PaneFooter } from '~/layout';
13 | import {
14 | getResourceURL,
15 | getTitleBySearchOptions,
16 | toggleSearchParams,
17 | } from '~/search';
18 | import type { SearchOptions } from '~/types';
19 | import { useMemo } from 'react';
20 | import clsx from 'clsx';
21 | import IconLink from '~/components/IconLink';
22 | import ShowMoreButton from '~/components/ShowMoreButton';
23 | import { useFeedScrollRestoration } from '~/scroll';
24 |
25 | interface ResourcesListProps {
26 | entries: Resource[];
27 | count: number;
28 | selectedResourceId: string | null | undefined;
29 | searchOptions: SearchOptions;
30 | }
31 |
32 | function isSearching(searchOptions: SearchOptions): boolean {
33 | const keys = ['list', 'sort', 'limit'];
34 |
35 | return Object.entries(searchOptions).some(
36 | ([key, value]) =>
37 | !keys.includes(key) &&
38 | (Array.isArray(value) ? value.length > 0 : value !== null),
39 | );
40 | }
41 |
42 | export default function ResourcesList({
43 | entries,
44 | count,
45 | selectedResourceId,
46 | searchOptions,
47 | }: ResourcesListProps) {
48 | const location = useLocation();
49 | const container = useFeedScrollRestoration();
50 | const [toggleSearchURL, toggleMenuURL] = useMemo(
51 | () => [
52 | `?${toggleSearchParams(location.search, 'search')}`,
53 | `?${toggleSearchParams(location.search, 'menu')}`,
54 | ],
55 | [location.search],
56 | );
57 |
58 | return (
59 |
60 | {!isSearching(searchOptions) ? (
61 |
62 |
71 |
72 | {getTitleBySearchOptions(searchOptions)}
73 |
74 |
82 |
83 | ) : (
84 |
85 |
86 |
87 |
88 | {getTitleBySearchOptions(searchOptions)}
89 |
90 |
91 |
98 |
99 | )}
100 |
101 | {entries.length === 0 ? (
102 |
103 | No resources found at the moment
104 |
105 | ) : (
106 |
107 | {entries.map((entry) => (
108 |
114 | ))}
115 | {entries.length < count ? (
116 |
120 | ) : null}
121 |
122 | )}
123 |
124 |
125 |
126 |
138 | New
139 |
140 |
152 | Hot
153 |
154 |
166 | Top
167 |
168 |
169 |
170 |
171 | );
172 | }
173 |
--------------------------------------------------------------------------------
/app/components/SearchList.tsx:
--------------------------------------------------------------------------------
1 | import { Form, Link } from '@remix-run/react';
2 | import { useRef, useState } from 'react';
3 | import clsx from 'clsx';
4 | import SvgIcon from '~/components/SvgIcon';
5 | import InputOption from '~/components/InputOption';
6 | import backIcon from '~/icons/back.svg';
7 | import timesIcon from '~/icons/times.svg';
8 | import { categories, integrations, platforms } from '~/config';
9 | import {
10 | PaneContainer,
11 | PaneHeader,
12 | PaneContent,
13 | List,
14 | PaneFooter,
15 | } from '~/layout';
16 | import type { SearchOptions } from '~/types';
17 |
18 | interface SearchListProps {
19 | searchOptions: SearchOptions;
20 | selectedResourceId: string | null | undefined;
21 | }
22 |
23 | function SearchList({
24 | searchOptions,
25 | selectedResourceId: selectedId,
26 | }: SearchListProps) {
27 | const ref = useRef(null);
28 | const [keyword, setKeyword] = useState(searchOptions.keyword ?? '');
29 |
30 | return (
31 |
173 | );
174 | }
175 |
176 | export default SearchList;
177 |
--------------------------------------------------------------------------------
/app/components/ShowMoreButton.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@remix-run/react';
2 | import { useInView } from 'react-intersection-observer';
3 | import { getResourceURL } from '~/search';
4 | import type { SearchOptions } from '~/types';
5 | import { useEffect, useMemo, useRef } from 'react';
6 |
7 | interface ShowMoreButtonProps {
8 | searchOptions: SearchOptions;
9 | selected: string | null | undefined;
10 | }
11 |
12 | function ShowMoreButton({ searchOptions, selected }: ShowMoreButtonProps) {
13 | const { ref, inView } = useInView();
14 | const link = useRef(null);
15 | const url = useMemo(
16 | () =>
17 | getResourceURL(
18 | {
19 | ...searchOptions,
20 | limit: (searchOptions.limit ?? 0) + 25,
21 | },
22 | selected,
23 | ),
24 | [searchOptions, selected],
25 | );
26 |
27 | useEffect(() => {
28 | if (!inView || !link.current) {
29 | return;
30 | }
31 |
32 | link.current.click();
33 | }, [inView]);
34 |
35 | return (
36 |
37 |
45 | Show more
46 |
47 |
48 | );
49 | }
50 |
51 | export default ShowMoreButton;
52 |
--------------------------------------------------------------------------------
/app/components/SuggestedResources.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react';
2 | import Card from '~/components/Card';
3 | import { getTitleBySearchOptions } from '~/search';
4 | import type { Resource, SearchOptions } from '~/types';
5 |
6 | interface SuggestedResourcesProps {
7 | entries: Resource[];
8 | searchOptions: SearchOptions;
9 | }
10 |
11 | function SuggestedResources({
12 | entries,
13 | searchOptions,
14 | }: SuggestedResourcesProps): ReactElement {
15 | return (
16 |
17 |
{getTitleBySearchOptions(searchOptions)}
18 | {entries.length === 0 ? (
19 |
20 | No resources found at the moment
21 |
22 | ) : (
23 |
24 | {entries.map((entry) => (
25 |
26 | ))}
27 |
28 | )}
29 |
30 | );
31 | }
32 |
33 | export default SuggestedResources;
34 |
--------------------------------------------------------------------------------
/app/components/SvgIcon.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement } from 'react';
2 |
3 | interface SvgIconProps extends Partial {
4 | href: string;
5 | }
6 |
7 | function SvgIcon({ href, ...rest }: SvgIconProps): ReactElement {
8 | return (
9 |
12 | );
13 | }
14 |
15 | export default SvgIcon;
16 |
--------------------------------------------------------------------------------
/app/config.ts:
--------------------------------------------------------------------------------
1 | import type { Category } from '~/types';
2 |
3 | export const categories: Category[] = ['package', 'repository', 'others'];
4 |
5 | export const platforms = [
6 | 'aws',
7 | 'azure',
8 | 'cloudflare',
9 | 'firebase',
10 | 'fly',
11 | 'netlify',
12 | 'render',
13 | 'vercel',
14 | ];
15 |
16 | export const integrations = [
17 | 'architect',
18 | 'cypress',
19 | 'express',
20 | 'prisma',
21 | 'tailwindcss',
22 | ];
23 |
24 | export const administrators = ['edmundhung'];
25 |
26 | export const maintainers = [
27 | 'marbiano',
28 | 'CanRau',
29 | 'ryanflorence',
30 | 'mjackson',
31 | 'kentcdodds',
32 | 'jacob-ebey',
33 | 'mcansh',
34 | 'kiliman',
35 | 'benborgers',
36 | 'gustavoguichard',
37 | 'nakleiderer',
38 | 'dev-xo',
39 | ];
40 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { RemixBrowser } from '@remix-run/react';
2 | import { startTransition, StrictMode } from 'react';
3 | import { hydrateRoot } from 'react-dom/client';
4 |
5 | function hydrate() {
6 | startTransition(() => {
7 | hydrateRoot(
8 | document,
9 |
10 |
11 | ,
12 | );
13 | });
14 | }
15 |
16 | if (window.requestIdleCallback) {
17 | window.requestIdleCallback(hydrate);
18 | } else {
19 | // Safari doesn't support requestIdleCallback
20 | // https://caniuse.com/requestidlecallback
21 | window.setTimeout(hydrate, 1);
22 | }
23 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { type EntryContext } from '@remix-run/cloudflare';
2 | import { RemixServer } from '@remix-run/react';
3 | import isbot from 'isbot';
4 | import { renderToReadableStream } from 'react-dom/server';
5 |
6 | export default async function handleRequest(
7 | request: Request,
8 | responseStatusCode: number,
9 | responseHeaders: Headers,
10 | remixContext: EntryContext,
11 | ) {
12 | const body = await renderToReadableStream(
13 | ,
14 | {
15 | onError: (error) => {
16 | responseStatusCode = 500;
17 | console.error(error);
18 | },
19 | signal: request.signal,
20 | },
21 | );
22 |
23 | if (isbot(request.headers.get('User-Agent'))) {
24 | await body.allReady;
25 | }
26 |
27 | const headers = new Headers(responseHeaders);
28 | headers.set('Content-Type', 'text/html');
29 |
30 | return new Response(body, {
31 | status: responseStatusCode,
32 | headers,
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/app/helpers.ts:
--------------------------------------------------------------------------------
1 | import type { AppLoadContext } from '@remix-run/cloudflare';
2 | import { administrators, maintainers } from '~/config';
3 |
4 | export function notFound(): Response {
5 | const statusText = 'Not Found';
6 |
7 | return new Response(statusText, { status: 404, statusText });
8 | }
9 |
10 | export function ok(body?: string | null): Response {
11 | return new Response(body, { status: body ? 200 : 204 });
12 | }
13 |
14 | export function getDescription(list?: string): string {
15 | switch (list) {
16 | case 'official':
17 | return 'Official resources provided by the Remix team';
18 | case 'packages':
19 | return 'NPM package directory that the community used with Remix';
20 | case 'tutorials':
21 | return 'Learning materials curated by the communtiy';
22 | case 'templates':
23 | return 'Find out a remix stack to generate a project quickly and easily';
24 | case 'talks':
25 | return 'Ideas and opinions all about Remix or the web';
26 | case 'examples':
27 | return 'List of websites that are built with Remix';
28 | case 'integrations':
29 | return 'Need help? Check out these examples integrating with Remix';
30 | default:
31 | return 'A platform for the Remix community';
32 | }
33 | }
34 |
35 | export function formatMeta(meta: Record) {
36 | const descriptor: Record = {
37 | title: 'Remix Guide',
38 | 'og:site_name': 'remix-guide',
39 | 'og:type': 'website',
40 | };
41 |
42 | for (const [key, value] of Object.entries(meta)) {
43 | if (!key || !value) {
44 | continue;
45 | }
46 |
47 | switch (key) {
48 | case 'title': {
49 | const title =
50 | value === descriptor['title']
51 | ? descriptor['title']
52 | : `${value} - ${descriptor['title']}`;
53 |
54 | descriptor['title'] = title;
55 | descriptor['og:title'] = title;
56 | descriptor['twitter:title'] = title;
57 | break;
58 | }
59 | case 'description': {
60 | descriptor['description'] = value;
61 | descriptor['og:description'] = value;
62 | descriptor['twitter:description'] = value;
63 | break;
64 | }
65 | default: {
66 | descriptor[key] = value;
67 | break;
68 | }
69 | }
70 | }
71 |
72 | return descriptor;
73 | }
74 |
75 | export function capitalize(text: string): string;
76 | export function capitalize(text: null | undefined): null;
77 | export function capitalize(text: string | null | undefined): string | null {
78 | if (!text) {
79 | return null;
80 | }
81 |
82 | return text[0].toUpperCase() + text.slice(1).toLowerCase();
83 | }
84 |
85 | export function isMaintainer(name: string | null | undefined) {
86 | if (!name) {
87 | return false;
88 | }
89 |
90 | if (process.env.NODE_ENV === 'development') {
91 | return true;
92 | }
93 |
94 | return maintainers.includes(name) || isAdministrator(name);
95 | }
96 |
97 | export function isAdministrator(name: string | null | undefined) {
98 | if (!name) {
99 | return false;
100 | }
101 |
102 | if (process.env.NODE_ENV === 'development') {
103 | return true;
104 | }
105 |
106 | return administrators.includes(name);
107 | }
108 |
109 | export async function requireAdministrator(context: AppLoadContext) {
110 | const profile = await context.session.getUserProfile();
111 |
112 | if (!profile || !isAdministrator(profile.name)) {
113 | throw notFound();
114 | }
115 |
116 | return profile;
117 | }
118 |
119 | export async function requireMaintainer(context: AppLoadContext) {
120 | const profile = await context.session.getUserProfile();
121 |
122 | if (!profile || !isMaintainer(profile.name)) {
123 | throw notFound();
124 | }
125 |
126 | return profile;
127 | }
128 |
--------------------------------------------------------------------------------
/app/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useMatches } from '@remix-run/react';
2 | import type { GuideMetadata, SessionData } from '~/types';
3 |
4 | export function useSessionData(): SessionData {
5 | const [rootMatch] = useMatches();
6 |
7 | return (rootMatch?.data as SessionData) ?? null;
8 | }
9 |
10 | export function useLists(): Required['lists'] {
11 | const [, layoutMatch] = useMatches();
12 |
13 | return layoutMatch?.data.lists ?? [];
14 | }
15 |
--------------------------------------------------------------------------------
/app/icons/back.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/badge-check.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/bookmark.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/box-open.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/bullhorn.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/calendar-alt.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/chalkboard-teacher.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/check-circle.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/circle.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/code.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/collapse.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/discord.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/exclamation-circle.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/expand.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/fire-alt.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/github.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/hexagon.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/history.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/home.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/info-circle.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/layer-group.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/link.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/logo.svg:
--------------------------------------------------------------------------------
1 |
25 |
--------------------------------------------------------------------------------
/app/icons/mail-bulk.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/map-pin.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/map-signs.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/meetup.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/menu.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/pencil.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/plus.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/remix.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/app/icons/rss-square.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/satellite-dish.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/icons/square.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/template.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/times-circle.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/times.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/trending.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/user.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/icons/video.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactElement, ReactNode } from 'react';
2 | import { forwardRef } from 'react';
3 | import clsx from 'clsx';
4 |
5 | type Padding = 'none' | 'minimum' | 'maximum';
6 |
7 | interface PaneContainerProps {
8 | children: ReactNode;
9 | }
10 |
11 | export const PaneContainer = forwardRef(
12 | ({ children }, ref) => {
13 | return (
14 |
20 | );
21 | },
22 | );
23 |
24 | PaneContainer.displayName = 'PaneContainer';
25 |
26 | interface PaneHeaderProps {
27 | padding?: Padding;
28 | children: ReactNode;
29 | }
30 |
31 | export function PaneHeader({
32 | padding = 'maximum',
33 | children,
34 | }: PaneHeaderProps): ReactElement {
35 | return (
36 |
41 |
46 | {children}
47 |
48 |
49 | );
50 | }
51 |
52 | interface PaneFooterProps {
53 | padding?: Padding;
54 | children: ReactNode;
55 | }
56 |
57 | export function PaneFooter({
58 | padding = 'none',
59 | children,
60 | }: PaneFooterProps): ReactElement {
61 | return (
62 |
73 | );
74 | }
75 |
76 | interface PaneContentProps {
77 | padding?: Padding;
78 | children: ReactNode;
79 | }
80 |
81 | export function PaneContent({
82 | padding = 'minimum',
83 | children,
84 | }: PaneContentProps): ReactElement {
85 | return (
86 |
91 |
96 | {children}
97 |
98 |
99 | );
100 | }
101 |
102 | interface ListProps {
103 | title?: string;
104 | action?: ReactElement | null;
105 | children: ReactNode;
106 | }
107 |
108 | export function List({ title, action, children }: ListProps): ReactElement {
109 | return (
110 |
111 | {title ? (
112 |
113 |
114 |
{title}
115 | {action}
116 |
117 |
118 | ) : null}
119 |
120 |
121 | {Array.isArray(children) ? (
122 | children.map((child, i) => - {child}
)
123 | ) : (
124 | - {children}
125 | )}
126 |
127 |
128 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/app/resources.tsx:
--------------------------------------------------------------------------------
1 | import { getSite } from '~/search';
2 | import type { SearchOptions, Resource, User } from '~/types';
3 |
4 | function calculateScore(resource: Resource): number {
5 | const timeScore =
6 | (new Date(resource.createdAt).valueOf() -
7 | new Date('2021-12-24T00:00:00.000Z').valueOf()) /
8 | 1000 /
9 | 3600 /
10 | 24;
11 | const bookmarkCount = resource.bookmarkUsers?.length ?? 0;
12 | const bookmarkScore = bookmarkCount > 0 ? Math.log10(bookmarkCount) + 1 : 0;
13 | const viewScore = Math.log10(resource.viewCount ?? 0);
14 |
15 | return 1 * timeScore + 10 * bookmarkScore + 1.5 * viewScore;
16 | }
17 |
18 | function compareResources(key: string, prev: Resource, next: Resource): number {
19 | let diff = 0;
20 |
21 | switch (key) {
22 | case 'timestamp':
23 | diff =
24 | new Date(next.createdAt).valueOf() - new Date(prev.createdAt).valueOf();
25 | break;
26 | case 'bookmarkCount':
27 | diff =
28 | (next.bookmarkUsers?.length ?? 0) - (prev.bookmarkUsers?.length ?? 0);
29 | break;
30 | case 'viewCount':
31 | diff = (next.viewCount ?? 0) - (prev.viewCount ?? 0);
32 | break;
33 | }
34 |
35 | return diff;
36 | }
37 |
38 | export function search(
39 | list: { [resourceId: string]: Resource },
40 | options: SearchOptions,
41 | ): { entries: Resource[]; count: number } {
42 | function match(
43 | wanted: string[],
44 | value: string | string[],
45 | partialMatch = false,
46 | ) {
47 | if (wanted.length === 0) {
48 | return true;
49 | }
50 |
51 | if (partialMatch || Array.isArray(value)) {
52 | return wanted.every((item) => value.includes(item));
53 | }
54 |
55 | return wanted.includes(value);
56 | }
57 |
58 | const entries = Object.values(list)
59 | .filter((resource) => {
60 | if (options.includes && !options.includes.includes(resource.id)) {
61 | return false;
62 | }
63 |
64 | if (options.excludes && options.excludes.includes(resource.id)) {
65 | return false;
66 | }
67 |
68 | const isMatching =
69 | match(options?.list ? [options.list] : [], resource.lists ?? []) &&
70 | match(
71 | options?.keyword ? options.keyword.toLowerCase().split(' ') : [],
72 | `${resource.title} ${resource.description}`.toLowerCase(),
73 | true,
74 | ) &&
75 | match(options.author ? [options.author] : [], resource.author ?? '') &&
76 | match(
77 | options.category ? [options.category] : [],
78 | resource.category ?? '',
79 | ) &&
80 | match(
81 | options.site ? [options.site] : [],
82 | new URL(resource.url).hostname,
83 | ) &&
84 | match(
85 | ([] as string[]).concat(
86 | options.platform ?? [],
87 | options.integrations ?? [],
88 | ),
89 | resource.integrations ?? [],
90 | );
91 |
92 | return isMatching;
93 | })
94 | .sort((prev, next) => {
95 | let diff = 0;
96 |
97 | switch (options.sort) {
98 | case 'top':
99 | for (const key of ['bookmarkCount', 'viewCount', 'timestamp']) {
100 | diff = compareResources(key, prev, next);
101 |
102 | if (diff !== 0) {
103 | break;
104 | }
105 | }
106 | break;
107 | case 'hot':
108 | diff = calculateScore(next) - calculateScore(prev);
109 | break;
110 | case 'new':
111 | default:
112 | diff = options.includes
113 | ? options.includes.indexOf(prev.id) -
114 | options.includes.indexOf(next.id)
115 | : compareResources('timestamp', prev, next);
116 | break;
117 | }
118 |
119 | return diff;
120 | });
121 |
122 | return {
123 | entries: options.limit ? entries.slice(0, options.limit) : entries,
124 | count: entries.length,
125 | };
126 | }
127 |
128 | export function getSuggestions(
129 | list: { [resourceId: string]: Resource },
130 | resource: Resource,
131 | ) {
132 | const suggestions: SearchOptions[] = [];
133 |
134 | if (resource.category === 'package' && resource.title) {
135 | suggestions.push({
136 | integrations: [resource.title],
137 | sort: 'top',
138 | });
139 | }
140 |
141 | if (resource.author) {
142 | suggestions.push({
143 | author: resource.author,
144 | sort: 'top',
145 | });
146 | }
147 |
148 | if (resource.category === 'others') {
149 | suggestions.push({
150 | site: getSite(resource.url),
151 | sort: 'top',
152 | });
153 | }
154 |
155 | const result = suggestions.map((searchOptions) => ({
156 | entries: search(list, {
157 | ...searchOptions,
158 | excludes: [resource.id],
159 | limit: 6,
160 | }).entries,
161 | searchOptions,
162 | }));
163 |
164 | return result;
165 | }
166 |
167 | export function patchResource(resource: Resource, user: User): Resource {
168 | const isUserBookmarked = user.bookmarked.includes(resource.id) ?? false;
169 | const isResourceBookmarked =
170 | resource.bookmarkUsers?.includes(user.profile.id) ?? false;
171 |
172 | if (isUserBookmarked === isResourceBookmarked) {
173 | return resource;
174 | }
175 |
176 | return {
177 | ...resource,
178 | bookmarkUsers: isUserBookmarked
179 | ? [...(resource.bookmarkUsers ?? []), user.profile.id]
180 | : resource.bookmarkUsers?.filter((id) => id !== user.profile.id),
181 | };
182 | }
183 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | LinksFunction,
3 | MetaFunction,
4 | LoaderArgs,
5 | } from '@remix-run/cloudflare';
6 | import { json } from '@remix-run/cloudflare';
7 | import type { ShouldReloadFunction } from '@remix-run/react';
8 | import {
9 | Meta,
10 | Links,
11 | Scripts,
12 | LiveReload,
13 | useCatch,
14 | Outlet,
15 | } from '@remix-run/react';
16 | import stylesUrl from '~/styles/tailwind.css';
17 |
18 | export let links: LinksFunction = () => {
19 | return [
20 | { rel: 'stylesheet', href: stylesUrl },
21 | { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
22 | ];
23 | };
24 |
25 | export let meta: MetaFunction = () => {
26 | return {
27 | 'color-scheme': 'dark',
28 | viewport: 'width=device-width, initial-scale=1',
29 | };
30 | };
31 |
32 | export async function loader({ context }: LoaderArgs) {
33 | const { session } = context;
34 | const [data, setCookieHeader] = await session.getData();
35 |
36 | return json(data, {
37 | headers: {
38 | 'Set-Cookie': setCookieHeader,
39 | },
40 | });
41 | }
42 |
43 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => {
44 | return submission?.formData.get('type') !== 'view';
45 | };
46 |
47 | function Document({
48 | children,
49 | title,
50 | }: {
51 | children: React.ReactNode;
52 | title?: string;
53 | }) {
54 | return (
55 |
56 |
57 |
58 | {title ? {title} : null}
59 |
60 |
61 |
66 |
67 |
68 | {children}
69 |
70 | {process.env.NODE_ENV === 'development' ? : null}
71 |
72 |
73 | );
74 | }
75 |
76 | export default function App() {
77 | return (
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | export function CatchBoundary() {
85 | let caught = useCatch();
86 |
87 | switch (caught.status) {
88 | case 401:
89 | case 404:
90 | return (
91 |
92 |
93 |
94 | {caught.status} {caught.statusText}
95 |
96 |
97 |
98 | );
99 |
100 | default:
101 | throw new Error(
102 | `Unexpected caught response with status: ${caught.status}`,
103 | );
104 | }
105 | }
106 |
107 | export function ErrorBoundary({ error }: { error: Error }) {
108 | console.error(error);
109 |
110 | return (
111 |
112 |
113 |
Sorry, something went wrong...
114 |
115 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/app/routes/[rss.xml].tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 | import { capitalize } from '~/helpers';
3 | import { search } from '~/resources';
4 | import { getSearchOptions } from '~/search';
5 |
6 | interface FeedEntry {
7 | title: string;
8 | pubDate: string;
9 | link: string;
10 | guid: string;
11 | }
12 |
13 | export async function loader({ request, context }: LoaderArgs) {
14 | const url = new URL(request.url);
15 | const searchOptions = getSearchOptions(request.url);
16 | const resources = await context.resourceStore.list();
17 | const list = search(resources, {
18 | ...searchOptions,
19 | sort: 'new',
20 | limit: 25,
21 | });
22 | const entries = list.entries.reduce((list, resource) => {
23 | if (resource.title) {
24 | list.push({
25 | title:
26 | !searchOptions.list && resource.lists
27 | ? `[${resource.lists.map((list) => capitalize(list)).join(', ')}] ${
28 | resource.title
29 | }`
30 | : resource.title,
31 | pubDate: new Date(resource.createdAt).toUTCString(),
32 | link: resource.url,
33 | guid: `${url.origin}/resources/${resource.id}`,
34 | });
35 | }
36 |
37 | return list;
38 | }, []);
39 |
40 | const rss = `
41 |
42 |
43 |
44 | Remix Guide
45 | A platform for the Remix community
46 | ${url.origin}
47 | en-us
48 | remix-guide
49 | 60
50 |
53 | ${entries
54 | .map((entry) =>
55 | `
56 | -
57 |
58 | ${entry.pubDate}
59 | ${entry.link}
60 | ${entry.guid}
61 |
62 | `.trim(),
63 | )
64 | .join('\n')}
65 |
66 |
67 | `.trim();
68 |
69 | return new Response(rss, {
70 | headers: {
71 | 'Content-Type': 'application/xml',
72 | 'Content-Length': String(new TextEncoder().encode(rss).length),
73 | },
74 | });
75 | }
76 |
--------------------------------------------------------------------------------
/app/routes/[sitemap.xml].tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 |
3 | interface SitemapEntry {
4 | loc: string;
5 | lastmod?: string;
6 | changefreq?:
7 | | 'never'
8 | | 'yearly'
9 | | 'monthly'
10 | | 'weekly'
11 | | 'daily'
12 | | 'hourly'
13 | | 'always';
14 | priority?: 1.0 | 0.9 | 0.8 | 0.7 | 0.6 | 0.5 | 0.4 | 0.3 | 0.2 | 0.1 | 0.0;
15 | }
16 |
17 | export async function loader({ request, context }: LoaderArgs) {
18 | const url = new URL(request.url);
19 | const guide = await context.resourceStore.getData();
20 | const entries: SitemapEntry[] = [
21 | { loc: url.origin, changefreq: 'hourly', priority: 1 },
22 | ];
23 |
24 | for (const list of guide.metadata.lists ?? []) {
25 | entries.push({
26 | loc: `${url.origin}/${list.slug}`,
27 | changefreq: 'hourly',
28 | priority: 0.8,
29 | });
30 | }
31 |
32 | for (const [resourceId, resource] of Object.entries(guide.value)) {
33 | entries.push({
34 | loc: `${url.origin}/resources/${resourceId}`,
35 | lastmod: resource.updatedAt,
36 | changefreq: 'weekly',
37 | priority: 0.5,
38 | });
39 | }
40 |
41 | const sitemap = `
42 |
43 |
44 | ${entries
45 | .map((entry) =>
46 | `
47 |
48 | ${entry.loc}
49 | ${entry.lastmod ? `${entry.lastmod}` : ''}
50 | ${entry.changefreq ? `${entry.changefreq}` : ''}
51 | ${entry.priority ? `${entry.priority}` : ''}
52 |
53 | `.trim(),
54 | )
55 | .join('\n')}
56 |
57 | `.trim();
58 |
59 | return new Response(sitemap, {
60 | headers: {
61 | 'Content-Type': 'application/xml',
62 | 'Content-Length': String(new TextEncoder().encode(sitemap).length),
63 | },
64 | });
65 | }
66 |
--------------------------------------------------------------------------------
/app/routes/_layout.$list.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs, MetaFunction } from '@remix-run/cloudflare';
2 | import { json } from '@remix-run/cloudflare';
3 | import { useLoaderData } from '@remix-run/react';
4 | import type { ShouldReloadFunction } from '@remix-run/react';
5 | import Feed from '~/components/Feed';
6 | import { capitalize, formatMeta, getDescription, notFound } from '~/helpers';
7 | import { search } from '~/resources';
8 | import { getRelatedSearchParams, getSearchOptions } from '~/search';
9 | import About from '~/components/About';
10 |
11 | export async function loader({ request, params, context }: LoaderArgs) {
12 | const { session, resourceStore, userStore } = context;
13 | const profile = await session.getUserProfile();
14 |
15 | switch (params.list) {
16 | case 'bookmarks':
17 | case 'history': {
18 | if (!profile) {
19 | throw notFound();
20 | }
21 |
22 | const searchOptions = getSearchOptions(request.url);
23 | const [list, includes] = await Promise.all([
24 | resourceStore.list(),
25 | userStore.getList(profile.id, params.list ?? null),
26 | ]);
27 |
28 | return json(search(list, { ...searchOptions, list: null, includes }));
29 | }
30 | default: {
31 | const searchOptions = getSearchOptions(request.url);
32 | const guide = await resourceStore.getData();
33 |
34 | if (!guide.metadata.lists?.find((list) => list.slug === params.list)) {
35 | throw notFound();
36 | }
37 |
38 | return json(search(guide.value, searchOptions));
39 | }
40 | }
41 | }
42 |
43 | export const meta: MetaFunction = ({ params }) => {
44 | return formatMeta({
45 | title: params.list ? capitalize(params.list) : '',
46 | description: getDescription(params.list),
47 | 'og:url': `https://remix.guide/${params.list}`,
48 | });
49 | };
50 |
51 | export const unstable_shouldReload: ShouldReloadFunction = ({
52 | url,
53 | prevUrl,
54 | submission,
55 | }) => {
56 | const nextSearch = getRelatedSearchParams(url.search).toString();
57 | const prevSearch = getRelatedSearchParams(prevUrl.search).toString();
58 |
59 | return (
60 | nextSearch !== prevSearch ||
61 | ['update', 'delete'].includes(
62 | submission?.formData.get('type')?.toString() ?? '',
63 | )
64 | );
65 | };
66 |
67 | export default function List() {
68 | const { entries, count } = useLoaderData();
69 |
70 | return (
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/app/routes/_layout.admin.index.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 | import { redirect } from '@remix-run/cloudflare';
3 | import { requireMaintainer } from '~/helpers';
4 |
5 | export async function loader({ context }: LoaderArgs) {
6 | await requireMaintainer(context);
7 |
8 | return redirect('/admin/pages');
9 | }
10 |
--------------------------------------------------------------------------------
/app/routes/_layout.admin.pages.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs, ActionArgs } from '@remix-run/cloudflare';
2 | import { json, redirect } from '@remix-run/cloudflare';
3 | import { Form, useLoaderData, useLocation } from '@remix-run/react';
4 | import { requireMaintainer } from '~/helpers';
5 | import { getSite } from '~/search';
6 | import type { PageMetadata } from '~/types';
7 |
8 | export async function action({ context, request }: ActionArgs) {
9 | const { session, pageStore } = context;
10 | const [formData] = await Promise.all([
11 | request.formData(),
12 | requireMaintainer(context),
13 | ]);
14 | const url = formData.get('url')?.toString();
15 |
16 | if (!url) {
17 | return new Response('Bad Request', { status: 400 });
18 | }
19 |
20 | await pageStore.refresh(url);
21 |
22 | return redirect(request.url, {
23 | headers: {
24 | 'Set-Cookie': await session.flash(
25 | 'Refresh successfull. Be aware that new content will be populated only after the cache is expired',
26 | 'success',
27 | ),
28 | },
29 | });
30 | }
31 |
32 | export async function loader({ context }: LoaderArgs) {
33 | const [entries] = await Promise.all([
34 | context.pageStore.listPageMetadata(),
35 | requireMaintainer(context),
36 | ]);
37 |
38 | return json({
39 | entries,
40 | });
41 | }
42 |
43 | export default function ListUsers() {
44 | const { entries } = useLoaderData<{ entries: PageMetadata[] }>();
45 | const location = useLocation();
46 |
47 | return (
48 |
49 |
50 |
Pages ({entries.length})
51 |
52 |
137 |
138 | );
139 | }
140 |
--------------------------------------------------------------------------------
/app/routes/_layout.admin.resources.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs, ActionArgs } from '@remix-run/cloudflare';
2 | import { json, redirect } from '@remix-run/cloudflare';
3 | import { useActionData } from '@remix-run/react';
4 | import { requireAdministrator } from '~/helpers';
5 | import BackupForm from '~/components/BackupForm';
6 |
7 | export async function loader({ context }: LoaderArgs) {
8 | await requireAdministrator(context);
9 |
10 | return json({});
11 | }
12 |
13 | export async function action({ request, context }: ActionArgs) {
14 | const { session, resourceStore } = context;
15 | const [formData] = await Promise.all([
16 | request.formData(),
17 | requireAdministrator(context),
18 | ]);
19 | const type = formData.get('type');
20 |
21 | switch (type) {
22 | case 'backup': {
23 | const data = await resourceStore.backup();
24 |
25 | return json(data);
26 | }
27 | case 'restore': {
28 | const input = formData.get('data');
29 | const data = input ? input.toString() : '';
30 |
31 | if (data.trim() === '') {
32 | return redirect('/admin/resources', {
33 | headers: {
34 | 'Set-Cookie': await session.flash(
35 | 'Please provide proper data before clicking restore',
36 | 'error',
37 | ),
38 | },
39 | });
40 | }
41 |
42 | await resourceStore.restore(JSON.parse(data.trim()));
43 |
44 | return redirect('/admin/resources', {
45 | headers: {
46 | 'Set-Cookie': await session.flash('Data restored', 'success'),
47 | },
48 | });
49 | }
50 | default:
51 | return redirect('/admin/resources', {
52 | headers: {
53 | 'Set-Cookie': await session.flash(
54 | 'Please select either backup or restore',
55 | 'error',
56 | ),
57 | },
58 | });
59 | }
60 | }
61 |
62 | export default function AdminResources() {
63 | const data = useActionData();
64 |
65 | return (
66 |
67 | Resources backup / restore
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/app/routes/_layout.admin.statistics.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs, ActionArgs } from '@remix-run/cloudflare';
2 | import { json, redirect } from '@remix-run/cloudflare';
3 | import { useActionData } from '@remix-run/react';
4 | import { requireAdministrator } from '~/helpers';
5 | import BackupForm from '~/components/BackupForm';
6 |
7 | export async function loader({ context }: LoaderArgs) {
8 | await requireAdministrator(context);
9 |
10 | return json({});
11 | }
12 |
13 | export async function action({ request, context }: ActionArgs) {
14 | const { session, pageStore } = context;
15 | const [formData] = await Promise.all([
16 | request.formData(),
17 | requireAdministrator(context),
18 | ]);
19 | const type = formData.get('type');
20 |
21 | switch (type) {
22 | case 'backup': {
23 | const data = await pageStore.backup();
24 |
25 | return json(data);
26 | }
27 | case 'restore': {
28 | const input = formData.get('data');
29 | const data = input ? input.toString() : '';
30 |
31 | if (data.trim() === '') {
32 | return redirect(request.url, {
33 | headers: {
34 | 'Set-Cookie': await session.flash(
35 | 'Please provide proper data before clicking restore',
36 | 'error',
37 | ),
38 | },
39 | });
40 | }
41 |
42 | await pageStore.restore(JSON.parse(data.trim()));
43 |
44 | return redirect(request.url, {
45 | headers: {
46 | 'Set-Cookie': await session.flash('Data restored', 'success'),
47 | },
48 | });
49 | }
50 | default:
51 | return redirect(request.url, {
52 | headers: {
53 | 'Set-Cookie': await session.flash(
54 | 'Please select either backup or restore',
55 | 'error',
56 | ),
57 | },
58 | });
59 | }
60 | }
61 |
62 | export default function PageStatistics() {
63 | const data = useActionData();
64 |
65 | return (
66 |
67 | Page Statistics
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/app/routes/_layout.admin.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 | import { json } from '@remix-run/cloudflare';
3 | import { Link, Outlet, useLocation } from '@remix-run/react';
4 | import { useMemo } from 'react';
5 | import menuIcon from '~/icons/menu.svg';
6 | import FlashMessage from '~/components/FlashMessage';
7 | import { PaneContainer, PaneHeader, PaneFooter, PaneContent } from '~/layout';
8 | import { toggleSearchParams } from '~/search';
9 | import IconLink from '~/components/IconLink';
10 | import { requireMaintainer, isAdministrator } from '~/helpers';
11 | import { useSessionData } from '~/hooks';
12 |
13 | export async function loader({ context }: LoaderArgs) {
14 | await requireMaintainer(context);
15 |
16 | return json({});
17 | }
18 |
19 | export default function Admin() {
20 | const location = useLocation();
21 | const { profile, message } = useSessionData();
22 |
23 | const toggleMenuURL = useMemo(
24 | () => `?${toggleSearchParams(location.search, 'menu')}`,
25 | [location.search],
26 | );
27 |
28 | return (
29 |
30 |
31 |
32 | Administrator
33 | {isAdministrator(profile?.name) ? (
34 |
35 |
36 | Pages
37 |
38 | /
39 |
40 | Users
41 |
42 | /
43 |
44 | Resources
45 |
46 | /
47 |
48 | Statistics
49 |
50 |
51 | ) : null}
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/app/routes/_layout.admin.users.$userId.backup.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionArgs } from '@remix-run/cloudflare';
2 | import { json, redirect } from '@remix-run/cloudflare';
3 | import { useActionData } from '@remix-run/react';
4 | import { requireAdministrator, notFound } from '~/helpers';
5 | import BackupForm from '~/components/BackupForm';
6 |
7 | export async function action({ params, request, context }: ActionArgs) {
8 | const { session, userStore } = context;
9 |
10 | if (!params.userId) {
11 | throw notFound();
12 | }
13 |
14 | const [formData] = await Promise.all([
15 | request.formData(),
16 | requireAdministrator(context),
17 | ]);
18 | const type = formData.get('type');
19 |
20 | switch (type) {
21 | case 'backup': {
22 | const data = await userStore.backup(params.userId);
23 |
24 | return json(data);
25 | }
26 | case 'restore': {
27 | const input = formData.get('data');
28 | const data = input ? input.toString() : '';
29 |
30 | if (data.trim() === '') {
31 | return redirect('/admin/users', {
32 | headers: {
33 | 'Set-Cookie': await session.flash(
34 | 'Please provide proper data before clicking restore',
35 | 'error',
36 | ),
37 | },
38 | });
39 | }
40 |
41 | await userStore.restore(params.userId, JSON.parse(data.trim()));
42 |
43 | return redirect('/admin/users', {
44 | headers: {
45 | 'Set-Cookie': await session.flash(
46 | `Data restored for user (${params.userId})`,
47 | 'success',
48 | ),
49 | },
50 | });
51 | }
52 | default:
53 | return redirect('/admin/users', {
54 | headers: {
55 | 'Set-Cookie': await session.flash(
56 | 'Please select either backup or restore',
57 | 'error',
58 | ),
59 | },
60 | });
61 | }
62 | }
63 |
64 | export default function AdminResources() {
65 | const data = useActionData();
66 |
67 | return (
68 |
69 | User backup / restore
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/app/routes/_layout.admin.users.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 | import { json } from '@remix-run/cloudflare';
3 | import { Link, useLoaderData } from '@remix-run/react';
4 | import { requireAdministrator } from '~/helpers';
5 | import type { UserProfile } from '~/types';
6 |
7 | export async function loader({ context }: LoaderArgs) {
8 | const [users] = await Promise.all([
9 | context.userStore.listUserProfiles(),
10 | requireAdministrator(context),
11 | ]);
12 |
13 | return json({
14 | users,
15 | });
16 | }
17 |
18 | export default function ListUsers() {
19 | const { users } = useLoaderData<{ users: UserProfile[] }>();
20 |
21 | return (
22 |
23 | Users ({users.length})
24 |
25 |
26 |
27 |
28 | Id |
29 |
30 | Name
31 | |
32 |
33 | Email
34 | |
35 |
36 | Action
37 | |
38 |
39 | Created at
40 | |
41 |
42 | Updated at
43 | |
44 |
45 |
46 |
47 | {users.map((user) => (
48 |
49 | {user.id} |
50 |
51 | {user.name}
52 | |
53 |
54 | {user.email ?? 'n/a'}
55 | |
56 |
57 |
58 | Backup
59 |
60 | |
61 |
62 | {user.createdAt ?? 'n/a'}
63 | |
64 |
65 | {user.updatedAt ?? 'n/a'}
66 | |
67 |
68 | ))}
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/app/routes/_layout.index.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs, MetaFunction } from '@remix-run/cloudflare';
2 | import { json } from '@remix-run/cloudflare';
3 | import { useLoaderData } from '@remix-run/react';
4 | import About from '~/components/About';
5 | import Feed from '~/components/Feed';
6 | import { formatMeta } from '~/helpers';
7 | import { search } from '~/resources';
8 | import { getSearchOptions } from '~/search';
9 |
10 | export async function loader({ request, context }: LoaderArgs) {
11 | const searchOptions = getSearchOptions(request.url);
12 | const list = await context.resourceStore.list();
13 |
14 | return json(search(list, searchOptions));
15 | }
16 |
17 | export const meta: MetaFunction = () => {
18 | return formatMeta({
19 | title: 'Remix Guide',
20 | description: 'A platform for the Remix community',
21 | 'og:url': `https://remix.guide`,
22 | });
23 | };
24 |
25 | export default function Index() {
26 | const { entries, count } = useLoaderData();
27 |
28 | return (
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/app/routes/_layout.resources.$resourceId.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | LoaderArgs,
3 | ActionArgs,
4 | MetaFunction,
5 | } from '@remix-run/cloudflare';
6 | import { json, redirect } from '@remix-run/cloudflare';
7 | import type { ShouldReloadFunction } from '@remix-run/react';
8 | import { Form, useLocation, useLoaderData } from '@remix-run/react';
9 | import { useMemo } from 'react';
10 | import clsx from 'clsx';
11 | import ResourcesDetails from '~/components/ResourcesDetails';
12 | import SuggestedResources from '~/components/SuggestedResources';
13 | import { formatMeta, isMaintainer, notFound } from '~/helpers';
14 | import { getSuggestions, patchResource } from '~/resources';
15 | import BookmarkDetails from '~/components/BookmarkDetails';
16 | import { useSessionData } from '~/hooks';
17 |
18 | export async function action({ params, context, request }: ActionArgs) {
19 | const { session, userStore, resourceStore } = context;
20 | const [profile, formData] = await Promise.all([
21 | session.getUserProfile(),
22 | request.formData(),
23 | ]);
24 | const type = formData.get('type');
25 | const resourceId = formData.get('resourceId')?.toString();
26 |
27 | if (!type || !resourceId) {
28 | return new Response('Bad Request', { status: 400 });
29 | }
30 |
31 | if (type === 'view') {
32 | const url = formData.get('url')?.toString();
33 |
34 | if (!url) {
35 | return new Response('Bad Request', { status: 400 });
36 | }
37 |
38 | await userStore.view(profile?.id ?? null, resourceId, url);
39 | } else {
40 | if (!profile) {
41 | return new Response('Unauthorized', { status: 401 });
42 | }
43 |
44 | switch (type) {
45 | case 'bookmark': {
46 | const url = formData.get('url')?.toString();
47 |
48 | if (!url) {
49 | return new Response('Bad Request', { status: 400 });
50 | }
51 |
52 | await userStore.bookmark(profile.id, resourceId, url);
53 | break;
54 | }
55 | case 'unbookmark': {
56 | const url = formData.get('url')?.toString();
57 |
58 | if (!url) {
59 | return new Response('Bad Request', { status: 400 });
60 | }
61 |
62 | await userStore.unbookmark(profile.id, resourceId, url);
63 | break;
64 | }
65 | case 'update': {
66 | if (!isMaintainer(profile.name)) {
67 | return new Response('Unauthorized', { status: 401 });
68 | }
69 |
70 | const description = formData.get('description')?.toString() ?? null;
71 | const lists = formData.getAll('lists').map((value) => value.toString());
72 |
73 | await resourceStore.updateBookmark(resourceId, description, lists);
74 |
75 | return redirect(request.url, {
76 | headers: {
77 | 'Set-Cookie': await session.flash(
78 | 'The bookmark is updated successfully',
79 | 'success',
80 | ),
81 | },
82 | });
83 | }
84 | case 'delete': {
85 | if (!isMaintainer(profile.name)) {
86 | return new Response('Unauthorized', { status: 401 });
87 | }
88 |
89 | await resourceStore.deleteBookmark(resourceId);
90 |
91 | return redirect('/', {
92 | headers: {
93 | 'Set-Cookie': await session.flash(
94 | 'The bookmark is deleted successfully',
95 | 'success',
96 | ),
97 | },
98 | });
99 | }
100 | default:
101 | return new Response('Bad Request', { status: 400 });
102 | }
103 | }
104 |
105 | return null;
106 | }
107 |
108 | export async function loader({ context, params }: LoaderArgs) {
109 | const { session, resourceStore, userStore } = context;
110 | const [list, profile] = await Promise.all([
111 | resourceStore.list(),
112 | session.getUserProfile(),
113 | ]);
114 | const resource = params.resourceId ? list[params.resourceId] : null;
115 |
116 | if (!resource) {
117 | throw notFound();
118 | }
119 |
120 | const user = profile?.id ? await userStore.getUser(profile.id) : null;
121 |
122 | return json({
123 | user,
124 | resource: user ? patchResource(resource, user) : resource,
125 | suggestions: getSuggestions(list, resource),
126 | });
127 | }
128 |
129 | export const meta: MetaFunction = ({ params, data }) => {
130 | return formatMeta({
131 | title: data.resource.title ?? '',
132 | description: data.resource.description ?? '',
133 | 'og:url': `https://remix.guide/resources/${params.resourceId}`,
134 | });
135 | };
136 |
137 | export const unstable_shouldReload: ShouldReloadFunction = ({
138 | prevUrl,
139 | url,
140 | submission,
141 | }) => {
142 | if (
143 | prevUrl.searchParams.get('resourceId') !==
144 | url.searchParams.get('resourceId')
145 | ) {
146 | return true;
147 | }
148 |
149 | return ['bookmark', 'unbookmark', 'update', 'delete'].includes(
150 | submission?.formData.get('type')?.toString() ?? '',
151 | );
152 | };
153 |
154 | export default function ResourcePreview() {
155 | const { resource, user, suggestions } = useLoaderData();
156 | const { message } = useSessionData();
157 | const location = useLocation();
158 | const [showBookmark, action] = useMemo(() => {
159 | const searchParams = new URLSearchParams(location.search);
160 | const showBookmark = searchParams.get('open') === 'bookmark';
161 |
162 | searchParams.delete('open');
163 |
164 | return [showBookmark, `?${searchParams.toString()}`];
165 | }, [location.search]);
166 |
167 | return (
168 |
169 |
170 |
171 | {suggestions.map(({ entries, searchOptions }) => (
172 |
177 | ))}
178 |
179 |
180 | {showBookmark ? (
181 |
188 | ) : null}
189 |
190 | );
191 | }
192 |
--------------------------------------------------------------------------------
/app/routes/_layout.resources.index.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from '@remix-run/cloudflare';
2 | import About from '~/components/About';
3 | import { formatMeta } from '~/helpers';
4 | import { getSearchOptions, getTitleBySearchOptions } from '~/search';
5 |
6 | export const meta: MetaFunction = ({ location }) => {
7 | const searchOptions = getSearchOptions(
8 | `${location.pathname}${location.search}`,
9 | );
10 | const title = getTitleBySearchOptions(searchOptions);
11 |
12 | return formatMeta({
13 | title,
14 | });
15 | };
16 |
17 | export default function Index() {
18 | return ;
19 | }
20 |
--------------------------------------------------------------------------------
/app/routes/_layout.resources.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 | import { json, redirect } from '@remix-run/cloudflare';
3 | import { Outlet, useLoaderData, useLocation } from '@remix-run/react';
4 | import type { ShouldReloadFunction } from '@remix-run/react';
5 | import Feed from '~/components/Feed';
6 | import { search } from '~/resources';
7 | import { getRelatedSearchParams, getSearchOptions } from '~/search';
8 |
9 | export async function loader({ request, context }: LoaderArgs) {
10 | const profile = await context.session.getUserProfile();
11 | const searchOptions = getSearchOptions(request.url);
12 |
13 | switch (searchOptions.list) {
14 | case 'bookmarks':
15 | case 'history': {
16 | if (!profile) {
17 | throw redirect('/');
18 | }
19 |
20 | const [list, includes] = await Promise.all([
21 | context.resourceStore.list(),
22 | context.userStore.getList(profile.id, searchOptions.list ?? null),
23 | ]);
24 |
25 | return json(search(list, { ...searchOptions, list: null, includes }));
26 | }
27 | default: {
28 | const list = await context.resourceStore.list();
29 |
30 | return json(search(list, searchOptions));
31 | }
32 | }
33 | }
34 |
35 | export const unstable_shouldReload: ShouldReloadFunction = ({
36 | url,
37 | prevUrl,
38 | submission,
39 | }) => {
40 | const nextSearch = getRelatedSearchParams(url.search).toString();
41 | const prevSearch = getRelatedSearchParams(prevUrl.search).toString();
42 |
43 | return (
44 | nextSearch !== prevSearch ||
45 | ['update', 'delete'].includes(
46 | submission?.formData.get('type')?.toString() ?? '',
47 | )
48 | );
49 | };
50 |
51 | export default function Resources() {
52 | const data = useLoaderData();
53 | const location = useLocation();
54 | const [selectedId] = location.pathname.startsWith('/resources/')
55 | ? location.pathname.slice(11).split('/')
56 | : [];
57 |
58 | return (
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/app/routes/_layout.submit.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction, ActionArgs } from '@remix-run/cloudflare';
2 | import { redirect } from '@remix-run/cloudflare';
3 | import { Form, useLocation } from '@remix-run/react';
4 | import { useMemo } from 'react';
5 | import menuIcon from '~/icons/menu.svg';
6 | import FlashMessage from '~/components/FlashMessage';
7 | import { PaneContainer, PaneHeader, PaneFooter, PaneContent } from '~/layout';
8 | import { formatMeta, isMaintainer } from '~/helpers';
9 | import { toggleSearchParams } from '~/search';
10 | import IconLink from '~/components/IconLink';
11 | import { useSessionData } from '~/hooks';
12 |
13 | export let meta: MetaFunction = () => {
14 | return formatMeta({
15 | title: 'Submit a new resource',
16 | description: 'Sharing with the community',
17 | 'og:url': 'https://remix.guide/submit',
18 | });
19 | };
20 |
21 | function isValidURL(text: string): boolean {
22 | try {
23 | const url = new URL(text);
24 |
25 | return (
26 | ['http:', 'https:'].includes(url.protocol) &&
27 | !/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/.test(url.hostname)
28 | );
29 | } catch (e) {
30 | return false;
31 | }
32 | }
33 |
34 | export async function action({ request, context }: ActionArgs) {
35 | const { session, resourceStore } = context;
36 | const profile = await session.getUserProfile();
37 |
38 | if (!profile) {
39 | return redirect('/submit', {
40 | headers: {
41 | 'Set-Cookie': await session.flash(
42 | 'Please login first before submitting new resources',
43 | 'warning',
44 | ),
45 | },
46 | });
47 | }
48 |
49 | const formData = await request.formData();
50 | const url = formData.get('url');
51 |
52 | if (!url || !isValidURL(url.toString())) {
53 | return redirect('/submit', {
54 | headers: {
55 | 'Set-Cookie': await session.flash('Invalid URL provided', 'warning'),
56 | },
57 | });
58 | }
59 |
60 | try {
61 | const { id, status } = await resourceStore.submit(
62 | url.toString(),
63 | profile.id,
64 | );
65 | const headers = new Headers();
66 |
67 | switch (status) {
68 | case 'PUBLISHED':
69 | headers.set(
70 | 'Set-Cookie',
71 | await session.flash(
72 | 'The submitted resource is now published',
73 | 'success',
74 | ),
75 | );
76 | break;
77 | case 'RESUBMITTED':
78 | headers.set(
79 | 'Set-Cookie',
80 | await session.flash('A resource with the same url is found', 'info'),
81 | );
82 | break;
83 | case 'INVALID':
84 | headers.set(
85 | 'Set-Cookie',
86 | await session.flash(
87 | 'The provided data looks invalid; Please make sure a proper category is selected',
88 | 'error',
89 | ),
90 | );
91 | break;
92 | }
93 |
94 | if (!id) {
95 | return redirect('/submit', { headers });
96 | }
97 |
98 | return redirect(
99 | isMaintainer(profile.name)
100 | ? `/resources/${id}?${new URLSearchParams({ open: 'bookmark' })}`
101 | : `/resources/${id}`,
102 | { headers },
103 | );
104 | } catch (error) {
105 | console.log('Error while submitting new url; Received', error);
106 | return redirect('/submit', {
107 | headers: {
108 | 'Set-Cookie': await session.flash(
109 | 'Something wrong with the URL; Please try again later',
110 | 'error',
111 | ),
112 | },
113 | });
114 | }
115 | }
116 |
117 | export default function Submit() {
118 | const { message } = useSessionData();
119 | const location = useLocation();
120 | const toggleMenuURL = useMemo(
121 | () => `?${toggleSearchParams(location.search, 'menu')}`,
122 | [location.search],
123 | );
124 |
125 | return (
126 |
127 |
128 |
129 |
130 | Submit a new Resource
131 |
132 |
133 |
134 |
135 |
159 |
160 |
161 |
162 |
163 |
164 |
165 | );
166 | }
167 |
--------------------------------------------------------------------------------
/app/routes/_layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ShouldReloadFunction } from '@remix-run/react';
2 | import { Outlet, useLoaderData, useLocation } from '@remix-run/react';
3 | import type { LoaderArgs } from '@remix-run/cloudflare';
4 | import { json } from '@remix-run/cloudflare';
5 | import clsx from 'clsx';
6 | import Progress from '~/components/Progress';
7 | import SidebarNavigation from '~/components/SidebarNavigation';
8 | import { useSessionData } from '~/hooks';
9 |
10 | export async function loader({ context }: LoaderArgs) {
11 | const { resourceStore } = context;
12 | const guide = await resourceStore.getData();
13 |
14 | return json({
15 | lists: guide.metadata.lists,
16 | });
17 | }
18 |
19 | export const unstable_shouldReload: ShouldReloadFunction = ({ submission }) => {
20 | return typeof submission !== 'undefined';
21 | };
22 |
23 | export default function Layout() {
24 | const { lists } = useLoaderData();
25 | const { profile } = useSessionData();
26 | const location = useLocation();
27 | const isMenuOpened =
28 | new URLSearchParams(location.search).get('open') === 'menu';
29 |
30 | return (
31 | <>
32 |
33 |
41 |
42 |
43 |
44 | >
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/app/routes/auth.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 |
3 | export async function loader({ context }: LoaderArgs) {
4 | return await context.session.login();
5 | }
6 |
--------------------------------------------------------------------------------
/app/routes/discover.$list.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 | import { redirect } from '@remix-run/cloudflare';
3 |
4 | export function loader({ request, params }: LoaderArgs) {
5 | if (!params.list) {
6 | throw new Error('params.list is not available');
7 | }
8 |
9 | const { searchParams } = new URL(request.url);
10 | const resourceId = searchParams.get('resourceId');
11 |
12 | searchParams.delete('resourceId');
13 |
14 | let url = `/${params.list}`;
15 |
16 | if (resourceId) {
17 | searchParams.set('list', params.list);
18 | url = `/resources/${resourceId}?${searchParams}`;
19 | } else if (Array.from(searchParams.keys()).length > 0) {
20 | searchParams.set('list', params.list);
21 | url = `/resources?${searchParams}`;
22 | }
23 |
24 | throw redirect(url, 301);
25 | }
26 |
--------------------------------------------------------------------------------
/app/routes/discover.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderArgs } from '@remix-run/cloudflare';
2 | import { redirect } from '@remix-run/cloudflare';
3 |
4 | export function loader({ request }: LoaderArgs) {
5 | const { searchParams } = new URL(request.url);
6 | const resourceId = searchParams.get('resourceId');
7 |
8 | searchParams.delete('resourceId');
9 |
10 | const search =
11 | Array.from(searchParams.keys()).length > 0
12 | ? `?${searchParams.toString()}`
13 | : '';
14 | const url = resourceId
15 | ? `/resources/${resourceId}${search}`
16 | : `/resources${search}`;
17 |
18 | throw redirect(url, 301);
19 | }
20 |
--------------------------------------------------------------------------------
/app/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionArgs } from '@remix-run/cloudflare';
2 | import { notFound } from '~/helpers';
3 |
4 | export async function loader() {
5 | return notFound();
6 | }
7 |
8 | export async function action({ context }: ActionArgs) {
9 | return await context.session.login();
10 | }
11 |
--------------------------------------------------------------------------------
/app/routes/logout.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionArgs } from '@remix-run/cloudflare';
2 | import { notFound } from '~/helpers';
3 |
4 | export async function action({ context }: ActionArgs) {
5 | return await context.session.logout();
6 | }
7 |
8 | export function loader() {
9 | return notFound();
10 | }
11 |
--------------------------------------------------------------------------------
/app/scroll.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
2 | import type { Location } from '@remix-run/react';
3 | import { useBeforeUnload, useLocation, useTransition } from '@remix-run/react';
4 |
5 | let STORAGE_KEY = 'feed-positions';
6 |
7 | let positions: { [key: string]: number } = {};
8 |
9 | if (typeof document !== 'undefined') {
10 | let sessionPositions = sessionStorage.getItem(STORAGE_KEY);
11 | if (sessionPositions) {
12 | positions = JSON.parse(sessionPositions);
13 | }
14 | }
15 |
16 | let hydrated = false;
17 |
18 | function getScrollTop(el: HTMLElement | null) {
19 | if (el && el.clientHeight < el.scrollHeight) {
20 | return el.scrollTop;
21 | } else {
22 | return window.scrollY;
23 | }
24 | }
25 |
26 | function isShowingMore(prev: Location, next: Location): boolean {
27 | const prevSearchParams = new URLSearchParams(prev.search);
28 | const nextSearchParams = new URLSearchParams(next.search);
29 | const prevLimit = Number(prevSearchParams.get('limit'));
30 | const nextLimit = Number(nextSearchParams.get('limit'));
31 |
32 | return !isNaN(nextLimit) && (isNaN(prevLimit) || nextLimit > prevLimit);
33 | }
34 |
35 | function isSelectingResources(prev: Location, next: Location) {
36 | return (
37 | prev.pathname !== next.pathname && next.pathname.startsWith('/resources/')
38 | );
39 | }
40 |
41 | export function scrollOnElemenIfScrollable(
42 | el: HTMLElement | null,
43 | x: number,
44 | y: number,
45 | ) {
46 | if (el && el.clientHeight < el.scrollHeight) {
47 | el.scrollTo(x, y);
48 | } else {
49 | window.scrollTo(x, y);
50 | }
51 | }
52 |
53 | export function useFeedScrollRestoration(prefix = 'feed') {
54 | let ref = useRef(null);
55 | let transition = useTransition();
56 | let location = useLocation();
57 | let wasSubmissionRef = useRef(false);
58 |
59 | // wait for the browser to restore it on its own
60 | useEffect(() => {
61 | window.history.scrollRestoration = 'manual';
62 | }, []);
63 |
64 | // let the browser restore on it's own for refresh
65 | useBeforeUnload(
66 | useCallback(() => {
67 | window.history.scrollRestoration = 'auto';
68 | }, []),
69 | );
70 |
71 | useEffect(() => {
72 | if (transition.submission) {
73 | wasSubmissionRef.current = true;
74 | }
75 | }, [transition]);
76 |
77 | useEffect(() => {
78 | if (!transition.location) {
79 | return;
80 | }
81 |
82 | // Maintain scroll top
83 | if (
84 | isSelectingResources(location, transition.location) ||
85 | isShowingMore(location, transition.location)
86 | ) {
87 | positions[prefix + transition.location.key] = getScrollTop(ref.current);
88 | }
89 |
90 | positions[prefix + location.key] = getScrollTop(ref.current);
91 | }, [transition, location, prefix]);
92 |
93 | useBeforeUnload(
94 | useCallback(() => {
95 | sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
96 | }, []),
97 | );
98 |
99 | if (typeof document !== 'undefined') {
100 | // eslint-disable-next-line
101 | useLayoutEffect(() => {
102 | // don't do anything on hydration, the component already did this with an
103 | // inline script.
104 | if (!hydrated) {
105 | hydrated = true;
106 | return;
107 | }
108 |
109 | let y = positions[prefix + location.key];
110 |
111 | // been here before, scroll to it
112 | if (y != undefined) {
113 | scrollOnElemenIfScrollable(ref.current, 0, y);
114 | return;
115 | }
116 |
117 | // don't do anything on submissions
118 | if (wasSubmissionRef.current === true) {
119 | wasSubmissionRef.current = false;
120 | return;
121 | }
122 |
123 | // otherwise go to the top on new locations
124 | scrollOnElemenIfScrollable(ref.current, 0, 0);
125 | }, [prefix, location]);
126 | }
127 |
128 | useEffect(() => {
129 | if (transition.submission) {
130 | wasSubmissionRef.current = true;
131 | }
132 | }, [transition]);
133 |
134 | return ref;
135 | }
136 |
--------------------------------------------------------------------------------
/app/search.ts:
--------------------------------------------------------------------------------
1 | import type { SearchOptions } from '~/types';
2 | import { capitalize } from '~/helpers';
3 |
4 | /**
5 | * Number of entries to be shown by default
6 | */
7 | const defaultLimit = 25;
8 |
9 | export function getRelatedSearchParams(search: string): URLSearchParams {
10 | const searchParams = new URLSearchParams(search);
11 | const supported = [
12 | 'list',
13 | 'q',
14 | 'category',
15 | 'platform',
16 | 'integration',
17 | 'author',
18 | 'site',
19 | 'sort',
20 | 'limit',
21 | ];
22 |
23 | for (const [key, value] of Array.from(searchParams.entries())) {
24 | if (!supported.includes(key) || value === '') {
25 | searchParams.delete(key);
26 | }
27 | }
28 |
29 | return searchParams;
30 | }
31 |
32 | export function getSearchOptions(url: string): SearchOptions {
33 | function parseNumber(text: string | null, defaultNumber: number): number {
34 | if (!text) {
35 | return defaultNumber;
36 | }
37 |
38 | const number = Number(text);
39 |
40 | return !isNaN(number) ? number : defaultNumber;
41 | }
42 |
43 | const { pathname, searchParams } = new URL(url, 'https://remix.guide');
44 | const options: SearchOptions = {
45 | keyword: searchParams.get('q'),
46 | author: searchParams.get('author'),
47 | list:
48 | pathname.startsWith('/resources') || pathname === '/rss.xml'
49 | ? searchParams.get('list')
50 | : pathname.slice(1).split('/').shift(),
51 | site: searchParams.get('site'),
52 | category: searchParams.get('category'),
53 | platform: searchParams.get('platform'),
54 | integrations: searchParams.getAll('integration'),
55 | limit: parseNumber(searchParams.get('limit'), defaultLimit),
56 | sort: searchParams.get('sort') ?? 'new',
57 | };
58 |
59 | return Object.fromEntries(
60 | Object.entries(options).map(([key, value]) => [key, value ? value : null]),
61 | );
62 | }
63 |
64 | export function excludeParams(
65 | key: string,
66 | searchParams: URLSearchParams,
67 | ): string {
68 | const search = new URLSearchParams(searchParams);
69 |
70 | if (search.has(key)) {
71 | search.delete(key);
72 | }
73 |
74 | return search.toString();
75 | }
76 |
77 | export function getSite(url: string): string {
78 | return new URL(url).hostname;
79 | }
80 |
81 | export function getResourceSearchParams(
82 | options: SearchOptions,
83 | ): URLSearchParams {
84 | return new URLSearchParams(
85 | Object.entries(options).flatMap(([key, value]) => {
86 | switch (key as keyof SearchOptions) {
87 | case 'author':
88 | case 'category':
89 | case 'platform':
90 | case 'site':
91 | case 'keyword':
92 | case 'limit':
93 | case 'list':
94 | case 'sort': {
95 | let k = key;
96 | let v = value;
97 |
98 | if (k === 'keyword') {
99 | k = 'q';
100 | } else if (
101 | (k === 'sort' && v === 'new') ||
102 | (k === 'limit' && v === defaultLimit)
103 | ) {
104 | v = '';
105 | }
106 |
107 | if (v) {
108 | return [[k, v]];
109 | }
110 |
111 | break;
112 | }
113 | case 'integrations':
114 | if (Array.isArray(value)) {
115 | return value.map((v) => ['integration', v]);
116 | }
117 |
118 | break;
119 | }
120 |
121 | return [];
122 | }),
123 | );
124 | }
125 |
126 | export function getResourceURL(
127 | options: SearchOptions,
128 | resourceId?: string | null,
129 | ): string {
130 | const searchParams = getResourceSearchParams(options);
131 |
132 | if (
133 | !resourceId &&
134 | searchParams.has('list') &&
135 | Array.from(searchParams.keys()).length === 1
136 | ) {
137 | return `/${options.list}`;
138 | }
139 |
140 | return resourceId
141 | ? `/resources/${resourceId}?${searchParams}`
142 | : `/resources?${searchParams}`;
143 | }
144 |
145 | export function toggleSearchParams(search: string, key: string): string {
146 | const searchParams = new URLSearchParams(search);
147 |
148 | if (searchParams.get('open') === key) {
149 | searchParams.delete('open');
150 | } else {
151 | searchParams.set('open', key);
152 | }
153 |
154 | return searchParams.toString();
155 | }
156 |
157 | export function getTitleBySearchOptions(searchOptions: SearchOptions): string {
158 | const options = Object.keys(searchOptions).reduce((result, key) => {
159 | switch (key as keyof SearchOptions) {
160 | case 'author':
161 | if (searchOptions.author) {
162 | result.push(`Made by ${searchOptions.author}`);
163 | }
164 | break;
165 | case 'category':
166 | if (searchOptions.category) {
167 | result.push(`Categorised as ${searchOptions.category}`);
168 | }
169 | break;
170 | case 'keyword':
171 | if (searchOptions.keyword?.trim()) {
172 | result.push(`Mentioned ${searchOptions.keyword}`);
173 | }
174 | break;
175 | case 'platform':
176 | if (searchOptions.platform) {
177 | result.push(`Hosted on ${searchOptions.platform}`);
178 | }
179 | break;
180 | case 'list':
181 | if (searchOptions.list) {
182 | result.push(capitalize(searchOptions.list));
183 | }
184 | break;
185 | case 'integrations':
186 | if ((searchOptions.integrations ?? []).length > 0) {
187 | result.push(`Built with ${searchOptions.integrations?.join(', ')}`);
188 | }
189 | break;
190 | case 'site':
191 | if (searchOptions.site) {
192 | result.push(`Published on ${searchOptions.site}`);
193 | }
194 | break;
195 | }
196 |
197 | return result;
198 | }, [] as string[]);
199 |
200 | if (options.length > 1) {
201 | return 'Search Result';
202 | }
203 |
204 | return options[0] ?? 'Discover';
205 | }
206 |
--------------------------------------------------------------------------------
/app/types.ts:
--------------------------------------------------------------------------------
1 | export * from '../worker/types';
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "remix-guide",
4 | "main": "./worker.js",
5 | "description": "A platform for the Remix community",
6 | "license": "MIT",
7 | "scripts": {
8 | "cleanup": "rimraf .cache ./dist ./build ./public/build ./app/styles/tailwind.css",
9 | "build:remix": "remix build",
10 | "build:style": "cross-env NODE_ENV=production tailwindcss -o ./app/styles/tailwind.css --minify",
11 | "build:worker": "cross-env NODE_ENV=production VERSION=$(git rev-parse --short HEAD) node ./scripts/build.mjs",
12 | "build": "npm run build:style && npm run build:remix && npm run build:worker",
13 | "dev:miniflare": "miniflare --build-command \"node ./scripts/build.mjs\" --build-watch-path ./worker --build-watch-path ./build/index.js --no-cache --watch --kv-persist --do-persist --open",
14 | "dev:remix": "remix watch",
15 | "dev:style": "tailwindcss -o ./app/styles/tailwind.css --watch",
16 | "dev": "concurrently \"npm:dev:*\"",
17 | "prebuild": "npm run cleanup",
18 | "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" playwright test",
19 | "start": "miniflare",
20 | "prepare": "husky install"
21 | },
22 | "dependencies": {
23 | "@cloudflare/kv-asset-handler": "^0.2.0",
24 | "@remix-run/cloudflare-workers": "^1.7.6",
25 | "@remix-run/react": "^1.7.6",
26 | "clsx": "^1.2.1",
27 | "html-entities": "^2.3.3",
28 | "isbot": "^3.6.5",
29 | "nanoid": "^3.3.4",
30 | "react": "^18.2.0",
31 | "react-dom": "^18.2.0",
32 | "react-intersection-observer": "^8.33.1",
33 | "remix-auth": "^3.3.0",
34 | "remix-auth-github": "^1.1.1",
35 | "toucan-js": "^2.6.0",
36 | "workers-logger": "^0.2.0"
37 | },
38 | "devDependencies": {
39 | "@cloudflare/workers-types": "^3.18.0",
40 | "@cloudflare/wrangler": "^1.20.0",
41 | "@playwright-testing-library/test": "^4.5.0",
42 | "@playwright/test": "^1.28.0",
43 | "@remix-run/dev": "^1.7.6",
44 | "@remix-run/eslint-config": "^1.7.6",
45 | "@remix-run/node": "^1.7.6",
46 | "@tailwindcss/aspect-ratio": "^0.4.2",
47 | "@tailwindcss/forms": "^0.5.3",
48 | "@tailwindcss/line-clamp": "^0.4.2",
49 | "@tailwindcss/typography": "^0.5.8",
50 | "@types/react": "^18.0.25",
51 | "@types/react-dom": "^18.0.9",
52 | "autoprefixer": "^10.4.13",
53 | "concurrently": "^7.5.0",
54 | "cross-env": "^7.0.3",
55 | "esbuild": "^0.15.14",
56 | "eslint": "^8.28.0",
57 | "eslint-config-prettier": "^8.5.0",
58 | "husky": "^8.0.2",
59 | "lint-staged": "^13.0.3",
60 | "miniflare": "^2.11.0",
61 | "postcss": "^8.4.19",
62 | "prettier": "^2.6.2",
63 | "remix-flat-routes": "^0.4.8",
64 | "rimraf": "^3.0.2",
65 | "tailwindcss": "^3.2.4",
66 | "typescript": "^4.9.3"
67 | },
68 | "engines": {
69 | "node": ">=16.7"
70 | },
71 | "sideEffects": false,
72 | "lint-staged": {
73 | "*.{js,mjs,ts,tsx,css,md,yml}": "prettier --write"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import type { PlaywrightTestConfig } from '@playwright/test';
2 | import { devices } from '@playwright/test';
3 |
4 | const config: PlaywrightTestConfig = {
5 | forbidOnly: !!process.env.CI,
6 | retries: process.env.CI ? 2 : 1,
7 | testDir: './tests',
8 | use: {
9 | trace: 'on-first-retry',
10 | },
11 | projects: [
12 | {
13 | name: 'chromium',
14 | use: { ...devices['Desktop Chrome'] },
15 | },
16 | ],
17 | };
18 |
19 | export default config;
20 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/edmundhung/remix-guide/e67d1a8c3d58a5dbe9c4d20a6c0580d1819796bc/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
33 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 |
4 | Sitemap: https://remix.guide/sitemap.xml
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | const { flatRoutes } = require('remix-flat-routes');
2 |
3 | /**
4 | * @type {import('@remix-run/dev/config').AppConfig}
5 | */
6 | module.exports = {
7 | appDirectory: 'app',
8 | assetsBuildDirectory: 'public/build',
9 | publicPath: '/build/',
10 | serverModuleFormat: 'esm',
11 | serverPlatform: 'neutral',
12 | serverBuildDirectory: 'build',
13 | devServerBroadcastDelay: 1000,
14 | ignoredRouteFiles: ['**/*'],
15 | routes: async (defineRoutes) => {
16 | return flatRoutes('routes', defineRoutes);
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | export {};
5 |
6 | interface Env {
7 | NODE_ENV: string;
8 | VERSION: string;
9 | }
10 |
11 | declare global {
12 | const process: { env: Partial };
13 | }
14 |
--------------------------------------------------------------------------------
/scripts/backup.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import fs from 'node:fs/promises';
3 | import path from 'node:path';
4 | import fetch from 'node-fetch';
5 |
6 | async function listKeys(accountId, namespaceId, token) {
7 | const response = await fetch(
8 | `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/keys`,
9 | {
10 | method: 'GET',
11 | headers: {
12 | 'Content-Type': 'application/json',
13 | Authorization: `Bearer ${token}`,
14 | },
15 | },
16 | );
17 |
18 | if (!response.ok) {
19 | throw new Error(
20 | `Fail retrieving all keys; Received response with status ${response.status} ${response.statusText}`,
21 | );
22 | }
23 |
24 | const { result } = await response.json();
25 |
26 | return result;
27 | }
28 |
29 | async function getKeyValue(accountId, namespaceId, key, token) {
30 | const response = await fetch(
31 | `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(
32 | key,
33 | )}`,
34 | {
35 | method: 'GET',
36 | headers: {
37 | 'Content-Type': 'application/json',
38 | Authorization: `Bearer ${token}`,
39 | },
40 | },
41 | );
42 |
43 | if (!response.ok) {
44 | throw new Error(
45 | `Fail retrieving value for key=${key}; Received response with status ${response.status} ${response.statusText}`,
46 | );
47 | }
48 |
49 | return await response.json();
50 | }
51 |
52 | async function backup() {
53 | const accountId = process.env.CF_ACCOUNT_ID;
54 | const namespaceId = process.env.CF_NAMESPACE_ID;
55 | const token = process.env.CF_API_TOKEN;
56 |
57 | const list = await listKeys(accountId, namespaceId, token);
58 | const result = [];
59 |
60 | // We can't get all values in paralel due to rate-limiting from Cloudflare
61 | for (const key of list) {
62 | const value = await getKeyValue(accountId, namespaceId, key.name, token);
63 |
64 | result.push({
65 | key: key.name,
66 | value,
67 | metadata: key.metadata,
68 | });
69 |
70 | console.log(`(${result.length}/${list.length}) ${key.name} downloaded`);
71 | }
72 |
73 | console.log(`${result.length} keys loaded`);
74 |
75 | const cwd = process.cwd();
76 | const output = path.resolve(cwd, `./${namespaceId}.json`);
77 |
78 | await fs.writeFile(output, JSON.stringify(result, null, 2));
79 | }
80 |
81 | backup().then(
82 | () => {
83 | console.log('Backup complete');
84 | },
85 | (e) => {
86 | console.error('Unknown error caught during backup');
87 | console.log(e);
88 | },
89 | );
90 |
--------------------------------------------------------------------------------
/scripts/build.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | import * as esbuild from 'esbuild';
3 |
4 | async function build() {
5 | const mode = process.env.NODE_ENV?.toLowerCase() ?? 'development';
6 | const version = process.env.VERSION ?? new Date().toISOString();
7 |
8 | console.log(`Building Worker in ${mode} mode for version ${version}`);
9 |
10 | const outfile = './dist/worker.mjs';
11 | const startTime = Date.now();
12 | const result = await esbuild.build({
13 | entryPoints: ['./worker/index.ts'],
14 | bundle: true,
15 | minify: mode === 'production',
16 | sourcemap: mode !== 'production',
17 | format: 'esm',
18 | metafile: true,
19 | external: ['__STATIC_CONTENT_MANIFEST'],
20 | define: {
21 | 'process.env.NODE_ENV': `"${mode}"`,
22 | 'process.env.VERSION': `"${version}"`,
23 | 'process.env.REMIX_DEV_SERVER_WS_PORT': `""`,
24 | },
25 | conditions: ['worker'], // Needed for diary to be built correctly
26 | outfile,
27 | });
28 | const endTime = Date.now();
29 |
30 | console.log(`Built in ${endTime - startTime}ms`);
31 |
32 | if (mode === 'production') {
33 | console.log(await esbuild.analyzeMetafile(result.metafile));
34 | }
35 | }
36 |
37 | build().catch((e) => console.error('Unknown error caught during build:', e));
38 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const colors = require('tailwindcss/colors');
2 |
3 | module.exports = {
4 | content: ['./app/**/*.tsx', './app/**/*.ts'],
5 | theme: {
6 | borderColor: (theme) => ({
7 | ...theme('colors'),
8 | DEFAULT: theme('colors.gray.800', 'currentColor'),
9 | }),
10 | extend: {
11 | screens: {
12 | '3xl': '1700px',
13 | '4xl': '1921px',
14 | },
15 | colors: {
16 | gray: colors.neutral,
17 | },
18 | },
19 | },
20 | plugins: [
21 | require('@tailwindcss/line-clamp'),
22 | require('@tailwindcss/aspect-ratio'),
23 | require('@tailwindcss/typography'),
24 | require('@tailwindcss/forms'),
25 | ],
26 | };
27 |
--------------------------------------------------------------------------------
/tests/authentication.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from './setup';
2 | import { login } from './utils';
3 |
4 | test.describe('Authentication', () => {
5 | test.beforeEach(async ({ page }) => {
6 | await page.goto('/');
7 | });
8 |
9 | test('shows a Logout button after login', async ({
10 | page,
11 | mockAgent,
12 | queries,
13 | }) => {
14 | await login(page, mockAgent);
15 |
16 | expect(await queries.queryByText(/Logout/i)).toBeDefined();
17 | });
18 |
19 | test('allows user to logout', async ({ page, mockAgent, queries }) => {
20 | await login(page, mockAgent);
21 |
22 | const logoutButton = await queries.findByText(/Logout/i);
23 |
24 | await logoutButton.click();
25 |
26 | expect(await queries.queryByText(/Login with GitHub/i)).toBeDefined();
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/tests/index.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from './setup';
2 | import {
3 | getPageResourceId,
4 | listResources,
5 | mockPage,
6 | submitURL,
7 | login,
8 | } from './utils';
9 |
10 | test.describe('Index', () => {
11 | test.beforeEach(async ({ page, mockAgent }) => {
12 | await page.goto('/');
13 | await login(page, mockAgent);
14 |
15 | for (let i = 1; i <= 3; i++) {
16 | await page.goto('/submit');
17 |
18 | const url = `http://example.com/test-resources-${i}`;
19 |
20 | mockPage(mockAgent, url, {
21 | head: `Test ${i}`,
22 | });
23 |
24 | await submitURL(page, url);
25 | }
26 |
27 | await page.goto('/');
28 | });
29 |
30 | test('shows all URLs submitted', async ({ page, mf }) => {
31 | const list = await listResources(mf);
32 | const feed = page.locator('section', { hasText: 'Discover' });
33 | const resources = Object.values(list ?? {});
34 |
35 | for (const resource of resources) {
36 | if (!resource.title) {
37 | throw new Error('resource title is undefined');
38 | }
39 |
40 | await feed.getByTitle(resource.title).click();
41 |
42 | expect(getPageResourceId(page)).toBe(resource.id);
43 | }
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/tests/setup.ts:
--------------------------------------------------------------------------------
1 | import { test as base, expect } from '@playwright/test';
2 | import type { TestingLibraryFixtures } from '@playwright-testing-library/test/fixture';
3 | import { fixtures } from '@playwright-testing-library/test/fixture';
4 | import { Miniflare } from 'miniflare';
5 | import { MockAgent, setGlobalDispatcher } from 'undici';
6 |
7 | interface TestFixtures extends TestingLibraryFixtures {
8 | mockAgent: MockAgent;
9 | }
10 |
11 | interface WorkerFixtures {
12 | mf: Miniflare;
13 | port: number;
14 | }
15 |
16 | export { expect };
17 |
18 | export const test = base.extend({
19 | // Setup queries from playwright-testing-library
20 | ...fixtures,
21 |
22 | // Assign a unique "port" for each worker process
23 | port: [
24 | // eslint-disable-next-line no-empty-pattern
25 | async ({}, use, workerInfo) => {
26 | await use(3000 + workerInfo.workerIndex);
27 | },
28 | { scope: 'worker' },
29 | ],
30 |
31 | // Ensure visits works with relative path
32 | baseURL: ({ port }, use) => {
33 | use(`http://localhost:${port}`);
34 | },
35 |
36 | mockAgent:
37 | // eslint-disable-next-line no-empty-pattern
38 | async ({}, use) => {
39 | const mockAgent = new MockAgent();
40 | mockAgent.disableNetConnect();
41 | setGlobalDispatcher(mockAgent);
42 |
43 | await use(mockAgent);
44 | },
45 |
46 | // Miniflare instance
47 | mf: [
48 | async ({ port }, use) => {
49 | const mf = new Miniflare({
50 | envPath: true,
51 | wranglerConfigPath: true,
52 | buildCommand: undefined,
53 | bindings: {
54 | SESSION_SECRETS: 'ReMixGuIDe',
55 | GITHUB_CLIENT_ID: 'test-client-id',
56 | GITHUB_CLIENT_SECRET: 'test-secret',
57 | GITHUB_CALLBACK_URL: `http://localhost:${port}/auth`,
58 | GOOGLE_API_KEY: 'test-google-api-key',
59 | },
60 | port,
61 | });
62 |
63 | // Start the server.
64 | let server = await mf.startServer();
65 |
66 | // Use the server in the tests.
67 | await use(mf);
68 |
69 | // Cleanup.
70 | await new Promise((resolve, reject) => {
71 | server.close((error) => (error ? reject(error) : resolve()));
72 | });
73 | },
74 | { scope: 'worker', auto: true },
75 | ],
76 | });
77 |
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import type { Miniflare } from 'miniflare';
2 | import type { Page } from 'playwright-core';
3 | import type { MockAgent } from 'undici';
4 | import { createCookie } from '@remix-run/node';
5 | import { queries, getDocument } from '@playwright-testing-library/test';
6 | import type { Resource } from '../worker/types';
7 | import { createPageStoreClient } from '../worker/store/PageStore';
8 |
9 | export async function setSessionCookie(value: any) {
10 | const cookie = createCookie('__session', {
11 | httpOnly: true,
12 | path: '/',
13 | sameSite: 'lax',
14 | secrets: ['ReMixGuIDe'],
15 | secure: false,
16 | });
17 |
18 | return await cookie.serialize(value);
19 | }
20 |
21 | export async function login(
22 | page: Page,
23 | mockAgent: MockAgent,
24 | name = 'edmundhung',
25 | ) {
26 | const $document = await getDocument(page);
27 | const loginButton = await queries.findByText($document, /Login with GitHub/i);
28 |
29 | const github = mockAgent.get('https://github.com');
30 | const githubAPI = mockAgent.get('https://api.github.com');
31 |
32 | github
33 | .intercept({
34 | path: '/login/oauth/access_token',
35 | method: 'POST',
36 | })
37 | .reply(
38 | 200,
39 | new URLSearchParams({
40 | access_token: 'a-platform-for-sharing-everything-about-remix',
41 | scope: 'emails',
42 | token_type: 'bearer',
43 | }).toString(),
44 | );
45 |
46 | githubAPI
47 | .intercept({
48 | path: '/user',
49 | method: 'GET',
50 | })
51 | .reply(200, {
52 | id: 'dev',
53 | login: name,
54 | name: 'Remix Guide Developer',
55 | email: 'dev@remix.guide',
56 | avatar_url: null,
57 | });
58 |
59 | await page.route('/login', async (route) => {
60 | // There is no way to intercept request in the middle of redirects
61 | // Which is currently a limitation from the devtool protocol
62 | // It is also slower if we mock the response after the page is redirected to github login page
63 | // The current solution completely mock the `/login` route behaviour by setting the state cookie itself
64 |
65 | const url = getPageURL(page);
66 | const state = 'build-better-website';
67 |
68 | route.fulfill({
69 | status: 302,
70 | headers: {
71 | 'Set-Cookie': await setSessionCookie({ 'oauth2:state': state }),
72 | Location: `${url.origin}/auth?code=remix-guide&state=${state}`,
73 | },
74 | });
75 | });
76 |
77 | await loginButton.click();
78 | }
79 |
80 | export async function submitURL(page: Page, url: string) {
81 | const $form = await page.$('form[action="/submit"]');
82 |
83 | if (!$form) {
84 | throw new Error('Fail to locate the submission form');
85 | }
86 |
87 | const input = await queries.findByLabelText(
88 | $form,
89 | /Please paste the URL here/i,
90 | );
91 |
92 | await input.fill(url);
93 |
94 | const submitButton = await queries.findByRole($form, 'button', {
95 | name: /Submit/i,
96 | });
97 |
98 | await Promise.all([
99 | page.waitForNavigation({ waitUntil: 'networkidle' }),
100 | submitButton.click(),
101 | ]);
102 | }
103 |
104 | interface MockGitHubOptions {
105 | login: string;
106 | description: string;
107 | files: string[];
108 | dependencies: Record;
109 | devDependencies: Record;
110 | }
111 |
112 | export function mockGitHubMetadata(
113 | mockAgent: MockAgent,
114 | repo: string,
115 | options: Partial,
116 | ) {
117 | const branch = 'remix-test-branch';
118 | const path = '';
119 | const githubAPI = mockAgent.get('https://api.github.com');
120 | const githubContent = mockAgent.get('https://raw.githubusercontent.com');
121 |
122 | githubAPI
123 | .intercept({
124 | path: `/repos/${repo}`,
125 | method: 'GET',
126 | })
127 | .reply(200, {
128 | full_name: repo,
129 | description: options.description,
130 | owner: {
131 | login: options.login,
132 | },
133 | default_branch: branch,
134 | });
135 |
136 | githubAPI
137 | .intercept({
138 | path: `/repos/${repo}/contents/${path}`,
139 | method: 'GET',
140 | })
141 | .reply(
142 | 200,
143 | options?.files?.map((name) => ({ name })) ?? [{ name: 'package.json' }],
144 | );
145 |
146 | githubContent
147 | .intercept({
148 | path: `/${repo}/${branch}/package.json`,
149 | method: 'GET',
150 | })
151 | .reply(200, {
152 | dependencies: options.dependencies ?? {},
153 | devDependencies: options.devDependencies ?? {},
154 | });
155 | }
156 |
157 | interface MockNpmOptions {
158 | description: string;
159 | repositoryURL: string;
160 | }
161 |
162 | export function mockNpmMetadata(
163 | mockAgent: MockAgent,
164 | packageName: string,
165 | options?: Partial,
166 | ) {
167 | const npmRegistry = mockAgent.get('https://registry.npmjs.org');
168 |
169 | npmRegistry
170 | .intercept({
171 | path: `/${packageName}`,
172 | method: 'GET',
173 | })
174 | .reply(200, {
175 | name: packageName,
176 | description: options?.description,
177 | repository: options?.repositoryURL
178 | ? {
179 | type: 'git',
180 | url: `git+${options.repositoryURL}.git`,
181 | }
182 | : null,
183 | });
184 | }
185 |
186 | export function mockYouTubeMetadata(
187 | mockAgent: MockAgent,
188 | videoId: string,
189 | apiKey: string,
190 | ) {
191 | const youtubeAPI = mockAgent.get('https://youtube.googleapis.com');
192 |
193 | youtubeAPI
194 | .intercept({
195 | path: `/youtube/v3/videos?id=${videoId}&key=${apiKey}&part=snippet`,
196 | method: 'GET',
197 | })
198 | .reply(200, {});
199 | }
200 |
201 | export function mockSafeBrowsingAPI(mockAgent: MockAgent, apiKey: string) {
202 | const safeBrowsingAPI = mockAgent.get('https://safebrowsing.googleapis.com');
203 |
204 | safeBrowsingAPI
205 | .intercept({
206 | path: `/v4/threatMatches:find?key=${apiKey}`,
207 | method: 'POST',
208 | })
209 | .reply(200, {});
210 | }
211 |
212 | export function mockPage(
213 | mockAgent: MockAgent,
214 | urlText: string,
215 | options?: { status?: number; head?: string; body?: string; headers?: any },
216 | ) {
217 | const url = new URL(urlText);
218 | const client = mockAgent.get(url.origin);
219 | const html = `
220 |
221 | ${options?.head}
222 | ${options?.body}
223 |
224 | `;
225 |
226 | client
227 | .intercept({
228 | path: urlText !== url.origin ? urlText.replace(url.origin, '') : '/',
229 | method: 'GET',
230 | })
231 | .reply(options?.status ?? 200, html, {
232 | headers: options?.headers,
233 | });
234 |
235 | // Assume it is always safe for now
236 | mockSafeBrowsingAPI(mockAgent, 'test-google-api-key');
237 | }
238 |
239 | export async function getResource(mf: Miniflare, resourceId: string | null) {
240 | if (!resourceId) {
241 | return null;
242 | }
243 |
244 | const resources = await listResources(mf);
245 | const resource = resources?.[resourceId] ?? null;
246 |
247 | return resource;
248 | }
249 |
250 | export async function getPage(mf: Miniflare, url: string) {
251 | const PAGE_STORE = await mf.getDurableObjectNamespace('PAGE_STORE');
252 | const client = createPageStoreClient(PAGE_STORE as any, 'global');
253 | const page = await client.getPage(url);
254 |
255 | return page;
256 | }
257 |
258 | export async function listResources(mf: Miniflare) {
259 | const content = await mf.getKVNamespace('CONTENT');
260 | const data = await content.get<{ [resourceId: string]: Resource }>(
261 | 'guides/discover',
262 | 'json',
263 | );
264 |
265 | return data;
266 | }
267 |
268 | export function getPageURL(page: Page): URL {
269 | return new URL(page.url());
270 | }
271 |
272 | export function getPageGuide(page: Page): string | null {
273 | const { pathname } = getPageURL(page);
274 |
275 | if (pathname.startsWith('/admin') || pathname.startsWith('/submit')) {
276 | return null;
277 | }
278 |
279 | return pathname.slice(1).split('/')[0];
280 | }
281 |
282 | export function getPageResourceId(page: Page): string | null {
283 | const url = getPageURL(page);
284 | const prefix = '/resources/';
285 | const [resourcedId] = url.pathname.startsWith(prefix)
286 | ? url.pathname.slice(prefix.length).split('/')
287 | : [];
288 |
289 | return resourcedId ?? null;
290 | }
291 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "esModuleInterop": true,
6 | "resolveJsonModule": true,
7 | "jsx": "react-jsx",
8 | "module": "CommonJS",
9 | "moduleResolution": "node",
10 | "target": "ESNext",
11 | "strict": true,
12 | "types": ["@cloudflare/workers-types"],
13 | "paths": {
14 | "~/*": ["./app/*"]
15 | },
16 | "noEmit": true,
17 | "baseUrl": ".",
18 | "allowJs": true,
19 | "forceConsistentCasingInFileNames": true,
20 | "isolatedModules": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/worker/adapter.ts:
--------------------------------------------------------------------------------
1 | // Required for installGlobals();
2 | import '@remix-run/cloudflare-workers';
3 |
4 | // Required for custom adapters
5 | import type { AppLoadContext, ServerBuild } from '@remix-run/cloudflare';
6 | import { createRequestHandler as createRemixRequestHandler } from '@remix-run/cloudflare';
7 |
8 | // Required only for Worker Site
9 | import type { Options as KvAssetHandlerOptions } from '@cloudflare/kv-asset-handler';
10 | import {
11 | getAssetFromKV,
12 | MethodNotAllowedError,
13 | NotFoundError,
14 | } from '@cloudflare/kv-asset-handler';
15 |
16 | // @ts-expect-error External JSON only available on CF runtime / Miniflare
17 | import manifest from '__STATIC_CONTENT_MANIFEST';
18 |
19 | export interface GetLoadContextFunction {
20 | (request: Request, env: Env, ctx: ExecutionContext): AppLoadContext;
21 | }
22 |
23 | export type RequestHandler = ReturnType;
24 |
25 | export function createRequestHandler({
26 | build,
27 | getLoadContext,
28 | mode,
29 | }: {
30 | build: ServerBuild;
31 | getLoadContext?: GetLoadContextFunction;
32 | mode?: string;
33 | }): ExportedHandlerFetchHandler {
34 | let handleRequest = createRemixRequestHandler(build, mode);
35 |
36 | return (request: Request, env: Env, ctx: ExecutionContext) => {
37 | let loadContext =
38 | typeof getLoadContext === 'function'
39 | ? getLoadContext(request, env, ctx)
40 | : undefined;
41 |
42 | return handleRequest(request, loadContext);
43 | };
44 | }
45 |
46 | export function createFetchHandler({
47 | build,
48 | getLoadContext,
49 | handleAsset,
50 | enableCache,
51 | mode,
52 | }: {
53 | build: ServerBuild;
54 | getLoadContext?: GetLoadContextFunction;
55 | handleAsset: (
56 | request: Request,
57 | env: Env,
58 | ctx: ExecutionContext,
59 | ) => Promise;
60 | enableCache?: boolean;
61 | mode?: string;
62 | }): ExportedHandlerFetchHandler {
63 | const handleRequest = createRequestHandler({
64 | build,
65 | getLoadContext,
66 | mode,
67 | });
68 |
69 | return async (request: Request, env: Env, ctx: ExecutionContext) => {
70 | try {
71 | let isHeadOrGetRequest =
72 | request.method === 'HEAD' || request.method === 'GET';
73 | let cache = enableCache ? await caches.open(build.assets.version) : null;
74 | let response: Response | undefined;
75 |
76 | if (isHeadOrGetRequest) {
77 | response = await handleAsset(request.clone(), env, ctx);
78 |
79 | if (response.status >= 200 && response.status < 400) {
80 | return response;
81 | }
82 | }
83 |
84 | if (cache && isHeadOrGetRequest) {
85 | response = await cache?.match(request);
86 |
87 | if (response) {
88 | return response;
89 | }
90 | }
91 |
92 | response = await handleRequest(request.clone(), env, ctx);
93 |
94 | if (cache && isHeadOrGetRequest && response.ok) {
95 | ctx.waitUntil(cache?.put(request, response.clone()));
96 | }
97 |
98 | return response;
99 | } catch (e: any) {
100 | console.log('Error caught', e.message, e);
101 |
102 | if (process.env.NODE_ENV === 'development') {
103 | return new Response(e instanceof Error ? e.message : e.toString(), {
104 | status: 500,
105 | });
106 | }
107 |
108 | return new Response('Internal Server Error', { status: 500 });
109 | }
110 | };
111 | }
112 |
113 | export function createWorkerAssetHandler(build: ServerBuild) {
114 | async function handleAsset(
115 | request: Request,
116 | env: Env,
117 | ctx: ExecutionContext,
118 | ): Promise {
119 | async function getAssetManifest(cache: Cache) {
120 | const nextAssetManifest = JSON.parse(manifest);
121 |
122 | try {
123 | const manifestCache = new Request('http://worker.localhost/');
124 | const response = await cache.match(manifestCache);
125 | const prevAssetManifest = await response?.json>();
126 | const assetManifest = {
127 | ...prevAssetManifest,
128 | ...nextAssetManifest,
129 | };
130 |
131 | ctx.waitUntil(
132 | cache.put(
133 | manifestCache,
134 | new Response(assetManifest, {
135 | headers: { 'cache-control': 'max-age=31536000' },
136 | }),
137 | ),
138 | );
139 |
140 | return assetManifest;
141 | } catch {
142 | return nextAssetManifest;
143 | }
144 | }
145 |
146 | try {
147 | const event = {
148 | request,
149 | waitUntil(promise: Promise) {
150 | return ctx.waitUntil(promise);
151 | },
152 | };
153 | const options: Partial = {
154 | ASSET_NAMESPACE: (env as any).__STATIC_CONTENT,
155 | ASSET_MANIFEST: await getAssetManifest(caches.default),
156 | };
157 |
158 | const assetpath = build.assets.url.split('/').slice(0, -1).join('/');
159 | const requestpath = new URL(request.url).pathname
160 | .split('/')
161 | .slice(0, -1)
162 | .join('/');
163 |
164 | if (requestpath.startsWith(assetpath)) {
165 | options.cacheControl = {
166 | bypassCache: false,
167 | edgeTTL: 31536000,
168 | browserTTL: 31536000,
169 | };
170 | }
171 |
172 | if (process.env.NODE_ENV === 'development') {
173 | options.cacheControl = {
174 | bypassCache: true,
175 | };
176 | }
177 |
178 | return await getAssetFromKV(event, options);
179 | } catch (error) {
180 | if (
181 | error instanceof MethodNotAllowedError ||
182 | error instanceof NotFoundError
183 | ) {
184 | return new Response('Not Found', { status: 404 });
185 | }
186 |
187 | throw error;
188 | }
189 | }
190 |
191 | return handleAsset;
192 | }
193 |
--------------------------------------------------------------------------------
/worker/cache.ts:
--------------------------------------------------------------------------------
1 | const cache = caches.default as Cache;
2 |
3 | function createCacheRequest(key: string): Request {
4 | return new Request(`http://remix.guide/__cache/${key}`, {
5 | method: 'GET',
6 | });
7 | }
8 |
9 | export async function matchCache(key: string): T | null {
10 | const cacheRequest = createCacheRequest(key);
11 | const response = await cache.match(cacheRequest);
12 |
13 | if (!response || !response.ok) {
14 | return null;
15 | }
16 |
17 | return await response.json();
18 | }
19 |
20 | export async function updateCache(
21 | key: string,
22 | data: T,
23 | maxAge: number,
24 | ): Promise {
25 | const cacheRequest = createCacheRequest(key);
26 | const cacheResponse = new Response(JSON.stringify(data), {
27 | status: 200,
28 | headers: {
29 | 'Cache-Control': `public, max-age=${maxAge}`,
30 | },
31 | });
32 |
33 | await cache.put(cacheRequest, cacheResponse);
34 | }
35 |
36 | export async function removeCache(key: string): Pormise {
37 | const cacheRequest = createCacheRequest(key);
38 |
39 | await cache.delete(cacheRequest);
40 | }
41 |
--------------------------------------------------------------------------------
/worker/index.ts:
--------------------------------------------------------------------------------
1 | import * as build from '../build/index.js';
2 | import { createFetchHandler, createWorkerAssetHandler } from './adapter';
3 | import { getPageStore } from './store/PageStore';
4 | import { getUserStore } from './store/UserStore';
5 | import { getResourceStore } from './store/ResourcesStore';
6 | import { createSession } from './session';
7 |
8 | import type { Env } from './types';
9 |
10 | // Setup Durable Objects
11 | export * from './store';
12 |
13 | declare module '@remix-run/server-runtime' {
14 | export interface AppLoadContext {
15 | session: Context['session'];
16 | resourceStore: Context['resourceStore'];
17 | pageStore: Context['pageStore'];
18 | userStore: Context['userStore'];
19 | }
20 | }
21 |
22 | type Context = ReturnType;
23 |
24 | function getLoadContext(request: Request, env: Env, ctx: ExecutionContext) {
25 | return {
26 | session: createSession(request, env, ctx),
27 | resourceStore: getResourceStore(env, ctx),
28 | pageStore: getPageStore(env, ctx),
29 | userStore: getUserStore(env, ctx),
30 | };
31 | }
32 |
33 | const worker: ExportedHandler = {
34 | fetch: createFetchHandler({
35 | build,
36 | getLoadContext,
37 | handleAsset: createWorkerAssetHandler(build),
38 | }),
39 | };
40 |
41 | export default worker;
42 |
--------------------------------------------------------------------------------
/worker/logging.ts:
--------------------------------------------------------------------------------
1 | import type { Env } from './types';
2 | import type { Reporter, Tracker } from 'workers-logger';
3 | import { track, enable } from 'workers-logger';
4 | import { defaultReporter } from 'diary';
5 | import Toucan from 'toucan-js';
6 |
7 | function createReporter(
8 | request: Request,
9 | env: Env,
10 | ctx?: ExecutionContext,
11 | ): Reporter {
12 | if (!env.SENTRY_DSN) {
13 | return (events) => {
14 | for (const event of events) {
15 | defaultReporter(event);
16 | }
17 | };
18 | }
19 |
20 | const options = {
21 | dsn: env.SENTRY_DSN,
22 | request,
23 | };
24 |
25 | if (ctx) {
26 | options.context = ctx;
27 | }
28 |
29 | const Sentry = new Toucan(options);
30 |
31 | return (events) => {
32 | for (const event of events) {
33 | switch (event.level) {
34 | case 'error':
35 | case 'fatal':
36 | Sentry.captureException(event.error);
37 | break;
38 | case 'info':
39 | case 'warn':
40 | case 'log':
41 | Sentry.captureMessage(event.message, event.level);
42 | break;
43 | }
44 | }
45 | };
46 | }
47 |
48 | function configureLogger(name: string) {
49 | function createLogger(
50 | request: Request,
51 | env: Env,
52 | ctx?: ExecutionContext,
53 | ): Tracker {
54 | const reporter = createReporter(request, env, ctx);
55 | const logger = track(request, name, reporter);
56 |
57 | enable(env.DEBUG ?? '*');
58 |
59 | return logger;
60 | }
61 |
62 | return createLogger;
63 | }
64 |
65 | export { configureLogger };
66 |
--------------------------------------------------------------------------------
/worker/session.ts:
--------------------------------------------------------------------------------
1 | import { Authenticator, AuthorizationError } from 'remix-auth';
2 | import { GitHubStrategy } from 'remix-auth-github';
3 | import { createCookieSessionStorage, redirect } from '@remix-run/cloudflare';
4 | import type { Env, MessageType, SessionData, UserProfile } from './types';
5 | import { getUserStore } from './store/UserStore';
6 |
7 | export type Session = ReturnType;
8 |
9 | export function createSession(
10 | request: Request,
11 | env: Env,
12 | ctx: ExecutionContext,
13 | ) {
14 | if (!env.SESSION_SECRETS) {
15 | throw new Error(
16 | 'Fail initialising the session storge; SESSION_SECRETS is missing',
17 | );
18 | }
19 |
20 | let sessionStorage = createCookieSessionStorage({
21 | cookie: {
22 | name: '__session',
23 | httpOnly: true,
24 | path: '/',
25 | sameSite: 'lax',
26 | secrets: env.SESSION_SECRETS.split(','),
27 | secure: env.GITHUB_CALLBACK_URL?.startsWith('https') ?? false,
28 | maxAge: 2_592_000, // 30 days
29 | },
30 | });
31 |
32 | let authenticator = new Authenticator(sessionStorage, {
33 | sessionErrorKey: 'message',
34 | throwOnError: true,
35 | });
36 |
37 | if (
38 | !env.GITHUB_CLIENT_ID ||
39 | !env.GITHUB_CLIENT_SECRET ||
40 | !env.GITHUB_CALLBACK_URL
41 | ) {
42 | throw new Error(
43 | 'Fail initialising the GitHub strategy; Some env variables are missing',
44 | );
45 | }
46 |
47 | authenticator.use(
48 | new GitHubStrategy(
49 | {
50 | clientID: env.GITHUB_CLIENT_ID,
51 | clientSecret: env.GITHUB_CLIENT_SECRET,
52 | callbackURL: env.GITHUB_CALLBACK_URL,
53 | userAgent: 'remix-guide',
54 | scope: 'email',
55 | },
56 | async ({ profile }) => {
57 | const userStore = getUserStore(env, profile.id);
58 | const userProfile: UserProfile = {
59 | id: profile.id,
60 | name: profile.displayName,
61 | email: profile.emails[0].value,
62 | };
63 |
64 | try {
65 | await userStore.updateProfile(userProfile);
66 | } catch (ex) {
67 | console.log(
68 | 'Fail updating user profile:',
69 | ex instanceof Error ? ex.message : ex,
70 | );
71 |
72 | throw ex;
73 | }
74 |
75 | return userProfile;
76 | },
77 | ),
78 | );
79 |
80 | return {
81 | async login(): Promise {
82 | try {
83 | await authenticator.authenticate('github', request, {
84 | successRedirect: '/',
85 | });
86 | } catch (ex) {
87 | if (ex instanceof AuthorizationError) {
88 | throw redirect('/', {
89 | headers: {
90 | 'set-cookie': await this.flash(ex.message, 'error'),
91 | },
92 | });
93 | }
94 |
95 | throw ex;
96 | }
97 | },
98 | async logout(): Promise {
99 | await authenticator.logout(request, {
100 | redirectTo: '/',
101 | });
102 | },
103 | async getData(): Promise<[SessionData, string]> {
104 | const session = await sessionStorage.getSession(
105 | request.headers.get('Cookie'),
106 | );
107 | const profile = session.get(authenticator.sessionKey) ?? null;
108 | const message = session.get('message') ?? null;
109 | const setCookieHeader = await sessionStorage.commitSession(session);
110 | const data = {
111 | profile,
112 | message,
113 | version: process.env.VERSION ?? 'development',
114 | };
115 |
116 | return [data, setCookieHeader];
117 | },
118 | async getUserProfile(): Promise {
119 | return await authenticator.isAuthenticated(request);
120 | },
121 | async flash(message: string, type: MessageType = 'info'): Promise {
122 | const session = await sessionStorage.getSession(
123 | request.headers.get('Cookie'),
124 | );
125 |
126 | session.flash('message', `${type}: ${message}`);
127 |
128 | return await sessionStorage.commitSession(session);
129 | },
130 | };
131 | }
132 |
--------------------------------------------------------------------------------
/worker/store/PageStore.ts:
--------------------------------------------------------------------------------
1 | import { scrapeHTML, getPageDetails, checkSafeBrowsingAPI } from '../scraping';
2 | import type { Env, Page, PageMetadata } from '../types';
3 | import { configureStore, restoreStoreData } from '../utils';
4 |
5 | type PageStatistics = Required>;
6 |
7 | function getPageMetadata(page: Page): PageMetadata {
8 | return {
9 | url: page.url,
10 | category: page.category,
11 | title: page.title,
12 | description: page.description?.slice(0, 100),
13 | isSafe: page.isSafe,
14 | createdAt: page.createdAt,
15 | updatedAt: page.updatedAt,
16 | viewCount: page.viewCount ?? 0,
17 | bookmarkCount: page.bookmarkUsers?.length ?? 0,
18 | };
19 | }
20 |
21 | const { Store, createClient } = configureStore(async ({ storage }, env) => {
22 | let pageMap = await initialise();
23 |
24 | async function initialise() {
25 | return await storage.list();
26 | }
27 |
28 | function getStatistics(page: Page | undefined): PageStatistics {
29 | return {
30 | bookmarkUsers: page?.bookmarkUsers ?? [],
31 | viewCount: page?.viewCount ?? 0,
32 | };
33 | }
34 |
35 | async function getPage(url: string) {
36 | const page = pageMap.get(url);
37 |
38 | return page ?? null;
39 | }
40 |
41 | async function updatePage(url: string, page: Page): Promise {
42 | pageMap.set(url, page);
43 |
44 | await storage.put(url, page);
45 | }
46 |
47 | async function list() {
48 | return Object.fromEntries(pageMap.entries());
49 | }
50 |
51 | async function refresh(url: string, page: Page) {
52 | const cachedPage = pageMap.get(url);
53 | const statistics = getStatistics(cachedPage ?? page);
54 |
55 | await updatePage(url, {
56 | ...page,
57 | ...statistics,
58 | createdAt: cachedPage?.createdAt ?? page.createdAt,
59 | updatedAt: page.createdAt,
60 | });
61 | }
62 |
63 | async function view(url: string) {
64 | const page = pageMap.get(url);
65 | const statistics = getStatistics(page);
66 |
67 | if (!page) {
68 | throw new Error(`No existing page found for ${url}`);
69 | }
70 |
71 | await updatePage(url, {
72 | ...page,
73 | viewCount: statistics.viewCount + 1,
74 | });
75 | }
76 |
77 | async function bookmark(userId: string, url: string) {
78 | const page = pageMap.get(url);
79 | const statistics = getStatistics(page);
80 |
81 | if (!page) {
82 | throw new Error(`No existing page found for ${url}`);
83 | }
84 |
85 | if (statistics.bookmarkUsers.includes(userId)) {
86 | return;
87 | }
88 |
89 | await updatePage(url, {
90 | ...page,
91 | bookmarkUsers: statistics.bookmarkUsers.concat(userId),
92 | });
93 | }
94 |
95 | async function unbookmark(userId: string, url: string) {
96 | const page = pageMap.get(url);
97 | const statistics = getStatistics(page);
98 |
99 | if (!page) {
100 | throw new Error(`No existing page found for ${url}`);
101 | }
102 |
103 | if (!statistics.bookmarkUsers.includes(userId)) {
104 | return;
105 | }
106 |
107 | await updatePage(url, {
108 | ...page,
109 | bookmarkUsers: statistics.bookmarkUsers.filter((id) => id !== userId),
110 | });
111 | }
112 |
113 | return {
114 | getPage,
115 | list,
116 | refresh,
117 | view,
118 | bookmark,
119 | unbookmark,
120 | async backup(): Promise> {
121 | const data = await storage.list();
122 |
123 | return Object.fromEntries(data);
124 | },
125 | async restore(data: Record): Promise {
126 | await restoreStoreData(storage, data);
127 | pageMap = await initialise();
128 | },
129 | };
130 | });
131 |
132 | export const PageStore = Store;
133 | export const createPageStoreClient = createClient;
134 |
135 | export function getPageStore(
136 | env: Env,
137 | ctx: ExecutionContext | DurableObjectState,
138 | ) {
139 | const { PAGE_STORE, GOOGLE_API_KEY, USER_AGENT } = env;
140 | const client = createClient(PAGE_STORE, 'global');
141 |
142 | async function createPage(url: string): Promise {
143 | const page = await scrapeHTML(url, USER_AGENT);
144 | const [pageDetails, isSafe] = await Promise.all([
145 | getPageDetails(page.url, env),
146 | GOOGLE_API_KEY
147 | ? checkSafeBrowsingAPI([page.url], GOOGLE_API_KEY)
148 | : process.env.NODE_ENV !== 'production', // Consider URL as safe for non production environment without GOOGLE_API_KEY,
149 | ]);
150 |
151 | return {
152 | ...page,
153 | ...pageDetails,
154 | isSafe,
155 | };
156 | }
157 |
158 | return {
159 | async getPage(url: string) {
160 | return await client.getPage(url);
161 | },
162 | async list() {
163 | return await client.list();
164 | },
165 | async listPageMetadata() {
166 | const data = await this.list();
167 |
168 | return Object.values(data)
169 | .map(getPageMetadata)
170 | .sort(
171 | (prev, next) =>
172 | new Date(next.createdAt).valueOf() -
173 | new Date(prev.createdAt).valueOf(),
174 | );
175 | },
176 | async getOrCreatePage(url: string) {
177 | let page = await this.getPage(url);
178 |
179 | if (!page) {
180 | page = await createPage(url);
181 |
182 | ctx.waitUntil(client.refresh(page.url, page));
183 | }
184 |
185 | return page;
186 | },
187 | async refresh(url: string) {
188 | return await client.refresh(url, await createPage(url));
189 | },
190 | async view(url: string) {
191 | return await client.view(url);
192 | },
193 | async bookmark(userId: string, url: string) {
194 | return await client.bookmark(userId, url);
195 | },
196 | async unbookmark(userId: string, url: string) {
197 | return await client.unbookmark(userId, url);
198 | },
199 | async backup() {
200 | return await client.backup();
201 | },
202 | async restore(data: Record) {
203 | return await client.restore(data);
204 | },
205 | };
206 | }
207 |
--------------------------------------------------------------------------------
/worker/store/UserStore.ts:
--------------------------------------------------------------------------------
1 | import { matchCache, removeCache, updateCache } from '../cache';
2 | import type { Env, UserProfile, User } from '../types';
3 | import { configureStore, restoreStoreData } from '../utils';
4 | import { getPageStore } from './PageStore';
5 |
6 | const { Store, createClient } = configureStore(async ({ storage }, env) => {
7 | const { CONTENT } = env as Env;
8 | let [profile = null, bookmarked = [], viewed = []] = await Promise.all([
9 | storage.get('profile'),
10 | storage.get('bookmarked'),
11 | storage.get('viewed'),
12 | ]);
13 |
14 | return {
15 | async getUser(): Promise {
16 | if (!profile) {
17 | return null;
18 | }
19 |
20 | return {
21 | profile,
22 | viewed,
23 | bookmarked,
24 | };
25 | },
26 | async updateProfile(newProfile: UserProfile): Promise {
27 | if (profile !== null && profile.id !== newProfile.id) {
28 | throw new Error(
29 | 'The user store is already registered with a different userId',
30 | );
31 | }
32 |
33 | const now = new Date().toISOString();
34 | const updated = {
35 | ...newProfile,
36 | createdAt: profile?.createdAt ?? now,
37 | updatedAt: now,
38 | };
39 |
40 | await Promise.all([
41 | storage.put('profile', updated),
42 | CONTENT.put(`user/${updated.id}`, JSON.stringify(updated), {
43 | metadata: updated,
44 | }),
45 | ]);
46 |
47 | profile = updated;
48 | },
49 | async view(userId: string, resourceId: string): Promise {
50 | if (profile?.id !== userId) {
51 | throw new Error(
52 | 'View failed; Please ensure the request is sent to the proper DO',
53 | );
54 | }
55 |
56 | viewed = viewed.filter((id) => id !== resourceId);
57 | viewed.unshift(resourceId);
58 | storage.put('viewed', viewed);
59 | },
60 | async bookmark(userId: string, resourceId: string): Promise {
61 | if (profile?.id !== userId) {
62 | throw new Error(
63 | 'Bookmark failed; Please ensure the request is sent to the proper DO',
64 | );
65 | }
66 |
67 | const isBookmarked = bookmarked.includes(resourceId);
68 |
69 | if (isBookmarked) {
70 | return;
71 | }
72 |
73 | bookmarked.unshift(resourceId);
74 |
75 | storage.put('bookmarked', bookmarked);
76 | },
77 | async unbookmark(userId: string, resourceId: string): Promise {
78 | if (profile?.id !== userId) {
79 | throw new Error(
80 | 'Unbookmark failed; Please ensure the request is sent to the proper DO',
81 | );
82 | }
83 |
84 | const isBookmarked = bookmarked.includes(resourceId);
85 |
86 | if (!isBookmarked) {
87 | return;
88 | }
89 |
90 | bookmarked = bookmarked.filter((id) => id !== resourceId);
91 |
92 | storage.put('bookmarked', bookmarked);
93 | },
94 | async backup(): Promise> {
95 | const data = await storage.list();
96 |
97 | return Object.fromEntries(data);
98 | },
99 | async restore(data: Record): Promise {
100 | await restoreStoreData(storage, data);
101 |
102 | [profile = null, bookmarked = [], viewed = []] = await Promise.all([
103 | storage.get('profile'),
104 | storage.get('bookmarked'),
105 | storage.get('viewed'),
106 | ]);
107 | },
108 | };
109 | });
110 |
111 | /**
112 | * UserStore - A durable object that keeps user profile, bookmarks and views
113 | */
114 | export const UserStore = Store;
115 |
116 | export function getUserStore(
117 | env: Env,
118 | ctx: ExecutionContext | DurableObjectState,
119 | ) {
120 | const pageStore = getPageStore(env, ctx);
121 |
122 | return {
123 | async getUser(userId: string): Promise {
124 | const client = createClient(env.USER_STORE, userId);
125 | let user = await matchCache(`users/${userId}`);
126 |
127 | if (!user) {
128 | user = await client.getUser();
129 |
130 | if (user) {
131 | ctx.waitUntil(updateCache(`users/${userId}`, user, 10800));
132 | }
133 | }
134 |
135 | return user;
136 | },
137 | async getList(
138 | userId: string,
139 | list: string | null,
140 | ): Promise {
141 | if (!list) {
142 | return null;
143 | }
144 |
145 | const user = await this.getUser(userId);
146 |
147 | switch (list) {
148 | case 'bookmarks':
149 | return user?.bookmarked ?? [];
150 | case 'history':
151 | return user?.viewed ?? [];
152 | default:
153 | return [];
154 | }
155 | },
156 | async listUserProfiles(): Promise {
157 | let list = await matchCache('users');
158 |
159 | if (!list) {
160 | const result = await env.CONTENT.list({
161 | prefix: 'user/',
162 | });
163 |
164 | list = result.keys.flatMap((key) => key.metadata ?? []);
165 |
166 | ctx.waitUntil(updateCache('users', list, 60));
167 | }
168 |
169 | return list;
170 | },
171 | async updateProfile(profile: UserProfile): Promise {
172 | const client = createClient(env.USER_STORE, profile.id);
173 |
174 | await client.updateProfile(profile);
175 | },
176 | async view(
177 | userId: string | null,
178 | resourceId: string,
179 | url: string,
180 | ): Promise {
181 | if (userId) {
182 | const client = createClient(env.USER_STORE, userId);
183 |
184 | await client.view(userId, resourceId);
185 |
186 | ctx.waitUntil(removeCache(`users/${userId}`));
187 | }
188 |
189 | ctx.waitUntil(pageStore.view(url));
190 | },
191 | async bookmark(
192 | userId: string,
193 | resourceId: string,
194 | url: string,
195 | ): Promise {
196 | const client = createClient(env.USER_STORE, userId);
197 |
198 | await client.bookmark(userId, resourceId);
199 |
200 | ctx.waitUntil(removeCache(`users/${userId}`));
201 | ctx.waitUntil(pageStore.bookmark(userId, url));
202 | },
203 | async unbookmark(
204 | userId: string,
205 | resourceId: string,
206 | url: string,
207 | ): Promise {
208 | const client = createClient(env.USER_STORE, userId);
209 |
210 | await client.unbookmark(userId, resourceId);
211 |
212 | ctx.waitUntil(removeCache(`users/${userId}`));
213 | ctx.waitUntil(pageStore.unbookmark(userId, url));
214 | },
215 | async backup(userId: string): Promise> {
216 | const client = createClient(env.USER_STORE, userId);
217 |
218 | return await client.backup();
219 | },
220 | async restore(userId: string, data: Record): Promise {
221 | const client = createClient(env.USER_STORE, userId);
222 |
223 | return await client.restore(data);
224 | },
225 | };
226 | }
227 |
--------------------------------------------------------------------------------
/worker/store/index.ts:
--------------------------------------------------------------------------------
1 | export { ResourcesStore } from './ResourcesStore';
2 | export { PageStore } from './PageStore';
3 | export { UserStore } from './UserStore';
4 |
--------------------------------------------------------------------------------
/worker/types.ts:
--------------------------------------------------------------------------------
1 | export interface Env {
2 | GITHUB_TOKEN?: string;
3 | GITHUB_CLIENT_ID?: string;
4 | GITHUB_CLIENT_SECRET?: string;
5 | GITHUB_CALLBACK_URL?: string;
6 | GOOGLE_API_KEY?: string;
7 | SESSION_SECRETS?: string;
8 | SENTRY_DSN?: string;
9 | USER_AGENT?: string;
10 | DEBUG?: string;
11 | CONTENT: KVNamespace;
12 | RESOURCES_STORE: DurableObjectNamespace;
13 | PAGE_STORE: DurableObjectNamespace;
14 | USER_STORE: DurableObjectNamespace;
15 | }
16 |
17 | export interface SessionData {
18 | profile: UserProfile | null;
19 | message: string | null;
20 | }
21 |
22 | export interface UserProfile {
23 | id: string;
24 | name: string;
25 | email: string;
26 | createdAt: string;
27 | updatedAt: string;
28 | }
29 |
30 | export interface User {
31 | profile: UserProfile;
32 | viewed: string[];
33 | bookmarked: string[];
34 | }
35 |
36 | export type Category = 'package' | 'repository' | 'others';
37 |
38 | export interface Page {
39 | url: string;
40 | author?: string;
41 | category?: string;
42 | title?: string | null;
43 | description?: string | null;
44 | dependencies?: Record;
45 | configs?: string[];
46 | image?: string | null;
47 | video?: string | null;
48 | isSafe?: boolean;
49 | viewCount?: number;
50 | bookmarkUsers?: string[];
51 | createdAt: string;
52 | updatedAt: string;
53 | }
54 |
55 | export interface PageMetadata
56 | extends Pick<
57 | Page,
58 | | 'url'
59 | | 'title'
60 | | 'description'
61 | | 'category'
62 | | 'isSafe'
63 | | 'createdAt'
64 | | 'updatedAt'
65 | > {
66 | viewCount: number;
67 | bookmarkCount: number;
68 | }
69 |
70 | export type SubmissionStatus = 'PUBLISHED' | 'RESUBMITTED' | 'INVALID';
71 |
72 | export type MessageType = 'success' | 'error' | 'warning' | 'info';
73 |
74 | export interface ResourceSummary {
75 | id: string;
76 | url: string;
77 | description?: string | null;
78 | lists?: string[];
79 | createdAt: string;
80 | createdBy: string;
81 | updatedAt: string;
82 | updatedBy: string;
83 | }
84 |
85 | export interface Resource extends Page, ResourceSummary {
86 | integrations: string[];
87 | }
88 |
89 | export interface List {
90 | slug: string;
91 | title: string;
92 | }
93 |
94 | export interface Guide {
95 | value: { [resourceId: string]: Resource };
96 | metadata: GuideMetadata;
97 | }
98 |
99 | export interface GuideMetadata {
100 | timestamp?: string;
101 | lists?: (List & { count: number })[];
102 | }
103 |
104 | export interface SearchOptions {
105 | keyword?: string | null;
106 | list?: string | null;
107 | author?: string | null;
108 | site?: string | null;
109 | category?: Category | null;
110 | platform?: string | null;
111 | integrations?: string[] | null;
112 | includes?: string[] | null;
113 | excludes?: string[] | null;
114 | limit?: number;
115 | sort?: string | null;
116 | }
117 |
--------------------------------------------------------------------------------
/worker/utils.ts:
--------------------------------------------------------------------------------
1 | export function configureStore>(
2 | handlersCreator: (state: DurableObjectState, env: Env) => Promise,
3 | ) {
4 | class Store {
5 | env: Env;
6 | state: DurableObjectState;
7 | handlers: T | null;
8 |
9 | constructor(state: DurableObjectState, env: Env) {
10 | this.state = state;
11 | this.env = env;
12 | this.handlers = null;
13 |
14 | state.blockConcurrencyWhile(async () => {
15 | this.handlers = await handlersCreator(state, env);
16 | });
17 | }
18 |
19 | async fetch(request: Request): Promise {
20 | const { method, args } = await request.json<{
21 | method: string;
22 | args: any[];
23 | }>();
24 |
25 | try {
26 | if (!this.handlers) {
27 | throw new Error(
28 | 'The handlers are not initialised; Please check if the store is setup properly',
29 | );
30 | }
31 |
32 | const handler = this.handlers[method];
33 |
34 | if (typeof handler === 'function') {
35 | const result = JSON.stringify(await handler(...args)) ?? null;
36 |
37 | return new Response(result, {
38 | status: result !== null ? 200 : 204,
39 | });
40 | } else {
41 | return new Response('Not Found', { status: 404 });
42 | }
43 | } catch (e) {
44 | console.error(e);
45 | return new Response('Internal Server Error', { status: 500 });
46 | }
47 | }
48 | }
49 |
50 | function createClient(namespace: DurableObjectNamespace, name: string): T {
51 | const id = namespace.idFromName(name);
52 | const stub = namespace.get(id);
53 | const client = new Proxy(
54 | {},
55 | {
56 | get(_, method) {
57 | return async (...args: any[]) => {
58 | const response = await stub.fetch('http://store', {
59 | headers: {
60 | 'content-type': 'application/json',
61 | },
62 | method: 'POST',
63 | body: JSON.stringify({ method, args }),
64 | });
65 |
66 | switch (response.status) {
67 | case 200:
68 | return await response.json();
69 | case 204:
70 | return;
71 | case 404:
72 | throw new Error(`Method ${method.toString()} is not available`);
73 | default:
74 | throw new Error(
75 | `Unknown error caught; Received a ${response.status} response`,
76 | );
77 | }
78 | };
79 | },
80 | },
81 | );
82 |
83 | return client as T;
84 | }
85 |
86 | return {
87 | Store,
88 | createClient,
89 | };
90 | }
91 |
92 | export async function restoreStoreData(
93 | storage: DurableObjectStorage,
94 | data: Record,
95 | ): Promise {
96 | const batches = [];
97 | const keys = Object.keys(data);
98 |
99 | for (let i = 0; i * 128 < keys.length; i++) {
100 | const entires = keys.slice(i * 128, (i + 1) * 128).reduce((result, key) => {
101 | result[key] = data[key];
102 |
103 | return result;
104 | }, {} as Record);
105 |
106 | batches.push(entires);
107 | }
108 |
109 | await storage.deleteAll();
110 | await Promise.all(batches.map((entries) => storage.put(entries)));
111 | }
112 |
--------------------------------------------------------------------------------
/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "remix-guide"
2 | type = "javascript"
3 | workers_dev = false
4 | account_id = "c63d756a160ad09cd9a82553c77e9174"
5 | zone_id = "e433232c2f0e3fddcd03a20251740130"
6 | route = "remix.guide/*"
7 | compatibility_date = "2022-11-22"
8 | compatibility_flags = ["streams_enable_constructors"]
9 |
10 | [[kv_namespaces]]
11 | binding = "CONTENT"
12 | id = "f1a419c9f5524449b2c0562c7d774dd4"
13 |
14 | [site]
15 | bucket = "./public"
16 | entry-point = "."
17 |
18 | [durable_objects]
19 | bindings = [
20 | { name = "RESOURCES_STORE", class_name = "ResourcesStore" },
21 | { name = "USER_STORE", class_name = "UserStore" },
22 | { name = "PAGE_STORE", class_name = "PageStore" },
23 | ]
24 |
25 | [[migrations]]
26 | tag = "v0.1"
27 | new_classes = ["Counter"]
28 |
29 | [[migrations]]
30 | tag = "v1.0"
31 | new_classes = ["ResourcesStore", "UserStore"]
32 | deleted_classes = ["Counter"]
33 |
34 | [[migrations]]
35 | tag = "v1.1"
36 | new_classes = ["PageStore"]
37 |
38 | [build]
39 | command = "npm run build"
40 | watch_dir = "./build/index.js"
41 |
42 | [build.upload]
43 | format = "modules"
44 | dir = "./dist"
45 | main = "./worker.mjs"
46 |
--------------------------------------------------------------------------------