boilerplate
This commit is contained in:
parent
e374f03f1f
commit
3977391740
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "next/core-web-vitals"
|
|
||||||
}
|
|
32
.eslintrc.ts
Normal file
32
.eslintrc.ts
Normal file
@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
29
app/_providers/index.tsx
Normal file
29
app/_providers/index.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<NDKProvider
|
||||||
|
relayUrls={RELAYS}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Toaster richColors className="dark:hidden" />
|
||||||
|
<Toaster theme="dark" className="hidden dark:block" />
|
||||||
|
<ModalProvider>{children}</ModalProvider>
|
||||||
|
</NDKProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
101
app/_providers/modal/index.tsx
Normal file
101
app/_providers/modal/index.tsx
Normal file
@ -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<SetStateAction<boolean>>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModal && (
|
||||||
|
<>
|
||||||
|
{isMobile && <Leaflet setShow={setShowModal}>{children}</Leaflet>}
|
||||||
|
{isDesktop && (
|
||||||
|
<>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{ initialFocus: false }}
|
||||||
|
active={false}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
ref={desktopModalRef}
|
||||||
|
key="desktop-modal"
|
||||||
|
className="fixed inset-0 z-modal hidden min-h-screen items-center justify-center md:flex"
|
||||||
|
initial={{ scale: 0.95 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
exit={{ scale: 0.95 }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (desktopModalRef.current === e.target) {
|
||||||
|
setShowModal(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="center grow overflow-hidden">{children}</div>
|
||||||
|
</motion.div>
|
||||||
|
</FocusTrap>
|
||||||
|
<motion.div
|
||||||
|
key="desktop-backdrop"
|
||||||
|
className="fixed inset-0 z-modal- bg-background bg-opacity-10 backdrop-blur"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
81
app/_providers/modal/leaflet.tsx
Normal file
81
app/_providers/modal/leaflet.tsx
Normal file
@ -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<SetStateAction<boolean>>;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const leafletRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
ref={leafletRef}
|
||||||
|
key="leaflet"
|
||||||
|
className="group fixed inset-x-0 bottom-0 z-modal max-h-[95vh] w-screen cursor-grab rounded-t-lg bg-background pb-5 active:cursor-grabbing sm:hidden standalone:pb-8"
|
||||||
|
initial={{ y: "100%" }}
|
||||||
|
animate={controls}
|
||||||
|
exit={{ y: "100%" }}
|
||||||
|
transition={transitionProps}
|
||||||
|
drag="y"
|
||||||
|
dragDirectionLock
|
||||||
|
onDragEnd={(_, i) => void handleDragEnd(_, i)}
|
||||||
|
dragElastic={{ top: 0, bottom: 1 }}
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-t-4xl -mb-1 flex h-7 w-full items-center justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="-mr-1 h-1 w-6 rounded-full bg-[#636363] transition-all group-active:rotate-12" />
|
||||||
|
<div className="h-1 w-6 rounded-full bg-[#636363] transition-all group-active:-rotate-12" />
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[calc(95vh_-_28px)] w-full overflow-y-auto scrollbar-track-background scrollbar-thumb-gray-900">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
key="leaflet-backdrop"
|
||||||
|
className="fixed inset-0 z-modal- bg-background bg-opacity-40 backdrop-blur"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShow(false)}
|
||||||
|
/>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
64
app/_providers/modal/provider.tsx
Normal file
64
app/_providers/modal/provider.tsx
Normal file
@ -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<ModalContextProps | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ModalProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [modalContent, setModalContent] = useState<ReactNode | null>(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 (
|
||||||
|
<ModalContext.Provider value={{ show, hide }}>
|
||||||
|
{children}
|
||||||
|
{showModal && (
|
||||||
|
<Modal showModal={showModal} setShowModal={setShowModal}>
|
||||||
|
{modalContent}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</ModalContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModal() {
|
||||||
|
return useContext(ModalContext);
|
||||||
|
}
|
69
app/_providers/ndk/context/Users.ts
Normal file
69
app/_providers/ndk/context/Users.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
143
app/_providers/ndk/context/index.tsx
Normal file
143
app/_providers/ndk/context/index.tsx
Normal file
@ -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<NDKEvent[]>;
|
||||||
|
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<undefined | NDKEvent>;
|
||||||
|
getUser: (_: string) => NDKUser;
|
||||||
|
getProfile: (_: string) => NDKUserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NDKContext = createContext<NDKContext>({
|
||||||
|
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 (
|
||||||
|
<NDKContext.Provider
|
||||||
|
value={{
|
||||||
|
ndk,
|
||||||
|
signer,
|
||||||
|
fetchEvents,
|
||||||
|
loginWithNip07,
|
||||||
|
loginWithNip46,
|
||||||
|
loginWithSecret,
|
||||||
|
signPublishEvent,
|
||||||
|
getUser,
|
||||||
|
getProfile,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NDKContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useNDK = () => {
|
||||||
|
const context = useContext(NDKContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("import NDKProvider to use useNDK");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NDKProvider, useNDK };
|
106
app/_providers/ndk/context/instance.ts
Normal file
106
app/_providers/ndk/context/instance.ts
Normal file
@ -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<NDK | undefined>(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<NDKEvent[]> {
|
||||||
|
if (ndk === undefined) return [];
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const events: Map<string, NDKEvent> = 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,
|
||||||
|
};
|
||||||
|
}
|
72
app/_providers/ndk/context/signers.ts
Normal file
72
app/_providers/ndk/context/signers.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
2
app/_providers/ndk/index.ts
Normal file
2
app/_providers/ndk/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./context";
|
||||||
|
export * from "./utils";
|
1
app/_providers/ndk/utils/index.ts
Normal file
1
app/_providers/ndk/utils/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./notes";
|
238
app/_providers/ndk/utils/notes.ts
Normal file
238
app/_providers/ndk/utils/notes.ts
Normal file
@ -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),
|
||||||
|
);
|
87
app/api/metadata/route.ts
Normal file
87
app/api/metadata/route.ts
Normal file
@ -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<MetaData> = {};
|
||||||
|
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 };
|
37
app/api/metadata/types.ts
Normal file
37
app/api/metadata/types.ts
Normal file
@ -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<string, RuleSet>;
|
||||||
|
}
|
12
app/api/well-known/nostr/[name]/route.ts
Normal file
12
app/api/well-known/nostr/[name]/route.ts
Normal file
@ -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 };
|
@ -1,27 +1,66 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
:root {
|
@layer base {
|
||||||
--foreground-rgb: 0, 0, 0;
|
|
||||||
--background-start-rgb: 214, 219, 220;
|
|
||||||
--background-end-rgb: 255, 255, 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
:root {
|
||||||
--foreground-rgb: 255, 255, 255;
|
--background: 0 0% 100%;
|
||||||
--background-start-rgb: 0, 0, 0;
|
--foreground: 20 14.3% 4.1%;
|
||||||
--background-end-rgb: 0, 0, 0;
|
--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));
|
@layer base {
|
||||||
background: linear-gradient(
|
* {
|
||||||
to bottom,
|
@apply border-border;
|
||||||
transparent,
|
}
|
||||||
rgb(var(--background-end-rgb))
|
body {
|
||||||
)
|
@apply bg-background text-foreground;
|
||||||
rgb(var(--background-start-rgb));
|
}
|
||||||
}
|
}
|
||||||
|
@layer components {
|
||||||
|
.center {
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +1,64 @@
|
|||||||
import './globals.css'
|
import "./globals.css";
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from "next";
|
||||||
import { Inter } from 'next/font/google'
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: 'Create Next App',
|
title,
|
||||||
description: 'Generated by create next app',
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning className="">
|
||||||
<body className={inter.className}>{children}</body>
|
<body
|
||||||
|
className={cn(inter.className, "w-full bg-background scrollbar-none")}
|
||||||
|
>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
|
||||||
export default function Home() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||||
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
||||||
|
16
components.json
Normal file
16
components.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
@ -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<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
@ -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<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
57
components/ui/button.tsx
Normal file
57
components/ui/button.tsx
Normal file
@ -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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
76
components/ui/card.tsx
Normal file
76
components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
119
components/ui/dialog.tsx
Normal file
119
components/ui/dialog.tsx
Normal file
@ -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<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<Cross2Icon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
205
components/ui/dropdown-menu.tsx
Normal file
205
components/ui/dropdown-menu.tsx
Normal file
@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
176
components/ui/form.tsx
Normal file
176
components/ui/form.tsx
Normal file
@ -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<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
25
components/ui/input.tsx
Normal file
25
components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
120
components/ui/select.tsx
Normal file
120
components/ui/select.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
}
|
29
components/ui/switch.tsx
Normal file
29
components/ui/switch.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-[20px] w-[36px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
24
components/ui/textarea.tsx
Normal file
24
components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
1
constants/index.ts
Normal file
1
constants/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./relays";
|
7
constants/relays.ts
Normal file
7
constants/relays.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export const RELAYS = [
|
||||||
|
"wss://nostr.pub.wellorder.net",
|
||||||
|
"wss://nostr.drss.io",
|
||||||
|
"wss://nostr.swiss-enigma.ch",
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
];
|
||||||
|
|
21
lib/hooks/useAutoSizeTextArea.ts
Normal file
21
lib/hooks/useAutoSizeTextArea.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Updates the height of a <textarea> when the value changes.
|
||||||
|
const useAutosizeTextArea = (
|
||||||
|
textAreaRef: HTMLTextAreaElement | null,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (textAreaRef) {
|
||||||
|
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
|
||||||
|
textAreaRef.style.height = "0px";
|
||||||
|
const scrollHeight = textAreaRef.scrollHeight;
|
||||||
|
|
||||||
|
// We then set the height directly, outside of the render loop
|
||||||
|
// Trying to set this with state or a ref will product an incorrect value.
|
||||||
|
textAreaRef.style.height = `${scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}, [textAreaRef, value]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAutosizeTextArea;
|
124
lib/hooks/useCacheFetch.ts
Normal file
124
lib/hooks/useCacheFetch.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
import { cache, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type DefaultReturnType = {
|
||||||
|
contentType: string;
|
||||||
|
};
|
||||||
|
type ReturnType =
|
||||||
|
| (DefaultReturnType &
|
||||||
|
(
|
||||||
|
| {
|
||||||
|
data: string;
|
||||||
|
type: "image";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
data: { image: string; title: string; description: string };
|
||||||
|
type: "link";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "unknown";
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
))
|
||||||
|
| null;
|
||||||
|
|
||||||
|
// export const fetchContent: (url: string) => Promise<ReturnType> = cache(
|
||||||
|
// async (url: string) => {
|
||||||
|
// try {
|
||||||
|
// console.log("Calling", url);
|
||||||
|
// const response = await fetch(
|
||||||
|
// "https://www.youtube.com/watch?si=O-IuZ94H1UwyYfl0&v=giaZnIr-faM&feature=youtu.be",
|
||||||
|
// ).catch((err) => console.log("Fetch error in content fetch", err));
|
||||||
|
// console.log("RESPONSE", response);
|
||||||
|
// if (!response) {
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
// const contentType = response.headers.get("content-type");
|
||||||
|
// if (contentType?.startsWith("text/html")) {
|
||||||
|
// const data = await response.json();
|
||||||
|
// return {
|
||||||
|
// data: data,
|
||||||
|
// contentType: contentType,
|
||||||
|
// type: "link",
|
||||||
|
// };
|
||||||
|
// } else if (contentType?.startsWith("image")) {
|
||||||
|
// const imageData = (await response.blob().then(
|
||||||
|
// (blob) =>
|
||||||
|
// new Promise((resolve, reject) => {
|
||||||
|
// const reader = new FileReader();
|
||||||
|
// reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
// reader.onerror = reject;
|
||||||
|
// reader.readAsDataURL(blob);
|
||||||
|
// }),
|
||||||
|
// )) as string;
|
||||||
|
// return {
|
||||||
|
// contentType,
|
||||||
|
// data: imageData,
|
||||||
|
// type: "image",
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// return null;
|
||||||
|
// } catch (err) {
|
||||||
|
// console.log("Content fetch failed", err);
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
export const fetchContent: (url: string) => Promise<ReturnType> = async (
|
||||||
|
url: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
console.log("Calling", url);
|
||||||
|
const response = await fetch(url).catch((err) =>
|
||||||
|
console.log("Fetch error"),
|
||||||
|
);
|
||||||
|
// console.log("RESPONSE", response);
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
if (contentType?.startsWith("image")) {
|
||||||
|
const imageData = (await response.blob().then(
|
||||||
|
(blob) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
}),
|
||||||
|
)) as string;
|
||||||
|
return {
|
||||||
|
contentType,
|
||||||
|
data: imageData,
|
||||||
|
type: "image",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Content fetch failed", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCacheFetch = ({ url }: { url: string }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<ReturnType | null>(null);
|
||||||
|
|
||||||
|
async function handleFetch() {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await fetchContent(url);
|
||||||
|
|
||||||
|
setData(response);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void handleFetch();
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCacheFetch;
|
40
lib/hooks/useElementOnScreen.ts
Normal file
40
lib/hooks/useElementOnScreen.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { useRef, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const useElementOnScreen = (options?: {
|
||||||
|
root: any;
|
||||||
|
rootMargin: string;
|
||||||
|
threshold: number;
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const defaultOptions = {
|
||||||
|
root: null,
|
||||||
|
rootMargin: "0px 0px 0px 0px",
|
||||||
|
threshold: 1,
|
||||||
|
};
|
||||||
|
const callbackFunction = (entries: IntersectionObserverEntry[]) => {
|
||||||
|
const [entry] = entries;
|
||||||
|
if (entry) {
|
||||||
|
setIsVisible(entry.isIntersecting);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
callbackFunction,
|
||||||
|
options ?? defaultOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (containerRef.current) observer.observe(containerRef.current);
|
||||||
|
return () => {
|
||||||
|
if (containerRef.current) observer.unobserve(containerRef.current);
|
||||||
|
};
|
||||||
|
}, [containerRef, options]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
isVisible,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useElementOnScreen;
|
45
lib/hooks/useLocalStorage.ts
Normal file
45
lib/hooks/useLocalStorage.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function useLocalStorage<T>(key: string, initialValue: T) {
|
||||||
|
// State to store our value
|
||||||
|
// Pass initial state function to useState so logic is only executed once
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get from local storage by key
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
// Parse stored json or if none return initialValue
|
||||||
|
return item ? JSON.parse(item) : initialValue;
|
||||||
|
} catch (error) {
|
||||||
|
// If error also return initialValue
|
||||||
|
console.log(error);
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a wrapped version of useState's setter function that ...
|
||||||
|
// ... persists the new value to localStorage.
|
||||||
|
const setValue = (value: T | ((val: T) => T)) => {
|
||||||
|
try {
|
||||||
|
// Allow value to be a function so we have same API as useState
|
||||||
|
const valueToStore =
|
||||||
|
value instanceof Function ? value(storedValue) : value;
|
||||||
|
// Save state
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
// Save to local storage
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// A more advanced implementation would handle the error case
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [storedValue, setValue] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLocalStorage;
|
62
lib/hooks/useQueryParams.ts
Normal file
62
lib/hooks/useQueryParams.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
export default function useQueryParams<T>() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const urlSearchParams = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
// function setQueryParams(params: Partial<T>) {
|
||||||
|
// Object.entries(params).forEach(([key, value]) => {
|
||||||
|
// if (value === undefined || value === null) {
|
||||||
|
// urlSearchParams.delete(key);
|
||||||
|
// } else {
|
||||||
|
// urlSearchParams.set(key, String(value));
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const search = urlSearchParams.toString();
|
||||||
|
// const query = search ? `?${search}` : "";
|
||||||
|
// // replace since we don't want to build a history
|
||||||
|
// router.replace(`${pathname}${query}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
function setQueryParams(
|
||||||
|
props:
|
||||||
|
| ((
|
||||||
|
current: URLSearchParams,
|
||||||
|
obj: Record<string, string | string[]>
|
||||||
|
) => Partial<T>)
|
||||||
|
| Partial<T>
|
||||||
|
) {
|
||||||
|
let params = props;
|
||||||
|
if (typeof params === "function") {
|
||||||
|
params = params(urlSearchParams, Object.fromEntries(urlSearchParams));
|
||||||
|
}
|
||||||
|
console.log("Params", params);
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
console.log("Value", value);
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
urlSearchParams.delete(key);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
// clear
|
||||||
|
urlSearchParams.delete(key);
|
||||||
|
for (const val of value) {
|
||||||
|
console.log("Setting", val);
|
||||||
|
urlSearchParams.append(key, String(val));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
urlSearchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = urlSearchParams.toString();
|
||||||
|
const query = search ? `?${search}` : "";
|
||||||
|
// replace since we don't want to build a history
|
||||||
|
router.replace(`${pathname}${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { queryParams: searchParams, setQueryParams };
|
||||||
|
}
|
18
lib/hooks/useRouteChange.ts
Normal file
18
lib/hooks/useRouteChange.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
type RouteChangeProps = (url: string) => void;
|
||||||
|
|
||||||
|
export default function NavigationEvents(callback: RouteChangeProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = `${pathname ?? ""}?${searchParams?.toString() ?? ""}`;
|
||||||
|
callback(url);
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
38
lib/hooks/useWindowSize.ts
Normal file
38
lib/hooks/useWindowSize.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
73
lib/utils/dates.ts
Normal file
73
lib/utils/dates.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import relative from "dayjs/plugin/relativeTime";
|
||||||
|
import updateLocale from "dayjs/plugin/updateLocale";
|
||||||
|
export function relativeTimeUnix(timestamp: number) {
|
||||||
|
const config = {
|
||||||
|
thresholds: [
|
||||||
|
{ l: "s", r: 1 },
|
||||||
|
{ l: "m", r: 1 },
|
||||||
|
{ l: "mm", r: 59, d: "minute" },
|
||||||
|
{ l: "h", r: 1 },
|
||||||
|
{ l: "hh", r: 23, d: "hour" },
|
||||||
|
{ l: "d", r: 1 },
|
||||||
|
{ l: "dd", r: 364, d: "day" },
|
||||||
|
{ l: "y", r: 1 },
|
||||||
|
{ l: "yy", d: "year" },
|
||||||
|
],
|
||||||
|
rounding: Math.floor,
|
||||||
|
};
|
||||||
|
dayjs.extend(updateLocale);
|
||||||
|
|
||||||
|
dayjs.updateLocale("en", {
|
||||||
|
relativeTime: {
|
||||||
|
future: "in %s",
|
||||||
|
past: "%s ago",
|
||||||
|
s: "%s seconds",
|
||||||
|
m: "1 min",
|
||||||
|
mm: "%d mins",
|
||||||
|
h: "1 hour",
|
||||||
|
hh: "%d hours",
|
||||||
|
d: "1 day",
|
||||||
|
dd: "%d days",
|
||||||
|
y: "1 year",
|
||||||
|
yy: "%d years",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dayjs.extend(relative, config);
|
||||||
|
return dayjs(timestamp * 1000).fromNow();
|
||||||
|
}
|
||||||
|
export function relativeTime(timestamp: Date) {
|
||||||
|
const config = {
|
||||||
|
thresholds: [
|
||||||
|
{ l: "s", r: 1 },
|
||||||
|
{ l: "m", r: 1 },
|
||||||
|
{ l: "mm", r: 59, d: "minute" },
|
||||||
|
{ l: "h", r: 1 },
|
||||||
|
{ l: "hh", r: 23, d: "hour" },
|
||||||
|
{ l: "d", r: 1 },
|
||||||
|
{ l: "dd", r: 364, d: "day" },
|
||||||
|
{ l: "y", r: 1 },
|
||||||
|
{ l: "yy", d: "year" },
|
||||||
|
],
|
||||||
|
rounding: Math.floor,
|
||||||
|
};
|
||||||
|
dayjs.extend(updateLocale);
|
||||||
|
|
||||||
|
dayjs.updateLocale("en", {
|
||||||
|
relativeTime: {
|
||||||
|
future: "in %s",
|
||||||
|
past: "%s ago",
|
||||||
|
s: "%s seconds",
|
||||||
|
m: "1 min",
|
||||||
|
mm: "%d mins",
|
||||||
|
h: "1 hour",
|
||||||
|
hh: "%d hours",
|
||||||
|
d: "1 day",
|
||||||
|
dd: "%d days",
|
||||||
|
y: "1 year",
|
||||||
|
yy: "%d years",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dayjs.extend(relative, config);
|
||||||
|
return dayjs(timestamp).fromNow();
|
||||||
|
}
|
68
lib/utils/index.ts
Normal file
68
lib/utils/index.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCount(count: number) {
|
||||||
|
if (count < 1000) {
|
||||||
|
return count;
|
||||||
|
} else if (count < 1_000_000) {
|
||||||
|
return `${Number((count / 1000).toFixed(1))}K`;
|
||||||
|
} else {
|
||||||
|
return `${Number((count / 1_000_000).toFixed(1))}M`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function cleanUrl(url?: string) {
|
||||||
|
if (!url) return "";
|
||||||
|
if (url.slice(-1) === ".") {
|
||||||
|
return url.slice(0, -1);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(text: string, size?: number) {
|
||||||
|
let length = size ?? 5;
|
||||||
|
return text.slice(0, length) + "..." + text.slice(-length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeDuplicates<T>(data: T[], key?: keyof T) {
|
||||||
|
if (key) {
|
||||||
|
const unique = data.filter((obj, index) => {
|
||||||
|
return index === data.findIndex((o) => obj[key] === o[key]);
|
||||||
|
});
|
||||||
|
return unique;
|
||||||
|
} else {
|
||||||
|
return data.filter((obj, index) => {
|
||||||
|
return index === data.findIndex((o) => obj === o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyText(text: string) {
|
||||||
|
return await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
export function formatNumber(number: number) {
|
||||||
|
if (typeof number === "number") {
|
||||||
|
return number.toLocaleString("en-US");
|
||||||
|
} else return "not a number";
|
||||||
|
}
|
||||||
|
export function log(
|
||||||
|
isOn: boolean | undefined,
|
||||||
|
type: "info" | "error" | "warn",
|
||||||
|
...args: unknown[]
|
||||||
|
) {
|
||||||
|
if (!isOn) return;
|
||||||
|
console[type](...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUrl(value: string) {
|
||||||
|
return /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function btcToSats(amount: number) {
|
||||||
|
return parseInt((amount * 100_000_000).toFixed());
|
||||||
|
}
|
@ -1,4 +1,17 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
async rewrites() {
|
||||||
module.exports = nextConfig
|
return [
|
||||||
|
{
|
||||||
|
source: "/.well-known/nostr.json",
|
||||||
|
destination: "/api/well-known",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
domains: ["t2.gstatic.com", "www.google.com"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
|
|
44
package.json
44
package.json
@ -9,19 +9,53 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
"@noble/hashes": "^1.3.2",
|
||||||
|
"@nostr-dev-kit/ndk": "^2.0.0",
|
||||||
|
"@nostr-dev-kit/ndk-react": "^0.1.1",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"crypto-js": "^4.1.1",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
|
"focus-trap-react": "^10.2.3",
|
||||||
|
"framer-motion": "^10.16.4",
|
||||||
|
"next": "13.5.4",
|
||||||
|
"node-html-parser": "^6.1.10",
|
||||||
|
"nostr-tools": "^1.16.0",
|
||||||
|
"ramda": "^0.29.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"next": "13.5.4"
|
"react-hook-form": "^7.47.0",
|
||||||
|
"react-icons": "^4.11.0",
|
||||||
|
"sonner": "^1.0.3",
|
||||||
|
"tailwind-merge": "^1.14.0",
|
||||||
|
"tailwind-scrollbar": "^3.0.5",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"zod-fetch": "^0.1.1",
|
||||||
|
"zustand": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@types/crypto-js": "^4.1.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/ramda": "^0.29.6",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
"postcss": "^8",
|
|
||||||
"tailwindcss": "^3",
|
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "13.5.4"
|
"eslint-config-next": "13.5.4",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.5.6",
|
||||||
|
"tailwindcss": "^3",
|
||||||
|
"typescript": "^5",
|
||||||
|
"webln": "^0.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
prettier.config.js
Normal file
3
prettier.config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
|
};
|
@ -1,20 +1,103 @@
|
|||||||
import type { Config } from 'tailwindcss'
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
const config: Config = {
|
darkMode: ["class"],
|
||||||
content: [
|
content: [
|
||||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
"./pages/**/*.{ts,tsx}",
|
||||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
"./components/**/*.{ts,tsx}",
|
||||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
"./containers/**/*.{ts,tsx}",
|
||||||
|
"./app/**/*.{ts,tsx}",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
backgroundImage: {
|
colors: {
|
||||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
border: "hsl(var(--border))",
|
||||||
'gradient-conic':
|
input: "hsl(var(--input))",
|
||||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
zIndex: {
|
||||||
|
60: 60,
|
||||||
|
70: 70,
|
||||||
|
80: 80,
|
||||||
|
90: 90,
|
||||||
|
99: 99,
|
||||||
|
mobileTabs: 980,
|
||||||
|
"header-": 989,
|
||||||
|
header: 990,
|
||||||
|
"header+": 991,
|
||||||
|
headerDialog: 991,
|
||||||
|
"modal-": 995,
|
||||||
|
modal: 996,
|
||||||
|
"modal+": 997,
|
||||||
|
"top-": 998,
|
||||||
|
top: 999,
|
||||||
|
},
|
||||||
|
flex: {
|
||||||
|
2: 2,
|
||||||
|
3: 3,
|
||||||
|
4: 4,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [
|
||||||
}
|
require("tailwindcss-animate"),
|
||||||
export default config
|
require("@tailwindcss/container-queries"),
|
||||||
|
require("tailwind-scrollbar"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@ -8,7 +8,8 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
// "moduleResolution": "bundler",
|
||||||
|
"moduleResolution": "Node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
@ -22,6 +23,12 @@
|
|||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"prettier.config.js"
|
||||||
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
0
types/global.ts
Normal file
0
types/global.ts
Normal file
Loading…
x
Reference in New Issue
Block a user