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 };
|
@ -2,26 +2,65 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--primary: 24.6 95% 53.1%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 24.6 95% 53.1%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@layer components {
|
||||
.center {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
}
|
@ -1,22 +1,64 @@
|
||||
import './globals.css'
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Providers } from "./_providers";
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
const title = "Flockstr";
|
||||
const description = "Own your flock";
|
||||
const image =
|
||||
"https://o-0-o-image-storage.s3.amazonaws.com/zachmeyer_a_cartoon_image_of_an_ostrich_wearing_sunglasses_at_a_e68ac83e-a3b8-4d81-9550-a1fb7ee1ee62.png";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
}
|
||||
title,
|
||||
description,
|
||||
icons: [
|
||||
{ rel: "apple-touch-icon", url: "/apple-touch-icon.png" },
|
||||
{ rel: "shortcut icon", url: "/favicon.ico" },
|
||||
],
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: [image],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: [image],
|
||||
creator: "@zachmeyer_",
|
||||
},
|
||||
metadataBase: new URL("https://flockstr.com"),
|
||||
viewport:
|
||||
"minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, viewport-fit=cover",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: title,
|
||||
statusBarStyle: "default",
|
||||
},
|
||||
applicationName: "Flockstr",
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
themeColor: {
|
||||
color: "#000000",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
<html lang="en" suppressHydrationWarning className="">
|
||||
<body
|
||||
className={cn(inter.className, "w-full bg-background scrollbar-none")}
|
||||
>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function Home() {
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<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">
|
||||
|
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} */
|
||||
const nextConfig = {}
|
||||
const nextConfig = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/.well-known/nostr.json",
|
||||
destination: "/api/well-known",
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
domains: ["t2.gstatic.com", "www.google.com"],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
module.exports = nextConfig
|
||||
|
44
package.json
44
package.json
@ -9,19 +9,53 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"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-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": {
|
||||
"typescript": "^5",
|
||||
"@types/crypto-js": "^4.1.2",
|
||||
"@types/node": "^20",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3",
|
||||
"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'
|
||||
|
||||
const config: Config = {
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./containers/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
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: [],
|
||||
}
|
||||
export default config
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/container-queries"),
|
||||
require("tailwind-scrollbar"),
|
||||
],
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "es2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
@ -8,7 +8,8 @@
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
// "moduleResolution": "bundler",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"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"]
|
||||
}
|
||||
|
0
types/global.ts
Normal file
0
types/global.ts
Normal file
Loading…
x
Reference in New Issue
Block a user