1
0
Code Issues Pull Requests Packages Projects Releases Wiki Activity GitHub Gitee

release 0.3.4

This commit is contained in:
songjunxi
2023-11-10 14:59:47 +08:00
parent 2b456637e9
commit 602f2059fd
161 changed files with 9921 additions and 347 deletions

View File

@@ -1,28 +0,0 @@
"use client";
import { useState } from "react";
import { Editor as InkeEditor } from "inke";
export default function Editor() {
const [saveStatus, setSaveStatus] = useState("Saved");
return (
<div className="relative w-full max-w-screen-lg">
<div className="absolute right-5 top-5 z-10 mb-5 rounded-lg bg-stone-100 px-2 py-1 text-sm text-stone-400">
{saveStatus}
</div>
<InkeEditor
onUpdate={() => {
setSaveStatus("Unsaved");
}}
onDebouncedUpdate={() => {
setSaveStatus("Saving...");
// Simulate a delay in saving.
setTimeout(() => {
setSaveStatus("Saved");
}, 500);
}}
/>
</div>
);
}

View File

@@ -1,4 +0,0 @@
export { default as FontDefault } from "./font-default";
export { default as FontSerif } from "./font-serif";
export { default as FontMono } from "./font-mono";
export { default as Github } from "./github";

View File

@@ -0,0 +1,116 @@
import Modal from "@/ui/shared/modal";
import { signIn } from "next-auth/react";
import {
useState,
Dispatch,
SetStateAction,
useCallback,
useMemo,
} from "react";
import { LoadingDots, Google, Github } from "@/ui/shared/icons";
import { fetcher } from "@/lib/utils";
import { Session } from "next-auth";
import { useUserInfoByEmail } from "@/app/post/[id]/request";
const EditNicknameModal = ({
session,
showEditModal,
setShowEditModal,
}: {
session: Session | null;
showEditModal: boolean;
setShowEditModal: Dispatch<SetStateAction<boolean>>;
}) => {
const { user } = useUserInfoByEmail(session?.user?.email || "");
const [nickname, setNickname] = useState("");
const [loading, setLoading] = useState(false);
const [isSendSuccess, setIsSendSuccess] = useState(false);
const handleSubmit = async () => {
if (!session?.user || !user || !nickname) return;
setLoading(true);
const res = await fetcher("/api/users", {
method: "POST",
body: JSON.stringify({
userId: user.id,
userName: nickname,
}),
});
if (res) {
setLoading(false);
setIsSendSuccess(true);
setShowEditModal(false);
}
};
const handleKeydown = (key: string) => {
if (key === "Enter") {
handleSubmit();
}
};
return (
<Modal showModal={showEditModal} setShowModal={setShowEditModal}>
<div className="w-full overflow-hidden bg-gray-50 shadow-xl md:max-w-md md:rounded-2xl md:border md:border-gray-200">
<div className="flex flex-col items-center justify-center space-y-3 border-b border-gray-200 bg-white px-4 py-6 pt-8 text-center md:px-16">
<h3 className="font-display text-2xl font-bold">Your Name</h3>
{/* <p className="text-sm text-gray-500"></p> */}
</div>
<div className="px-14 py-10">
<input
className="shadow-blue-gray-200 mb-4 w-full rounded-md border border-slate-200 bg-[#f8f8f8a1] px-3 py-3 text-sm placeholder-gray-400 shadow-inner"
type="text"
// value={user?.name}
placeholder={user?.name || "Enter your"}
onChange={(e) => setNickname(e.target.value)}
onKeyDown={(e) => handleKeydown(e.key)}
/>
<button
// disabled={loading}
onClick={handleSubmit}
className={`
${
loading
? "border-gray-300 bg-gray-200"
: `${
isSendSuccess
? " border-blue-500 bg-blue-500 hover:text-blue-500"
: "border-black bg-black hover:text-black"
} hover:bg-gray-100`
}
h-10 w-full rounded-md border px-2 py-1 text-sm text-slate-100 transition-all `}
>
{loading ? (
<LoadingDots color="gray" />
) : (
<div className="flex items-center justify-center">
{isSendSuccess && !loading ? "修改成功" : "提交"}
</div>
)}
</button>
</div>
</div>
</Modal>
);
};
export function useEditNicknameModal(session: Session | null) {
const [showEditModal, setShowEditModal] = useState(false);
const EditModalCallback = useCallback(() => {
return (
<EditNicknameModal
session={session}
showEditModal={showEditModal}
setShowEditModal={setShowEditModal}
/>
);
}, [showEditModal, setShowEditModal]);
return useMemo(
() => ({ setShowEditModal, EditModal: EditModalCallback }),
[setShowEditModal, EditModalCallback],
);
}

View File

@@ -0,0 +1,196 @@
import Modal from "@/ui/shared/modal";
import {
useState,
Dispatch,
SetStateAction,
useCallback,
useMemo,
} from "react";
import { LoadingDots } from "@/ui/shared/icons";
import { fetcher } from "@/lib/utils";
import { Session } from "next-auth";
import { useUserInfoByEmail } from "@/app/post/[id]/request";
import shortid from "shortid";
import { IResponse } from "@/lib/types/response";
import { Collaboration } from "@prisma/client";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { defaultEditorContent } from "@/lib/consts";
import { v4 as uuidv4 } from "uuid";
import { ContentItem } from "@/lib/types/note";
import { Shapes } from "lucide-react";
import Link from "next/link";
import { addNote } from "@/store/db.model";
const CreatRoomModal = ({
initTitle,
localId,
session,
showEditModal,
setShowEditModal,
}: {
initTitle: string;
localId: string;
session: Session | null;
showEditModal: boolean;
setShowEditModal: Dispatch<SetStateAction<boolean>>;
}) => {
const [title, setTitle] = useState("");
const [loading, setLoading] = useState(false);
const [isSendSuccess, setIsSendSuccess] = useState(false);
const router = useRouter();
const handleSubmit = async () => {
if (!session?.user) {
toast("Please login first");
return;
}
if (!title || title.length < 3 || title.length > 20) {
toast("Invalid space name");
return;
}
setLoading(true);
const new_localId = uuidv4();
const roomId = shortid.generate().replace("_", "A").replace("-", "a");
const res = await fetcher<IResponse<Collaboration | null>>(
"/api/collaboration",
{
method: "POST",
body: JSON.stringify({
roomId,
localId: new_localId, // 本地创建新笔记关联空间
title,
}),
},
);
if (res.code !== 200) {
toast(res.msg, {
icon: "😅",
});
} else {
const date = new Date();
const newItem: ContentItem = {
id: new_localId,
title: `Untitled-${new_localId.slice(0, 6)}-${
date.getMonth() + 1
}/${date.getDate()}`,
content: defaultEditorContent,
tag: "",
created_at: date.getTime(),
updated_at: date.getTime(),
};
addNote(newItem);
toast.success(res.msg, {
icon: "🎉",
});
router.push(`/post/${new_localId}?work=${roomId}`);
}
if (res) {
setLoading(false);
setIsSendSuccess(true);
setShowEditModal(false);
}
};
const handleKeydown = (key: string) => {
if (key === "Enter") {
handleSubmit();
}
};
return (
<Modal showModal={showEditModal} setShowModal={setShowEditModal}>
<div className="w-full overflow-hidden bg-gray-50 shadow-xl md:max-w-md md:rounded-2xl md:border md:border-gray-200">
<div className="flex flex-col items-center justify-center space-y-3 border-b border-gray-200 bg-white px-4 py-6 pt-8 text-center md:px-10">
<Shapes className="inline h-12 w-12 text-cyan-500" />
<h3 className="font-display text-2xl font-bold">
Collaboration space
</h3>
</div>
<div className="px-14 py-10">
<input
className="shadow-blue-gray-200 mb-4 w-full rounded-md border border-slate-200 bg-[#f8f8f8a1] px-3 py-3 text-sm placeholder-gray-400 shadow-inner"
type="text"
placeholder={"Enter space name (3-20 characters)"}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => handleKeydown(e.key)}
/>
<button
// disabled={loading}
onClick={handleSubmit}
className={`
${
loading
? "border-gray-300 bg-gray-200"
: `${
isSendSuccess
? " border-blue-500 bg-blue-500 hover:text-blue-500"
: "border-black bg-black hover:text-black"
} hover:bg-gray-100`
}
h-10 w-full rounded-md border px-2 py-1 text-sm text-slate-100 transition-all `}
>
{loading ? (
<LoadingDots color="gray" />
) : (
<div className="flex items-center justify-center">
{isSendSuccess && !loading ? "Success!" : "Create"}
</div>
)}
</button>
<p className="mt-6 text-start text-sm text-gray-600">
You are creating a collaboration space, just enter the space name
and click <strong className="text-cyan-500">Create</strong>.
</p>
<p className="text-start text-sm text-gray-600">
Once created successfully, it will automatically jump to your
collaboration space and generate an{" "}
<strong className="text-cyan-500">invitation link</strong>, which
allows you to invite your team to join the collaboration.
</p>
<p className="text-start text-sm text-gray-600">
{" "}
See more about{" "}
<Link
className="text-cyan-500 after:content-['_↗'] hover:opacity-80"
href={`/collaboration`}
target="_blank"
>
collaboration
</Link>{" "}
space.
</p>
</div>
</div>
</Modal>
);
};
export function useCreatRoomModal(
session: Session | null,
initTitle: string,
localId: string,
) {
const [showRoomModal, setShowRoomModal] = useState(false);
const RoomModalCallback = useCallback(() => {
return (
<CreatRoomModal
initTitle={initTitle}
localId={localId}
session={session}
showEditModal={showRoomModal}
setShowEditModal={setShowRoomModal}
/>
);
}, [showRoomModal, setShowRoomModal]);
return useMemo(
() => ({ setShowRoomModal, RoomModal: RoomModalCallback }),
[setShowRoomModal, RoomModalCallback],
);
}

View File

@@ -0,0 +1,115 @@
import Modal from "@/ui/shared/modal";
import {
useState,
Dispatch,
SetStateAction,
useCallback,
useMemo,
} from "react";
import { LoadingDots } from "@/ui/shared/icons";
import { fetcher } from "@/lib/utils";
import { Session } from "next-auth";
import { useUserInfoByEmail } from "@/app/post/[id]/request";
const EditNicknameModal = ({
session,
showEditModal,
setShowEditModal,
}: {
session: Session | null;
showEditModal: boolean;
setShowEditModal: Dispatch<SetStateAction<boolean>>;
}) => {
const { user } = useUserInfoByEmail(session?.user?.email || "");
const [nickname, setNickname] = useState("");
const [loading, setLoading] = useState(false);
const [isSendSuccess, setIsSendSuccess] = useState(false);
const handleSubmit = async () => {
if (!session?.user || !user || !nickname) return;
if (nickname.length < 3 || nickname.length > 20) return;
setLoading(true);
const res = await fetcher("/api/users", {
method: "POST",
body: JSON.stringify({
userId: user.id,
userName: nickname,
}),
});
if (res) {
setLoading(false);
setIsSendSuccess(true);
setShowEditModal(false);
}
};
const handleKeydown = (key: string) => {
if (key === "Enter") {
handleSubmit();
}
};
return (
<Modal showModal={showEditModal} setShowModal={setShowEditModal}>
<div className="w-full overflow-hidden bg-gray-50 shadow-xl md:max-w-md md:rounded-2xl md:border md:border-gray-200">
<div className="flex flex-col items-center justify-center space-y-3 border-b border-gray-200 bg-white px-4 py-6 pt-8 text-center md:px-16">
<h3 className="font-display text-2xl font-bold">Edit nickname</h3>
{/* <p className="text-sm text-gray-500"></p> */}
</div>
<div className="px-14 py-10">
<input
className="shadow-blue-gray-200 mb-4 w-full rounded-md border border-slate-200 bg-[#f8f8f8a1] px-3 py-3 text-sm placeholder-gray-400 shadow-inner"
type="text"
placeholder={user?.name || "Enter nickname (3-20 characters)"}
onChange={(e) => setNickname(e.target.value)}
onKeyDown={(e) => handleKeydown(e.key)}
/>
<button
// disabled={loading}
onClick={handleSubmit}
className={`
${
loading
? "border-gray-300 bg-gray-200"
: `${
isSendSuccess
? " border-blue-500 bg-blue-500 hover:text-blue-500"
: "border-black bg-black hover:text-black"
} hover:bg-gray-100`
}
h-10 w-full rounded-md border px-2 py-1 text-sm text-slate-100 transition-all `}
>
{loading ? (
<LoadingDots color="gray" />
) : (
<div className="flex items-center justify-center">
{isSendSuccess && !loading ? "Success!" : "Submit"}
</div>
)}
</button>
</div>
</div>
</Modal>
);
};
export function useEditNicknameModal(session: Session | null) {
const [showEditModal, setShowEditModal] = useState(false);
const EditModalCallback = useCallback(() => {
return (
<EditNicknameModal
session={session}
showEditModal={showEditModal}
setShowEditModal={setShowEditModal}
/>
);
}, [showEditModal, setShowEditModal]);
return useMemo(
() => ({ setShowEditModal, EditModal: EditModalCallback }),
[setShowEditModal, EditModalCallback],
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import toast, { Toaster } from "react-hot-toast";
import { isEmail } from "@/lib/utils";
import { Github, Google, LoadingDots } from "../shared/icons";
import { usePathname } from "next/navigation";
export default function EmailButton() {
const pathname = usePathname();
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [isSendSuccess, setIsSendSuccess] = useState(false);
const [signInGithubClicked, setSignInGithubClicked] = useState(false);
const [signInGoogleClicked, setSignInGoogleClicked] = useState(false);
const handleSubmit = async () => {
if (email === "") {
toast("Empty email", {
icon: "😅",
});
return;
}
if (!isEmail(email)) {
toast("Invalid email format", {
icon: "😅",
});
return;
}
setIsSendSuccess(false);
setLoading(true);
const sign_req = await signIn("email", {
email: email,
callbackUrl: pathname,
redirect: false,
});
setTimeout(() => {
setLoading(false);
}, 20000);
if (sign_req?.ok) {
setLoading(false);
setIsSendSuccess(true);
} else if (sign_req?.error) {
toast("Sending failed, please try again", {
icon: "😅",
});
setLoading(false);
setIsSendSuccess(false);
}
};
const handleKeydown = (key: string) => {
if (key === "Enter") {
handleSubmit();
}
};
return (
<>
<Toaster />
<input
className="mb-4 rounded-md border border-slate-200 px-3 py-3 shadow-inner"
type="text"
placeholder="Enter your email"
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => handleKeydown(e.key)}
/>
<button
disabled={loading}
onClick={handleSubmit}
className={`
${
loading
? "border-gray-300 bg-gray-200"
: ` ${
isSendSuccess
? "border-blue-500 bg-blue-500 hover:text-blue-500"
: "border-black bg-black hover:text-black"
} hover:bg-gray-100`
}
h-10 w-full rounded-md border px-2 py-1 text-sm text-slate-100 transition-all `}
>
{loading ? (
<LoadingDots color="gray" />
) : (
<span className="font-medium">
{isSendSuccess && !loading
? "Successfully sent! please check email"
: "Sign in with email"}
</span>
)}
</button>
<div className="my-3 flex items-center justify-center">
<span className="w-full border border-b-0"></span>
<span className="px-3 text-gray-400">or</span>
<span className="w-full border border-b-0"></span>
</div>
<button
disabled={signInGithubClicked}
className={`${
signInGithubClicked
? "cursor-not-allowed bg-gray-100"
: "border text-black hover:bg-gray-50"
} nice-border flex h-10 w-full items-center justify-center space-x-3 rounded-md border text-sm shadow transition-all duration-75 hover:border-gray-800 focus:outline-none`}
onClick={() => {
setSignInGithubClicked(true);
signIn("github", { callbackUrl: pathname, redirect: false });
}}
>
{signInGithubClicked ? (
<LoadingDots color="#808080" />
) : (
<>
<Github className="h-5 w-5" />
<p>Sign in with Github</p>
</>
)}
</button>
<button
disabled={signInGoogleClicked}
className={`${
signInGoogleClicked
? "cursor-not-allowed bg-gray-100"
: "border text-black hover:bg-gray-50"
} nice-border flex h-10 w-full items-center justify-center space-x-3 rounded-md border text-sm shadow transition-all duration-75 hover:border-gray-800 focus:outline-none`}
onClick={() => {
setSignInGoogleClicked(true);
signIn("google", { callbackUrl: pathname, redirect: false });
}}
>
{signInGoogleClicked ? (
<LoadingDots color="#808080" />
) : (
<>
<Google className="h-5 w-5" />
<p>Sign in with Google</p>
</>
)}
</button>
</>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import Link from "next/link";
import Logo from "../shared/icons/logo";
export default function FooterPublish() {
return (
<div className=" h-32 w-full bg-white py-5 text-center ">
<Link
href="/"
className="font-display flex flex-col items-center justify-center text-xl"
>
<Logo className="h-8 w-8" />
</Link>
<div className="mt-2 flex items-center justify-center gap-1 text-sm">
<span>Powered by</span>
<Link href="/" className="font-bold">
<span className="bg-gradient-to-r from-cyan-500 via-cyan-600 to-cyan-800 bg-clip-text text-transparent ">
INKE & AI Notebook
</span>
</Link>
</div>
{/* <p>Thanks A Note</p> */}
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import Link from "next/link";
import { Github, Mail, MessageSquare, Youtube } from "lucide-react";
import ProductHunt from "../shared/icons/product-hunt";
import pkg from "../../package.json";
export default function Footer() {
return (
<div className="mt-6 flex h-full min-h-[256px] w-screen flex-col items-start justify-between gap-4 bg-slate-800 px-5 py-8 sm:flex-row sm:px-16">
<div className="flex flex-col items-start justify-start">
<Link href="/" className="mb-4 flex items-end text-3xl font-bold">
<span className="bg-gradient-to-r from-cyan-500 via-cyan-600 to-cyan-800 bg-clip-text text-transparent ">
INKE
</span>
<span className="text-slate-300">.AI</span>
</Link>
<p className="flex items-center gap-1 font-mono text-slate-200">
AI notebook, empowering writing.
</p>
<div className="my-4 flex items-start justify-center gap-1 text-sm text-slate-400">
<span className="text-slate-200">© 2023</span>
<Link href="/" className="font-bold">
<span className="bg-gradient-to-r from-slate-400 via-slate-500 to-slate-600 bg-clip-text text-transparent ">
INK
</span>
<span className="text-slate-200">E</span>.
</Link>
All rights reserved. - Inke.app
</div>
<Link
className="mb-6 mt-2"
href="https://www.producthunt.com/products/inke/launches"
target="_blank"
>
<ProductHunt />
</Link>
</div>
<div className="flex flex-col gap-20 text-slate-200 sm:flex-row">
<div className="flex flex-col items-start">
<p className="mb-4 font-bold">Information</p>
<Link
className="mb-2 font-mono text-sm text-slate-200 hover:text-slate-400"
href="/pricing"
>
Pricing
</Link>
<Link
className="mb-2 font-mono text-sm text-slate-200 hover:text-slate-400"
href="/document"
>
Document
</Link>
<Link
className="mb-2 font-mono text-sm text-slate-200 hover:text-slate-400"
href="/collaboration"
>
Collaboration
</Link>
<Link
className="mb-2 font-mono text-sm text-slate-200 hover:text-slate-400"
href="/shortcuts"
>
Shortcuts Reference
</Link>
<Link
className="mb-2 font-mono text-sm text-slate-200 hover:text-slate-400"
href="/privacy"
>
Privacy Policy
</Link>
<Link
className="flex items-center gap-1 font-mono text-sm text-slate-200 hover:text-slate-400"
href="mailto:team@inke.app"
>
Contact Mail
</Link>
</div>
<div className="flex flex-col items-start">
<p className="mb-4 font-bold">Community</p>
<Link
className="mb-2 flex items-center gap-1 font-mono text-sm text-slate-200 hover:text-slate-400"
href="/feedback"
target="_blank"
>
<MessageSquare className="h-4 w-4" /> Feedback
</Link>
<Link
className="mb-2 flex items-center gap-1 font-mono text-sm text-slate-200 hover:text-slate-400"
href="https://www.youtube.com/watch?v=Te3Piqtv1NQ"
target="_blank"
>
<Youtube className="h-4 w-4" /> Youtube
</Link>
<Link
className="mb-2 flex items-center justify-center gap-1 bg-gradient-to-r from-indigo-400 via-purple-400 to-purple-500 bg-clip-text text-sm font-semibold text-transparent hover:text-slate-400 "
href="https://github.com/yesmore/inke"
target="_blank"
>
<Github className="h-4 w-4 text-slate-100" />
Open Source
</Link>
<span className="text-sm font-semibold text-slate-300">
v{pkg.version}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import Navbar from "./navbar";
import { getServerSession } from "next-auth/next";
import { authOptions } from "app/api/auth/[...nextauth]/route";
export default async function Nav() {
const session = await getServerSession(authOptions);
return <Navbar session={session} />;
}

View File

@@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import useScroll from "@/lib/hooks/use-scroll";
import { useSignInModal } from "./sign-in-modal";
import UserDropdown from "./user-dropdown";
import { Session } from "next-auth";
import { Suspense } from "react";
import { useEditNicknameModal } from "./edit-nickname-modal";
import Logo from "../shared/icons/logo";
export default function NavBar({ session }: { session: Session | null }) {
const { SignInModal, setShowSignInModal } = useSignInModal();
const { EditModal, setShowEditModal } = useEditNicknameModal(session);
const scrolled = useScroll(50);
return (
<>
<SignInModal />
<EditModal />
<div
className={`fixed top-0 flex w-full justify-center ${
scrolled
? "bg-white/50 border-b border-gray-200 backdrop-blur-xl"
: "bg-white/0"
} z-[9999] transition-all`}
>
<div className="mx-5 flex h-16 w-full max-w-screen-xl items-center justify-between">
<div className="flex gap-2">
<Link
href="/"
className="font-display flex flex-col items-center justify-center text-xl"
>
<Logo className="h-5 w-5" />
</Link>
<Link href="/" className="font-bold">
<span className="bg-gradient-to-r from-cyan-500 via-cyan-600 to-cyan-800 bg-clip-text text-transparent ">
INKE
</span>
<span>.AI</span>
</Link>
</div>
<Suspense>
<div className="flex items-center justify-center gap-3 md:gap-5">
{/*<Link
href="https://www.producthunt.com/products/inke?utm_source=badge-follow&utm_medium=badge&utm_souce=badge-inke"
target="_blank"
>
<Image
src="/follow.svg"
alt="Inke | Product Hunt"
width="86"
height="32"
/>
</Link> */}
<Link
className="text-slate-600 transition-all hover:text-slate-300"
href={"/document"}
>
Docs
</Link>
<Link
className="text-slate-600 transition-all hover:text-slate-300"
href={"/pricing"}
>
Pricing
</Link>
{session ? (
<UserDropdown
session={session}
setShowEditModal={setShowEditModal}
/>
) : (
<button
className="bg-gradient-to-r from-slate-400 via-slate-600 to-slate-800 bg-clip-text py-1.5 font-semibold text-transparent transition-all hover:text-slate-300"
onClick={() => setShowSignInModal(true)}
>
Sign in
</button>
)}
</div>
</Suspense>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import Image from "next/image";
import Link from "next/link";
export default function UINotFound() {
return (
<>
<div className="z-10 mx-auto mt-24 flex w-full max-w-xl flex-col items-center justify-center px-5">
<Image
src="/cat.png"
alt="404"
width="250"
height="250"
className="ml-4 rounded-sm"
priority
/>
<Link
href="/"
className="mt-24 rounded-md border px-4 py-2 text-sm hover:border-gray-800"
>
Oops, Cat Not Found!
</Link>
</div>
</>
);
}
export function NoteNotFound() {
return (
<>
<div className="z-10 mx-auto mt-24 flex w-full max-w-xl flex-col items-center justify-center px-5">
<Image
src="/cat.png"
alt="404"
width="250"
height="250"
className="ml-4 rounded-sm"
priority
/>
<Link
href="/"
className="mt-24 rounded-md border px-4 py-2 text-sm hover:border-gray-800"
>
Oops, Cat Not Found!
</Link>
</div>
</>
);
}

View File

@@ -0,0 +1,60 @@
import Modal from "@/ui/shared/modal";
import {
useState,
Dispatch,
SetStateAction,
useCallback,
useMemo,
} from "react";
import EmailButton from "./email-login-button";
import Link from "next/link";
const SignInModal = ({
showSignInModal,
setShowSignInModal,
}: {
showSignInModal: boolean;
setShowSignInModal: Dispatch<SetStateAction<boolean>>;
}) => {
return (
<Modal showModal={showSignInModal} setShowModal={setShowSignInModal}>
<div className="w-full overflow-hidden shadow-xl md:max-w-md md:rounded-2xl md:border md:border-gray-200">
<div className="flex flex-col items-center justify-center space-y-3 border-b border-gray-200 bg-white px-4 py-5 pt-8 text-center md:px-16">
<h3 className="font-display text-2xl font-bold">Inke</h3>
<p className="text-sm text-gray-500">
Please enter your email, sign in to unlock more features.{" "}
<Link
className="text-blue-500 after:content-['_↗'] hover:text-blue-300"
href="/pricing"
>
Learn more
</Link>{" "}
about our plan.
</p>
</div>
<div className="mx-auto flex flex-col space-y-4 bg-gray-50 px-6 pb-8 pt-2 md:px-16">
<EmailButton />
</div>
</div>
</Modal>
);
};
export function useSignInModal() {
const [showSignInModal, setShowSignInModal] = useState(false);
const SignInModalCallback = useCallback(() => {
return (
<SignInModal
showSignInModal={showSignInModal}
setShowSignInModal={setShowSignInModal}
/>
);
}, [showSignInModal, setShowSignInModal]);
return useMemo(
() => ({ setShowSignInModal, SignInModal: SignInModalCallback }),
[setShowSignInModal, SignInModalCallback],
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { Dispatch, SetStateAction, useState } from "react";
import { signOut } from "next-auth/react";
import { LogOut, UserCog, ShieldCheck, Settings, Mail } from "lucide-react";
import Popover from "@/ui/shared/popover";
import Image from "next/image";
import { Session } from "next-auth";
import { generateName, greeting } from "@/lib/utils";
import { useUserInfoByEmail } from "@/app/post/[id]/request";
import { useRouter } from "next/navigation";
export default function UserDropdown({
className,
session,
setShowEditModal,
}: {
className?: string;
session: Session;
setShowEditModal: Dispatch<SetStateAction<boolean>>;
}) {
const router = useRouter();
const { email } = session?.user || {};
const { user } = useUserInfoByEmail(session?.user?.email || "");
const [openPopover, setOpenPopover] = useState(false);
if (!email) return null;
return (
<div className={className + " relative inline-block text-left"}>
<Popover
content={
<div className="w-full rounded-md bg-white p-2 sm:w-[270px]">
{user && (
<button className="relative flex w-full items-center justify-start space-x-2 rounded-md p-2 text-left text-sm transition-all duration-75 hover:bg-gray-100">
<span className="truncate font-semibold text-slate-700">
{greeting()}, {user?.name || `${generateName(user.id || "")}`}
</span>
</button>
)}
<button className="relative flex w-full items-center justify-start space-x-2 rounded-md p-2 text-left text-sm transition-all duration-75 hover:bg-gray-100">
<Mail className="h-4 w-4" />
<span className="truncate text-sm">{email}</span>
</button>
<hr className="my-2" />
<button
className="relative flex w-full items-center justify-start space-x-2 rounded-md p-2 text-left text-sm transition-all duration-75 hover:bg-gray-100"
onClick={() => {
router.push("/pricing");
}}
>
<ShieldCheck
className={
user?.plan !== "0"
? "h-4 w-4 text-blue-500"
: "h-4 w-4 text-yellow-500"
}
/>
<span
className={
"text-sm " + (user?.plan !== "0" ? "" : "text-yellow-500")
}
>
{user && (
<span className="text-sm">
{user?.plan !== "0" ? "Actived plan" : "Upgrade to Pro"}
</span>
)}
</span>
</button>
<button
className="relative flex w-full items-center justify-start space-x-2 rounded-md p-2 text-left text-sm transition-all duration-75 hover:bg-gray-100"
onClick={() => {
setShowEditModal(true);
setOpenPopover(false);
}}
>
<UserCog className="h-4 w-4" />
<span className="text-sm">Edit nickname</span>
</button>
{/* <button
className="relative flex w-full items-center justify-start space-x-2 rounded-md p-2 text-left text-sm transition-all duration-75 hover:bg-gray-100"
onClick={() => {
router.push("/settings");
}}
>
<Settings className="h-4 w-4" />
<span className="text-sm">Preferences</span>
</button> */}
<hr className="my-2" />
<button
className="relative flex w-full items-center justify-start space-x-2 rounded-md p-2 text-left text-sm transition-all duration-75 hover:bg-gray-100"
onClick={() => signOut()}
>
<LogOut className="h-4 w-4" />
<span className="text-sm">Sign out</span>
</button>
</div>
}
align="end"
openPopover={openPopover}
setOpenPopover={setOpenPopover}
>
<button
onClick={() => setOpenPopover(!openPopover)}
className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full border border-yellow-50 transition-all duration-75 focus:outline-none active:scale-95 sm:h-9 sm:w-9"
>
<Image
alt={email}
src={user && user.image ? user.image : "/cat.png"}
width={40}
height={40}
/>
</button>
</Popover>
</div>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { AppContext } from "@/app/providers";
import {
Popover,
PopoverTrigger,
@@ -8,23 +9,34 @@ import {
// import { useContext } from "react";
// import { AppContext } from "../app/providers";
// import { FontDefault, FontSerif, FontMono } from "@/ui/icons";
import { Check, Menu as MenuIcon, Monitor, Moon, SunDim } from "lucide-react";
import {
Check,
FileJson,
FileText,
Menu as MenuIcon,
Monitor,
Moon,
SunDim,
} from "lucide-react";
import { useTheme } from "next-themes";
import { useContext, useState } from "react";
import { FontDefault, FontMono, FontSerif } from "./shared/icons";
import ImageDown from "./shared/icons/image-down";
// const fonts = [
// {
// font: "Default",
// icon: <FontDefault className="h-4 w-4" />,
// },
// {
// font: "Serif",
// icon: <FontSerif className="h-4 w-4" />,
// },
// {
// font: "Mono",
// icon: <FontMono className="h-4 w-4" />,
// },
// ];
const fonts = [
{
font: "Default",
icon: <FontDefault className="h-4 w-4" />,
},
{
font: "Serif",
icon: <FontSerif className="h-4 w-4" />,
},
{
font: "Mono",
icon: <FontMono className="h-4 w-4" />,
},
];
const appearances = [
{
theme: "System",
@@ -40,38 +52,63 @@ const appearances = [
},
];
export default function Menu() {
export default function Menu({
onExportImage,
onExportJson,
onExportTxT,
onExportPDF,
}: {
onExportImage: () => void;
onExportJson: () => void;
onExportTxT: () => void;
onExportPDF: () => void;
}) {
// const { font: currentFont, setFont } = useContext(AppContext);
const { theme: currentTheme, setTheme } = useTheme();
return (
<Popover>
<PopoverTrigger className="absolute bottom-5 right-5 z-10 flex h-8 w-8 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-stone-100 active:bg-stone-200 sm:bottom-auto sm:top-5">
<PopoverTrigger className="z-10 flex h-8 w-8 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-stone-100 active:bg-stone-200">
<MenuIcon className="text-stone-600" width={16} />
</PopoverTrigger>
<PopoverContent className="w-52 divide-y divide-stone-200" align="end">
{/* <div className="p-2">
<p className="p-2 text-xs font-medium text-stone-500">Font</p>
{fonts.map(({ font, icon }) => (
<button
key={font}
className="flex w-full items-center justify-between rounded px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
onClick={() => {
setFont(font);
}}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-stone-200 p-1">
{icon}
</div>
<span>{font}</span>
</div>
{currentFont === font && <Check className="h-4 w-4" />}
</button>
))}
</div> */}
<div className="p-2">
<p className="p-2 text-xs font-medium text-stone-500">Appearance</p>
<p className="p-2 text-xs font-medium text-stone-500">Exports file</p>
<button
className="flex w-full items-center gap-3 rounded px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
onClick={() => {
onExportImage();
}}
>
<ImageDown className="h-6 w-6 rounded-sm border border-stone-100 p-1" />
<span>Export as Image</span>
</button>
<button
className="flex w-full items-center gap-3 rounded px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
onClick={onExportTxT}
>
<FileText className="h-6 w-6 rounded-sm border border-stone-100 p-1" />
<span>Export as Markdown</span>
</button>
<button
className="flex w-full items-center gap-3 rounded px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
onClick={onExportJson}
>
<FileJson className="h-6 w-6 rounded-sm border border-stone-100 p-1" />
<span>Export as JSON</span>
</button>
<button
className="flex w-full items-center gap-3 rounded px-2 py-1 text-sm text-stone-600 hover:bg-stone-100"
onClick={onExportPDF}
>
<FileJson className="h-6 w-6 rounded-sm border border-stone-100 p-1" />
<span>Export as PDF</span>
</button>
</div>
<div className="p-2">
<div className="p-2 text-xs font-medium text-stone-500">Themes</div>
{appearances.map(({ theme, icon }) => (
<button
key={theme}
@@ -81,7 +118,7 @@ export default function Menu() {
}}
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border border-stone-200 p-1">
<div className="rounded-sm border border-stone-100 p-1">
{icon}
</div>
<span>{theme}</span>

View File

@@ -0,0 +1,91 @@
"use client";
import { v4 as uuidv4 } from "uuid";
import { defaultEditorContent } from "@/lib/consts";
import { useRouter } from "next/navigation";
import { ContentItem } from "@/lib/types/note";
import { useState } from "react";
import { LoadingDots } from "./shared/icons";
import { JSONContent } from "@tiptap/react";
import { Plus } from "lucide-react";
import { addNote, noteTable } from "@/store/db.model";
import { useLiveQuery } from "dexie-react-hooks";
export default function NewPostButton({
className,
text,
from = "home",
defaultContent = defaultEditorContent,
isShowIcon = false,
callback,
}: {
className?: string;
text: string;
from?: "home" | "post" | "publish";
defaultContent?: JSONContent;
isShowIcon?: boolean;
callback?: () => void;
}) {
const router = useRouter();
const [clickNew, setClickNew] = useState(false);
const contents = useLiveQuery<ContentItem[]>(() => noteTable.toArray());
const handleClick = () => {
if (from === "post" || contents.length === 0) {
handleNewNote();
} else if (from === "home" && contents.length > 0) {
handleHistoryNote();
} else if (from === "publish") {
handleNewNote();
callback();
}
};
const handleHistoryNote = () => {
setClickNew(true);
router.push(
`/post/${contents.sort((a, b) => b.updated_at - a.updated_at)[0].id}`,
);
};
const handleNewNote = () => {
setClickNew(true);
const id = uuidv4();
const date = new Date();
const newItem: ContentItem = {
id,
title: `Untitled-${id.slice(0, 6)}-${
date.getMonth() + 1
}/${date.getDate()}`,
content: defaultContent,
tag: "",
created_at: date.getTime(),
updated_at: date.getTime(),
};
addNote(newItem);
router.push(`/post/${newItem.id}`);
};
return (
<>
<button
className={
className +
" flex items-center justify-center gap-1 rounded-md bg-cyan-500 px-3 text-center text-sm text-slate-100 transition-all hover:opacity-80"
}
onClick={handleClick}
disabled={clickNew}
>
{clickNew ? (
<LoadingDots color="#fff" />
) : (
<>
{isShowIcon && (
<Plus className="inline h-5 w-5 scale-95 text-slate-50" />
)}
{text}
</>
)}
</button>
</>
);
}

View File

@@ -0,0 +1,20 @@
import "@/styles/search-btn.css";
export default function SearchInput({
onChange,
}: {
onChange: (value: string) => void;
}) {
return (
<label className="search-label">
<input
type="text"
name="text"
className="input"
placeholder="Search..."
onChange={(e) => onChange(e.target.value)}
/>
<kbd className="search-button"></kbd>
</label>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useEffect, useState } from "react";
export default function CountingNumbers({
value,
className,
start = 0,
duration = 800,
}: {
value: number;
className: string;
start?: number;
duration?: number;
}) {
const [count, setCount] = useState(start);
useEffect(() => {
let startTime: number | undefined;
const animateCount = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const timePassed = timestamp - startTime;
const progress = timePassed / duration;
const currentCount = easeOutQuad(progress, 0, value, 1);
if (currentCount >= value) {
setCount(value);
return;
}
setCount(currentCount);
requestAnimationFrame(animateCount);
};
requestAnimationFrame(animateCount);
}, [value, duration]);
return <p className={className}>{Intl.NumberFormat().format(count)}</p>;
}
const easeOutQuad = (t: number, b: number, c: number, d: number) => {
t = t > d ? d : t / d;
return Math.round(-c * t * (t - 2) + b);
};

View File

@@ -0,0 +1,18 @@
export default function Box({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
export default function Checked() {
return (
<svg
className="text-2xs mr-2 rounded-full bg-green-500 p-1 text-white"
fill="none"
width="19"
height="19"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}

View File

@@ -0,0 +1,20 @@
export default function Color({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
</svg>
);
}

View File

@@ -0,0 +1,36 @@
export default function ExpandingArrow({ className }: { className?: string }) {
return (
<div className="group relative flex items-center">
<svg
className={`${
className ? className : "h-4 w-4"
} absolute transition-all group-hover:translate-x-1 group-hover:opacity-0`}
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 16 16"
width="16"
height="16"
>
<path
fillRule="evenodd"
d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"
></path>
</svg>
<svg
className={`${
className ? className : "h-4 w-4"
} absolute opacity-0 transition-all group-hover:translate-x-1 group-hover:opacity-100`}
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 16 16"
width="16"
height="16"
>
<path
fillRule="evenodd"
d="M8.22 2.97a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06l2.97-2.97H3.75a.75.75 0 010-1.5h7.44L8.22 4.03a.75.75 0 010-1.06z"
></path>
</svg>
</div>
);
}

View File

@@ -0,0 +1,47 @@
export default function Google({ className }: { className: string }) {
return (
<svg viewBox="0 0 100 100" className={className}>
<linearGradient
id="b"
x1="55.41"
x2="12.11"
y1="96.87"
y2="21.87"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#1e8e3e" />
<stop offset="1" stopColor="#34a853" />
</linearGradient>
<linearGradient
id="c"
x1="42.7"
x2="86"
y1="100"
y2="25.13"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#fcc934" />
<stop offset="1" stopColor="#fbbc04" />
</linearGradient>
<linearGradient
id="a"
x1="6.7"
x2="93.29"
y1="31.25"
y2="31.25"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#d93025" />
<stop offset="1" stopColor="#ea4335" />
</linearGradient>
<path fill="url(#a)" d="M93.29 25a50 50 90 0 0-86.6 0l3 54z" />
<path fill="url(#b)" d="M28.35 62.5 6.7 25A50 50 90 0 0 50 100l49-50z" />
<path fill="url(#c)" d="M71.65 62.5 50 100a50 50 90 0 0 43.29-75H50z" />
<path fill="#fff" d="M50 75a25 25 90 1 0 0-50 25 25 90 0 0 0 50z" />
<path
fill="#1a73e8"
d="M50 69.8a19.8 19.8 90 1 0 0-39.6 19.8 19.8 90 0 0 0 39.6z"
/>{" "}
</svg>
);
}

View File

@@ -0,0 +1,22 @@
export default function ImageDown({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="9" cy="9" r="2" />
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10.8" />
<path d="m21 15-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
<path d="m14 19.5 3 3v-6" />
<path d="m17 22.5 3-3" />
</svg>
);
}

View File

@@ -0,0 +1,12 @@
export { default as LoadingDots } from "./loading-dots";
export { default as LoadingCircle } from "./loading-circle";
export { default as LoadingSpinner } from "./loading-spinner";
export { default as ExpandingArrow } from "./expanding-arrow";
export { default as Github } from "./github";
export { default as Twitter } from "./twitter";
export { default as Google } from "./google";
export { default as Widgets } from "./widgets";
export { default as Color } from "./color";
export { default as FontDefault } from "./font-default";
export { default as FontSerif } from "./font-serif";
export { default as FontMono } from "./font-mono";

View File

@@ -0,0 +1,22 @@
export default function LoadingCircle({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={
className + " h-4 w-4 animate-spin fill-gray-600 text-gray-200"
}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
);
}

View File

@@ -0,0 +1,40 @@
.loading {
display: inline-flex;
align-items: center;
}
.loading .spacer {
margin-right: 2px;
}
.loading span {
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
width: 5px;
height: 5px;
border-radius: 50%;
display: inline-block;
margin: 0 1px;
}
.loading span:nth-of-type(2) {
animation-delay: 0.2s;
}
.loading span:nth-of-type(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}

View File

@@ -0,0 +1,13 @@
import styles from "./loading-dots.module.css";
const LoadingDots = ({ color = "#000" }: { color?: string }) => {
return (
<span className={styles.loading}>
<span style={{ backgroundColor: color }} />
<span style={{ backgroundColor: color }} />
<span style={{ backgroundColor: color }} />
</span>
);
};
export default LoadingDots;

View File

@@ -0,0 +1,79 @@
.spinner {
color: gray;
display: inline-block;
position: relative;
width: 80px;
height: 80px;
transform: scale(0.3) translateX(-95px);
}
.spinner div {
transform-origin: 40px 40px;
animation: spinner 1.2s linear infinite;
}
.spinner div:after {
content: " ";
display: block;
position: absolute;
top: 3px;
left: 37px;
width: 6px;
height: 20px;
border-radius: 20%;
background: black;
}
.spinner div:nth-child(1) {
transform: rotate(0deg);
animation-delay: -1.1s;
}
.spinner div:nth-child(2) {
transform: rotate(30deg);
animation-delay: -1s;
}
.spinner div:nth-child(3) {
transform: rotate(60deg);
animation-delay: -0.9s;
}
.spinner div:nth-child(4) {
transform: rotate(90deg);
animation-delay: -0.8s;
}
.spinner div:nth-child(5) {
transform: rotate(120deg);
animation-delay: -0.7s;
}
.spinner div:nth-child(6) {
transform: rotate(150deg);
animation-delay: -0.6s;
}
.spinner div:nth-child(7) {
transform: rotate(180deg);
animation-delay: -0.5s;
}
.spinner div:nth-child(8) {
transform: rotate(210deg);
animation-delay: -0.4s;
}
.spinner div:nth-child(9) {
transform: rotate(240deg);
animation-delay: -0.3s;
}
.spinner div:nth-child(10) {
transform: rotate(270deg);
animation-delay: -0.2s;
}
.spinner div:nth-child(11) {
transform: rotate(300deg);
animation-delay: -0.1s;
}
.spinner div:nth-child(12) {
transform: rotate(330deg);
animation-delay: 0s;
}
@keyframes spinner {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,20 @@
import styles from "./loading-spinner.module.css";
export default function LoadingSpinner() {
return (
<div className={styles.spinner}>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export default function Pill({ className }: { className?: string }) {
return (
<svg
className={className}
width="512"
height="512"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M262.97 19.438a221.3 221.3 0 0 0-10.595.375c37.426 5.91 74.12 23.423 102.188 49.624c-55.762-26.124-129.46-27.253-186.875-3.5c10.37-9.73 21.777-17.51 33.875-23.343C48.768 80.06-6.44 197.116 56.72 343.938c-16.45-26.78-29.106-55.588-35.626-84.688c-5.23 74.055 32.02 134.952 102.47 197.406c.06.063.124.126.186.188c12.107 12.125 24.238 22.045 32.875 27.03c64.588 37.292 121.345-63.365 57.78-100.062c-11.465-6.62-33.518-14.218-56.56-18.875c-76.657-36.295-93.91-155.886-20.282-240.687c-6.654 16.82-11.594 34.836-14.844 53.375c76.21-134.99 312.3-129.124 324.124 72.063c-10.722-61.622-53.708-113.837-121.03-135.344c56.69 23.942 96.28 79.752 96.28 145.25c0 94.252-72.826 148.403-154.594 165.625c42.582 2.34 94.684-13.826 125.438-36.314c-23.357 39.58-72.146 67.082-123.25 81.594c72.736-2.804 136.515-41.146 175.406-97.375c-10.316 11.652-22.718 22.04-36.78 30.97c46.54-55.267 70.795-137.97 61.31-210.25c8.428 16.284 13.583 33.51 15.782 51.374C485.26 97.63 372.46 18.3 262.97 19.437z"
/>
</svg>
);
}

View File

@@ -0,0 +1,75 @@
export default function ProductHunt() {
return (
<svg
width="250"
height="54"
viewBox="0 0 250 54"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g transform="translate(-130.000000, -73.000000)">
<g transform="translate(130.000000, 73.000000)">
<rect
stroke="#FF6154"
strokeWidth="1"
fill="#FFFFFF"
x="0.5"
y="0.5"
width="249"
height="53"
rx="10"
></rect>
<text
fontFamily="Helvetica-Bold, Helvetica"
fontSize="9"
fontWeight="bold"
fill="#FF6154"
>
<tspan x="53" y="20">
FEATURED ON
</tspan>
</text>
<text
fontFamily="Helvetica-Bold, Helvetica"
fontSize="21"
fontWeight="bold"
fill="#FF6154"
>
<tspan x="52" y="40">
Product Hunt
</tspan>
</text>
<g transform="translate(201.000000, 13.000000)" fill="#FF6154">
<g>
<polygon points="26.0024997 10 15 10 20.5012498 0"></polygon>
<text
fontFamily="Helvetica-Bold, Helvetica"
fontSize="13"
fontWeight="bold"
line-spacing="20"
>
<tspan x="12.4" y="27">
70
</tspan>
</text>
</g>
</g>
<g transform="translate(11.000000, 12.000000)">
<path
d="M31,15.5 C31,24.0603917 24.0603917,31 15.5,31 C6.93960833,31 0,24.0603917 0,15.5 C0,6.93960833 6.93960833,0 15.5,0 C24.0603917,0 31,6.93960833 31,15.5"
fill="#FF6154"
></path>
<path
d="M17.4329412,15.9558824 L17.4329412,15.9560115 L13.0929412,15.9560115 L13.0929412,11.3060115 L17.4329412,11.3060115 L17.4329412,11.3058824 C18.7018806,11.3058824 19.7305882,12.3468365 19.7305882,13.6308824 C19.7305882,14.9149282 18.7018806,15.9558824 17.4329412,15.9558824 M17.4329412,8.20588235 L17.4329412,8.20601152 L10.0294118,8.20588235 L10.0294118,23.7058824 L13.0929412,23.7058824 L13.0929412,19.0560115 L17.4329412,19.0560115 L17.4329412,19.0558824 C20.3938424,19.0558824 22.7941176,16.6270324 22.7941176,13.6308824 C22.7941176,10.6347324 20.3938424,8.20588235 17.4329412,8.20588235"
fill="#FFFFFF"
></path>
</g>
</g>
</g>
</g>
</svg>
);
}

View File

@@ -0,0 +1,14 @@
export default function Twitter({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 248 204"
className={className}
>
<path
fill="currentColor"
d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
export default function Widgets({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.077.877.528 1.073 1.01a2.5 2.5 0 1 0 3.259-3.259c-.482-.196-.933-.558-1.01-1.073-.05-.336.062-.676.303-.917l1.525-1.525A2.402 2.402 0 0 1 12 1.998c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef, ReactNode, Dispatch, SetStateAction } from "react";
import { AnimatePresence, motion, useAnimation } from "framer-motion";
export default function Leaflet({
setShow,
showBlur,
children,
}: {
setShow: Dispatch<SetStateAction<boolean>>;
showBlur: boolean;
children: ReactNode;
}) {
const leafletRef = useRef<HTMLDivElement>(null);
const controls = useAnimation();
const transitionProps = { type: "spring", stiffness: 500, damping: 30 };
useEffect(() => {
controls.start({
y: 20,
transition: transitionProps,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleDragEnd(_: any, info: any) {
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 {
controls.start({ y: 0, transition: transitionProps });
}
}
return (
<AnimatePresence>
<motion.div
ref={leafletRef}
key="leaflet"
className="group fixed inset-x-0 bottom-0 z-40 w-screen cursor-grab overflow-y-scroll bg-white pb-5 active:cursor-grabbing sm:hidden"
style={{ maxHeight: "65%" }}
initial={{ y: "100%" }}
animate={controls}
exit={{ y: "100%" }}
transition={transitionProps}
drag="y"
dragDirectionLock
onDragEnd={handleDragEnd}
dragElastic={{ top: 0, bottom: 1 }}
dragConstraints={{ top: 0, bottom: 0 }}
>
<div
className={`rounded-t-4xl -mb-1 flex h-7 w-full items-center justify-center border-t border-gray-200`}
>
<div className="-mr-1 h-1 w-6 rounded-full bg-gray-300 transition-all group-active:rotate-12" />
<div className="h-1 w-6 rounded-full bg-gray-300 transition-all group-active:-rotate-12" />
</div>
{children}
</motion.div>
{showBlur && (
<motion.div
key="leaflet-backdrop"
className="fixed inset-0 z-30 bg-gray-100 bg-opacity-10 backdrop-blur"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShow(false)}
/>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,84 @@
"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/use-window-size";
export default function Modal({
children,
showModal,
setShowModal,
showBlur = true,
}: {
children: React.ReactNode;
showModal: boolean;
setShowModal: Dispatch<SetStateAction<boolean>>;
showBlur?: 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();
return (
<AnimatePresence>
{showModal && (
<>
{isMobile && (
<Leaflet setShow={setShowModal} showBlur={showBlur}>
{children}
</Leaflet>
)}
{isDesktop && showBlur && (
<>
<motion.div
ref={desktopModalRef}
key="desktop-modal"
className="fixed inset-0 z-[1000] min-h-screen flex-col 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);
}
}}
>
{children}
</motion.div>
<motion.div
key="desktop-backdrop"
className="fixed inset-0 z-30 bg-gray-100 bg-opacity-10 backdrop-blur"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowModal(false)}
/>
</>
)}
</>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,15 @@
import "@/styles/placeholder.css";
export default function PlaceHolder() {
return (
<div className="placeholder">
<label className="avatar"></label>
<label className="info">
<span className="info-1"></span>
<span className="info-2"></span>
</label>
<div className="content-1"></div>
<div className="content-2"></div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { Dispatch, SetStateAction, ReactNode, useRef } from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import useWindowSize from "@/lib/hooks/use-window-size";
import Leaflet from "./leaflet";
export default function Popover({
children,
content,
align = "center",
openPopover,
setOpenPopover,
}: {
children: ReactNode;
content: ReactNode | string;
align?: "center" | "start" | "end";
openPopover: boolean;
setOpenPopover: Dispatch<SetStateAction<boolean>>;
}) {
const { isMobile, isDesktop } = useWindowSize();
if (!isMobile && !isDesktop) return <>{children}</>;
return (
<>
{isMobile && children}
{openPopover && isMobile && (
<Leaflet setShow={setOpenPopover} showBlur={true}>
{content}
</Leaflet>
)}
{isDesktop && (
<PopoverPrimitive.Root
open={openPopover}
onOpenChange={(isOpen) => setOpenPopover(isOpen)}
>
<PopoverPrimitive.Trigger className="inline-flex" asChild>
{children}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Content
sideOffset={4}
align={align}
className="z-20 animate-slide-up-fade items-center rounded-md border border-gray-200 bg-white drop-shadow-lg"
>
{content}
</PopoverPrimitive.Content>
</PopoverPrimitive.Root>
)}
</>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { ReactNode, useState } from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import useWindowSize from "@/lib/hooks/use-window-size";
import Leaflet from "./leaflet";
export default function Tooltip({
children,
content,
fullWidth,
}: {
children: ReactNode;
content: ReactNode | string;
fullWidth?: boolean;
}) {
const [openTooltip, setOpenTooltip] = useState(false);
const { isMobile, isDesktop } = useWindowSize();
return (
<>
{/* {isMobile && (
<button
type="button"
className={`${fullWidth ? "w-full" : "inline-flex"}`}
onClick={() => setOpenTooltip(true)}
>
{children}
</button>
)} */}
{/* {openTooltip && isMobile && (
<Leaflet setShow={setOpenTooltip} showBlur={true}>
{typeof content === "string" ? (
<span className="flex min-h-[150px] w-full items-center justify-center bg-white px-10 text-center text-sm text-gray-700">
{content}
</span>
) : (
content
)}
</Leaflet>
)} */}
{
<TooltipPrimitive.Provider delayDuration={100}>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger className="" asChild>
{children}
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Content
sideOffset={4}
side="top"
className="animate-slide-up-fade z-30 items-center overflow-hidden rounded-md border border-gray-200 bg-white drop-shadow-lg"
>
<TooltipPrimitive.Arrow className="fill-current text-white" />
{typeof content === "string" ? (
<div className="p-5">
<span className="block max-w-xs text-center text-sm text-gray-700">
{content}
</span>
</div>
) : (
content
)}
<TooltipPrimitive.Arrow className="fill-current text-white" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
}
</>
);
}