Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Experimental] Live AsciiDoc editor #96

Draft
wants to merge 37 commits into
base: main
Choose a base branch
from
Draft
Changes from 8 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2910331
Rough? Gross? Cool?
benjaminleonard Apr 16, 2024
eecdbe0
Pre-demo commit
benjaminleonard Apr 30, 2024
7288132
Merge branch 'tome' into live-asciidoc-notes
benjaminleonard Dec 17, 2024
8fd2114
Switch to monaco
benjaminleonard Dec 17, 2024
cea30e3
Continued cleanup
benjaminleonard Dec 17, 2024
5e49280
Tweaks
benjaminleonard Dec 17, 2024
bf47ae8
Update API to use env vars and general type fixes
benjaminleonard Dec 17, 2024
f8e52a3
More type fixes
benjaminleonard Dec 17, 2024
f895851
Licenses
benjaminleonard Dec 17, 2024
d7788ee
Ignore notes subfolder
benjaminleonard Dec 17, 2024
2a4357e
Test user for preview
benjaminleonard Dec 18, 2024
b5f63d3
Missed an auth check
benjaminleonard Dec 18, 2024
a1585d2
Merge branch 'main' into live-asciidoc-notes
benjaminleonard Jan 15, 2025
0826a92
Updates
benjaminleonard Jan 24, 2025
6fedc26
Merge branch 'main' into live-asciidoc-notes
benjaminleonard Jan 24, 2025
c4c2791
Update app/routes/notes._index.tsx
benjaminleonard Jan 24, 2025
724ba5a
Lint
benjaminleonard Jan 24, 2025
a899da0
Try `noExternal` code mirror basic setup
benjaminleonard Jan 24, 2025
6942ac8
Add `@uiw/codemirror-themes` to `noExternal`
benjaminleonard Jan 24, 2025
adca510
API within Remix instead
benjaminleonard Jan 27, 2025
4994518
Switch to liveblocks
benjaminleonard Jan 31, 2025
541d86b
Remove unused deps
benjaminleonard Jan 31, 2025
01a9b70
Auth tweak
benjaminleonard Jan 31, 2025
4d7f5d6
Last updated / tweaks / use storage / live title update
benjaminleonard Feb 3, 2025
2d0071d
Move server stuff into `notes.server`
benjaminleonard Feb 3, 2025
81ff67f
Tweak errors and URL
benjaminleonard Feb 3, 2025
c667f14
Title input improvements
benjaminleonard Feb 3, 2025
5dcc881
Merge branch 'main' into live-asciidoc-notes
benjaminleonard Feb 3, 2025
7c00686
Licenses
benjaminleonard Feb 3, 2025
585956b
`allowImportingTsExtensions`
benjaminleonard Feb 3, 2025
ccb061f
Use react query for sidebar
benjaminleonard Feb 3, 2025
9837b90
Readd lodash types
benjaminleonard Feb 3, 2025
1be5a8a
Remove upload artifact
benjaminleonard Feb 3, 2025
f185d3c
Rough migration to tiptap instead of codemirror
benjaminleonard Feb 19, 2025
6c190d8
Update package-lock.json
benjaminleonard Feb 19, 2025
c03ef2d
Add window mode (add focus)
benjaminleonard Feb 20, 2025
bf18572
Upgrade DS
benjaminleonard Feb 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions app/components/AsciidocBlocks/Document.tsx
Original file line number Diff line number Diff line change
@@ -9,16 +9,14 @@ import { useDelegatedReactRouterLinks } from '@oxide/design-system/components/di
import { Content, type DocumentBlock } from '@oxide/react-asciidoc'
import { useRef } from 'react'

// add styles for main
// max-w-full flex-shrink overflow-hidden 800:overflow-visible 800:pr-10 1200:w-[calc(100%-var(--toc-width))] 1200:pr-16 print:p-0
const CustomDocument = ({ document }: { document: DocumentBlock }) => {
let ref = useRef<HTMLDivElement>(null)
useDelegatedReactRouterLinks(ref, document.title)

return (
<div
id="content"
className="asciidoc-body max-w-full flex-shrink overflow-hidden 800:overflow-visible 800:pr-10 1200:w-[calc(100%-var(--toc-width))] 1200:pr-16 print:p-0"
ref={ref}
>
<div id="content" className="asciidoc-body" ref={ref}>
<Content blocks={document.blocks} />
</div>
)
30 changes: 15 additions & 15 deletions app/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -19,18 +19,18 @@ export const dropdownInnerStyles = `focus:outline-0 focus:bg-hover px-3 py-2 pr-

export const DropdownItem = ({
children,
classNames,
className,
onSelect,
}: {
children: ReactNode | string
classNames?: string
className?: string
onSelect?: () => void
}) => (
<Dropdown.Item
onSelect={onSelect}
className={cn(
dropdownOuterStyles,
classNames,
className,
dropdownInnerStyles,
!onSelect && 'cursor-default',
)}
@@ -42,12 +42,12 @@ export const DropdownItem = ({

export const DropdownSubTrigger = ({
children,
classNames,
className,
}: {
children: JSX.Element | string
classNames?: string
className?: string
}) => (
<Dropdown.SubTrigger className={cn(dropdownOuterStyles, classNames, dropdownInnerStyles)}>
<Dropdown.SubTrigger className={cn(dropdownOuterStyles, className, dropdownInnerStyles)}>
{children}
<Icon
name="carat-down"
@@ -59,13 +59,13 @@ export const DropdownSubTrigger = ({

export const DropdownLink = ({
children,
classNames,
className,
internal = false,
to,
disabled = false,
}: {
children: React.ReactNode
classNames?: string
className?: string
internal?: boolean
to: string
disabled?: boolean
@@ -76,7 +76,7 @@ export const DropdownLink = ({
className={cn(
'block ',
dropdownOuterStyles,
classNames,
className,
disabled && 'pointer-events-none',
)}
>
@@ -88,18 +88,18 @@ export const DropdownLink = ({

export const DropdownMenu = ({
children,
classNames,
className,
align = 'end',
}: {
children: React.ReactNode
classNames?: string
className?: string
align?: 'end' | 'start' | 'center' | undefined
}) => (
<Dropdown.Portal>
<Dropdown.Content
className={cn(
'menu overlay-shadow z-30 mt-2 min-w-[12rem] rounded border bg-raise border-secondary [&>*:last-child]:border-b-0',
classNames,
className,
)}
align={align}
>
@@ -110,16 +110,16 @@ export const DropdownMenu = ({

export const DropdownSubMenu = ({
children,
classNames,
className,
}: {
children: JSX.Element[]
classNames?: string
className?: string
}) => (
<Dropdown.Portal>
<Dropdown.SubContent
className={cn(
'menu overlay-shadow z-10 ml-2 max-h-[30vh] min-w-[12rem] overflow-y-auto rounded border bg-raise border-secondary [&>*:last-child]:border-b-0',
classNames,
className,
)}
>
{children}
10 changes: 8 additions & 2 deletions app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -10,10 +10,10 @@ import { buttonStyle } from '@oxide/design-system'
import * as Dropdown from '@radix-ui/react-dropdown-menu'
import { Link, useFetcher } from '@remix-run/react'
import { useCallback, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'

import Icon from '~/components/Icon'
import NewRfdButton from '~/components/NewRfdButton'
import { useKey } from '~/hooks/use-key'
import { useRootLoaderData } from '~/root'
import type { RfdItem, RfdListItem } from '~/services/rfd.server'

@@ -53,7 +53,7 @@ export default function Header({ currentRfd }: { currentRfd?: RfdItem }) {
return false // Returning false prevents default behaviour in Firefox
}, [open])

useKey('mod+k', toggleSearchMenu)
useHotkeys('mod+k', toggleSearchMenu)

return (
<div className="sticky top-0 z-20">
@@ -79,6 +79,12 @@ export default function Header({ currentRfd }: { currentRfd?: RfdItem }) {
<Icon name="search" size={16} />
</button>
<Search open={open} onClose={() => setOpen(false)} />
<Link
to="/notes"
className="flex h-8 w-8 items-center justify-center rounded border text-tertiary bg-secondary border-secondary elevation-1 hover:bg-hover"
>
<Icon name="edit" size={16} />
</Link>
<NewRfdButton />

{user ? (
6 changes: 3 additions & 3 deletions app/components/SelectRfdCombobox.tsx
Original file line number Diff line number Diff line change
@@ -10,9 +10,9 @@ import { Link, useNavigate } from '@remix-run/react'
import cn from 'classnames'
import fuzzysort from 'fuzzysort'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'

import Icon from '~/components/Icon'
import { useKey } from '~/hooks/use-key'
import { useSteppedScroll } from '~/hooks/use-stepped-scroll'
import type { RfdItem, RfdListItem } from '~/services/rfd.server'
import { classed } from '~/utils/classed'
@@ -33,7 +33,7 @@ const SelectRfdCombobox = ({
// memoized to avoid render churn in useKey
const toggleCombobox = useCallback(() => setOpen(!open), [setOpen, open])

useKey('mod+/', toggleCombobox)
useHotkeys('mod+/', toggleCombobox)

const handleDismiss = () => setOpen(false)

@@ -220,7 +220,7 @@ const ComboboxItem = ({
}) => {
const [shouldPrefetch, setShouldPrefetch] = useState(false)

const timer = useRef<NodeJS.Timeout | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)

function clear() {
if (timer.current) clearTimeout(timer.current)
2 changes: 1 addition & 1 deletion app/components/home/FilterDropdown.tsx
Original file line number Diff line number Diff line change
@@ -149,7 +149,7 @@ const DropdownFilterItem = ({
}) => (
<DropdownItem
onSelect={onSelect}
classNames={selected ? 'bg-accent-secondary text-accent' : ''}
className={selected ? 'text-accent bg-accent-secondary' : ''}
>
{selected && <Outline />}
<div className="flex items-center justify-between">
55 changes: 55 additions & 0 deletions app/components/note/Editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Editor, useMonaco } from '@monaco-editor/react'
import { shikiToMonaco } from '@shikijs/monaco'
import { useEffect } from 'react'
import { getHighlighter } from 'shiki'

import theme from './oxide-dark.json'

const EditorWrapper = ({
body,
onChange,
}: {
body: string
onChange: (string: string | undefined) => void
}) => {
const monaco = useMonaco()

useEffect(() => {
if (!monaco) {
return
}

const highlight = async () => {
const highlighter = await getHighlighter({
themes: [theme],
langs: ['asciidoc'],
})

monaco.languages.register({ id: 'asciidoc' })
shikiToMonaco(highlighter, monaco)
}

highlight()
}, [monaco])

return (
<Editor
value={body}
onChange={onChange}
theme="oxide-dark"
language="asciidoc"
options={{
minimap: { enabled: false },
fontFamily: 'GT America Mono',
fontSize: 13,
wordWrap: 'on',
quickSuggestions: false,
suggestOnTriggerCharacters: false,
acceptSuggestionOnEnter: 'off',
snippetSuggestions: 'none',
}}
/>
)
}

export default EditorWrapper
282 changes: 282 additions & 0 deletions app/components/note/NoteForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { Spinner } from '@oxide/design-system'
import { Asciidoc, prepareDocument } from '@oxide/react-asciidoc'
import * as Dropdown from '@radix-ui/react-dropdown-menu'
import { useFetcher } from '@remix-run/react'
import dayjs from 'dayjs'
import { useCallback, useEffect, useMemo, useState } from 'react'

import { opts } from '~/components/AsciidocBlocks'
import { DropdownItem, DropdownLink, DropdownMenu } from '~/components/Dropdown'
import Icon from '~/components/Icon'
import { useDebounce } from '~/hooks/use-debounce'
import { ad } from '~/utils/asciidoctor'

import EditorWrapper from './Editor'
import { SidebarIcon } from './Sidebar'

type EditorStatus = 'idle' | 'unsaved' | 'saving' | 'saved' | 'error'

export const NoteForm = ({
id,
initialTitle = '',
initialBody = '',
updated,
published,
onSave,
fetcher,
sidebarOpen,
setSidebarOpen,
}: {
id: string
initialTitle?: string
initialBody?: string
updated: string
published: 1 | 0
onSave: (title: string, body: string) => void
fetcher: any
sidebarOpen: boolean
setSidebarOpen: (bool: boolean) => void
}) => {
const [status, setStatus] = useState<EditorStatus>('idle')
const [body, setBody] = useState(initialBody)
const [title, setTitle] = useState(initialTitle)

const debouncedBody = useDebounce(body, 750)
const debouncedTitle = useDebounce(title, 750)

useEffect(() => {
const hasChanges = body !== initialBody || title !== initialTitle

const hasError = fetcher.data?.status === 'error'

if (hasError && status !== 'error') {
setStatus('error')
}

const isSaving = fetcher.state === 'submitting'
const isSaved = fetcher.state === 'idle' && status === 'saving'

if (!hasChanges && (isSaving || isSaved)) {
if (isSaving) {
setStatus('saving')
} else if (isSaved) {
setStatus('saved')
}
}

if (debouncedBody === body && debouncedTitle === title && status === 'unsaved') {
onSave(title, body)
setStatus('saving')
}
}, [
body,
title,
initialBody,
initialTitle,
debouncedBody,
debouncedTitle,
fetcher,
status,
onSave,
])

// Handle window resizing
const [leftPaneWidth, setLeftPaneWidth] = useState(50) // Initial width in percentage

const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault()
const startX = e.clientX
const startWidth = leftPaneWidth

const handleMouseMove = (moveEvent: MouseEvent) => {
const dx = moveEvent.clientX - startX
const newWidth =
(((startWidth / 100) * window.innerWidth + dx) * 100) / window.innerWidth
setLeftPaneWidth(Math.max(20, Math.min(80, newWidth)))
}

const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}

document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[leftPaneWidth],
)

const doc = useMemo(() => {
return prepareDocument(
ad.load(body, {
standalone: true,
}),
)
}, [body])

return (
<>
<fetcher.Form method="post" action="/notes/edit">
<div className="flex h-14 w-full items-center justify-between border-b px-6 border-b-secondary">
<div className="flex items-center gap-2">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="-m-2 -mr-1 rounded p-2 hover:bg-hover"
type="button"
>
<SidebarIcon />
</button>
<div className="relative w-[min-content] min-w-[1em] rounded px-2 py-1 hover:ring-1 hover:ring-default">
<span className="invisible whitespace-pre text-sans-xl ">
{title ? title : 'Title...'}
</span>
<input
value={title}
onChange={(el) => {
setStatus('unsaved')
setTitle(el.target.value)
}}
name="title"
placeholder="Title..."
required
className="absolute left-1 w-full bg-transparent p-0 text-sans-xl text-raise placeholder:text-tertiary focus:outline-none"
/>
</div>

<MoreDropdown id={id} published={published} />

{fetcher.data?.status === 'error' && (
<div className="text-sans-md text-error">{fetcher.data.error}</div>
)}
</div>

<SavingIndicator status={status} updated={updated} />
</div>
<div className="flex h-[calc(100vh-60px)] flex-grow overflow-hidden">
<div
style={{ width: `${leftPaneWidth}%` }} // Apply dynamic width here
className="h-full cursor-text overflow-scroll"
>
<input type="hidden" name="body" value={body} />
<input type="hidden" name="published" value={published} />
<EditorWrapper
body={body}
onChange={(val) => {
setStatus('unsaved')
setBody(val || '')
}}
/>
</div>
<div
onMouseDown={handleMouseDown}
className="flex w-4 cursor-col-resize items-center bg-transparent"
>
<div className="h-full w-px border-r border-secondary" />
</div>
<div
className="h-full overflow-scroll px-4 py-6"
style={{
width: `calc(${100 - leftPaneWidth}% - 2px)`,
}}
>
<Asciidoc document={doc} options={opts} />
</div>
</div>
</fetcher.Form>
</>
)
}

const TypingIndicator = () => (
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
)

const SavingIndicator = ({
status,
updated,
}: {
status: EditorStatus
updated: string
}) => {
return (
<div className="flex items-center gap-2 text-sans-md text-tertiary">
{dayjs(updated).format('MMM D YYYY, h:mm A')}
{status === 'unsaved' ? (
<TypingIndicator />
) : status === 'error' ? (
<Icon name="error" size={12} className="text-error" />
) : status === 'saved' ? (
<Icon name="success" size={12} className="text-accent" />
) : status === 'saving' ? (
<Spinner />
) : (
<Icon name="success" size={12} className="text-tertiary" />
)}
</div>
)
}

const MoreDropdown = ({ id, published }: { id: string; published: 1 | 0 }) => {
const fetcher = useFetcher()

const handleDelete = () => {
if (window.confirm('Are you sure you want to delete this note?')) {
fetcher.submit(
{ id: id },
{
method: 'post',
action: `/notes/${id}/delete`,
encType: 'application/x-www-form-urlencoded',
},
)
}
}

const handlePublish = async () => {
const isPublished = published === 1
const confirmationMessage = isPublished
? 'Are you sure you want to unpublish this note?'
: 'Are you sure you want to publish this note?'

if (window.confirm(confirmationMessage)) {
fetcher.submit(
{ publish: isPublished ? 0 : 1 },
{
method: 'post',
action: `/notes/${id}/publish`,
encType: 'application/json',
},
)
}
}

return (
<Dropdown.Root modal={false}>
<Dropdown.Trigger className="rounded border p-2 align-[3px] border-default hover:bg-hover">
<Icon name="more" size={12} className="text-default" />
</Dropdown.Trigger>

<DropdownMenu>
<DropdownLink to={`/notes/${id}`}>View</DropdownLink>
<DropdownItem onSelect={handlePublish}>
{published ? 'Unpublish' : 'Publish'}
</DropdownItem>
<DropdownItem className="text-error" onSelect={handleDelete}>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown.Root>
)
}
111 changes: 111 additions & 0 deletions app/components/note/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { buttonStyle } from '@oxide/design-system'
import { Link, NavLink, useMatches } from '@remix-run/react'
import cn from 'classnames'
import { type ReactNode } from 'react'

import Icon from '~/components/Icon'
import { type NoteItem } from '~/routes/notes'

const navLinkStyles = ({ isActive }: { isActive: boolean }) => {
const activeStyle = isActive
? 'bg-accent-secondary hover:!bg-accent-secondary-hover text-accent'
: null
return `block text-sans-md text-secondary hover:bg-hover px-2 py-1 rounded flex items-center group justify-between ${activeStyle}`
}

const Divider = ({ className }: { className?: string }) => (
<div className={cn('mb-3 h-[1px] border-t border-secondary 800:-mx-[2rem]', className)} />
)

export const SidebarIcon = () => (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-quaternary"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.75 0C1.33579 0 1 0.335786 1 0.75V11.25C1 11.6642 1.33579 12 1.75 12H10.25C10.6642 12 11 11.6642 11 11.25V0.75C11 0.335786 10.6642 0 10.25 0H1.75ZM8.75 1.5H4V10.5H8.75C9.16421 10.5 9.5 10.1642 9.5 9.75V2.25C9.5 1.83579 9.16421 1.5 8.75 1.5Z"
fill="currentColor"
/>
</svg>
)

interface HandleData {
notes: NoteItem[]
}

export const Sidebar = () => {
const matches = useMatches()

const data = matches[1]?.data as HandleData
const notes = data.notes || undefined

return (
<nav className="300:max-800:max-w-[400px] 300:w-[80vw] flex h-full w-full flex-col justify-between space-y-6 border-r pb-4 border-secondary elevation-2 800:elevation-0 print:hidden">
<div className="flex flex-col">
{notes && (
<div className="relative space-y-6 overflow-y-auto overflow-x-hidden pb-8">
<div className="mt-0 flex h-14 items-center border-b px-4 border-secondary">
<Link to="/" className="flex items-center gap-2 text-sans-md text-secondary">
<Icon name="prev-arrow" size={12} className="text-tertiary" /> Back to RFDs
</Link>
</div>

<LinkSection label="Published">
{notes.map(
(note: NoteItem) =>
note.published === 1 && (
<NavLink
key={note.id}
to={`/notes/${note.id}/edit`}
className={navLinkStyles}
>
<div className="line-clamp-2 text-ellipsis">{note.title}</div>
</NavLink>
),
)}
</LinkSection>
<Divider />
<LinkSection label="Drafts">
{notes.map(
(note: NoteItem) =>
note.published === 0 && (
<NavLink
key={note.id}
to={`/notes/${note.id}/edit`}
className={navLinkStyles}
>
<div className="line-clamp-2 text-ellipsis">{note.title}</div>
</NavLink>
),
)}
</LinkSection>
</div>
)}
</div>

<div className="flex-shrink-0 px-4">
<Link
to="/notes/new"
className={cn(buttonStyle({ variant: 'secondary', size: 'sm' }), 'w-full')}
>
<div className="flex items-center gap-1">
<Icon name="add-roundel" size={12} className="text-quaternary" /> New
</div>
</Link>
</div>
</nav>
)
}

const LinkSection = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="px-4">
<div className="mb-1 text-mono-sm text-quaternary">{label}</div>
<ul>{children}</ul>
</div>
)
1,386 changes: 1,386 additions & 0 deletions app/components/note/oxide-dark.json

Large diffs are not rendered by default.

49 changes: 0 additions & 49 deletions app/components/rfd/index.css
Original file line number Diff line number Diff line change
@@ -23,52 +23,3 @@
.dialog[data-leave] {
transition-duration: 50ms;
}

.spinner {
--radius: 4;
--PI: 3.14159265358979;
--circumference: calc(var(--PI) * var(--radius) * 2px);
animation: rotate 5s linear infinite;
}

.spinner .path {
stroke-dasharray: var(--circumference);
transform-origin: center;
animation: dash 4s ease-in-out infinite;
stroke: var(--content-accent);
}

@media (prefers-reduced-motion) {
.spinner {
animation: rotate 6s linear infinite;
}

.spinner .path {
animation: none;
stroke-dasharray: 20;
stroke-dashoffset: 100;
}

.spinner-lg .path {
stroke-dasharray: 50;
}
}

.spinner .bg {
stroke: var(--content-default);
}

@keyframes rotate {
100% {
transform: rotate(360deg);
}
}

@keyframes dash {
from {
stroke-dashoffset: var(--circumference);
}
to {
stroke-dashoffset: calc(var(--circumference) * -1);
}
}
27 changes: 27 additions & 0 deletions app/hooks/use-debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'

/**
* Custom hook for debouncing a value.
* @template T - The type of the value to be debounced.
* @param {T} value - The value to be debounced.
* @param {number} [delay] - The delay in milliseconds for debouncing. Defaults to 500 milliseconds.
* @returns {T} The debounced value.
* @see [Documentation](https://usehooks-ts.com/react-hook/use-debounce)
* @example
* const debouncedSearchTerm = useDebounce(searchTerm, 300);
*/
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay ?? 500)

return () => {
clearTimeout(timer)
}
}, [value, delay])

return debouncedValue
}
29 changes: 0 additions & 29 deletions app/hooks/use-key.ts

This file was deleted.

8 changes: 8 additions & 0 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@ import {
useLoaderData,
useRouteError,
useRouteLoaderData,
type ShouldRevalidateFunction,
} from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

@@ -41,6 +42,13 @@ import styles from '~/styles/index.css?url'
import LoadingBar from './components/LoadingBar'
import { inlineCommentsCookie, themeCookie } from './services/cookies.server'

export const shouldRevalidate: ShouldRevalidateFunction = ({ currentUrl, nextUrl }) => {
if (currentUrl.pathname.startsWith('/notes/') && nextUrl.pathname.startsWith('/notes/')) {
return false
}
return true
}

export const meta: MetaFunction = () => {
return [{ title: 'RFD / Oxide' }]
}
4 changes: 2 additions & 2 deletions app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@ import cn from 'classnames'
import dayjs from 'dayjs'
import fuzzysort from 'fuzzysort'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'

import { ClientOnly } from '~/components/ClientOnly'
import Container from '~/components/Container'
@@ -34,7 +35,6 @@ import FilterDropdown from '~/components/home/FilterDropdown'
import StatusBadge from '~/components/StatusBadge'
import { ExactMatch, SuggestedAuthors, SuggestedLabels } from '~/components/Suggested'
import { useIsOverflow } from '~/hooks/use-is-overflow'
import { useKey } from '~/hooks/use-key'
import { useRootLoaderData } from '~/root'
import { rfdSortCookie } from '~/services/cookies.server'
import type { RfdListItem } from '~/services/rfd.server'
@@ -190,7 +190,7 @@ export default function Index() {
return false
}, [inputEl])

useKey('/', focusInput)
useHotkeys('/', focusInput)

const fetcher = useFetcher()
const submitSortOrder = (newSortAttr: SortAttr) => {
30 changes: 30 additions & 0 deletions app/routes/notes.$id.delete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { json, redirect, type ActionFunction } from '@remix-run/node'

import { isAuthenticated } from '~/services/authn.server'

export const action: ActionFunction = async ({ request, params }) => {
const user = await isAuthenticated(request)

if (!user) throw new Response('User not found', { status: 401 })

const response = await fetch(`${process.env.NOTES_API}/notes/${params.id}`, {
method: 'DELETE',
headers: {
'x-api-key': process.env.NOTES_API_KEY || '',
},
})

if (response.ok) {
return redirect(`/notes`)
} else {
const result = await response.json()
return json({ error: result.error }, { status: response.status })
}
}
34 changes: 34 additions & 0 deletions app/routes/notes.$id.publish.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { json, type ActionFunction } from '@remix-run/node'

import { isAuthenticated } from '~/services/authn.server'

export const action: ActionFunction = async ({ request, params }) => {
const user = await isAuthenticated(request)

if (!user) throw new Response('User not found', { status: 401 })

const { publish } = await request.json()

const response = await fetch(`${process.env.NOTES_API}/notes/${params.id}/publish`, {
method: 'POST',
headers: {
'x-api-key': process.env.NOTES_API_KEY || '',
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({ publish }),
})

if (response.ok) {
return json({ status: response.status })
} else {
const result = await response.json()
return json({ error: result.error }, { status: response.status })
}
}
97 changes: 97 additions & 0 deletions app/routes/notes.$id_.edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { json, type ActionFunction, type LoaderFunction } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { makePatches, stringifyPatches } from '@sanity/diff-match-patch'
import cn from 'classnames'
import { useState } from 'react'

import { NoteForm } from '~/components/note/NoteForm'
import { Sidebar } from '~/components/note/Sidebar'
import { isAuthenticated } from '~/services/authn.server'

export const loader: LoaderFunction = async ({ params: { id } }) => {
const response = await fetch(`${process.env.NOTES_API}/notes/${id}`, {
headers: {
'x-api-key': process.env.NOTES_API_KEY || '',
},
})
if (!response.ok) {
throw new Response('Not Found', { status: 404 })
}
const data = await response.json()
return data
}

export const action: ActionFunction = async ({ request, params }) => {
try {
const formData = await request.formData()
const title = formData.get('title')
const body = formData.get('body')

const user = await isAuthenticated(request)
if (!user) throw new Response('User not found', { status: 401 })

const response = await fetch(`${process.env.NOTES_API}/notes/${params.id}`, {
method: 'PUT',
headers: {
'x-api-key': process.env.NOTES_API_KEY || '',
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({ title, body }),
})

if (!response.ok) {
const result = await response.json()
throw new Response(result.error, { status: response.status })
}

return json({ status: 'success', message: 'Note updated successfully' })
} catch (error) {
if (error instanceof Response) {
return json({ status: 'error', error: await error.text() }, { status: error.status })
}
return json({ status: 'error', error: 'An unexpected error occurred' }, { status: 500 })
}
}

export default function NoteEdit() {
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof loader>()

const [sidebarOpen, setSidebarOpen] = useState(true)

const handleSave = (title: string, body: string) => {
const patches = makePatches(data.body, body)

fetcher.submit({ title, body: stringifyPatches(patches) }, { method: 'post' })
}

return (
<div
className={cn(
'purple-theme grid h-[100dvh] overflow-hidden',
sidebarOpen ? 'grid-cols-[14.25rem,minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]',
)}
>
{sidebarOpen && <Sidebar />}
<NoteForm
id={data.id}
key={data.id}
initialTitle={data.title}
initialBody={data.body}
updated={data.updated}
published={data.published}
onSave={handleSave}
sidebarOpen={sidebarOpen}
setSidebarOpen={(bool) => setSidebarOpen(bool)}
fetcher={fetcher}
/>
</div>
)
}
37 changes: 37 additions & 0 deletions app/routes/notes._index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import { redirect, type LoaderFunction } from '@remix-run/node'

import { isAuthenticated } from '~/services/authn.server'

export const loader: LoaderFunction = async ({ request }) => {
const user = await isAuthenticated(request)

if (!user) throw new Response('Not authorized', { status: 401 })

console.log(`${process.env.NOTES_API}/user/${user.id}`)

const response = await fetch(`${process.env.NOTES_API}/user/${user.id}`, {
headers: {
'x-api-key': process.env.NOTES_API_KEY || '',
},
})
if (!response.ok) {
throw new Error(`Error fetching: ${response.statusText}`)
}
const data = await response.json()

console.log('REDIRECT')

if (data.length > 0) {
return redirect(`/notes/${data[0].id}/edit`)
} else {
return redirect('/notes/new')
}
}
37 changes: 37 additions & 0 deletions app/routes/notes.new.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { json, redirect, type ActionFunction, type LoaderFunction } from '@remix-run/node'

import { isAuthenticated } from '~/services/authn.server'

export const action: ActionFunction = async ({ request }) => {
const user = await isAuthenticated(request)

if (!user) throw new Response('User not found', { status: 401 })

const response = await fetch('${process.env.NOTES_API}/notes', {
method: 'POST',
headers: {
'x-api-key': process.env.NOTES_API_KEY || '',
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({ title: 'Untitled', user: user.id, body: '' }),
})

const result = await response.json()

if (response.ok) {
return redirect(`/notes/${result.id}/edit`)
} else {
return json({ error: result.error }, { status: response.status })
}
}

export const loader: LoaderFunction = async (args) => {
return action(args)
}
47 changes: 47 additions & 0 deletions app/routes/notes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import { type LoaderFunction } from '@remix-run/node'
import { Outlet } from '@remix-run/react'

import { isAuthenticated } from '~/services/authn.server'

export const loader: LoaderFunction = async ({ request }) => {
const user = await isAuthenticated(request)

if (!user) throw new Response('Not authorized', { status: 401 })

const response = await fetch(`${process.env.NOTES_API}/user/${user.id}`, {
headers: {
'x-api-key': process.env.NOTES_API_KEY || '',
},
})

if (!response.ok) {
throw new Error(`Error fetching: ${response.statusText}`)
}
const data = await response.json()
return {
notes: data,
user,
}
}

export type NoteItem = {
id: string
title: string
user: string
body: string
created: string
updated: string
published: 1 | 0
}

export default function Note() {
return <Outlet />
}
2 changes: 1 addition & 1 deletion app/routes/rfd.$slug.tsx
Original file line number Diff line number Diff line change
@@ -332,7 +332,7 @@ export default function Rfd() {
)
}

const PropertyRow = ({
export const PropertyRow = ({
label,
children,
className,
51 changes: 45 additions & 6 deletions app/styles/index.css
Original file line number Diff line number Diff line change
@@ -63,6 +63,19 @@ body {
@apply bg-default;
}

body.note {
@apply m-0;
}

.cm-line {
@apply pl-4;
}

#code_mirror_wrapper .cm-line,
#code_mirror_wrapper .cm-gutters {
@apply !text-[15px] !normal-case !tracking-normal text-mono-md;
}

@layer base {
body {
@apply text-sans-sm text-raise;
@@ -118,12 +131,38 @@ input[type='checkbox']:focus:not(:focus-visible) {
@apply outline-0 ring-0;
}

.link-with-underline {
@apply text-default hover:text-raise;
text-decoration: underline;
text-decoration-color: var(--content-quinary);
.typing-indicator {
display: flex;
align-items: center;
justify-content: space-around;
width: 12px;
height: 12px;
}

.typing-indicator span {
display: block;
width: 3px;
height: 3px;
background-color: var(--content-accent);
border-radius: 50%;
animation: bounce 1.4s infinite both;
}

.typing-indicator span:nth-child(1) {
animation-delay: -0.32s;
}

&:hover {
text-decoration-color: var(--content-tertiary);
.typing-indicator span:nth-child(2) {
animation-delay: -0.16s;
}

@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-4px);
}
}
1 change: 1 addition & 0 deletions notes/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
db/notes.db
5 changes: 5 additions & 0 deletions notes/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
if (!process.env.API_KEYS) {
throw new Error('API_KEYS environment variable is required')
}

export const API_KEYS = new Set(process.env.API_KEYS.split(','))
114 changes: 114 additions & 0 deletions notes/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Elysia } from 'elysia'

Check failure on line 1 in notes/api/index.ts

GitHub Actions / ci

Cannot find module 'elysia' or its corresponding type declarations.

import { API_KEYS } from './auth'
import {
addNote,
deleteNote,
getNote,
listAllNotes,
listNotes,
updateNote,
updateNotePublished,
type NoteBody,
} from './main'

const ServerError = { error: 'Something went wrong' }

const validateApiKey = (request: Request) => {
const apiKey = request.headers.get('x-api-key')
return API_KEYS.has(apiKey ?? '')
}

export const run = () => {
new Elysia()
.onRequest(({ request, set }) => {

Check failure on line 24 in notes/api/index.ts

GitHub Actions / ci

Binding element 'request' implicitly has an 'any' type.

Check failure on line 24 in notes/api/index.ts

GitHub Actions / ci

Binding element 'set' implicitly has an 'any' type.
if (!validateApiKey(request)) {
set.status = 401
return { error: 'Not Authorized' }
}
})
.get('/user/:userId', async ({ params: { userId }, set }) => {

Check failure on line 30 in notes/api/index.ts

GitHub Actions / ci

Binding element 'userId' implicitly has an 'any' type.

Check failure on line 30 in notes/api/index.ts

GitHub Actions / ci

Binding element 'set' implicitly has an 'any' type.
try {
const notes = await listNotes(userId)
set.status = 200
return notes
} catch (error) {
console.error(error)
set.status = 500
return ServerError
}
})
.get('/notes', async ({ set }) => {

Check failure on line 41 in notes/api/index.ts

GitHub Actions / ci

Binding element 'set' implicitly has an 'any' type.
try {
const notes = await listAllNotes()
set.status = 200
return notes
} catch (error) {
console.error(error)
set.status = 500
return ServerError
}
})
.get('/notes/:id', async ({ params: { id }, set }) => {

Check failure on line 52 in notes/api/index.ts

GitHub Actions / ci

Binding element 'id' implicitly has an 'any' type.

Check failure on line 52 in notes/api/index.ts

GitHub Actions / ci

Binding element 'set' implicitly has an 'any' type.
try {
const note = await getNote(id)
if (!note) {
set.status = 404
return 'Not Found'
}
return note
} catch (error) {
console.error(error)
set.status = 500
return ServerError
}
})
.post('/notes', async ({ body, set }) => {

Check failure on line 66 in notes/api/index.ts

GitHub Actions / ci

Binding element 'body' implicitly has an 'any' type.

Check failure on line 66 in notes/api/index.ts

GitHub Actions / ci

Binding element 'set' implicitly has an 'any' type.
try {
const { title, user, body: noteBody } = body as NoteBody
const id = await addNote(title, user, noteBody)
set.status = 201
return { id }
} catch (error) {
console.error(error)
set.status = 400
return { error: (error as Error).message }
}
})
.post('/notes/:id/publish', async ({ params, request, set }) => {
try {
const { id } = params
const { publish } = await request.json()
await updateNotePublished(id, publish)
set.status = 200
return { message: `Note ${publish ? 'published' : 'unpublished'} successfully.` }
} catch (error) {
console.error(error)
set.status = 400
return { error: (error as Error).message }
}
})
.put('/notes/:id', async ({ params: { id }, body, set }) => {
try {
const { title, body: noteBody } = body as NoteBody
await updateNote(id, title, noteBody)
set.status = 200
return { message: 'Note updated successfully' }
} catch (error) {
console.error(error)
set.status = 500
return { error: (error as Error).message }
}
})
.delete('/notes/:id', async ({ params: { id }, set }) => {
try {
await deleteNote(id)
set.status = 204
} catch (error) {
console.error(error)
set.status = 500
return ServerError
}
})
.listen(8000)
}
95 changes: 95 additions & 0 deletions notes/api/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { applyPatches, parsePatch } from '@sanity/diff-match-patch'
import { Database } from 'bun:sqlite'
import { nanoid } from 'nanoid'
import z from 'zod'

const db = new Database('./db/notes.db')

export interface NoteBody {
title: string
user: string
body: string
published: boolean
}

const noteCreateSchema = z.object({
title: z.string().min(1, 'Title must not be empty'),
user: z.string().min(3, 'User must not be empty'),
})

export const addNote = async (title: string, user: string, body: string) => {
const validationResult = noteCreateSchema.safeParse({ title, user })
if (!validationResult.success) {
throw new Error(`Validation failed: ${validationResult.error.message}`)
}

const id = nanoid(6)
const created = new Date().toISOString()
const updated = created

const statement = db.prepare(
'INSERT INTO notes (id, title, created, updated, user, body, published) VALUES (?, ?, ?, ?, ?, ?, ?)',
)
statement.run(id, title, created, updated, user, body, 0)

return id
}

export const getNote = async (id: string) => {
const query = db.query('SELECT * FROM notes WHERE id = $id')
return await query.get({ $id: id })
}

const noteUpdateSchema = z.object({
title: z.string().min(1, 'Title must not be empty'),
})

export const updateNote = async (id: string, title: string, body: string) => {
const validationResult = noteUpdateSchema.safeParse({ title })
if (!validationResult.success) {
throw new Error(`Validation failed: ${validationResult.error.message}`)
}

const currentNote = await getNote(id)
if (!currentNote) {
throw new Error('Note not found')
}
const [newBody] = applyPatches(parsePatch(body), (currentNote as NoteBody).body)

const updated = new Date().toISOString()

const statement = db.prepare(
'UPDATE notes SET title = ?, updated = ?, body = ? WHERE id = ?',
)
statement.run(title, updated, newBody, id)
}

export const updateNotePublished = async (id: string, publish: boolean) => {
const published = publish ? 1 : 0 // Convert boolean to integer for SQL
const statement = db.prepare('UPDATE notes SET published = ? WHERE id = ?')
statement.run(published, id)
}

export const deleteNote = async (id: string) => {
const statement = db.prepare('DELETE FROM notes WHERE id = ?')
statement.run(id)
}

export const listNotes = async (userId: string) => {
const query = db.query('SELECT * FROM notes WHERE user = $userId')
const notes = query.all({ $userId: userId })

// We only want the first 20 lines so we're not sending a huge response
const trimmedNotes = notes.map((note) => ({
...(note as NoteBody),
body: (note as NoteBody).body.split('\n').slice(0, 20).join('\n'),
}))

return trimmedNotes
}

export const listAllNotes = async () => {
const query = db.query('SELECT * FROM notes')

return query.all()
}
Binary file added notes/bun.lockb
Binary file not shown.
10 changes: 10 additions & 0 deletions notes/db/drop.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/bash

DATABASE="notes.db"

if [ -f "$DATABASE" ]; then
rm "$DATABASE"
echo "Database $DATABASE destroyed."
else
echo "Database $DATABASE does not exist."
fi
9 changes: 9 additions & 0 deletions notes/db/init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash

DATABASE="notes.db"

if [ ! -f "$DATABASE" ]; then
sqlite3 "$DATABASE" < "seed.sql" && echo "Database $DATABASE initialized."
else
echo "Database $DATABASE already exists."
fi
17 changes: 17 additions & 0 deletions notes/db/seed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
BEGIN TRANSACTION;

CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT,
created TEXT,
updated TEXT,
user TEXT,
body TEXT,
published BOOLEAN
);

INSERT INTO notes (id, title, created, updated, user, body, published) VALUES
('AoGfVy', 'First Note', '2024-04-12 12:00:00', '2024-04-12 12:00:00', '96861ac2-f56e-4b7d-b128-c04467e6dd5f', 'In a time of enchantment when the moon played hide and seek with the stars, the mystical lands have awoken.', TRUE),
('Z5dQt1', 'Second Note', '2024-04-13 14:15:00', '2024-04-13 14:15:00', '96861ac2-f56e-4b7d-b128-c04467e6dd5f', 'The ancient runes whispered tales of forgotten magic, painting images of sparkling fountains and palaces of precious stones.', FALSE);

COMMIT;
3 changes: 3 additions & 0 deletions notes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { run } from './api'

run()
19 changes: 19 additions & 0 deletions notes/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "notes",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "^1.0.12"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@sanity/diff-match-patch": "^3.1.1",
"bun-types": "^1.1.3",
"elysia": "^1.0.13",
"nanoid": "^5.0.7",
"tsc": "",
"zod": "^3.22.4"
}
}
29 changes: 29 additions & 0 deletions notes/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,

// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,

// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,

// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,

"types": ["bun-types"]
}
}
83 changes: 76 additions & 7 deletions package-lock.json
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -17,13 +17,17 @@
"@asciidoctor/core": "^3.0.4",
"@floating-ui/react": "^0.17.0",
"@meilisearch/instant-meilisearch": "^0.8.2",
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "^4.6.0",
"@oxide/design-system": "^1.8.2",
"@oxide/react-asciidoc": "^1.0.2",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@remix-run/node": "2.13.1",
"@remix-run/react": "2.13.1",
"@remix-run/serve": "2.13.1",
"@sanity/diff-match-patch": "^3.1.1",
"@shikijs/monaco": "^1.24.2",
"@tanstack/react-query": "^4.3.9",
"@vercel/remix": "^2.13.1",
"classnames": "^2.3.1",
@@ -36,15 +40,15 @@
"marked": "^4.2.5",
"mermaid": "^11.4.1",
"mime-types": "^2.1.35",
"mousetrap": "^1.6.5",
"octokit": "^3.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.6.1",
"react-instantsearch": "^7.13.4",
"remeda": "^2.17.4",
"remix-auth": "^3.6.0",
"remix-auth-oauth2": "^1.11.1",
"shiki": "^1.23.1",
"shiki": "^1.5.1",
"simple-text-diff": "^1.7.0",
"tunnel-rat": "^0.1.2",
"zod": "^3.22.3"