This commit is contained in:
Jared Palmer 2023-05-19 12:33:56 -04:00
commit a04776256d
56 changed files with 6808 additions and 0 deletions

62
lib/analytics.ts Normal file
View file

@ -0,0 +1,62 @@
import type { NextRequest, NextFetchEvent } from "next/server";
import type { NextApiRequest } from "next";
export const initAnalytics = ({
request,
event,
}: {
request: NextRequest | NextApiRequest | Request;
event?: NextFetchEvent;
}) => {
const endpoint = process.env.VERCEL_URL;
return {
track: async (eventName: string, data?: any) => {
try {
if (!endpoint && process.env.NODE_ENV === "development") {
console.log(
`[Vercel Web Analytics] Track "${eventName}"` +
(data ? ` with data ${JSON.stringify(data || {})}` : "")
);
return;
}
const headers: { [key: string]: string } = {};
Object.entries(request.headers).map(([key, value]) => {
headers[key] = value;
});
const body = {
o: headers.referer,
ts: new Date().getTime(),
r: "",
en: eventName,
ed: data,
};
const promise = fetch(
`https://${process.env.VERCEL_URL}/_vercel/insights/event`,
{
headers: {
"content-type": "application/json",
"user-agent": headers["user-agent"] as string,
"x-forwarded-for": headers["x-forwarded-for"] as string,
"x-va-server": "1",
},
body: JSON.stringify(body),
method: "POST",
}
);
if (event) {
event.waitUntil(promise);
}
{
await promise;
}
} catch (err) {
console.error(err);
}
},
};
};

21
lib/fonts.ts Normal file
View file

@ -0,0 +1,21 @@
import {
JetBrains_Mono as FontMono,
IBM_Plex_Sans as FontMessage,
Inter as FontSans,
} from "next/font/google";
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export const fontMessage = FontMessage({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-message",
});
export const fontMono = FontMono({
subsets: ["latin"],
variable: "--font-mono",
});

View file

@ -0,0 +1,10 @@
import { isAppleDevice } from "@/lib/tinykeys";
/**
* Uses the Meta key on macOS, and the Ctrl key on other platforms.
*/
export const isModifierPressed = (
event: KeyboardEvent | React.KeyboardEvent
): boolean => {
return isAppleDevice() ? event.metaKey : event.ctrlKey;
};

View file

@ -0,0 +1,21 @@
import type { RefObject } from "react";
import { useRef } from "react";
import { isModifierPressed } from "./is-modifier-pressed";
export function useCmdEnterSubmit(): {
formRef: RefObject<HTMLFormElement>;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
} {
const formRef = useRef<HTMLFormElement>(null);
const handleKeyDown = (
event: React.KeyboardEvent<HTMLTextAreaElement>
): void => {
// Capture `⌘`|Ctrl + Enter
if (isModifierPressed(event) && event.key === "Enter") {
formRef.current?.requestSubmit();
}
};
return { formRef, onKeyDown: handleKeyDown };
}

View file

@ -0,0 +1,32 @@
'use client';
import { useState } from 'react';
export interface useCopyToClipboardProps {
timeout?: number;
}
export function useCopyToClipboard({
timeout = 2000,
}: useCopyToClipboardProps) {
const [isCopied, setIsCopied] = useState<Boolean>(false);
const copyToClipboard = (value: string) => {
if (
typeof window === 'undefined' ||
!navigator.clipboard ||
!navigator.clipboard.writeText
) {
return;
}
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, timeout);
});
};
return { isCopied, copyToClipboard };
}

View file

@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
const scrollToEnd = () => {
window.scrollTo({
top: document.body.scrollHeight,
left: 0,
});
};
export function useFollowScroll(shouldFollow: boolean) {
const shouldFollowRef = useRef(shouldFollow);
const scrollRef = useRef(false);
const triggeredBySelfRef = useRef(false);
useEffect(() => {
const handleScroll = () => {
if (triggeredBySelfRef.current) {
triggeredBySelfRef.current = false;
return;
}
scrollRef.current = true;
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
if (!scrollRef.current && shouldFollowRef.current) {
setTimeout(() => {
triggeredBySelfRef.current = true;
scrollToEnd();
});
}
});
useEffect(() => {
shouldFollowRef.current = shouldFollow;
if (!shouldFollow) {
// Reset scrollRef
scrollRef.current = false;
}
}, [shouldFollow]);
}

View file

@ -0,0 +1,43 @@
import { RefObject, useEffect, useState } from "react";
interface Args extends IntersectionObserverInit {
freezeOnceVisible?: boolean;
}
function useIntersectionObserver(
elementRef: RefObject<Element>,
{
threshold = 0,
root = null,
rootMargin = "0%",
freezeOnceVisible = false,
}: Args
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>();
const frozen = entry?.isIntersecting && freezeOnceVisible;
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
setEntry(entry);
};
useEffect(() => {
const node = elementRef?.current; // DOM Ref
const hasIOSupport = !!window.IntersectionObserver;
if (!hasIOSupport || frozen || !node) return;
const observerParams = { threshold, root, rootMargin };
const observer = new IntersectionObserver(updateEntry, observerParams);
observer.observe(node);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threshold, root, rootMargin, frozen]);
return entry;
}
export default useIntersectionObserver;

View file

@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
const useLocalStorage = <T>(
key: string,
initialValue: T
): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState(initialValue);
useEffect(() => {
// Retrieve from localStorage
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
}
}, [key]);
const setValue = (value: T) => {
// Save state
setStoredValue(value);
// Save to localStorage
window.localStorage.setItem(key, JSON.stringify(value));
};
return [storedValue, setValue];
};
export default useLocalStorage;

16
lib/hooks/use-scroll.ts Normal file
View file

@ -0,0 +1,16 @@
import { useCallback, useEffect, useState } from "react";
export default function useScroll(threshold: number) {
const [scrolled, setScrolled] = useState(false);
const onScroll = useCallback(() => {
setScrolled(window.pageYOffset > threshold);
}, [threshold]);
useEffect(() => {
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);
return scrolled;
}

View file

@ -0,0 +1,39 @@
"use client";
import { useEffect, useState } from "react";
export default function useWindowSize() {
const [windowSize, setWindowSize] = useState<{
width: number | undefined;
height: number | undefined;
}>({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return {
windowSize,
isMobile: typeof windowSize?.width === "number" && windowSize?.width < 768,
isDesktop:
typeof windowSize?.width === "number" && windowSize?.width >= 768,
};
}

62
lib/openai.ts Normal file
View file

@ -0,0 +1,62 @@
import {
createParser,
type ParsedEvent,
type ReconnectInterval,
} from "eventsource-parser";
import { Configuration, OpenAIApi } from "openai-edge";
const configuration = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
});
export const openai = new OpenAIApi(configuration);
if (!process.env.OPENAI_API_KEY) {
throw new Error("Missing env var from OpenAI");
}
export async function OpenAIStream(res: Response) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let counter = 0;
const stream = new ReadableStream({
async start(controller) {
function onParse(event: ParsedEvent | ReconnectInterval) {
if (event.type === "event") {
const data = event.data;
if (data === "[DONE]") {
controller.close();
return;
}
try {
const json = JSON.parse(data) as {
choices: any;
};
const text =
json.choices[0]?.delta?.content ?? json.choices[0]?.text ?? "";
if (counter < 2 && (text.match(/\n/) || []).length) {
return;
}
const queue = encoder.encode(JSON.stringify(text) + "\n");
controller.enqueue(queue);
counter++;
} catch (e) {
controller.error(e);
}
}
}
const parser = createParser(onParse);
for await (const chunk of res.body as any) {
parser.feed(decoder.decode(chunk));
}
},
});
return stream;
}

11
lib/prisma.tsx Normal file
View file

@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV === "development") global.prisma = prisma;
export { prisma };

209
lib/tinykeys.tsx Normal file
View file

@ -0,0 +1,209 @@
// forked from https://github.com/jamiebuilds/tinykeys
// to fix navigator not being defined in SSR context
// import { type ModifierKey } from "react";
type ModifierKey = any;
/*
MIT License
Copyright (c) 2020 Jamie Kyle
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
type KeyBindingPress = [string[], string];
/**
* A map of keybinding strings to event handlers.
*/
export interface KeyBindingMap {
[keybinding: string]: (event: React.KeyboardEvent) => void;
}
export interface Options {
ignoreFocus?: boolean;
}
/**
* These are the modifier keys that change the meaning of keybindings.
*
* Note: Ignoring "AltGraph" because it is covered by the others.
*/
let KEYBINDING_MODIFIER_KEYS = ["Shift", "Meta", "Alt", "Control"];
/**
* Keybinding sequences should timeout if individual key presses are more than
* 1s apart.
*/
let TIMEOUT = 1000;
/**
* When focus is on these elements, ignore the keydown event.
*/
let inputs = ["select", "textarea", "input"];
export const isAppleDevice = (): boolean => {
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
};
/**
* Parses a "Key Binding String" into its parts
*
* grammar = `<sequence>`
* <sequence> = `<press> <press> <press> ...`
* <press> = `<key>` or `<mods>+<key>`
* <mods> = `<mod>+<mod>+...`
*/
function parse(str: string): KeyBindingPress[] {
const modifierKey = (isAppleDevice() ? "Meta" : "Control") as ModifierKey;
return str
.trim()
.split(" ")
.map((press) => {
let mods = press.split("+");
let key = mods.pop()!;
mods = mods.map((mod) => (mod === "$mod" ? modifierKey : mod));
return [mods, key];
});
}
/**
* This tells us if a series of events matches a key binding sequence either
* partially or exactly.
*/
function match(event: React.KeyboardEvent, press: KeyBindingPress): boolean {
// prettier-ignore
return !(
// Allow either the `event.key` or the `event.code`
// MDN event.key: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
// MDN event.code: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
(
press[1].toUpperCase() !== event.key.toUpperCase() &&
press[1] !== event.code
) ||
// Ensure all the modifiers in the keybinding are pressed.
press[0].find((mod) => {
return !event.getModifierState(mod as ModifierKey)
}) ||
// KEYBINDING_MODIFIER_KEYS (Shift/Control/etc) change the meaning of a
// keybinding. So if they are pressed but aren't part of this keybinding,
// then we don't have a match.
KEYBINDING_MODIFIER_KEYS.find(mod => {
return !press[0].includes(mod) && event.getModifierState(mod as ModifierKey)
})
)
}
/**
* Subscribes to keybindings.
*
* Returns an unsubscribe method.
*
* @example
* ```js
* import keybindings from "../src/keybindings"
*
* keybindings(window, {
* "Shift+d": () => {
* alert("The 'Shift' and 'd' keys were pressed at the same time")
* },
* "y e e t": () => {
* alert("The keys 'y', 'e', 'e', and 't' were pressed in order")
* },
* "$mod+d": () => {
* alert("Either 'Control+d' or 'Meta+d' were pressed")
* },
* })
* ```
*/
export default function keybindings(
target: Window | HTMLElement,
keyBindingMap: KeyBindingMap,
options: Options = {}
): () => void {
const keyBindings = Object.keys(keyBindingMap).map((key) => {
return [parse(key), keyBindingMap[key]] as const;
});
const possibleMatches = new Map<KeyBindingPress[], KeyBindingPress[]>();
let timer: any = null;
const onKeyDown = (event: React.KeyboardEvent): void => {
// Ignore modifier keydown events
// Note: This works because:
// - non-modifiers will always return false
// - if the current keypress is a modifier then it will return true when we check its state
// MDN: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState
if (
event.getModifierState &&
event.getModifierState(event.key as ModifierKey)
) {
return;
}
// Ignore event when a focusable item is focused
if (options.ignoreFocus) {
if (document.activeElement) {
if (
inputs.indexOf(document.activeElement.tagName.toLowerCase()) !== -1 ||
(document.activeElement as HTMLElement).contentEditable === "true"
) {
return;
}
}
}
keyBindings.forEach((keyBinding) => {
let sequence = keyBinding[0];
let callback = keyBinding[1];
let prev = possibleMatches.get(sequence);
let remainingExpectedPresses = prev ? prev : sequence;
let currentExpectedPress = remainingExpectedPresses[0];
let matches = match(event, currentExpectedPress);
if (!matches) {
possibleMatches.delete(sequence);
} else if (remainingExpectedPresses.length > 1) {
possibleMatches.set(sequence, remainingExpectedPresses.slice(1));
} else {
possibleMatches.delete(sequence);
callback(event);
}
});
clearTimeout(timer);
timer = setTimeout(possibleMatches.clear.bind(possibleMatches), TIMEOUT);
};
target.addEventListener("keydown", onKeyDown as any);
return (): void => {
target.removeEventListener("keydown", onKeyDown as any);
};
}

34
lib/utils.ts Normal file
View file

@ -0,0 +1,34 @@
import { clsx, type ClassValue } from "clsx";
import { customAlphabet } from "nanoid";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const nanoid = customAlphabet(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
7
); // 7-character random string
export async function fetcher<JSON = any>(
input: RequestInfo,
init?: RequestInit
): Promise<JSON> {
const res = await fetch(input, init);
if (!res.ok) {
const json = await res.json();
if (json.error) {
const error = new Error(json.error) as Error & {
status: number;
};
error.status = res.status;
throw error;
} else {
throw new Error("An unexpected error occurred");
}
}
return res.json();
}