boilerplate

This commit is contained in:
zmeyer44 2023-10-13 09:23:11 -04:00
parent e374f03f1f
commit 3977391740
50 changed files with 2805 additions and 57 deletions

View File

@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

32
.eslintrc.ts Normal file
View 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
View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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,
};
};

View 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 };

View 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,
};
}

View 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;
}
}

View File

@ -0,0 +1,2 @@
export * from "./context";
export * from "./utils";

View File

@ -0,0 +1 @@
export * from "./notes";

View 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
View 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
View 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>;
}

View 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 };

View File

@ -2,26 +2,65 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
:root { :root {
--foreground-rgb: 0, 0, 0; --background: 0 0% 100%;
--background-start-rgb: 214, 219, 220; --foreground: 20 14.3% 4.1%;
--background-end-rgb: 255, 255, 255; --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) { .dark {
:root { --background: 20 14.3% 4.1%;
--foreground-rgb: 255, 255, 255; --foreground: 60 9.1% 97.8%;
--background-start-rgb: 0, 0, 0; --card: 20 14.3% 4.1%;
--background-end-rgb: 0, 0, 0; --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 { body {
color: rgb(var(--foreground-rgb)); @apply bg-background text-foreground;
background: linear-gradient( }
to bottom, }
transparent, @layer components {
rgb(var(--background-end-rgb)) .center {
) @apply flex items-center justify-center;
rgb(var(--background-start-rgb)); }
} }

View File

@ -1,22 +1,64 @@
import './globals.css' import "./globals.css";
import type { Metadata } from 'next' import type { Metadata } from "next";
import { Inter } from 'next/font/google' import { Inter } from "next/font/google";
import { cn } from "@/lib/utils";
import { Providers } from "./_providers";
const inter = Inter({ subsets: ['latin'] }) const inter = Inter({ subsets: ["latin"] });
const title = "Flockstr";
const description = "Own your flock";
const image =
"https://o-0-o-image-storage.s3.amazonaws.com/zachmeyer_a_cartoon_image_of_an_ostrich_wearing_sunglasses_at_a_e68ac83e-a3b8-4d81-9550-a1fb7ee1ee62.png";
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Create Next App', title,
description: 'Generated by create next app', description,
} icons: [
{ rel: "apple-touch-icon", url: "/apple-touch-icon.png" },
{ rel: "shortcut icon", url: "/favicon.ico" },
],
openGraph: {
title,
description,
images: [image],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [image],
creator: "@zachmeyer_",
},
metadataBase: new URL("https://flockstr.com"),
viewport:
"minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, viewport-fit=cover",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
title: title,
statusBarStyle: "default",
},
applicationName: "Flockstr",
formatDetection: {
telephone: false,
},
themeColor: {
color: "#000000",
},
};
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning className="">
<body className={inter.className}>{children}</body> <body
className={cn(inter.className, "w-full bg-background scrollbar-none")}
>
<Providers>{children}</Providers>
</body>
</html> </html>
) );
} }

View File

@ -1,6 +1,6 @@
import Image from 'next/image' import Image from 'next/image'
export default function Home() { export default function LandingPage() {
return ( return (
<main className="flex min-h-screen flex-col items-center justify-between p-24"> <main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex"> <div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">

BIN
bun.lockb

Binary file not shown.

16
components.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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 }

View 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
View File

@ -0,0 +1 @@
export * from "./relays";

7
constants/relays.ts Normal file
View 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",
];

View 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
View 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;

View 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;

View 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;

View 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 };
}

View 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;
}

View 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
View 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
View 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());
}

View File

@ -1,4 +1,17 @@
/** @type {import('next').NextConfig} */ /** @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

View File

@ -9,19 +9,53 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.2",
"@noble/hashes": "^1.3.2",
"@nostr-dev-kit/ndk": "^2.0.0",
"@nostr-dev-kit/ndk-react": "^0.1.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"crypto-js": "^4.1.1",
"dayjs": "^1.11.10",
"focus-trap-react": "^10.2.3",
"framer-motion": "^10.16.4",
"next": "13.5.4",
"node-html-parser": "^6.1.10",
"nostr-tools": "^1.16.0",
"ramda": "^0.29.1",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"next": "13.5.4" "react-hook-form": "^7.47.0",
"react-icons": "^4.11.0",
"sonner": "^1.0.3",
"tailwind-merge": "^1.14.0",
"tailwind-scrollbar": "^3.0.5",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4",
"zod-fetch": "^0.1.1",
"zustand": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@types/crypto-js": "^4.1.2",
"@types/node": "^20", "@types/node": "^20",
"@types/ramda": "^0.29.6",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"autoprefixer": "^10", "autoprefixer": "^10",
"postcss": "^8",
"tailwindcss": "^3",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "13.5.4" "eslint-config-next": "13.5.4",
"postcss": "^8",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3",
"typescript": "^5",
"webln": "^0.3.2"
} }
} }

3
prettier.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
plugins: ["prettier-plugin-tailwindcss"],
};

View File

@ -1,20 +1,103 @@
import type { Config } from 'tailwindcss' /** @type {import('tailwindcss').Config} */
module.exports = {
const config: Config = { darkMode: ["class"],
content: [ content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}', "./pages/**/*.{ts,tsx}",
'./components/**/*.{js,ts,jsx,tsx,mdx}', "./components/**/*.{ts,tsx}",
'./app/**/*.{js,ts,jsx,tsx,mdx}', "./containers/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
], ],
theme: { theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: { extend: {
backgroundImage: { colors: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', border: "hsl(var(--border))",
'gradient-conic': input: "hsl(var(--input))",
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
zIndex: {
60: 60,
70: 70,
80: 80,
90: 90,
99: 99,
mobileTabs: 980,
"header-": 989,
header: 990,
"header+": 991,
headerDialog: 991,
"modal-": 995,
modal: 996,
"modal+": 997,
"top-": 998,
top: 999,
},
flex: {
2: 2,
3: 3,
4: 4,
}, },
}, },
}, },
plugins: [], plugins: [
} require("tailwindcss-animate"),
export default config require("@tailwindcss/container-queries"),
require("tailwind-scrollbar"),
],
};

View File

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
@ -8,7 +8,8 @@
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", // "moduleResolution": "bundler",
"moduleResolution": "Node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "preserve",
@ -22,6 +23,12 @@
"@/*": ["./*"] "@/*": ["./*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"prettier.config.js"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

0
types/global.ts Normal file
View File