diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bffb357..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/.eslintrc.ts b/.eslintrc.ts new file mode 100644 index 0000000..d30ea24 --- /dev/null +++ b/.eslintrc.ts @@ -0,0 +1,32 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + overrides: [ + { + extends: [ + "plugin:@typescript-eslint/recommended-requiring-type-checking", + ], + files: ["*.ts", "*.tsx"], + parserOptions: { + project: "tsconfig.json", + }, + }, + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.json", + }, + plugins: ["@typescript-eslint"], + extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], + rules: { + "import/no-anonymous-default-export": ["off"], + "@typescript-eslint/no-unsafe-assignment": ["error"], + "@typescript-eslint/no-empty-interface": ["warn"], + "@typescript-eslint/consistent-type-imports": [ + "warn", + { + prefer: "type-imports", + fixStyle: "inline-type-imports", + }, + ], + }, +}; diff --git a/app/_providers/index.tsx b/app/_providers/index.tsx new file mode 100644 index 0000000..4d09648 --- /dev/null +++ b/app/_providers/index.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Toaster } from "sonner"; +import { ModalProvider } from "./modal/provider"; +import useRouteChange from "@/lib/hooks/useRouteChange"; +import { NDKProvider } from "./ndk"; +import { RELAYS } from "@/constants"; + +export function Providers({ children }: { children: React.ReactNode }) { + const handleRouteChange = (url: string) => { + const RichHistory = sessionStorage.getItem("RichHistory"); + if (!RichHistory) { + sessionStorage.setItem("RichHistory", "true"); + } + }; + useRouteChange(handleRouteChange); + return ( + <> + + + + + {children} + + + ); +} diff --git a/app/_providers/modal/index.tsx b/app/_providers/modal/index.tsx new file mode 100644 index 0000000..511015b --- /dev/null +++ b/app/_providers/modal/index.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, +} from "react"; +import FocusTrap from "focus-trap-react"; +import { AnimatePresence, motion } from "framer-motion"; +import Leaflet from "./leaflet"; +import useWindowSize from "@/lib/hooks/useWindowSize"; + +export default function Modal({ + children, + showModal, + setShowModal, +}: { + children: React.ReactNode; + showModal: boolean; + setShowModal: Dispatch>; +}) { + const desktopModalRef = useRef(null); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + setShowModal(false); + } + }, + [setShowModal] + ); + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onKeyDown]); + + const { isMobile, isDesktop } = useWindowSize(); + + useEffect(() => { + if (showModal) { + document.body.style.top = `-${window.scrollY}px`; + document.body.style.position = "fixed"; + } else { + const scrollY = document.body.style.top; + document.body.style.position = ""; + document.body.style.top = ""; + window.scrollTo(0, parseInt(scrollY || "0") * -1); + } + return () => { + const scrollY = document.body.style.top; + document.body.style.position = ""; + document.body.style.top = ""; + window.scrollTo(0, parseInt(scrollY || "0") * -1); + }; + }, [showModal]); + + return ( + + {showModal && ( + <> + {isMobile && {children}} + {isDesktop && ( + <> + + { + if (desktopModalRef.current === e.target) { + setShowModal(false); + } + }} + > +
{children}
+
+
+ setShowModal(false)} + /> + + )} + + )} +
+ ); +} diff --git a/app/_providers/modal/leaflet.tsx b/app/_providers/modal/leaflet.tsx new file mode 100644 index 0000000..65ba8de --- /dev/null +++ b/app/_providers/modal/leaflet.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, ReactNode, Dispatch, SetStateAction } from "react"; +import { AnimatePresence, motion, useAnimation } from "framer-motion"; +import { cn } from "@/lib/utils"; + +export default function Leaflet({ + setShow, + children, +}: { + setShow: Dispatch>; + children: ReactNode; +}) { + const leafletRef = useRef(null); + const controls = useAnimation(); + const transitionProps = { type: "spring", stiffness: 500, damping: 30 }; + useEffect(() => { + void controls.start({ + y: 20, + transition: transitionProps, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleDragEnd( + _: any, + info: { + delta: { x: number; y: number }; + offset: { x: number; y: number }; + point: { x: number; y: number }; + velocity: { x: number; y: number }; + } + ) { + const offset = info.offset.y; + const velocity = info.velocity.y; + const height = leafletRef.current?.getBoundingClientRect().height || 0; + if (offset > height / 2 || velocity > 800) { + await controls.start({ y: "100%", transition: transitionProps }); + setShow(false); + } else { + void controls.start({ y: 0, transition: transitionProps }); + } + } + + return ( + + void handleDragEnd(_, i)} + dragElastic={{ top: 0, bottom: 1 }} + dragConstraints={{ top: 0, bottom: 0 }} + > +
+
+
+
+
+ {children} +
+ + setShow(false)} + /> + + ); +} diff --git a/app/_providers/modal/provider.tsx b/app/_providers/modal/provider.tsx new file mode 100644 index 0000000..07407bd --- /dev/null +++ b/app/_providers/modal/provider.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Modal from "."; +import { + ReactElement, + ReactNode, + createContext, + useContext, + useState, +} from "react"; + +export enum Modals {} + +type ModalsType = { + [key in Modals]: (props: any) => JSX.Element; +}; + +const ModalOptions: ModalsType = {}; + +type ModalProps = ReactElement | Modals; + +type ModalContextProps = { + show: (content: ModalProps) => void; + hide: () => void; +}; + +const ModalContext = createContext(undefined); + +export function ModalProvider({ children }: { children: ReactNode }) { + const [modalContent, setModalContent] = useState(null); + const [showModal, setShowModal] = useState(false); + + const show = (content: ModalProps) => { + if (typeof content === "string" && ModalOptions[content]) { + setModalContent(ModalOptions[content]); + } else { + setModalContent(content); + } + + setShowModal(true); + }; + + const hide = () => { + setShowModal(false); + setTimeout(() => { + setModalContent(null); + }, 300); // Adjust this timeout as per your transition duration + }; + + return ( + + {children} + {showModal && ( + + {modalContent} + + )} + + ); +} + +export function useModal() { + return useContext(ModalContext); +} diff --git a/app/_providers/ndk/context/Users.ts b/app/_providers/ndk/context/Users.ts new file mode 100644 index 0000000..7d2a6e7 --- /dev/null +++ b/app/_providers/ndk/context/Users.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useRef, useState } from "react"; +import NDK, { NDKUser } from "@nostr-dev-kit/ndk"; + +export const Users = (ndk: NDK | undefined) => { + const [users, setUsers] = useState<{ [id: string]: NDKUser }>({}); + const refUsers = useRef<{ [id: string]: NDKUser }>({}); + + async function fetchUser(id: string) { + if (ndk == undefined) { + return; + } + + if (refUsers.current[id]) { + return; + } + + refUsers.current = { + ...refUsers.current, + [id]: NDKUser.prototype, + }; + + let user; + + if (id.startsWith("npub")) { + user = ndk.getUser({ + npub: id, + }); + } else { + user = ndk.getUser({ + hexpubkey: id, + }); + } + + await user.fetchProfile(); + + if (user.profile) { + refUsers.current = { + ...refUsers.current, + [id]: user, + }; + setUsers(refUsers.current); + } + } + + function getUser(id: string) { + if (users[id]) { + return users[id]; + } else { + fetchUser(id); + } + return NDKUser.prototype; + } + + function getProfile(id: string) { + if (users[id]) { + return users[id].profile!; + } else { + fetchUser(id); + } + return {}; + } + + return { + getUser, + getProfile, + }; +}; diff --git a/app/_providers/ndk/context/index.tsx b/app/_providers/ndk/context/index.tsx new file mode 100644 index 0000000..b900ba4 --- /dev/null +++ b/app/_providers/ndk/context/index.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { PropsWithChildren, createContext, useContext } from "react"; +import NDK, { + NDKEvent, + NDKFilter, + NDKNip07Signer, + NDKNip46Signer, + NDKPrivateKeySigner, + NDKUser, + NDKUserProfile, +} from "@nostr-dev-kit/ndk"; +import NDKInstance from "./instance"; +import { _loginWithNip07, _loginWithNip46, _loginWithSecret } from "./signers"; +import { Users } from "./Users"; + +interface NDKContext { + ndk: NDK | undefined; + signer: NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer | undefined; + fetchEvents: (filter: NDKFilter) => Promise; + loginWithNip46: ( + npub: string, + sk?: string, + ) => Promise< + | undefined + | { + npub: string; + sk: string | undefined; + token: string; + remoteSigner: NDKNip46Signer; + localSigner: NDKPrivateKeySigner; + } + >; + loginWithSecret: (skOrNsec: string) => Promise< + | undefined + | { + npub: string; + sk: string; + signer: NDKPrivateKeySigner; + } + >; + loginWithNip07: () => Promise< + | undefined + | { + npub: string; + signer: NDKNip07Signer; + user: NDKUser; + } + >; + signPublishEvent: ( + event: NDKEvent, + params?: + | { + repost: boolean; + publish: boolean; + } + | undefined, + ) => Promise; + getUser: (_: string) => NDKUser; + getProfile: (_: string) => NDKUserProfile; +} + +const NDKContext = createContext({ + ndk: undefined, + signer: undefined, + fetchEvents: (_: NDKFilter) => Promise.resolve([]), + loginWithNip46: (_: string, __?: string) => Promise.resolve(undefined), + loginWithSecret: (_: string) => Promise.resolve(undefined), + loginWithNip07: () => Promise.resolve(undefined), + signPublishEvent: (_: NDKEvent, __?: {}) => Promise.resolve(undefined), + getUser: (_: string) => { + return NDKUser.prototype; + }, + getProfile: (_: string) => { + return {}; + }, +}); + +const NDKProvider = ({ + children, + relayUrls, +}: PropsWithChildren<{ + relayUrls: string[]; +}>) => { + const { ndk, signer, setSigner, fetchEvents, signPublishEvent } = + NDKInstance(relayUrls); + const { getUser, getProfile } = Users(ndk); + + async function loginWithNip46(npub: string, sk?: string) { + if (ndk === undefined) return undefined; + const res = await _loginWithNip46(ndk, npub, sk); + if (res) { + await setSigner(res.remoteSigner); + return res; + } + } + + async function loginWithSecret(skOrNsec: string) { + const res = await _loginWithSecret(skOrNsec); + if (res) { + const { signer } = res; + await setSigner(signer); + return res; + } + } + + async function loginWithNip07() { + const res = await _loginWithNip07(); + if (res) { + const { signer } = res; + await setSigner(signer); + return res; + } + } + + return ( + + {children} + + ); +}; + +const useNDK = () => { + const context = useContext(NDKContext); + if (context === undefined) { + throw new Error("import NDKProvider to use useNDK"); + } + return context; +}; + +export { NDKProvider, useNDK }; diff --git a/app/_providers/ndk/context/instance.ts b/app/_providers/ndk/context/instance.ts new file mode 100644 index 0000000..84f1660 --- /dev/null +++ b/app/_providers/ndk/context/instance.ts @@ -0,0 +1,106 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import NDK, { + NDKEvent, + NDKFilter, + NDKNip07Signer, + NDKNip46Signer, + NDKPrivateKeySigner, +} from "@nostr-dev-kit/ndk"; + +export default function NDKInstance(explicitRelayUrls: string[]) { + const loaded = useRef(false); + + const [ndk, _setNDK] = useState(undefined); + const [signer, _setSigner] = useState< + NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer | undefined + >(undefined); + + useEffect(() => { + async function load() { + if (ndk === undefined && loaded.current === false) { + loaded.current = true; + await loadNdk(explicitRelayUrls); + } + } + load(); + }, []); + + async function loadNdk( + explicitRelayUrls: string[], + signer?: NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer, + ) { + const ndkInstance = new NDK({ explicitRelayUrls, signer }); + if (process.env.NODE_ENV === "development") { + ndkInstance.pool.on("connect", () => console.log("✅ connected")); + ndkInstance.pool.on("disconnect", () => console.log("❌ disconnected")); + } + + if (signer) { + _setSigner(signer); + } + + try { + await ndkInstance.connect(); + _setNDK(ndkInstance); + } catch (error) { + console.error("ERROR loading NDK NDKInstance", error); + } + } + + async function setSigner( + signer: NDKPrivateKeySigner | NDKNip46Signer | NDKNip07Signer, + ) { + loadNdk(explicitRelayUrls, signer); + } + + async function fetchEvents(filter: NDKFilter): Promise { + if (ndk === undefined) return []; + + return new Promise((resolve) => { + const events: Map = new Map(); + + const relaySetSubscription = ndk.subscribe(filter, { + closeOnEose: true, + }); + + relaySetSubscription.on("event", (event: NDKEvent) => { + event.ndk = ndk; + events.set(event.tagId(), event); + }); + + relaySetSubscription.on("eose", () => { + setTimeout(() => resolve(Array.from(new Set(events.values()))), 3000); + }); + }); + } + + async function signPublishEvent( + event: NDKEvent, + params: { repost: boolean; publish: boolean } | undefined = { + repost: false, + publish: true, + }, + ) { + if (ndk === undefined) return; + event.ndk = ndk; + if (params.repost) { + await event.repost(); + } else { + await event.sign(); + } + if (params.publish) { + await event.publish(); + } + return event; + } + + return { + ndk, + signer, + loadNdk, + setSigner, + fetchEvents, + signPublishEvent, + }; +} diff --git a/app/_providers/ndk/context/signers.ts b/app/_providers/ndk/context/signers.ts new file mode 100644 index 0000000..2532740 --- /dev/null +++ b/app/_providers/ndk/context/signers.ts @@ -0,0 +1,72 @@ +"use client"; + +import { nip19 } from "nostr-tools"; +import NDK, { + NDKNip07Signer, + NDKNip46Signer, + NDKPrivateKeySigner, +} from "@nostr-dev-kit/ndk"; + +export async function _loginWithSecret(skOrNsec: string) { + try { + let privkey = skOrNsec; + + if (privkey.substring(0, 4) === "nsec") { + privkey = nip19.decode(privkey).data as string; + } + + const signer = new NDKPrivateKeySigner(privkey); + return signer.user().then(async (user) => { + if (user.npub) { + return { + user: user, + npub: user.npub, + sk: privkey, + signer: signer, + }; + } + }); + } catch (e) { + throw e; + } +} + +export async function _loginWithNip46(ndk: NDK, token: string, sk?: string) { + try { + let localSigner = NDKPrivateKeySigner.generate(); + if (sk) { + localSigner = new NDKPrivateKeySigner(sk); + } + + const remoteSigner = new NDKNip46Signer(ndk, token, localSigner); + + return remoteSigner.user().then(async (user) => { + if (user.npub) { + await remoteSigner.blockUntilReady(); + return { + user: user, + npub: (await remoteSigner.user()).npub, + sk: localSigner.privateKey, + token: token, + remoteSigner: remoteSigner, + localSigner: localSigner, + }; + } + }); + } catch (e) { + throw e; + } +} + +export async function _loginWithNip07() { + try { + const signer = new NDKNip07Signer(); + return signer.user().then(async (user) => { + if (user.npub) { + return { user: user, npub: user.npub, signer: signer }; + } + }); + } catch (e) { + throw e; + } +} diff --git a/app/_providers/ndk/index.ts b/app/_providers/ndk/index.ts new file mode 100644 index 0000000..3f3bd57 --- /dev/null +++ b/app/_providers/ndk/index.ts @@ -0,0 +1,2 @@ +export * from "./context"; +export * from "./utils"; diff --git a/app/_providers/ndk/utils/index.ts b/app/_providers/ndk/utils/index.ts new file mode 100644 index 0000000..b9f45ef --- /dev/null +++ b/app/_providers/ndk/utils/index.ts @@ -0,0 +1 @@ +export * from "./notes"; diff --git a/app/_providers/ndk/utils/notes.ts b/app/_providers/ndk/utils/notes.ts new file mode 100644 index 0000000..b43d7cc --- /dev/null +++ b/app/_providers/ndk/utils/notes.ts @@ -0,0 +1,238 @@ +import { last, pluck, identity } from "ramda"; +import { nip19 } from "nostr-tools"; + +export const NEWLINE = "newline"; +export const TEXT = "text"; +export const TOPIC = "topic"; +export const LINK = "link"; +export const INVOICE = "invoice"; +export const NOSTR_NOTE = "nostr:note"; +export const NOSTR_NEVENT = "nostr:nevent"; +export const NOSTR_NPUB = "nostr:npub"; +export const NOSTR_NPROFILE = "nostr:nprofile"; +export const NOSTR_NADDR = "nostr:naddr"; + +function first(list: any) { + return list ? list[0] : undefined; +} + +export const fromNostrURI = (s: string) => s.replace(/^[\w\+]+:\/?\/?/, ""); + +export const urlIsMedia = (url: string) => + !url.match(/\.(apk|docx|xlsx|csv|dmg)/) && + last(url.split("://"))?.includes("/"); + +export const parseContent = ({ + content, + tags = [], +}: { + content: string; + tags: any[]; +}): { type: string; value: string }[] => { + const result: { type: string; value: string }[] = []; + + let text = content.trim(); + let buffer = ""; + + const parseNewline = () => { + const newline = first(text.match(/^\n+/)); + + if (newline) { + return [NEWLINE, newline, newline]; + } + }; + + const parseMention = () => { + // Convert legacy mentions to bech32 entities + const mentionMatch = text.match(/^#\[(\d+)\]/i); + + if (mentionMatch) { + const i = parseInt(mentionMatch[1]); + + if (tags[i]) { + const [tag, value, url] = tags[i]; + const relays = [url].filter(identity); + + let type, data, entity; + if (tag === "p") { + type = "nprofile"; + data = { pubkey: value, relays }; + entity = nip19.nprofileEncode(data); + } else { + type = "nevent"; + data = { id: value, relays }; + entity = nip19.neventEncode(data); + } + + return [`nostr:${type}`, mentionMatch[0], { ...data, entity }]; + } + } + }; + + const parseTopic = () => { + const topic = first(text.match(/^#\w+/i)); + + // Skip numeric topics + if (topic && !topic.match(/^#\d+$/)) { + return [TOPIC, topic, topic.slice(1)]; + } + }; + + const parseBech32 = () => { + const bech32 = first( + text.match( + /^(web\+)?(nostr:)?\/?\/?n(event|ote|profile|pub|addr)1[\d\w]+/i, + ), + ); + + if (bech32) { + try { + const entity = fromNostrURI(bech32); + const { type, data } = nip19.decode(entity) as { + type: string; + data: object; + }; + + let value = data; + if (type === "note") { + value = { id: data }; + } else if (type === "npub") { + value = { pubkey: data }; + } + + return [`nostr:${type}`, bech32, { ...value, entity }]; + } catch (e) { + console.log(e); + // pass + } + } + }; + + const parseInvoice = () => { + const invoice = first(text.match(/^ln(bc|url)[\d\w]{50,1000}/i)); + + if (invoice) { + return [INVOICE, invoice, invoice]; + } + }; + + const parseUrl = () => { + const raw = first( + text.match( + /^([a-z\+:]{2,30}:\/\/)?[^\s]+\.[a-z]{2,6}[^\s]*[^\.!?,:\s]/gi, + ), + ); + + // Skip url if it's just the end of a filepath + if (raw) { + const prev: any = last(result); + + if (prev?.type === "text" && prev.value.endsWith("/")) { + return; + } + + let url = raw; + + // Skip ellipses and very short non-urls + if (url.match(/\.\./)) { + return; + } + + if (!url.match("://")) { + url = "https://" + url; + } + + return [LINK, raw, { url, isMedia: urlIsMedia(url) }]; + } + }; + + while (text) { + const part = + parseNewline() || + parseMention() || + parseTopic() || + parseBech32() || + parseUrl() || + parseInvoice(); + + if (part) { + if (buffer) { + result.push({ type: "text", value: buffer }); + buffer = ""; + } + + const [type, raw, value] = part; + + result.push({ type, value }); + text = text.slice(raw.length); + } else { + // Instead of going character by character and re-running all the above regular expressions + // a million times, try to match the next word and add it to the buffer + const match = first(text.match(/^[\w\d]+ ?/i)) || text[0]; + + buffer += match; + text = text.slice(match.length); + } + } + + if (buffer) { + result.push({ type: TEXT, value: buffer }); + } + + return result; +}; + +export const truncateContent = ( + content: { value: string; type: string; isMedia: boolean }[], + { + showEntire, + maxLength, + showMedia = false, + }: { showEntire: boolean; maxLength: number; showMedia: boolean }, +) => { + if (showEntire) { + return content; + } + + let length = 0; + const result: any[] = []; + const truncateAt = maxLength * 0.6; + + content.every((part, i) => { + const isText = + [TOPIC, TEXT].includes(part.type) || + (part.type === LINK && !part.isMedia); + const isMedia = + part.type === INVOICE || part.type.startsWith("nostr:") || part.isMedia; + + if (isText) { + length += part.value.length; + } + + if (isMedia) { + length += showMedia ? maxLength / 3 : part.value.length; + } + + result.push(part); + + if (length > truncateAt && i < content.length - 1) { + if (isText || (isMedia && !showMedia)) { + result.push({ type: TEXT, value: "..." }); + } + + return false; + } + + return true; + }); + + return result; +}; + +export const getLinks = ( + parts: { value: string; type: string; isMedia: boolean }[], +) => + pluck( + "value", + parts.filter((x) => x.type === LINK && x.isMedia), + ); diff --git a/app/api/metadata/route.ts b/app/api/metadata/route.ts new file mode 100644 index 0000000..0da7aa2 --- /dev/null +++ b/app/api/metadata/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { parse } from "node-html-parser"; +import { z } from "zod"; +import { validateUrl } from "@/lib/utils"; + + +type MetaData = { + title: string; + description: string; + image: string; + creator: string; + "theme-color": string; + type: string; +}; +const keys = [ + "title", + "description", + "image", + "creator", + "theme-color", + "type", +] as const; +const bodySchema = z.object({ + url: z.string(), +}); +async function handler(req: NextRequest) { + const body = await req.json(); + let { url } = bodySchema.parse(body); + + if (url.includes("open.spotify")) { + url = url.split("?")[0] as string; + } else if (url.startsWith("https://t.co/")) { + // const data = await getMetaData({ + // url: url, + // ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36", + // }); + // if (data.title) { + // url = data.title; + // } + console.log("Twitter"); + } + + const decode = decodeURIComponent(url); + try { + const retreivedHtml = await fetch(decode); + const html = await retreivedHtml.text(); + const root = parse(html); + + const metadata: Partial = {}; + const titleElement = root.querySelector("title"); + const title = titleElement?.text; + metadata.title = title; + const metaTags = root.querySelectorAll("meta"); + + for (const metaTag of metaTags) { + const name = + metaTag.getAttribute("name") ?? metaTag.getAttribute("property"); + + const content = metaTag.getAttribute("content"); + if (!name || !content) continue; + for (const key of keys) { + if (name.includes(key)) { + if (key === "image" && !validateUrl(content)) { + continue; + } + const current = metadata[key]; + if (!current || content.length > current.length) { + metadata[key] = content; + } + } + } + console.log(`Name: ${name}, Content: ${content}`); + } + + const data = { + ...metadata, + url: decode, + }; + return NextResponse.json({ + data, + }); + } catch (err) { + console.log("Error", err); + } +} + +export { handler as GET, handler as POST }; diff --git a/app/api/metadata/types.ts b/app/api/metadata/types.ts new file mode 100644 index 0000000..fa46220 --- /dev/null +++ b/app/api/metadata/types.ts @@ -0,0 +1,37 @@ +export interface MetaData { + title?: string; + description?: string; + icon?: string; + image?: string; + keywords?: string[]; + language?: string; + type?: string; + url?: string; + provider?: string; + [x: string]: string | string[] | undefined; +} + +export type MetadataRule = [string, (el: Element) => string | null]; + +export interface Context { + url: string; + options: Options; +} + +export interface RuleSet { + rules: MetadataRule[]; + defaultValue?: (context: Context) => string | string[]; + scorer?: (el: Element, score: any) => any; + processor?: (input: any, context: Context) => any; +} + +export interface Options { + maxRedirects?: number; + ua?: string; + lang?: string; + timeout?: number; + forceImageHttps?: boolean; + html?: string; + url?: string; + customRules?: Record; +} diff --git a/app/api/well-known/nostr/[name]/route.ts b/app/api/well-known/nostr/[name]/route.ts new file mode 100644 index 0000000..636d146 --- /dev/null +++ b/app/api/well-known/nostr/[name]/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; + +function handler() { + return NextResponse.json({ + names: { + zach: "17717ad4d20e2a425cda0a2195624a0a4a73c4f6975f16b1593fc87fa46f2d58", + _: "17717ad4d20e2a425cda0a2195624a0a4a73c4f6975f16b1593fc87fa46f2d58", + }, + }); +} + +export { handler as GET, handler as POST }; diff --git a/app/globals.css b/app/globals.css index fd81e88..b5c2a0d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,27 +1,66 @@ @tailwind base; @tailwind components; @tailwind utilities; + -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { +@layer base { :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + --primary: 24.6 95% 53.1%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + --accent: 60 4.8% 95.9%; + --accent-foreground: 24 9.8% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + --ring: 24.6 95% 53.1%; + --radius: 0.75rem; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + --primary: 20.5 90.2% 48.2%; + --primary-foreground: 60 9.1% 97.8%; + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 20.5 90.2% 48.2%; } } -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } +@layer components { + .center { + @apply flex items-center justify-center; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index ae84562..1ec0f7d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,22 +1,64 @@ -import './globals.css' -import type { Metadata } from 'next' -import { Inter } from 'next/font/google' +import "./globals.css"; +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import { cn } from "@/lib/utils"; +import { Providers } from "./_providers"; -const inter = Inter({ subsets: ['latin'] }) +const inter = Inter({ subsets: ["latin"] }); +const title = "Flockstr"; +const description = "Own your flock"; +const image = + "https://o-0-o-image-storage.s3.amazonaws.com/zachmeyer_a_cartoon_image_of_an_ostrich_wearing_sunglasses_at_a_e68ac83e-a3b8-4d81-9550-a1fb7ee1ee62.png"; export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} + title, + description, + icons: [ + { rel: "apple-touch-icon", url: "/apple-touch-icon.png" }, + { rel: "shortcut icon", url: "/favicon.ico" }, + ], + openGraph: { + title, + description, + images: [image], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [image], + creator: "@zachmeyer_", + }, + metadataBase: new URL("https://flockstr.com"), + viewport: + "minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, viewport-fit=cover", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + title: title, + statusBarStyle: "default", + }, + applicationName: "Flockstr", + formatDetection: { + telephone: false, + }, + themeColor: { + color: "#000000", + }, +}; export default function RootLayout({ children, }: { - children: React.ReactNode + children: React.ReactNode; }) { return ( - - {children} + + + {children} + - ) + ); } diff --git a/app/page.tsx b/app/page.tsx index 7a8286b..941ad8c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import Image from 'next/image' -export default function Home() { +export default function LandingPage() { return (
diff --git a/bun.lockb b/bun.lockb index 8090c72..0923e1b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..48ade7d --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..4ecf369 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..77e9fb7 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..cf284e9 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,119 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..242b07a --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,205 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..f6afdaf --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +