release 0.3.4
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
@@ -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";
|
116
apps/web/ui/layout/active-licence-modal.tsx
Normal file
116
apps/web/ui/layout/active-licence-modal.tsx
Normal 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],
|
||||
);
|
||||
}
|
196
apps/web/ui/layout/create-room-modal.tsx
Normal file
196
apps/web/ui/layout/create-room-modal.tsx
Normal 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],
|
||||
);
|
||||
}
|
115
apps/web/ui/layout/edit-nickname-modal.tsx
Normal file
115
apps/web/ui/layout/edit-nickname-modal.tsx
Normal 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],
|
||||
);
|
||||
}
|
146
apps/web/ui/layout/email-login-button.tsx
Normal file
146
apps/web/ui/layout/email-login-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
27
apps/web/ui/layout/footer-publish.tsx
Normal file
27
apps/web/ui/layout/footer-publish.tsx
Normal 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>
|
||||
);
|
||||
}
|
114
apps/web/ui/layout/footer.tsx
Normal file
114
apps/web/ui/layout/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
8
apps/web/ui/layout/nav.tsx
Normal file
8
apps/web/ui/layout/nav.tsx
Normal 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} />;
|
||||
}
|
91
apps/web/ui/layout/navbar.tsx
Normal file
91
apps/web/ui/layout/navbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
50
apps/web/ui/layout/not-found.tsx
Normal file
50
apps/web/ui/layout/not-found.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
60
apps/web/ui/layout/sign-in-modal.tsx
Normal file
60
apps/web/ui/layout/sign-in-modal.tsx
Normal 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],
|
||||
);
|
||||
}
|
124
apps/web/ui/layout/user-dropdown.tsx
Normal file
124
apps/web/ui/layout/user-dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
|
91
apps/web/ui/new-post-button.tsx
Normal file
91
apps/web/ui/new-post-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
20
apps/web/ui/search-input.tsx
Normal file
20
apps/web/ui/search-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
apps/web/ui/shared/counting-numbers.tsx
Normal file
40
apps/web/ui/shared/counting-numbers.tsx
Normal 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);
|
||||
};
|
18
apps/web/ui/shared/icons/box.tsx
Normal file
18
apps/web/ui/shared/icons/box.tsx
Normal 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>
|
||||
);
|
||||
}
|
18
apps/web/ui/shared/icons/checked.tsx
Normal file
18
apps/web/ui/shared/icons/checked.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
apps/web/ui/shared/icons/color.tsx
Normal file
20
apps/web/ui/shared/icons/color.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
apps/web/ui/shared/icons/expanding-arrow.tsx
Normal file
36
apps/web/ui/shared/icons/expanding-arrow.tsx
Normal 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>
|
||||
);
|
||||
}
|
47
apps/web/ui/shared/icons/google.tsx
Normal file
47
apps/web/ui/shared/icons/google.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
apps/web/ui/shared/icons/image-down.tsx
Normal file
22
apps/web/ui/shared/icons/image-down.tsx
Normal 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>
|
||||
);
|
||||
}
|
12
apps/web/ui/shared/icons/index.tsx
Normal file
12
apps/web/ui/shared/icons/index.tsx
Normal 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";
|
22
apps/web/ui/shared/icons/loading-circle.tsx
Normal file
22
apps/web/ui/shared/icons/loading-circle.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
apps/web/ui/shared/icons/loading-dots.module.css
Normal file
40
apps/web/ui/shared/icons/loading-dots.module.css
Normal 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;
|
||||
}
|
||||
}
|
13
apps/web/ui/shared/icons/loading-dots.tsx
Normal file
13
apps/web/ui/shared/icons/loading-dots.tsx
Normal 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;
|
79
apps/web/ui/shared/icons/loading-spinner.module.css
Normal file
79
apps/web/ui/shared/icons/loading-spinner.module.css
Normal 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;
|
||||
}
|
||||
}
|
20
apps/web/ui/shared/icons/loading-spinner.tsx
Normal file
20
apps/web/ui/shared/icons/loading-spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
16
apps/web/ui/shared/icons/logo.tsx
Normal file
16
apps/web/ui/shared/icons/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
75
apps/web/ui/shared/icons/product-hunt.tsx
Normal file
75
apps/web/ui/shared/icons/product-hunt.tsx
Normal 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>
|
||||
);
|
||||
}
|
14
apps/web/ui/shared/icons/twitter.tsx
Normal file
14
apps/web/ui/shared/icons/twitter.tsx
Normal 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>
|
||||
);
|
||||
}
|
16
apps/web/ui/shared/icons/widgets.tsx
Normal file
16
apps/web/ui/shared/icons/widgets.tsx
Normal 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>
|
||||
);
|
||||
}
|
73
apps/web/ui/shared/leaflet.tsx
Normal file
73
apps/web/ui/shared/leaflet.tsx
Normal 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>
|
||||
);
|
||||
}
|
84
apps/web/ui/shared/modal.tsx
Normal file
84
apps/web/ui/shared/modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
15
apps/web/ui/shared/placeholder.tsx
Normal file
15
apps/web/ui/shared/placeholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
50
apps/web/ui/shared/popover.tsx
Normal file
50
apps/web/ui/shared/popover.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
71
apps/web/ui/shared/tooltip.tsx
Normal file
71
apps/web/ui/shared/tooltip.tsx
Normal 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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user