1
0
Fork 0

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

103
README.md
View File

@ -1,29 +1,42 @@
# Inke
<p align="center">
<img width="108" src="https://inke.app/logo-256.png">
</p>
<a href="https://www.npmjs.org/package/inkejs" target='_blank'>
<img src="https://img.shields.io/npm/v/inkejs">
</a>
<a href="https://npmcharts.com/compare/inkejs?minimal=true" target='_blank'>
<img src="https://img.shields.io/npm/dt/inkejs.svg">
</a>
<a href="https://github.com/yesmore/inke/blob/master/LICENSE">
<img src="https://img.shields.io/github/license/yesmore/inke?label=license&logo=github&color=f80&logoColor=fff" alt="License" />
</a>
<a href="https://inke.app">
<img src="https://badgen.net/https/inke.app/api/status" alt="status"/>
</a>
<p align="center"><strong> Inke - Small is beautiful</strong></p>
Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions.
<p align="center">
<a href="https://inke.app">
<img src="https://badgen.net/https/inke.app/api/status" alt="status"/>
</a>
<a href="https://github.com/yesmore/inke/blob/master/LICENSE">
<img src="https://img.shields.io/github/license/yesmore/inke?label=license&logo=github&color=f80&logoColor=fff" alt="License" />
</a>
<a href="https://github.com/yesmore/inke"><img src="https://img.shields.io/github/stars/yesmore/inke?style=social" alt="inke.app's GitHub repo"></a>
</p>
See live demo: [inke-web](https://inke.app)
# About Inke
[Inke](https://inke.app/) is a notebook with AI assisted writing and real-time collaboration.
<img alt="Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions." src="https://inke.app/desktop.png">
# Install Inke
## Features
```bash
npm install inkejs
```
- 😗 WYSIWYG Editing like markdown
- 😄 Efficient Shortcut Inputs
- 😍 AI-powered Text Autocomplete
- 🥰 Local Data Storage
- 🥳 Image uploads(use command or drag)
- 😍 Cloud storage notes
- 😄 Export as json/image/markdown
- 🥰 Install as PWA App to your desktop
## Self Hosting
You can deploy your own version of Inke to Vercel with one click:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-link=https%3A%2F%2Fgithub.com%2Fyesmore%2Finke&env=OPENAI_API_KEY&envDescription=Find%20your%20OpenAI%20API%20Key%20by%20click%20the%20right%20Learn%20More%20button.%20%20&envLink=https%3A%2F%2Fplatform.openai.com%2Faccount%2Fapi-keys&project-name=inke&repository-name=inke)
## Setting Up Locally
@ -42,39 +55,29 @@ pnpm build
pnpm dev
```
Then, you can use it in your code like this:
## Environment Variable
```jsx
import { Editor } from "inkejs";
| Prop | Type | Description | Example |
| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `OPENAI_API_KEY` | `string` | The API Key to use for the OpenAI completion API. | `sk-xxx` |
| `BLOB_READ_WRITE_TOKEN` | `string` | OPTIONAL: Vercel Blob (for uploading images). Get your Vercel Blob credentials [here](https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart) | `vercel_blob_xxxx` |
| `KV_REST_API_URL` | `string` | OPTIONAL: Vercel KV (for ratelimiting). Get your Vercel KV credentials [here](https://vercel.com/docs/storage/vercel-kv/quickstart#quickstart) | [`"https//xxx.com"`](https://github.com/steven-tey/novel/blob/main/packages/core/src/ui/editor/default-content.tsx) |
| `KV_REST_API_TOKEN` | `string` | OPTIONAL: Vercel KV (for ratelimiting). Get your Vercel KV credentials [here](https://vercel.com/docs/storage/vercel-kv/quickstart#quickstart). | `abcdefg` |
| `NEXTAUTH_SECRET` | `string` | Only for production generate one here: [generate-secret](https://generate-secret.vercel.app/32). | `fasgagahhjerherg` |
| `DATABASE_URL` | `string` | Database url, recommend using [MongoDB Atlas](https://account.mongodb.com/account/login?signedOut=true) | `mongodb+srv://xxxx` |
| `EMAIL_FROM` | `string` | Next Auth Provider: [Email](https://next-auth.js.org/providers/email) | `Inke <email@inke.app>` |
| `EMAIL_SERVER` | `string` | Next Auth Provider: [Email](https://next-auth.js.org/providers/email) | `smtps://xxxx` |
| `GITHUB_ID` | `string` | Next Auth Provider: [Github](https://next-auth.js.org/providers/github) | `aaaaaaaa` |
| `GITHUB_SECRET` | `string` | Next Auth Provider: [Github](https://next-auth.js.org/providers/github) | `aaaaaaaa` |
| `GOOGLE_CLIENT_ID` | `string` | Next Auth Provider: [Google](https://next-auth.js.org/providers/google) | `aaaaaaaa` |
| `GOOGLE_CLIENT_SECRET` | `string` | Next Auth Provider: [Google](https://next-auth.js.org/providers/google) | `aaaaaaaa` |
export default function App() {
return <Editor />;
}
# Install Inke
```bash
npm install inkejs
```
The `Editor` is a React component that takes in the following props:
| Prop | Type | Description | Default |
| --------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `completionApi` | `string` | The API route to use for the OpenAI completion API. | `/api/generate` |
| `className` | `string` | Editor container classname. | `"relative min-h-[500px] w-full max-w-screen-lg border-stone-200 bg-white sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"` |
| `defaultValue` | `JSONContent` or `string` | The default value to use for the editor. | [`defaultEditorContent`](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/default-content.tsx) |
| `extensions` | `Extension[]` | A list of extensions to use for the editor, in addition to the [default Novel extensions](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/extensions/index.tsx). | `[]` |
| `editorProps` | `EditorProps` | Props to pass to the underlying Tiptap editor, in addition to the [default Novel editor props](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/props.ts). | `{}` |
| `onUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated. | `() => {}` |
| `onDebouncedUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated, but only after the defined debounce duration. | `() => {}` |
| `debounceDuration` | `number` | The duration (in milliseconds) to debounce the `onDebouncedUpdate` callback. | `750` |
| `storageKey` | `string` | The key to use for storing the editor's value in local storage. | `novel__content` |
| `disableLocalStorage` | `boolean` | Enabling this option will prevent read/write content from/to local storage. | `false` |
> **Note**: Make sure to define an API endpoint that matches the `completionApi` prop (default is `/api/generate`). This is needed for the AI autocompletions to work. Here's an example: https://github.com/yesmore/inke/blob/main/apps/web/app/api/generate/route.ts
## Deploy Your Own
You can deploy your own version of Novel to Vercel with one click:
[![Deploy with Vercel](https://vercel.com/button)]()
## Tech Stack
Inke is built on the following stack:
@ -90,6 +93,10 @@ Inke is built on the following stack:
[![Star History Chart](https://api.star-history.com/svg?repos=yesmore/inke&type=Date)](https://star-history.com/#yesmore/inke&Date)
<a href="https://www.producthunt.com/posts/inke?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-inke">
<img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419235&theme=light" alt="Product Hunt"/>
</a>
## License
[Apache-2.0](./LICENSE) © [yesmore](https://github.com/yesmore)

View File

@ -4,6 +4,7 @@
# Get your OpenAI API key here: https://platform.openai.com/account/api-keys
OPENAI_API_KEY=
OPENAI_API_KEYs=
# OPTIONAL: Vercel Blob (for uploading images)
# Get your Vercel Blob credentials here: https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart
@ -13,3 +14,20 @@ BLOB_READ_WRITE_TOKEN=
# Get your Vercel KV credentials here: https://vercel.com/docs/storage/vercel-kv/quickstart#quickstart
KV_REST_API_URL=
KV_REST_API_TOKEN=
# Only for production generate one here: https://generate-secret.vercel.app/32
NEXTAUTH_SECRET=
## Only required for localhost
NEXTAUTH_URL=http://localhost:3000
DATABASE_URL=
EMAIL_FROM=
EMAIL_SERVER=
GITHUB_ID=
GITHUB_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

1
apps/web/.npmrc Normal file
View File

@ -0,0 +1 @@
enable-pre-post-scripts=true

View File

@ -0,0 +1,106 @@
import NextAuth, { NextAuthOptions, Theme } from "next-auth";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import prisma from "@/lib/db/prisma";
import EmailProvider from "next-auth/providers/email";
import GithubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";
import { createTransport } from "nodemailer";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID as string,
clientSecret: process.env.GITHUB_SECRET as string,
}),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
}),
EmailProvider({
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
maxAge: 10 * 60, // 10min, 邮箱链接失效时间默认24小时
async sendVerificationRequest({
identifier: email,
url,
provider,
theme,
}) {
const { host } = new URL(url);
const transport = createTransport(provider.server);
const result = await transport.sendMail({
to: email,
from: provider.from,
subject: `You are logging in to Inke`,
text: text({ url, host }),
html: html({ url, host, theme }),
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
},
}),
],
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
/**
*使HTML body
*/
function html(params: { url: string; host: string; theme: Theme }) {
const { url, host, theme } = params;
const escapedHost = host.replace(/\./g, "&#8203;.");
const brandColor = theme.brandColor || "#346df1";
const color = {
background: "#f9f9f9",
text: "#444",
mainBackground: "#fff",
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText: theme.buttonText || "#fff",
};
return `
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="10" cellpadding="0"
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
<strong>Welcome to Inke</strong> 🎉
</td>
</tr>
<tr>
<td align="center" style="padding: 5px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
target="_blank"
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
in now</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left"
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
Button click without response? Try open this <a href="${url}" target="_blank">link</a> in your browser. If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
`;
}
/** 不支持HTML 的邮件客户端会显示下面的文本信息 */
function text({ url, host }: { url: string; host: string }) {
return `Welcome to Inke! This is a magic link, click on it to log in ${url}\n`;
}

View File

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { findCollaborationInviteCount } from "@/lib/db/collaboration";
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id || id === "undefined") {
return NextResponse.json({
code: 403,
msg: "Empty roomId",
data: null,
});
}
const res = await findCollaborationInviteCount(id);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Not joined the collaboration space",
data: null,
});
} catch (error) {
return NextResponse.json(error);
}
}
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,45 @@
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "../../auth/[...nextauth]/route";
import { getUserByEmail } from "@/lib/db/user";
import {
findCollaborationByDBId,
findCollaborationInviteCount,
} from "@/lib/db/collaboration";
// /invite/:id 邀请页调用,查询此邀请详细信息,不需要登录,点击“加入协作”后才需要鉴权
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({
code: 403,
msg: "Empty roomId",
data: null,
});
}
const res = await findCollaborationByDBId(id);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Not joined the collaboration space",
data: null,
});
} catch (error) {
return NextResponse.json(error);
}
}
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,65 @@
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "../../auth/[...nextauth]/route";
import { getUserByEmail } from "@/lib/db/user";
import {
findCollaborationByDBId,
findCollaborationBylocalId,
} from "@/lib/db/collaboration";
// /invite/:id 邀请页调用,查询此邀请详细信息,不需要登录,点击“加入协作”后才需要鉴权
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({
code: 401,
msg: "Unauthorized! Please login",
data: null,
});
}
const user = await getUserByEmail(session.user.email);
if (!user) {
return NextResponse.json({
code: 403,
msg: "Something wrong",
data: null,
});
}
const { searchParams } = new URL(req.url);
const id = searchParams.get("localId");
if (!id) {
return NextResponse.json({
code: 403,
msg: "Empty id",
data: null,
});
}
const res = await findCollaborationBylocalId(id, user.id);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Not joined collaboration",
data: null,
});
} catch (error) {
return NextResponse.json(error);
}
}
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,107 @@
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "../../auth/[...nextauth]/route";
import { getUserByEmail } from "@/lib/db/user";
import { findCollaborationByRoomId } from "@/lib/db/collaboration";
// post页面获取详情时调用需要用户id查询是否已经加入加入才开启协作模式
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({
code: 401,
msg: "Unauthorized! Please login first",
data: null,
});
}
const user = await getUserByEmail(session.user.email);
if (!user) {
return NextResponse.json({
code: 403,
msg: "Not found user",
data: null,
});
}
const { searchParams } = new URL(req.url);
const id = searchParams.get("roomId");
if (!id) {
return NextResponse.json({
code: 403,
msg: "Empty roomId",
data: null,
});
}
// 查询用户是否已经加入了这个协作已经创建了这个room记录
const res = await findCollaborationByRoomId(id, user.id);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "You haven't joined this collaboration yet",
data: null,
});
} catch (error) {
return NextResponse.json({
code: 500,
msg: error,
data: null,
});
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const { roomId } = await req.json();
if (!roomId) {
return NextResponse.json({
code: 403,
msg: "Empty roomId",
data: null,
});
}
const res = await findCollaborationByRoomId(roomId);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Not found",
data: null,
});
} catch (error) {
return NextResponse.json({
code: 500,
msg: error,
data: null,
});
}
}
// fix error: "DYNAMIC_SERVER_USAGE"
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,180 @@
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "../auth/[...nextauth]/route";
import { getUserByEmail } from "@/lib/db/user";
import {
createCollaboration,
deleteCollaborationNote,
findCollaborationByRoomId,
findUserCollaborations,
} from "@/lib/db/collaboration";
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({
code: 401,
msg: "Unauthorized! Please login",
data: null,
});
}
const user = await getUserByEmail(session.user.email);
if (!user) {
return NextResponse.json({
code: 403,
msg: "Something wrong",
data: null,
});
}
const res = await findUserCollaborations(user.id);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Not joined collaboration",
data: null,
});
} catch (error) {
return NextResponse.json(error);
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({
code: 401,
msg: "Unauthorized! Please login",
data: null,
});
}
const user = await getUserByEmail(session.user.email);
if (!user) {
return NextResponse.json({
code: 403,
msg: "Something wrong",
data: null,
});
}
const { localId, roomId, title } = await req.json();
if (!localId || !roomId) {
return NextResponse.json({
code: 405,
msg: "Empty params",
data: null,
});
}
// 判断用户是否已经加入此协作
const find_res = await findCollaborationByRoomId(roomId, user.id);
if (find_res) {
return NextResponse.json({
code: 301,
msg: "Joined! Redirecting...",
data: find_res,
});
}
// TODO: 限制新建个数
// 新建
const res = await createCollaboration(user.id, localId, roomId, title);
if (res) {
return NextResponse.json({
code: 200,
msg: "SuccessfullyRedirecting...",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Something wrong",
data: null,
});
} catch (error) {
return NextResponse.json({
code: 500,
msg: error,
data: null,
});
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({
code: 401,
msg: "Unauthorized! Please login",
data: null,
});
}
const user = await getUserByEmail(session.user.email);
if (!user) {
return NextResponse.json({
code: 403,
msg: "Something wrong",
data: null,
});
}
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({
code: 403,
msg: "Empty id",
data: null,
});
}
const res = await deleteCollaborationNote(id);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Something wrong",
data: null,
});
} catch (error) {
return NextResponse.json(error);
}
}
// fix error: "DYNAMIC_SERVER_USAGE"
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,109 @@
import OpenAI from "openai";
import { OpenAIStream, StreamingTextResponse } from "ai";
import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";
import { getRandomElement } from "@/lib/utils";
import { Account_Plans } from "../../../../lib/consts";
const api_key = process.env.OPENAI_API_KEY || "";
const api_keys = process.env.OPENAI_API_KEYs || "";
const openai = new OpenAI({
baseURL: process.env.OPENAI_API_PROXY || "https://api.openai.com",
});
// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
export const runtime = "edge";
export async function POST(req: Request): Promise<Response> {
try {
// Check if the OPENAI_API_KEY is set, if not return 400
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
return new Response(
"Missing OPENAI_API_KEY  make sure to add it to your .env file.",
{
status: 400,
},
);
}
const { prompt, plan, messages, system } = await req.json();
const planN = Number(plan || "5");
if (
messages &&
messages.length > Account_Plans[planN].ai_bot_history_length
) {
return new Response("You have reached the history message limit.", {
status: 429,
});
}
if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) {
const ip = req.headers.get("x-forwarded-for");
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(
Account_Plans[planN].ai_generate_day,
"1 d",
),
});
// console.log("plan", planN, Account_Plans[planN], ip);
const { success, limit, reset, remaining } = await ratelimit.limit(
`novel_ratelimit_${ip}`,
);
if (!success) {
return new Response(
"You have reached your request limit for the day.",
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
},
);
}
}
openai.apiKey = getRandomElement(api_keys.split(",")) || api_key;
const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo-16k",
messages: [
{
role: "system",
content:
"As a note assistant, communicate with users based on the input note content.",
// "Do not reply to questions unrelated to the notes. If there are questions unrelated to the notes, please reply 'Please ask questions related to the notes'",
},
{
role: "system",
content: `Note content: \n${system}`,
},
...messages,
],
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
n: 1,
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
} catch (error) {
return new Response(`Server error\n${error}`, {
status: 500,
});
}
}

View File

@ -0,0 +1,99 @@
import OpenAI from "openai";
import { OpenAIStream, StreamingTextResponse } from "ai";
import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";
import { getRandomElement } from "@/lib/utils";
import { Account_Plans } from "../../../../lib/consts";
const api_key = process.env.OPENAI_API_KEY || "";
const api_keys = process.env.OPENAI_API_KEYs || "";
const openai = new OpenAI();
// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
export const runtime = "edge";
export async function POST(req: Request): Promise<Response> {
try {
// Check if the OPENAI_API_KEY is set, if not return 400
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
return new Response(
"Missing OPENAI_API_KEY  make sure to add it to your .env file.",
{
status: 400,
},
);
}
const { prompt, plan } = await req.json();
const planN = Number(plan || "5");
if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) {
const ip = req.headers.get("x-forwarded-for");
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(
Account_Plans[planN].ai_generate_day,
"1 d",
),
});
// console.log("plan", planN, Account_Plans[planN], ip);
const { success, limit, reset, remaining } = await ratelimit.limit(
`novel_ratelimit_${ip}`,
);
if (!success) {
return new Response(
"You have reached your request limit for the day.",
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
},
);
}
}
openai.apiKey = getRandomElement(api_keys.split(",")) || api_key;
const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo-16k",
messages: [
{
role: "system",
content:
"You are an AI writing assistant that continues existing text based on context from prior text." +
"Give more weight/priority to the later characters than the beginning ones. " +
`Limit your response to no more than ${Account_Plans[planN].ai_generate_chars} characters, but make sure to construct complete sentences.`,
// "Use Markdown formatting when appropriate.",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
n: 1,
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
} catch (error) {
return new Response("Server error", {
status: 500,
});
}
}

View File

@ -0,0 +1,101 @@
import OpenAI from "openai";
import { OpenAIStream, StreamingTextResponse } from "ai";
import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";
import { getRandomElement } from "@/lib/utils";
import { Account_Plans } from "../../../../lib/consts";
const api_key = process.env.OPENAI_API_KEY || "";
const api_keys = process.env.OPENAI_API_KEYs || "";
const openai = new OpenAI();
// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
export const runtime = "edge";
export async function POST(req: Request): Promise<Response> {
try {
// Check if the OPENAI_API_KEY is set, if not return 400
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
return new Response(
"Missing OPENAI_API_KEY  make sure to add it to your .env file.",
{
status: 400,
},
);
}
const { prompt, plan } = await req.json();
const planN = Number(plan || "5");
if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) {
const ip = req.headers.get("x-forwarded-for");
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(
Account_Plans[planN].ai_generate_day,
"1 d",
),
});
// console.log("plan", planN, Account_Plans[planN], ip);
const { success, limit, reset, remaining } = await ratelimit.limit(
`novel_ratelimit_${ip}`,
);
if (!success) {
return new Response(
"You have reached your request limit for the day.",
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
},
);
}
}
openai.apiKey = getRandomElement(api_keys.split(",")) || api_key;
const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo-16k",
messages: [
{
role: "system",
content: `I hope you can take on roles such as spell proofreading and rhetorical improvement,
or other roles related to text editing, optimization, and abbreviation. I will
communicate with you in any language, and you will recognize the language. Please only answer the corrected and improved parts, and
do not write explanations.
Limit your response to no more than ${Account_Plans[planN].ai_generate_chars} characters,
but make sure to construct complete sentences.`,
// "Use Markdown formatting when appropriate.",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
n: 1,
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
} catch (error) {
return new Response("Server error", {
status: 500,
});
}
}

View File

@ -1,81 +0,0 @@
import OpenAI from "openai";
import { OpenAIStream, StreamingTextResponse } from "ai";
import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";
// Create an OpenAI API client (that's edge friendly!)
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY || "",
});
// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
export const runtime = "edge";
export async function POST(req: Request): Promise<Response> {
// Check if the OPENAI_API_KEY is set, if not return 400
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
return new Response(
"Missing OPENAI_API_KEY  make sure to add it to your .env file.",
{
status: 400,
},
);
}
if (
process.env.NODE_ENV != "development" &&
process.env.KV_REST_API_URL &&
process.env.KV_REST_API_TOKEN
) {
const ip = req.headers.get("x-forwarded-for");
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(50, "1 d"),
});
const { success, limit, reset, remaining } = await ratelimit.limit(
`novel_ratelimit_${ip}`,
);
if (!success) {
return new Response("You have reached your request limit for the day.", {
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
});
}
}
let { prompt } = await req.json();
const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content:
"You are an AI writing assistant that continues existing text based on context from prior text. " +
"Give more weight/priority to the later characters than the beginning ones. " +
"Limit your response to no more than 200 characters, but make sure to construct complete sentences.",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
n: 1,
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}

View File

@ -0,0 +1,96 @@
import OpenAI from "openai";
import { OpenAIStream, StreamingTextResponse } from "ai";
import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";
import { getRandomElement } from "@/lib/utils";
import { Account_Plans } from "../../../../lib/consts";
const api_key = process.env.OPENAI_API_KEY || "";
const api_keys = process.env.OPENAI_API_KEYs || "";
const openai = new OpenAI();
// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime
export const runtime = "edge";
export async function POST(req: Request): Promise<Response> {
try {
// Check if the OPENAI_API_KEY is set, if not return 400
if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") {
return new Response(
"Missing OPENAI_API_KEY  make sure to add it to your .env file.",
{
status: 400,
},
);
}
const { prompt, plan } = await req.json();
const planN = Number(plan || "5");
if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) {
const ip = req.headers.get("x-forwarded-for");
const ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(
Account_Plans[planN].ai_generate_day,
"1 d",
),
});
// console.log("plan", planN, Account_Plans[planN], ip);
const { success, limit, reset, remaining } = await ratelimit.limit(
`novel_ratelimit_${ip}`,
);
if (!success) {
return new Response(
"You have reached your request limit for the day.",
{
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
},
);
}
}
openai.apiKey = getRandomElement(api_keys.split(",")) || api_key;
const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo-16k",
messages: [
{
role: "system",
content:
"I hope you can play the role of translator and spell proofreader. I will communicate with you in any language, and you will recognize the language and translate it to answer me.",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
n: 1,
});
// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
} catch (error) {
return new Response("Server error", {
status: 500,
});
}
}

View File

@ -0,0 +1,51 @@
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "../../auth/[...nextauth]/route";
import { getUserByEmail } from "@/lib/db/user";
import { findUserShares } from "@/lib/db/share";
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({
code: 401,
msg: "UnAuth",
data: null,
});
}
const user = await getUserByEmail(session.user.email);
if (!user) {
return NextResponse.json({
code: 403,
msg: "Something wrong",
data: null,
});
}
const res = await findUserShares(user.id);
if (res) {
return NextResponse.json({
code: 200,
msg: "",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Something wrong",
data: null,
});
} catch (error) {
return NextResponse.json(error);
}
}
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,168 @@
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "../auth/[...nextauth]/route";
import { getUserByEmail } from "@/lib/db/user";
import {
createShareNote,
deleteShareNote,
findShareByLocalId,
findUserSharesCount,
updateShareClick,
updateShareKeeps,
updateShareNote,
} from "@/lib/db/share";
import { Account_Plans } from "@/lib/consts";
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({
code: 403,
msg: "Empty id",
data: null,
});
}
const res = await findShareByLocalId(id);
if (res) {
await updateShareClick(res.id, res.click); // 数据库id
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Something wrong",
data: null,
});
} catch (error) {
return NextResponse.json(error);
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({
code: 401,
msg: "Unauthorized! Please login",
data: null,
});
}
const user = await getUserByEmail(session.user.email);
if (!user) {
return NextResponse.json({
code: 403,
msg: "Something wrong",
data: null,
});
}
const { data } = await req.json();
if (!data) {
return NextResponse.json({
code: 405,
msg: "Empty data",
data: null,
});
}
const find_share_count = await findUserSharesCount(user.id);
if (
find_share_count >= Account_Plans[Number(user.plan)].note_upload_count
) {
return NextResponse.json({
code: 429,
msg: "You have exceeded the maximum number of uploads, please upgrade your plan.",
data: null,
});
}
// 必需要用户ID
const find_res = await findShareByLocalId(data.id, user.id);
if (find_res) {
const update_res = await updateShareNote(data, find_res.id);
return NextResponse.json({
code: 200,
msg: "Updated!",
data: update_res,
});
}
const res = await createShareNote(data, user.id);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Something wrong",
data: null,
});
} catch (error) {
return NextResponse.json({
code: 500,
msg: error,
data: null,
});
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({
code: 403,
msg: "Empty id",
data: null,
});
}
const res = await deleteShareNote(id);
if (res) {
return NextResponse.json({
code: 200,
msg: "Successed!",
data: res,
});
}
return NextResponse.json({
code: 404,
msg: "Something wrong",
data: null,
});
} catch (error) {
return NextResponse.json(error);
}
}
// fix error: "DYNAMIC_SERVER_USAGE"
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { findShareByDBId, updateShareKeeps } from "@/lib/db/share";
export async function POST(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const { id } = await req.json();
if (!id) {
return NextResponse.json({
code: 405,
msg: "Empty id",
data: null,
});
}
const find_res = await findShareByDBId(id);
if (find_res) {
const res = await updateShareKeeps(id, find_res.keeps);
return NextResponse.json({
code: 200,
msg: "success",
data: null,
});
}
return NextResponse.json({
code: 404,
msg: "Something wrong",
data: null,
});
} catch (error) {
return NextResponse.json({
code: 500,
msg: error,
data: null,
});
}
}
// fix error: "DYNAMIC_SERVER_USAGE"
export const dynamic = "force-dynamic";

View File

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
subject: "website",
status: "live",
color: "green",
});
}

View File

@ -1,17 +1,46 @@
import { put } from "@vercel/blob";
import { Account_Plans } from "@/lib/consts";
import { NextResponse } from "next/server";
// import { getServerSession } from "next-auth";
// import { authOptions } from "../auth/[...nextauth]/route";
// import { getUserByEmail } from "@/lib/db/user";
import COS from "cos-nodejs-sdk-v5";
import { Readable } from "stream";
export const runtime = "edge";
const uploadFile = (stream: Readable, filename: string): Promise<any> => {
return new Promise((resolve, reject) => {
const params = {
Bucket: "gcloud-1303456836",
Region: "ap-chengdu",
Key: "inke/" + filename,
Body: stream,
};
cos.putObject(params, (err: any, data: any) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
};
export const runtime = "nodejs";
var cos = new COS({
SecretId: process.env.TencentSecretID || "",
SecretKey: process.env.TencentSecretKey || "",
});
export async function POST(req: Request) {
if (!process.env.BLOB_READ_WRITE_TOKEN) {
return new Response(
"Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.",
{
status: 401,
},
);
}
// if (!process.env.BLOB_READ_WRITE_TOKEN) {
// return new Response(
// "Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.",
// {
// status: 401,
// },
// );
// }
const file = req.body || "";
const filename = req.headers.get("x-vercel-filename") || "file.txt";
@ -22,10 +51,25 @@ export async function POST(req: Request) {
const finalName = filename.includes(fileType)
? filename
: `${filename}${fileType}`;
const blob = await put(finalName, file, {
contentType,
access: "public",
});
return NextResponse.json(blob);
const fileStream = Readable.from(file as any);
const res = await uploadFile(fileStream, finalName);
// console.log("上传结果", res, res.Location);
if (res && res.statusCode === 200) {
return NextResponse.json({ url: "https://" + res.Location });
}
// if (
// blob &&
// Number(blob.size) > Account_Plans[plan].image_upload_size * 1024 * 1024
// ) {
// return new Response(
// "You have exceeded the maximum size of uploads, please upgrade your plan.",
// {
// status: 429,
// },
// );
// }
}

View File

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { getUserByEmail, getUserById, updateUserName } from "@/lib/db/user";
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const { searchParams } = new URL(req.url);
const email = searchParams.get("email");
const id = searchParams.get("id");
if (email) {
const user = await getUserByEmail(email);
return NextResponse.json(user);
} else if (id && id !== "undefined") {
const user = await getUserById(id);
return NextResponse.json(user);
}
return NextResponse.json("empty params");
} catch (error) {
return NextResponse.json(error);
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Record<string, string | string | undefined[]> },
) {
try {
const { userId, userName } = await req.json();
if (!userId || !userName) {
return NextResponse.json("empty content");
}
const res = await updateUserName(userId, userName);
if (res) {
return NextResponse.json(res);
}
return NextResponse.json("something wrong");
} catch {
return NextResponse.json("error");
}
}
// fix error: "DYNAMIC_SERVER_USAGE"
export const dynamic = "force-dynamic";

53
apps/web/app/error.tsx Normal file
View File

@ -0,0 +1,53 @@
"use client"; // Error components must be Client Components
// import { Note_Storage_Key } from "@/lib/consts";
import { useEffect } from "react";
import Image from "next/image";
import Link from "next/link";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div className="z-10 mt-32 text-center">
<Image
src="/cat.png"
alt="404"
width="250"
height="250"
className="mx-auto mb-4 rounded-sm"
/>
<h2>Oops, Something went wrong!</h2>
<Link href="/">
<button className="mt-4 rounded-md border px-4 py-2 text-sm hover:border-gray-800">
Back to home
</button>
</Link>
<Link className="ml-4" href="/feedback">
<button className="mt-4 rounded-md border px-4 py-2 text-sm hover:border-gray-800">
Report error
</button>
</Link>
{/* <button
className="mt-4 rounded-md border px-4 py-2 text-sm text-red-500 hover:border-gray-800"
onClick={() => {
localStorage.removeItem(Note_Storage_Key);
}}
>
Clear local storage
</button> */}
</div>
);
}

View File

@ -0,0 +1,24 @@
import { ReactNode } from "react";
export function CardItem({
bgColor = "bg-yellow-400",
rotate = "rotate-12",
icon,
}: {
bgColor?: string;
rotate?: string;
icon: ReactNode;
}) {
return (
<>
<div
className={
`${bgColor} ${rotate}` +
" flex h-14 w-14 cursor-pointer items-center justify-center rounded-xl text-xl shadow-lg transition-all hover:rotate-0 md:h-20 md:w-20"
}
>
<span className="font-bold text-slate-100 md:scale-150">{icon}</span>
</div>
</>
);
}

View File

@ -0,0 +1,305 @@
"use client";
import NewPostButton from "@/ui/new-post-button";
import Image from "next/image";
import Link from "next/link";
import { Session } from "next-auth";
import { TypeAnimation } from "react-type-animation";
import Checked from "@/ui/shared/icons/checked";
import { Account_Plans } from "@/lib/consts";
import { CardItem } from "./card";
import { motion } from "framer-motion";
export function Welcome() {
return (
<div className="grids mt-3 flex w-full max-w-6xl flex-col items-center justify-center py-6">
<p className="title-font animate-fade-up font-display text-center text-3xl font-bold tracking-[-0.02em] text-slate-700 drop-shadow-sm md:text-5xl">
<span className="bg-gradient-to-r from-slate-400 via-slate-500 to-slate-800 bg-clip-text text-transparent ">
Lightweight
</span>{" "}
. <br className="block sm:hidden" />
AI Powered . <br className="block sm:hidden" />
<span className="bg-gradient-to-r from-slate-800 via-slate-500 to-slate-400 bg-clip-text text-transparent ">
Markdown
</span>
</p>
<p className="mx-auto mb-6 mt-3 w-[270px] text-center font-mono text-lg font-semibold text-slate-600 md:mt-5 md:w-full">
<TypeAnimation
className="w-[320px]"
sequence={[
"AI notebook, empowering writing.",
1000,
"AI notebook, empowering editing.",
1000,
"AI notebook, empowering translation.",
1000,
"AI notebook, empowering collaboration.",
1000,
"AI notebook, empowering anything.",
3000,
]}
preRenderFirstString={true}
speed={50}
repeat={5}
/>
</p>
<NewPostButton
className="h-10 w-36 py-2 font-medium shadow-md md:h-12 md:w-44 md:px-3 md:text-lg"
text="Start writing now"
/>
</div>
);
}
export function Landing({ session }: { session: Session | null }) {
return (
<>
<div className="mt-12 w-full max-w-6xl px-6">
<div className="flex flex-col items-center justify-around gap-10 md:flex-row">
<Image
className="rounded-lg shadow-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
src="/desktop.png"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
width={430}
height={280}
/>
<div className="grids px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
Rich editing components
</h3>
<p className="text-lg">
📖 Integrate rich text, Markdown, and final render with JSON.
</p>
</div>
</div>
<div className="my-14 flex flex-col items-center justify-around gap-10 md:flex-row-reverse">
<Image
className="rounded-lg shadow-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
src="/e1.png"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
width={450}
height={280}
/>
<div className="grids px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
AI empowering writing
</h3>
<p className="text-lg">
🎉 Continue writing, editing, translation, chat with AI, all in
one.
</p>
</div>
</div>
<div className="my-14 flex flex-col items-center justify-around gap-10 md:flex-row">
<Image
className="rounded-lg shadow-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
src="/e2.png"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
width={430}
height={280}
/>
<div className="grids px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
Online Collaboration
</h3>
<p className="text-lg">
👨👩👦 One click to start real-time online collaboration among
multiple people.
</p>
</div>
</div>
<div className="flex flex-col items-center justify-around gap-10 md:flex-row-reverse">
<Image
className="rounded-lg shadow-lg transition-all hover:opacity-90 hover:shadow-xl"
alt={"example"}
src="/e3.png"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAACCSURBVBhXZYzBCgIxDEQnTdPau+hveBB/XtiLn+NJQdoNS2Orq6zuO0zgZRhSVbvegeAJGx7hvUeMAUSEzu1RUesEKuNkIgyrFaoFzB4i8i1+cDEwXHOuRc65lbVpe38XuPm+YMdIKa3WOj9F60vWcj0IOg8Xy7ngdDxgv9vO+h/gCZNAKuSRdQ2rAAAAAElFTkSuQmCC"
width={460}
height={280}
/>
<div className="grids px-2 py-4">
<h3 className="mb-6 text-xl font-bold md:text-3xl">
Export & Theme
</h3>
<p className="text-lg">
🍥 One click simple export of PDF, images, Markdown, Json files
</p>
</div>
</div>
<h1 className="my-12 text-center text-3xl font-bold">PLAN</h1>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<div
className={
"dark:bg-zinc-850 relative flex flex-col justify-between rounded-lg border border-gray-300 bg-white p-6 shadow-lg"
}
>
<div>
<h3 className="text-center text-2xl font-bold">Free</h3>
<div className="mt-4 text-center text-zinc-600 dark:text-zinc-400">
<span className="text-4xl font-bold">
${Account_Plans[0].pay}
</span>
</div>
<ul className="mt-4 space-y-2 text-sm">
<li className="flex items-center">
<Checked />
Unlimited number of local notes
</li>
<li className="flex items-center">
<Checked />
{Account_Plans[0].note_upload_count} notes upload to Cloud
</li>
<li className="flex items-center">
<Checked />
AI generates {Account_Plans[0].ai_generate_day} times per day
</li>
<li className="flex items-center">
<Checked />
AI generates up to {Account_Plans[0].ai_generate_chars}{" "}
characters per time
</li>
<li className="flex items-center">
<Checked />
Less than {Account_Plans[0].image_upload_size}MB for upload
image per time
</li>
</ul>
</div>
<div className="mt-6">
<button className="w-full rounded-lg bg-black px-3 py-2 font-semibold text-slate-100 shadow-md">
Sign in for free
</button>
</div>
</div>
<div
className={
"dark:bg-zinc-850 relative flex flex-col justify-between rounded-lg border-2 border-purple-500 bg-white p-6 shadow-lg"
}
>
<div className="absolute left-1/2 top-0 inline-block -translate-x-1/2 -translate-y-1/2 transform rounded-full bg-gradient-to-r from-pink-500 to-purple-500 px-4 py-1 text-sm text-slate-100">
Beta for free
</div>
<div>
<h3 className="text-center text-2xl font-bold">Basic</h3>
<div className="mt-4 text-center text-zinc-600 dark:text-zinc-400">
<p className="text-4xl font-bold">${Account_Plans[1].pay}</p>
</div>
<ul className="mt-4 space-y-2 text-sm">
<li className="flex items-center">
<Checked />
Unlimited number of local notes
</li>
<li className="flex items-center">
<Checked />
Unlimited number of Cloud notes
</li>
<li className="flex items-center">
<Checked />
AI generates {Account_Plans[1].ai_generate_day} times per day
</li>
<li className="flex items-center">
<Checked />
AI generates up to {Account_Plans[1].ai_generate_chars}{" "}
characters per time
</li>
<li className="flex items-center">
<Checked />
Less than {Account_Plans[1].image_upload_size}MB for upload
image per time
</li>
<li className="flex items-center">
<Checked />
All subsequent features will be used for free
</li>
</ul>
</div>
<div className="mt-6">
<Link href={"/pricing"}>
<button className="w-full rounded-lg bg-gradient-to-r from-pink-500 to-purple-500 px-3 py-2 font-semibold text-slate-100 shadow-md">
Apply for free
</button>
</Link>
</div>
</div>
<div
className={
"dark:bg-zinc-850 relative flex flex-col justify-between rounded-lg border border-gray-300 bg-white p-6 shadow-lg"
}
>
<div>
<h3 className="text-center text-2xl font-bold">Pro</h3>
<div className="mt-4 text-center text-zinc-600 dark:text-zinc-400">
<p className="text-4xl font-bold">${Account_Plans[2].pay}</p>
</div>
<ul className="mt-4 space-y-2 text-sm">
<li className="flex items-center">
<Checked />
Unlimited number of local notes
</li>
<li className="flex items-center">
<Checked />
Unlimited number of Cloud notes
</li>
<li className="flex items-center">
<Checked />
AI generates {Account_Plans[2].ai_generate_day} times per day
</li>
<li className="flex items-center">
<Checked />
AI generates up to {Account_Plans[2].ai_generate_chars}{" "}
characters per time
</li>
<li className="flex items-center">
<Checked />
Less than {Account_Plans[2].image_upload_size}MB for upload
image per time
</li>
<li className="flex items-center">
<Checked />
All subsequent features will be used for free
</li>
</ul>
</div>
<div className="mt-6">
<button className="w-full rounded-lg bg-black px-3 py-2 font-semibold text-slate-100 shadow-md">
Coming soon
</button>
</div>
</div>
</div>
</div>
<div className="grids mt-10 flex w-full max-w-6xl items-center justify-center gap-8 pb-6 pt-6 md:gap-14 md:pb-10 md:pt-10">
<CardItem
bgColor="bg-cyan-400"
rotate="rotate-12 origin-top-left"
icon={"✏️"}
/>
<CardItem bgColor="bg-orange-400" rotate="rotate-45" icon="👻" />
<CardItem rotate="rotate-12 origin-top-left" icon={"💯"} />
<CardItem bgColor="bg-pink-400" rotate="-rotate-12" icon="🎓" />
</div>
<NewPostButton
className="my-10 h-10 w-36 py-2 font-medium shadow-md md:h-12 md:w-44 md:px-3 md:text-lg"
text="Start writing now"
/>
</>
);
}

View File

@ -0,0 +1,27 @@
import Providers from "@/app/providers";
import { siteConfig } from "@/config/site";
import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
export const metadata: Metadata = {
title: "Feedback | Inke",
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<Providers>{children}</Providers>
</>
);
}

View File

@ -0,0 +1,19 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Nav from "@/ui/layout/nav";
import Wrapper from "./wrapper";
import Footer from "@/ui/layout/footer";
export default async function Page() {
const session = await getServerSession(authOptions);
return (
<>
<div className="pt-16">
{/* @ts-expect-error Server Component */}
<Nav />
<Wrapper session={session} />
<Footer />
</div>
</>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import { Session } from "next-auth";
import Giscus from "@giscus/react";
export default function Wrapper({ session }: { session: Session | null }) {
return (
<>
<div className="mx-auto min-h-screen max-w-3xl px-6">
<Giscus
id="feedback"
repo="yesmore/inke"
repoId="R_kgDOKYZChQ"
category="Q&A"
categoryId="DIC_kwDOKYZChc4CZ8wk"
mapping="title"
term="Welcome to Inke!"
reactionsEnabled="1"
emitMetadata="0"
inputPosition="top"
theme="light"
lang="en"
loading="lazy"
/>
</div>
</>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import Script from "next/script";
const GoogleAnalytics = () => {
return (
<>
<Script
id="googletagmanager-a"
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=G-YK9MQYLLLR`}
/>
<Script
id="gtag-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-YK9MQYLLLR', {
page_path: window.location.pathname,
});
`,
}}
/>
</>
);
};
export default GoogleAnalytics;

View File

@ -0,0 +1,28 @@
import Providers from "@/app/providers";
import { siteConfig } from "@/config/site";
import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
export const metadata: Metadata = {
title: "Invite | Inke",
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<Providers>{children}</Providers>
</>
);
}

View File

@ -0,0 +1,19 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Nav from "@/ui/layout/nav";
import Wrapper from "./wrapper";
import Footer from "@/ui/layout/footer";
export default async function Page({ params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
return (
<>
<div className="pt-16">
{/* @ts-expect-error Server Component */}
<Nav />
<Wrapper session={session} id={params.id} />
<Footer />
</div>
</>
);
}

View File

@ -0,0 +1,233 @@
"use client";
import {
useCollaborationById,
useCollaborationByRoomId,
useCollaborationInviteCount,
} from "@/app/post/[id]/request";
import { ContentItem } from "@/lib/types/note";
import { IResponse } from "@/lib/types/response";
import { fetcher } from "@/lib/utils";
import { addNote, noteTable } from "@/store/db.model";
import UINotFound from "@/ui/layout/not-found";
import { LoadingCircle, LoadingDots } from "@/ui/shared/icons";
import { Collaboration, User } from "@prisma/client";
import { useLiveQuery } from "dexie-react-hooks";
import { motion } from "framer-motion";
import { Shapes, Users } from "lucide-react";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast, { Toaster } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
export default function Wrapper({
session,
id,
}: {
session: Session | null;
id: string;
}) {
const { room, isLoading } = useCollaborationById(id);
const { count } = useCollaborationInviteCount(room?.data?.roomId);
const router = useRouter();
const contents = useLiveQuery<ContentItem[]>(() => noteTable.toArray());
const [isJoined, setIsJoined] = useState(false);
const [isClickJoin, setClickJoin] = useState(false);
const [creator, setCreator] = useState<User>();
const [firstCreatedRoom, setFirstCreatedRoom] = useState<Collaboration>();
useEffect(() => {
if (room && room.code === 200) {
// 查询第一个空间创建者
onRequestCreator(room.data.roomId);
}
}, [room]);
useEffect(() => {
// console.log(room?.data);
if (room && room.data) {
const index = contents.findIndex((item) => item.id === room.data.localId);
if (index !== -1) {
setIsJoined(true);
}
}
}, [contents, room]);
const onRequestCreator = async (roomId: string) => {
const res = await fetcher<IResponse<Collaboration>>(
"/api/collaboration/room",
{
method: "POST",
body: JSON.stringify({ roomId }),
},
);
if (res.code === 200) {
setFirstCreatedRoom(res.data);
const user = await fetcher<User>(`/api/users?id=${res.data.userId}`);
if (user) {
setCreator(user);
}
}
};
const handleJoin = async () => {
if (firstCreatedRoom && firstCreatedRoom.deletedAt) {
toast("Space has been deleted");
return;
}
setClickJoin(true);
const localId = uuidv4();
const res = await fetcher<IResponse<Collaboration | null>>(
"/api/collaboration",
{
method: "POST",
body: JSON.stringify({
roomId: room.data.roomId,
localId,
title: room.data.title,
}),
},
);
if (res.code === 200) {
toast.success(res.msg, {
icon: "🎉",
});
newPost(localId);
router.push(`/post/${localId}?work=${room.data.roomId}`);
} else if (res.code === 301) {
toast.success(res.msg, {
icon: "🎉",
});
// 其他设备加入了
const index = contents.findIndex((item) => item.id === room.data.localId);
if (index === -1) {
newPost(room.data.localId);
router.push(`/post/${room.data.localId}?work=${room.data.roomId}`);
} else {
router.push(`/post/${localId}?work=${room.data.roomId}`);
}
} else {
toast(res.msg);
setClickJoin(false);
}
};
const newPost = (localId: string) => {
const date = new Date();
addNote({
id: localId,
title: `Untitled-${localId.slice(0, 6)}-${
date.getMonth() + 1
}/${date.getDate()}`,
content: {},
tag: "",
created_at: date.getTime(),
updated_at: date.getTime(),
});
};
if (isLoading)
return (
<div className="flex h-screen w-full justify-center px-6 py-6 text-center">
<LoadingCircle className="h-6 w-6" />
</div>
);
if (!id || room.code !== 200) return <UINotFound />;
return (
<>
<Toaster />
<div className="mx-auto h-screen max-w-3xl px-6 py-6 text-center">
<Shapes className="mx-auto h-12 w-12 text-cyan-500 hover:text-slate-500" />
<h1 className="my-4 text-center text-2xl font-semibold">
🎉 Invite to Join Collaboration
</h1>
<p>You are being invited to join the collaboration space</p>
<motion.div
className="mx-auto mb-4 mt-6 w-80 rounded-lg border border-slate-100 p-3 text-sm shadow-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<li className="flex items-center justify-between border-b border-slate-100 pb-2">
<span>Space name</span>
<span className="font-semibold text-cyan-500">
{room?.data?.title}
</span>
</li>
<li className="flex items-center justify-between border-b border-slate-100 py-2">
<span>Joined members</span>
<span className="font-semibold">{count?.data || "-"}</span>
</li>
{creator && (
<>
<li className="flex items-center justify-between border-b border-slate-100 py-2">
<span>Space owner</span>
<span className="font-semibold">{creator.name}</span>
</li>
</>
)}
{firstCreatedRoom && (
<>
<li className="flex items-center justify-between border-b border-slate-100 py-2">
<span>Created at</span>
<span className="font-semibold">
{firstCreatedRoom.createdAt.toString().slice(0, 10)}
</span>
</li>
<li className="flex items-center justify-between pt-2">
<span>Space status</span>
<span
className={
`${
firstCreatedRoom.deletedAt
? "text-yellow-500"
: "text-green-500"
}` + " font-semibold"
}
>
{firstCreatedRoom.deletedAt ? "Deleted" : "Active"}
</span>
</li>
</>
)}
</motion.div>
{isJoined ? (
<button
className="mx-auto mt-6 flex h-10 min-w-[200px] items-center justify-center rounded-md bg-blue-500 px-3 py-2 text-slate-50 shadow-md hover:bg-blue-400"
disabled={isClickJoin}
onClick={() => {
setClickJoin(true);
router.push(
`/post/${room.data.localId}?work=${room.data.roomId}`,
);
}}
>
{isClickJoin ? (
<LoadingDots color="#f6f6f6" />
) : (
"Joined, click for quick access"
)}
</button>
) : (
<button
className="mx-auto mt-6 flex h-10 w-64 items-center justify-center rounded-md bg-blue-500 px-3 py-2 text-slate-50 shadow-md hover:bg-blue-400"
onClick={handleJoin}
disabled={isClickJoin}
>
{isClickJoin ? <LoadingDots color="#f6f6f6" /> : "Join Now"}
</button>
)}
</div>
</>
);
}

View File

@ -3,33 +3,28 @@ import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
import Providers from "./providers";
const title =
"Inke  Notion-style WYSIWYG editor with AI-powered autocompletions";
const description =
"Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions. Built with Tiptap, OpenAI, and Vercel AI SDK.";
import { siteConfig } from "@/config/site";
import GoogleAnalytics from "./google-analytics";
export const metadata: Metadata = {
title,
description,
openGraph: {
title,
description,
},
twitter: {
title,
description,
card: "summary_large_image",
creator: "@steventey",
},
metadataBase: new URL("https://inke.app"),
themeColor: "#ffffff",
title: siteConfig.name,
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<GoogleAnalytics />
<Providers>{children}</Providers>
</body>
</html>

26
apps/web/app/manifest.ts Normal file
View File

@ -0,0 +1,26 @@
import { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Inke",
short_name: "Inke",
description:
"Inke is a WYSIWYG editor with AI-powered autocompletions. Built with Tiptap, OpenAI, and Vercel AI SDK.",
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: "#fff",
icons: [
{
src: "/favicon.ico",
sizes: "32x32",
type: "image/x-icon",
},
{
src: "/logo-256.png",
sizes: "256x256",
type: "image/png",
},
],
};
}

View File

@ -0,0 +1,9 @@
import UINotFound from "@/ui/layout/not-found";
export default async function NotFound() {
return (
<>
<UINotFound />
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View File

@ -1,19 +1,18 @@
import { Github } from "@/ui/icons";
import Menu from "@/ui/menu";
import Editor from "@/ui/editor";
import Nav from "@/ui/layout/nav";
import { Landing, Welcome } from "./features/guide";
import Footer from "@/ui/layout/footer";
import { getServerSession } from "next-auth";
import { authOptions } from "./api/auth/[...nextauth]/route";
export default function Page() {
export default async function Page() {
const session = await getServerSession(authOptions);
return (
<div className="flex min-h-screen flex-col items-center sm:px-5 sm:pt-[calc(20vh)]">
<a
href="https://github.com/yesmore/inke"
target="_blank"
className="absolute bottom-5 left-5 z-10 max-h-fit rounded-lg p-2 transition-colors duration-200 hover:bg-stone-100 sm:bottom-auto sm:top-5"
>
<Github />
</a>
<Menu />
<Editor />
<div className="mt-16 flex flex-col items-center sm:mx-6 sm:px-3">
{/* @ts-expect-error Server Component */}
<Nav />
<Welcome />
<Landing session={session} />
<Footer />
</div>
);
}

View File

@ -0,0 +1,506 @@
"use client";
import {
useCallback,
useEffect,
useState,
useRef,
Dispatch,
SetStateAction,
} from "react";
import { Editor as InkeEditor } from "inkejs";
import { JSONContent } from "@tiptap/react";
import useLocalStorage from "@/lib/hooks/use-local-storage";
import { useDebouncedCallback } from "use-debounce";
import { Content_Storage_Key, Default_Debounce_Duration } from "@/lib/consts";
import { ContentItem } from "@/lib/types/note";
import {
exportAsJson,
exportAsMarkdownFile,
fetcher,
fomatTmpDate,
timeAgo,
} from "@/lib/utils";
import Menu from "@/ui/menu";
import UINotFound from "@/ui/layout/not-found";
import { toPng } from "html-to-image";
import { usePDF } from "react-to-pdf";
import { Session } from "next-auth";
import { IResponse } from "@/lib/types/response";
import { ShareNote } from "@prisma/client";
import { LoadingCircle, LoadingDots } from "@/ui/shared/icons";
import { BadgeInfo, ExternalLink, Shapes, Clipboard } from "lucide-react";
import toast, { Toaster } from "react-hot-toast";
import {
useCollaborationByLocalId,
useCollaborationRoomId,
useUserInfoByEmail,
useUserShareNotes,
} from "./request";
import Link from "next/link";
import Tooltip from "@/ui/shared/tooltip";
import { useSearchParams } from "next/navigation";
import db, { noteTable, updateNote } from "@/store/db.model";
export default function Editor({
id,
session,
contents,
setShowRoomModal,
}: {
id?: string;
session: Session | null;
contents: ContentItem[];
setShowRoomModal: Dispatch<SetStateAction<boolean>>;
}) {
const params = useSearchParams();
const [collaboration, setCollaboration] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [debounceDuration, setDebounceDuration] = useState(
Default_Debounce_Duration,
);
const [saveStatus, setSaveStatus] = useState("Saved");
const [isLoading, setLoading] = useState(true);
const [isSharing, setSharing] = useState(false);
const [isShowShareLink, setShowShareLink] = useState(false);
const [currentRoomId, setCurrentRoomId] = useState("");
const [currentIndex, setCurrentIndex] = useState(-1);
const [currentContent, setCurrentContent] = useLocalStorage<JSONContent>(
Content_Storage_Key,
{},
);
const [currentPureContent, setPureContent] = useState("");
const { shares } = useUserShareNotes();
const { user } = useUserInfoByEmail(session?.user.email);
const { room, isLoading: isLoadingRoom } = useCollaborationRoomId(
params.get("work"),
);
const { room: localRoom } = useCollaborationByLocalId(id);
const { toPDF, targetRef } = usePDF({ filename: "note.pdf" });
useEffect(() => {
const roomId = params.get("work");
if (roomId) {
if (room && room.code === 200) {
setCurrentRoomId(roomId);
setCollaboration(true);
}
if (id && contents.length > 0) {
const index = contents.findIndex((item) => item.id === id);
if (index !== -1 && contents[index]) {
setCurrentContent({});
setCurrentIndex(index);
document.title = "Space | Inke";
}
}
} else {
if (id && contents.length > 0) {
setLoading(true);
const index = contents.findIndex((item) => item.id === id);
if (index !== -1 && contents[index]) {
setCurrentContent(contents[index].content ?? {});
setCurrentIndex(index);
document.title = `${contents[index].title} | Inke`;
}
}
}
setLoading(false);
}, [id, contents, room]);
const debouncedUpdates = useDebouncedCallback(
async (value, text, markdown) => {
handleUpdateItem(id, value);
setPureContent(markdown);
},
debounceDuration,
);
const handleUpdateItem = (id: string, updatedContent: JSONContent) => {
if (currentIndex !== -1) {
updateNote({
...contents[currentIndex],
content: updatedContent,
updated_at: new Date().getTime(),
});
}
};
const handleExportImage = useCallback(() => {
if (ref.current === null || currentIndex === -1 || saveStatus !== "Saved") {
return;
}
toPng(ref.current, {
cacheBust: true,
width: ref.current.scrollWidth,
height: ref.current.scrollHeight,
})
.then((dataUrl) => {
const link = document.createElement("a");
link.download = contents[currentIndex].title + ".png";
link.href = dataUrl;
link.click();
})
.catch((err) => {
console.log(err);
});
}, [ref, currentIndex, contents]);
const handleExportJson = () => {
if (!contents || currentIndex === -1 || saveStatus !== "Saved") return;
exportAsJson(contents[currentIndex], contents[currentIndex].title);
};
const handleExportMarkdown = () => {
if (
currentPureContent.length === 0 ||
currentIndex === -1 ||
saveStatus !== "Saved"
)
return;
exportAsMarkdownFile(currentPureContent, contents[currentIndex].title);
};
const handleExportPDF = () => {
toPDF();
};
const handleCreateShare = async () => {
if (saveStatus !== "Saved") return;
setSharing(true);
const res = await fetcher<IResponse<ShareNote | null>>("/api/share", {
method: "POST",
body: JSON.stringify({
data: contents[currentIndex],
}),
});
if (res.code !== 200) {
toast(res.msg, {
icon: "😅",
});
} else {
toast.success(res.msg, {
icon: "🎉",
});
if (!isShowShareLink) setShowShareLink(true);
}
setSharing(false);
};
const handleCreateCollaboration = async () => {
// 用户当前本地笔记是否已加入协作
if (localRoom && localRoom.code === 200) return;
if (!currentRoomId) {
setShowRoomModal(true);
} else if (currentRoomId && !collaboration) {
// url有roomid但是没有加入
console.log("url有roomid但是没有加入", room);
} else if (currentRoomId && collaboration) {
// url有roomid且已经加入
return;
}
};
if (isLoading || (params.get("work") && isLoadingRoom))
return (
<div className="m-6">
<LoadingCircle className="h-6 w-6" />
</div>
);
if (params.get("work") && room.code !== 200)
return (
<>
<div className="relative mx-auto h-screen w-full overflow-auto px-12 pt-12">
<Shapes className="mx-auto h-12 w-12 text-purple-400 hover:text-slate-500" />
<h1 className="my-4 text-center text-2xl font-semibold">
Wrong collaboration space
</h1>
<p>
You are accessing a multiplayer collaboration space, but there seems
to be an unexpected issue:{" "}
<span className="font-bold text-slate-800">{room.msg}</span>. Please
check your space id (<strong>{params.get("work")}</strong>) and try
it again.
</p>
</div>
</>
);
return (
<>
<Toaster />
<div className="relative flex h-screen w-full justify-center overflow-auto">
<div className="bg-white/50 absolute z-10 mb-5 flex w-full items-center justify-end gap-2 px-3 py-2 backdrop-blur-xl">
<span className="hidden text-xs text-slate-400 md:block">
Created at{" "}
{currentIndex !== -1 &&
fomatTmpDate(contents[currentIndex]?.created_at || 0)}
</span>
<div className="mr-auto flex items-center justify-center gap-2 rounded-lg bg-stone-100 px-2 py-1 text-sm ">
<i
style={{
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor:
saveStatus === "Saved"
? "#00d2ee"
: saveStatus === "Saving..."
? "#ff6b2c"
: "#919191",
display: "block",
transition: "all 0.5s",
}}
/>
<span className="text-xs text-slate-400 transition-all">
{saveStatus}{" "}
{saveStatus === "Saved" &&
currentIndex !== -1 &&
timeAgo(contents[currentIndex]?.updated_at || 0)}
</span>
</div>
<Tooltip
content={
<div className="w-72 px-3 py-2 text-sm text-slate-400">
<div className="flex items-center justify-between">
<h1 className="font-semibold text-slate-500">
Collaborative Space
</h1>
{collaboration && room && room.data && (
<Clipboard
onClick={() => {
navigator.clipboard.writeText(
`https://inke.app/invite/${room.data.id}`,
);
toast("Copied to clipboard");
}}
className="h-4 w-4 cursor-pointer text-cyan-500 hover:text-slate-300 active:text-green-500 "
/>
)}
</div>
{collaboration && room && room.data ? (
<p className="mt-2 hyphens-manual">
This note has enabled multi person collaboration, Copy the{" "}
<Link
className="text-cyan-500 after:content-['_↗'] hover:opacity-80"
href={`/invite/${room.data.id}`}
target="_blank"
>
invite link
</Link>{" "}
to invite others to join the collaboration.
</p>
) : localRoom && localRoom.code === 200 ? (
<p className="mt-2 hyphens-manual">
This local note is already associated with a collaboration
space. Click the link below to jump to the collaboration:{" "}
<Link
className="text-cyan-500 after:content-['_↗'] hover:text-cyan-300"
href={`/post/${id}?work=${localRoom.data.roomId}`}
target="_blank"
>
space-{localRoom.data.roomId}
</Link>
</p>
) : (
<p className="mt-2 hyphens-manual">
Now, Inke supports collaborative editing of docs by multiple
team members. Start by creating collaborative space. Learn
more about{" "}
<Link
className="text-cyan-600 after:content-['_↗'] hover:text-cyan-300"
href={`/collaboration`}
target="_blank"
>
collaboration space
</Link>
. <br />
Note: You need to{" "}
<strong className="text-slate-900">sign in first</strong> to
try this feature.
</p>
)}
</div>
}
fullWidth={false}
>
<div className="flex items-center justify-center gap-2">
{collaboration && room && room.data ? (
<button
onClick={() => {
navigator.clipboard.writeText(
`https://inke.app/invite/${room.data.id}`,
);
toast("Copied to clipboard");
}}
className="hover:opacity-800 mr-2 text-sm text-cyan-500"
>
Invite
</button>
) : (
<button className="mr-2" onClick={handleCreateCollaboration}>
<Shapes className="h-5 w-5 text-cyan-500 hover:opacity-80" />
</button>
)}
</div>
</Tooltip>
{((shares &&
shares.data &&
shares.data.find((i) => i.localId === id)) ||
isShowShareLink) && (
<Link href={`/publish/${id}`} target="_blank">
<ExternalLink className="h-5 w-5 text-cyan-500 hover:text-cyan-300" />
</Link>
)}
<button
className="ml-1 flex h-7 w-20 items-center justify-center gap-1 rounded-md bg-cyan-500 px-4 py-1 text-sm text-white transition-all hover:opacity-80"
onClick={handleCreateShare}
disabled={isSharing || saveStatus !== "Saved"}
>
{isSharing ? (
<LoadingDots color="#fff" />
) : (
<span className="bg-gradient-to-r from-red-200 via-yellow-300 to-orange-200 bg-clip-text font-semibold text-transparent">
Publish
</span>
)}
</button>
<Tooltip
content={
<div className="w-64 px-3 py-2 text-sm text-slate-400">
<h1 className="mb-2 font-semibold text-slate-500">
Publish and Share
</h1>
<p>
Click the <code>`Publish`</code> button to save your note
remotely and generate a sharing link, allowing you to share
your notes with others. Your notes will be uploaded after
serialization. e.g{" "}
<a
className="text-cyan-500 after:content-['_↗'] hover:text-cyan-300"
href="https://inke.app/publish/0e1be533-ae66-4ffa-9725-bd6b84899e78"
target="_blank"
>
link
</a>
.
</p>
<p>
You need to <strong>sign in</strong> first to try this
feature.
</p>
</div>
}
fullWidth={false}
>
<button className="hidden sm:block">
<BadgeInfo className="h-4 w-4 text-slate-400 hover:text-slate-500" />
</button>
</Tooltip>
<Menu
onExportImage={handleExportImage}
onExportJson={handleExportJson}
onExportTxT={handleExportMarkdown}
onExportPDF={handleExportPDF}
/>
</div>
{id &&
currentIndex === -1 &&
!isLoading &&
(collaboration && room && room.data ? (
<div className="relative mx-auto mt-10 h-screen w-full overflow-auto px-12 pt-12">
<Shapes className="mx-auto h-12 w-12 text-cyan-500" />
<h1 className="my-4 text-center text-2xl font-semibold">
Sync collaboration space
</h1>
<p className="mt-2 hyphens-manual">
It seems that you have joined this collaboration space (
{room.data.title}), but this device has not been created. Copy
this{" "}
<Link
className="text-cyan-500 after:content-['_↗'] hover:opacity-80"
href={`/invite/${room.data.id}`}
target="_blank"
>
invite link
</Link>{" "}
and recreate a local record to enter.
</p>
</div>
) : (
<UINotFound />
))}
{contents && currentIndex !== -1 && (
<div ref={ref} className="w-full max-w-screen-lg overflow-auto">
<div ref={targetRef}>
{params.get("work") ? (
<InkeEditor
className="relative min-h-screen overflow-y-auto overflow-x-hidden border-stone-200 bg-white pt-1"
storageKey={Content_Storage_Key}
debounceDuration={debounceDuration}
defaultValue={currentContent}
plan={user?.plan || "5"}
bot={true}
id={params.get("work")}
collaboration={true}
userName={user?.name || "unknown"}
onUpdate={() => setSaveStatus("Unsaved")}
onDebouncedUpdate={(
json: JSONContent,
text: string,
markdown: string,
) => {
setSaveStatus("Saving...");
if (json) debouncedUpdates(json, text, markdown);
setTimeout(() => {
setSaveStatus("Saved");
}, 500);
}}
/>
) : (
<InkeEditor
className="relative min-h-screen overflow-y-auto overflow-x-hidden border-stone-200 bg-white pt-1"
storageKey={Content_Storage_Key}
debounceDuration={debounceDuration}
defaultValue={currentContent}
plan={user?.plan || "5"}
bot={true}
onUpdate={() => setSaveStatus("Unsaved")}
onDebouncedUpdate={(
json: JSONContent,
text: string,
markdown: string,
) => {
setSaveStatus("Saving...");
if (json) debouncedUpdates(json, text, markdown);
setTimeout(() => {
setSaveStatus("Saved");
}, 500);
}}
/>
)}
</div>
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,29 @@
import Providers from "@/app/providers";
import { siteConfig } from "@/config/site";
import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
// import Providers from "./providers";
export const metadata: Metadata = {
title: siteConfig.name,
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<Providers>{children}</Providers>
</>
);
}

View File

@ -0,0 +1,13 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import EditorWrapper from "./wrapper";
// export async function generateMetadata({ params, searchParams }): Metadata {
// const data = await getDetail(params.slug);
// return { title: data.title };
// }
export default async function Page({ params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
return <EditorWrapper id={params.id} session={session} />;
}

View File

@ -0,0 +1,182 @@
import { fetcher } from "@/lib/utils";
import useSWR from "swr";
import { User } from "@/lib/types/user";
import { Collaboration, ShareNote } from "@prisma/client";
import { IResponse } from "@/lib/types/response";
export function useUserInfoByEmail(email: string) {
let api = `/api/users?email=${email}`;
const { data, error, isLoading } = useSWR<User>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
user: data,
isLoading,
isError: error,
};
}
export function useUserInfoById(id: string) {
let api = `/api/users?id=${id}`;
const { data, error, isLoading } = useSWR<User>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
user: data,
isLoading,
isError: error,
};
}
export function useUserShareNotes() {
let api = `/api/share/all`;
const { data, error, isLoading } = useSWR<IResponse<ShareNote[]>>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
shares: data,
isLoading,
isError: error,
};
}
export function useShareNoteByLocalId(id: string) {
const api = `/api/share?id=${id}`;
const { data, error, isLoading } = useSWR<IResponse<ShareNote>>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
share: data,
isLoading,
isError: error,
};
}
export function useCollaborationRoomId(id: string) {
const api = `/api/collaboration/room?roomId=${id}`;
const { data, error, isLoading } = useSWR<IResponse<Collaboration>>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
room: data,
isLoading,
isError: error,
};
}
export function useCollaborationById(id: string) {
const api = `/api/collaboration/id?id=${id}`;
const { data, error, isLoading } = useSWR<IResponse<Collaboration>>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
room: data,
isLoading,
isError: error,
};
}
export function useCollaborationByLocalId(id: string) {
const api = `/api/collaboration/local-id?localId=${id}`;
const { data, error, isLoading } = useSWR<IResponse<Collaboration>>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
room: data,
isLoading,
isError: error,
};
}
export function useCollaborationInviteCount(roomId: string) {
const api = `/api/collaboration/count?id=${roomId}`;
const { data, error, isLoading } = useSWR<IResponse<number>>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
count: data,
isLoading,
isError: error,
};
}
export function useCollaborationByUserId() {
const api = `/api/collaboration`;
const { data, error, isLoading } = useSWR<IResponse<Collaboration[]>>(
api,
() =>
fetcher(api, {
method: "GET",
}),
{ revalidateOnFocus: false },
);
return {
rooms: data,
isLoading,
isError: error,
};
}
// 查询第一个空间创建者
export function useCollaborationByRoomId(roomId: string) {
const api = `/api/collaboration/room`;
const { data, error, isLoading } = useSWR<IResponse<Collaboration>>(
api,
() =>
fetcher(api, {
method: "POST",
body: JSON.stringify({ roomId }),
}),
{ revalidateOnFocus: false },
);
return {
room_creator: data,
isLoading,
isError: error,
};
}

View File

@ -0,0 +1,655 @@
"use client";
import {
useState,
useEffect,
Suspense,
Dispatch,
SetStateAction,
useRef,
} from "react";
import { motion, useAnimation } from "framer-motion";
import { ContentItem } from "@/lib/types/note";
import { useRouter, useSearchParams } from "next/navigation";
import NewPostButton from "@/ui/new-post-button";
import UserDropdown from "@/ui/layout/user-dropdown";
import { Session } from "next-auth";
import { useCollaborationByUserId, useUserShareNotes } from "./request";
import Link from "next/link";
import { exportAsJson, fetcher } from "@/lib/utils";
import { Collaboration, ShareNote } from "@prisma/client";
import SearchInput from "@/ui/search-input";
import {
Check,
ChevronLeft,
ChevronRight,
Download,
DownloadCloud,
Edit,
ExternalLink,
Minus,
Plus,
Trash2,
Shapes,
FolderClosed,
FolderOpen,
FolderEdit,
} from "lucide-react";
import Tooltip from "@/ui/shared/tooltip";
import useWindowSize from "@/lib/hooks/use-window-size";
import toast from "react-hot-toast";
import { addNote, deleteNote, patchNote, updateNote } from "@/store/db.model";
import useLocalStorage from "@/lib/hooks/use-local-storage";
import { Note_Storage_Key } from "@/lib/consts";
export default function Sidebar({
id,
session,
contents,
setShowSignInModal,
setShowEditModal,
setShowRoomModal,
}: {
id?: string;
session: Session | null;
contents: ContentItem[];
setShowSignInModal: Dispatch<SetStateAction<boolean>>;
setShowEditModal: Dispatch<SetStateAction<boolean>>;
setShowRoomModal: Dispatch<SetStateAction<boolean>>;
}) {
const router = useRouter();
const params = useSearchParams();
const { isMobile } = useWindowSize();
const [active, setActive] = useState(false);
const [showEditInput, setShowEditInput] = useState(false);
const [showEditCate, setShowEditCate] = useState(false);
const [searchKey, setSearchKey] = useState("");
const controls = useAnimation();
const controlText = useAnimation();
const controlTitleText = useAnimation();
const [contentsCache, setContentsCache] = useState<ContentItem[]>([]);
const [categorizedData, setCategorizedData] = useState<{
[key: string]: ContentItem[];
}>();
const { shares, isLoading } = useUserShareNotes();
const [sharesCache, setSharesCache] = useState<ShareNote[]>([]);
const { rooms } = useCollaborationByUserId();
const [roomsCache, setRoomsCache] = useState<Collaboration[]>([]);
const [openHistory, setOpenHistory] = useState(true);
const [openShares, setOpenShares] = useState(false);
const [openRooms, setOpenRooms] = useState(false);
const editCateRef = useRef<HTMLInputElement>(null);
const editTitleRef = useRef<HTMLInputElement>(null);
const [oldContents, setOldContents] = useLocalStorage<ContentItem[]>(
Note_Storage_Key,
[],
);
const showMore = () => {
controls.start({
width: "270px",
transition: { duration: 0.001 },
});
controlText.start({
opacity: 1,
display: "block",
transition: { delay: 0.3 },
});
controlTitleText.start({
opacity: 1,
transition: { delay: 0.3 },
});
setActive(true);
};
const showLess = () => {
controls.start({
width: "0px",
transition: { duration: 0.001 },
});
controlText.start({
opacity: 0,
display: "none",
});
controlTitleText.start({
opacity: 0,
});
setActive(false);
};
// patch
useEffect(() => {
if (oldContents.length > 0) {
patchNote(oldContents);
setOldContents([]);
}
}, [oldContents]);
useEffect(() => {
showMore();
}, []);
useEffect(() => {
if (isMobile) {
showLess();
}
}, [isMobile]);
useEffect(() => {
if (searchKey === "") {
setContentsCache(contents);
setSharesCache(shares?.data || []);
setCategorizedData(() => {
return (
contents
// .sort((a, b) => b.updated_at - a.updated_at)
.reduce((acc, item) => {
const tag = item.tag || ""; // If tag is undefined, default it to an empty string
if (!acc[tag]) {
acc[tag] = [];
}
acc[tag].push(item);
return acc;
}, {} as { [key: string]: ContentItem[] })
);
});
}
}, [searchKey, contents, shares]);
useEffect(() => {
if (shares && shares.data) {
setSharesCache(shares.data);
}
}, [shares]);
useEffect(() => {
if (rooms && rooms.data) {
setRoomsCache(rooms.data);
}
}, [rooms]);
const handleDeleteItem = (_id: string) => {
deleteNote(_id);
};
const handleDeletePublicItem = async (_id: string) => {
const res = await fetcher(`/api/share?id=${_id}`, {
method: "DELETE",
});
const updatedList = shares.data.filter((item) => item.id !== _id);
setSharesCache(updatedList);
};
const handleEditTitle = (itemId: string) => {
if (showEditInput && id === itemId) {
setShowEditInput(false);
const index = contents.findIndex((item) => item.id === id);
if (index !== -1) {
updateNote({
...contents[index],
title: editTitleRef.current.value,
});
}
} else {
setShowEditInput(true);
}
};
const handleEditCate = (itemId: string) => {
if (showEditCate && id === itemId) {
setShowEditCate(false);
const index = contents.findIndex((item) => item.id === id);
if (index !== -1) {
updateNote({
...contents[index],
tag: editCateRef.current.value,
});
}
} else {
setShowEditCate(true);
}
};
const handleExportJson = () => {
if (!contents) return;
exportAsJson(contents, "Inke-notes-local");
};
const handleInputSearch = (value: string) => {
if (value.length > 0) {
setSearchKey(value);
const local_res = contents.filter((item) => {
if (
item.title.includes(value) ||
JSON.stringify(item.content).includes(value) ||
(item.tag && item.tag.includes(value))
) {
return item;
}
});
setContentsCache(local_res);
setCategorizedData(() => {
return (
local_res
// .sort((a, b) => b.updated_at - a.updated_at)
.reduce((acc, item) => {
const tag = item.tag || ""; // If tag is undefined, default it to an empty string
if (!acc[tag]) {
acc[tag] = [];
}
acc[tag].push(item);
return acc;
}, {} as { [key: string]: ContentItem[] })
);
});
if (shares && shares.data) {
const publish_res = shares.data.filter((item) => {
if (item.data.includes(value)) {
return item;
}
});
setSharesCache(publish_res);
}
} else {
setSearchKey("");
}
};
const handleClickPublishNote = (publishId: string, localId: string) => {
const localIndex = contentsCache.findIndex((i) => i.id === localId);
if (localIndex !== -1) {
router.push(`/post/${localId}`);
} else {
router.push(`/publish/${localId}`);
}
};
const handleSyncPublisToLocal = (localId: string, remoteDate: string) => {
const data = JSON.parse(remoteDate || "{}");
if (remoteDate && data) {
addNote(data);
router.push(`/post/${data.id}`);
}
};
const handleQuitSpace = async (id: string, roomId: string) => {
const res = await fetcher(`/api/collaboration?id=${id}`, {
method: "DELETE",
});
if (res && res.code === 200) {
toast("Exit space");
}
};
const handleCreateSpace = () => {
setShowRoomModal(true);
};
const handleToggleCollapse = (tag: string) => {
setCategorizedData((prevData) => {
const updatedData = { ...prevData };
updatedData[tag].forEach((item) => {
item.collapsed = !item.collapsed;
});
return updatedData;
});
};
return (
<div className="relative">
<motion.div
animate={controls}
className={
`${active ? "border-r" : ""}` +
" animate group flex h-screen w-[270px] flex-col gap-3 overflow-y-auto border-slate-200/60 py-6 duration-300"
}
>
{active && (
<button
onClick={showLess}
className="absolute -right-4 top-28 z-[10] cursor-pointer rounded-r bg-slate-100 py-2 shadow transition-all hover:bg-slate-200 "
>
<ChevronLeft className="h-4 w-4 text-slate-400" />
</button>
)}
{!active && (
<button
onClick={showMore}
className="absolute -right-4 top-28 z-[10] cursor-pointer rounded-r bg-slate-100 py-2 shadow transition-all hover:bg-slate-200"
>
<ChevronRight className="h-4 w-4 text-slate-400" />
</button>
)}
<div className="mx-3 flex flex-col gap-2">
<SearchInput onChange={handleInputSearch} />
<div className="flex items-center justify-between gap-2">
<NewPostButton
isShowIcon={true}
className="h-9 w-full shadow"
text="Note"
from="post"
/>
<button
className="flex h-9 w-full items-center justify-center gap-1 rounded-md bg-cyan-500 px-3 text-center text-sm text-slate-100 shadow transition-all hover:opacity-80"
onClick={handleCreateSpace}
>
<Shapes className="inline h-4 w-4 text-slate-50" /> Space
</button>
</div>
</div>
<div className="border-b border-slate-200/70" />
<div className="h-[40%] w-full grow overflow-y-auto px-3">
<div
className="flex cursor-pointer items-center justify-between"
onClick={() => {
setOpenHistory(!openHistory);
}}
>
<p className="font-mono text-sm font-semibold text-slate-400">
History({contents.length})
</p>
<button className="rounded bg-slate-100 hover:bg-slate-200">
{openHistory ? (
<Minus className="h-5 w-5 cursor-pointer p-1 text-slate-500" />
) : (
<Plus className="h-5 w-5 cursor-pointer p-1 text-slate-500" />
)}
</button>
</div>
{openHistory &&
categorizedData &&
Object.keys(categorizedData).map((tag) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
key={tag}
>
<h2
className={
`${
categorizedData[tag].findIndex((i) => i.id === id) !== -1
? "text-cyan-500"
: "text-gray-500"
}` +
" flex cursor-pointer items-center justify-start gap-1 pt-2 font-mono text-xs font-semibold transition-all hover:text-slate-300"
}
onClick={() => handleToggleCollapse(tag)}
>
{categorizedData[tag][0].collapsed ? (
<FolderOpen className="h-3 w-3 text-slate-400" />
) : (
<FolderClosed className="h-3 w-3 text-slate-400" />
)}
{tag || "Uncategorized"}
</h2>
{categorizedData[tag][0].collapsed &&
categorizedData[tag].map((item) => (
<div
className="group/item my-2 mb-2 flex items-center justify-between gap-2 pl-4 transition-all"
key={item.id}
>
{showEditInput && id === item.id ? (
<input
ref={editTitleRef}
type="text"
className="rounded border px-2 py-1 text-xs text-slate-500"
defaultValue={item.title}
placeholder="Enter note title"
/>
) : showEditCate && id === item.id ? (
<input
ref={editCateRef}
type="text"
className="rounded border px-2 py-1 text-xs text-slate-500"
defaultValue={item.tag}
placeholder="Enter note category"
/>
) : (
<p
className={
"flex cursor-pointer items-center justify-start gap-2 truncate font-mono text-xs hover:opacity-80 " +
`${
id === item.id ? "text-cyan-500" : "text-gray-500"
}`
}
onClick={() => router.push(`/post/${item.id}`)}
>
{item.title.length > 0 ? item.title : "Untitled"}
</p>
)}
<div className="ml-auto hidden group-hover/item:block">
<div className="flex items-center justify-end gap-2">
{id === item.id && (
<button onClick={() => handleEditTitle(item.id)}>
{showEditInput ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Edit className="h-4 w-4 text-slate-300 hover:text-slate-500" />
)}
</button>
)}
{id === item.id && (
<button onClick={() => handleEditCate(item.id)}>
{showEditCate ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<FolderEdit className="h-4 w-4 text-slate-300 hover:text-slate-500" />
)}
</button>
)}
{id !== item.id && (
<button onClick={() => handleDeleteItem(item.id)}>
<Trash2 className="h-4 w-4 text-slate-300" />
</button>
)}
</div>
</div>
{sharesCache.length > 0 &&
sharesCache.find((i) => i.localId === item.id) && (
<Link href={`/publish/${item.id}`} target="_blank">
<ExternalLink className="h-4 w-4 text-cyan-500" />
</Link>
)}
</div>
))}
</motion.div>
))}
{sharesCache.length > 0 && (
<>
<div
className="mt-3 flex cursor-pointer items-center justify-between border-t border-slate-200/50 pt-3"
onClick={() => {
setOpenShares(!openShares);
}}
>
<p className="font-mono text-sm font-semibold text-slate-400">
Published({shares.data.length})
</p>
<button className="rounded bg-slate-100 hover:bg-slate-200">
{openShares ? (
<Minus className="h-5 w-5 cursor-pointer p-1 text-slate-500" />
) : (
<Plus className="h-5 w-5 cursor-pointer p-1 text-slate-500" />
)}
</button>
</div>
{openShares &&
sharesCache.map((item) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
key={item.id}
className="group/item mt-2 flex items-center justify-between"
>
<button
onClick={() =>
handleClickPublishNote(item.id, item.localId)
}
className={
`${
item.localId === id
? "text-cyan-500"
: "text-gray-500"
}` + " truncate font-mono text-xs hover:opacity-80"
}
>
{JSON.parse(item.data || "{}").title || "Untitled"}
</button>
<button
className="ml-auto hidden group-hover/item:block"
onClick={() => handleDeletePublicItem(item.id)}
>
<Trash2 className="h-4 w-4 text-slate-300" />
</button>
{contentsCache.findIndex((i) => i.id === item.localId) ===
-1 && (
<Tooltip
content={
<div className="w-64 px-3 py-2 text-sm text-slate-400">
<h1 className="mb-2 font-semibold text-slate-500">
Cross device sync note
</h1>
<p>
Sync your notes from other devices to the current
device (history list).
</p>
</div>
}
fullWidth={false}
>
<button
className="ml-2"
onClick={() =>
handleSyncPublisToLocal(item.localId, item.data)
}
>
<DownloadCloud className="h-4 w-4 text-slate-400" />
</button>
</Tooltip>
)}
</motion.div>
))}
</>
)}
{roomsCache.length > 0 && (
<>
<div
className="mt-3 flex cursor-pointer items-center justify-between border-t border-slate-200/50 pt-3"
onClick={() => {
setOpenRooms(!openRooms);
}}
>
<p className="font-mono text-sm font-semibold text-slate-400">
Collaborations({rooms.data.length})
</p>
<button className="rounded bg-slate-100 hover:bg-slate-200">
{openRooms ? (
<Minus className="h-5 w-5 cursor-pointer p-1 text-slate-500" />
) : (
<Plus className="h-5 w-5 cursor-pointer p-1 text-slate-500" />
)}
</button>
</div>
{openRooms &&
roomsCache.map((item) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
key={item.id}
className="group/item mt-2 flex items-center justify-between"
>
<button
onClick={() =>
router.push(`/post/${item.localId}?work=${item.roomId}`)
}
className={
`${
item.localId === id
? "text-cyan-500"
: "text-gray-500"
}` + " truncate font-mono text-xs hover:opacity-80"
}
>
{item.title}
</button>
<button
className="ml-auto hidden group-hover/item:block"
onClick={() => handleQuitSpace(item.id, item.roomId)}
>
<Trash2 className="h-4 w-4 text-slate-300" />
</button>
</motion.div>
))}
</>
)}
</div>
<div className="border-b border-slate-200/70" />
<Suspense>
{session ? (
<div className="-mb-2 text-center">
<UserDropdown
session={session}
setShowEditModal={setShowEditModal}
/>
</div>
) : (
<button
className="mx-3 mt-3 rounded-md border border-slate-800 bg-slate-800 px-3 py-2 text-sm font-semibold text-slate-100 transition-all hover:bg-slate-600"
onClick={() => setShowSignInModal(true)}
>
Sign in for more
</button>
)}
</Suspense>
<div className="-mb-1 flex items-center justify-center text-sm">
<Link className="hover:text-slate-300" href="/">
Home
</Link>
<span className="mx-2"></span>
<Link
className="hover:text-slate-300"
href="/document"
target="_blank"
>
Document
</Link>
<span className="mx-2"></span>
<Link className="hover:text-slate-300" href="/pricing">
Pricing
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import Editor from "@/app/post/[id]/editor";
import Sidebar from "@/app/post/[id]/sider";
import { ContentItem } from "@/lib/types/note";
import { noteTable } from "@/store/db.model";
import { useCreatRoomModal } from "@/ui/layout/create-room-modal";
import { useEditNicknameModal } from "@/ui/layout/edit-nickname-modal";
import { useSignInModal } from "@/ui/layout/sign-in-modal";
import { useLiveQuery } from "dexie-react-hooks";
import { Session } from "next-auth";
export default function Wrapper({
id,
session,
}: {
id: string;
session: Session | null;
}) {
const { EditModal, setShowEditModal } = useEditNicknameModal(session);
const { SignInModal, setShowSignInModal } = useSignInModal();
const { RoomModal, setShowRoomModal } = useCreatRoomModal(session, "", id);
const notes = useLiveQuery<ContentItem[]>(() =>
noteTable.orderBy("updated_at").reverse().toArray(),
);
return (
<>
<SignInModal />
<EditModal />
<RoomModal />
<div className="flex">
{notes && (
<>
<Sidebar
id={id}
session={session}
contents={notes}
setShowEditModal={setShowEditModal}
setShowSignInModal={setShowSignInModal}
setShowRoomModal={setShowRoomModal}
/>
<Editor
id={id}
session={session}
contents={notes}
setShowRoomModal={setShowRoomModal}
/>
</>
)}
</div>
</>
);
}

View File

@ -0,0 +1,28 @@
import Providers from "@/app/providers";
import { siteConfig } from "@/config/site";
import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
export const metadata: Metadata = {
title: "Pricing | Inke",
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<Providers>{children}</Providers>
</>
);
}

View File

@ -0,0 +1,19 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Nav from "@/ui/layout/nav";
import Wrapper from "./wrapper";
import Footer from "@/ui/layout/footer";
export default async function Page() {
const session = await getServerSession(authOptions);
return (
<>
<div className="pt-16">
{/* @ts-expect-error Server Component */}
<Nav />
<Wrapper session={session} />
<Footer />
</div>
</>
);
}

View File

@ -0,0 +1,254 @@
"use client";
import Checked from "@/ui/shared/icons/checked";
import { Session } from "next-auth";
import { useEffect, useState } from "react";
import { useUserInfoByEmail } from "../post/[id]/request";
import Link from "next/link";
import { Account_Plans } from "@/lib/consts";
export default function Wrapper({ session }: { session: Session | null }) {
const { user } = useUserInfoByEmail(session?.user?.email || "");
const [currentPlan, setCurrentPlan] = useState("5");
useEffect(() => {
if (user && user.plan) {
setCurrentPlan(user.plan);
}
}, [user]);
return (
<>
<div className="mx-auto min-h-screen">
<PlanCards activeIndex={currentPlan} />
</div>
</>
);
}
export function PlanCards({ activeIndex }: { activeIndex: string }) {
return (
<section className="mt-3 flex w-full justify-center py-6 dark:from-zinc-900 dark:to-zinc-800">
<div className="container px-4 md:px-6">
<h1 className=" text-center text-4xl font-bold">PLAN</h1>
<div className="mx-auto mt-10 px-3">
<p>
🎉 For users who are not logged in, the AI generation frequency will
be limited to <strong>50</strong> times per day. Once logged in,
they can receive <strong>100</strong> times. Sign in and upgrade
now!{" "}
<span className="text-blue-500">
Moreover, Inke is currently in beta version and you can apply for
the Basic plan for free. Please refer to `About plan` below for
the application method.
</span>
</p>
</div>
<div className="mt-16 grid grid-cols-1 gap-6 md:grid-cols-3">
<div
className={
(activeIndex === "0"
? "border-2 border-purple-500"
: "border border-gray-300") +
" dark:bg-zinc-850 relative flex flex-col justify-between rounded-lg border bg-white p-6 shadow-lg"
}
>
{activeIndex === "0" && (
<div className="absolute left-1/2 top-0 inline-block -translate-x-1/2 -translate-y-1/2 transform rounded-full bg-gradient-to-r from-pink-500 to-purple-500 px-4 py-1 text-sm text-slate-100">
Current
</div>
)}
<div>
<h3 className="text-center text-2xl font-bold">Free</h3>
<div className="mt-4 text-center text-zinc-600 dark:text-zinc-400">
<span className="text-4xl font-bold">
${Account_Plans[0].pay}
</span>
</div>
<ul className="mt-4 space-y-2 text-sm">
<li className="flex items-center">
<Checked />
Unlimited number of local notes
</li>
<li className="flex items-center">
<Checked />
{Account_Plans[0].note_upload_count} notes upload to Cloud
</li>
<li className="flex items-center">
<Checked />
AI generates {Account_Plans[0].ai_generate_day} times per day
</li>
<li className="flex items-center">
<Checked />
AI generates up to {Account_Plans[0].ai_generate_chars}{" "}
characters per time
</li>
<li className="flex items-center">
<Checked />
Less than {Account_Plans[0].image_upload_size}MB for upload
image per time
</li>
</ul>
</div>
<div className="mt-6">
<button className="w-full rounded-lg bg-black px-3 py-2 font-semibold text-slate-100 shadow-md">
Sign in for free
</button>
</div>
</div>
<div
className={
(activeIndex === "1"
? "border-2 border-purple-500"
: "border border-gray-300") +
" dark:bg-zinc-850 relative flex flex-col justify-between rounded-lg bg-white p-6 shadow-lg"
}
>
{activeIndex === "1" ? (
<div className="absolute left-1/2 top-0 inline-block -translate-x-1/2 -translate-y-1/2 transform rounded-full bg-gradient-to-r from-pink-500 to-purple-500 px-4 py-1 text-sm text-slate-100">
Current
</div>
) : (
<div className="absolute left-1/2 top-0 inline-block -translate-x-1/2 -translate-y-1/2 transform rounded-full bg-gradient-to-r from-pink-500 to-purple-500 px-4 py-1 text-sm text-slate-100">
Beta for free
</div>
)}
<div>
<h3 className="text-center text-2xl font-bold">Basic</h3>
<div className="mt-4 text-center text-zinc-600 dark:text-zinc-400">
<p className="text-4xl font-bold">${Account_Plans[1].pay}</p>
</div>
<ul className="mt-4 space-y-2 text-sm">
<li className="flex items-center">
<Checked />
Unlimited number of local notes
</li>
<li className="flex items-center">
<Checked />
Unlimited number of Cloud notes
</li>
<li className="flex items-center">
<Checked />
AI generates {Account_Plans[1].ai_generate_day} times per day
</li>
<li className="flex items-center">
<Checked />
AI generates up to {Account_Plans[1].ai_generate_chars}{" "}
characters per time
</li>
<li className="flex items-center">
<Checked />
Less than {Account_Plans[1].image_upload_size}MB for upload
image per time
</li>
<li className="flex items-center">
<Checked />
All subsequent features will be used for free
</li>
</ul>
</div>
<div className="mt-6">
<button className="w-full rounded-lg bg-gradient-to-r from-pink-500 to-purple-500 px-3 py-2 font-semibold text-slate-100 shadow-md">
Apply for free
</button>
</div>
</div>
<div
className={
(activeIndex === "2"
? "border-2 border-purple-500"
: "border border-gray-300") +
" dark:bg-zinc-850 relative flex flex-col justify-between rounded-lg border bg-white p-6 shadow-lg"
}
>
{activeIndex === "2" && (
<div className="absolute left-1/2 top-0 inline-block -translate-x-1/2 -translate-y-1/2 transform rounded-full bg-gradient-to-r from-pink-500 to-purple-500 px-4 py-1 text-sm text-slate-100">
Current
</div>
)}
<div>
<h3 className="text-center text-2xl font-bold">Pro</h3>
<div className="mt-4 text-center text-zinc-600 dark:text-zinc-400">
<p className="text-4xl font-bold">${Account_Plans[2].pay}</p>
</div>
<ul className="mt-4 space-y-2 text-sm">
<li className="flex items-center">
<Checked />
Unlimited number of local notes
</li>
<li className="flex items-center">
<Checked />
Unlimited number of Cloud notes
</li>
<li className="flex items-center">
<Checked />
AI generates {Account_Plans[2].ai_generate_day} times per day
</li>
<li className="flex items-center">
<Checked />
AI generates up to {Account_Plans[2].ai_generate_chars}{" "}
characters per time
</li>
<li className="flex items-center">
<Checked />
Less than {Account_Plans[2].image_upload_size}MB for upload
image per time
</li>
<li className="flex items-center">
<Checked />
All subsequent features will be used for free
</li>
</ul>
</div>
<div className="mt-6">
<button className="w-full rounded-lg bg-black px-3 py-2 font-semibold text-slate-100 shadow-md">
Coming soon
</button>
</div>
</div>
</div>
<div className="mx-3">
<h3 className="mb-4 mt-10 text-lg font-semibold" id="about-plan">
About Plan
</h3>
<p>
All paid plans are one-time purchases, allowing users to permanently
access all features.
</p>
<p className="my-2">
🎉 We have just introduced the Basic plan. And the best part is that
Basic plan is currently available for free activation indefinitely!
Simply give us a UPVOTE on{" "}
<Link
className="text-blue-500 after:content-['_↗'] hover:text-blue-300"
href="https://www.producthunt.com/posts/inke"
target="_blank"
>
Product Hunt
</Link>
, and send an email named{" "}
<strong>
<code>Apply for Upgrade</code>
</strong>{" "}
to{" "}
<strong>
<code>team@inke.app</code>
</strong>{" "}
, please include your registered email address (inke.app) and
Product Hunt nickname in the email content .
</p>
<p>
we will process your request within 1-2 business days. All you need
to do is stay updated on the latest status of this page. Thank you
for your continued support!
</p>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,28 @@
import Providers from "@/app/providers";
import { siteConfig } from "@/config/site";
import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
export const metadata: Metadata = {
title: "Privacy | Inke",
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<Providers>{children}</Providers>
</>
);
}

View File

@ -0,0 +1,19 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Nav from "@/ui/layout/nav";
import Wrapper from "./wrapper";
import Footer from "@/ui/layout/footer";
export default async function Page() {
const session = await getServerSession(authOptions);
return (
<>
<div className="pt-16">
{/* @ts-expect-error Server Component */}
<Nav />
<Wrapper session={session} />
<Footer />
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
"use client";
import { Session } from "next-auth";
export default function Wrapper({ session }: { session: Session | null }) {
return (
<>
<div className="mx-auto max-w-3xl px-6 py-6">
<h2 className="text-lg font-bold"> Privacy Policy</h2>
If you choose to use the services I provide, it means you agree to the
collection and use of information related to this policy. The personal
information I collect is used to provide and improve the services.
Unless otherwise stated in this privacy policy, I will not use or share
your information with anyone else. Unless otherwise specified in this
privacy policy, the terms used in this privacy policy have the same
meaning as our terms and conditions, which can be accessed in Inke.
<h2 className="mt-2 text-lg font-bold">
Information Collection and Use
</h2>
In order to provide a better experience, when using our services, I may
ask you to provide certain personal identity information, including but
not limited to email, avatar. The information I request will be retained
on your device and will not be collected by me in any way.
<h2 className="mt-2 text-lg font-bold">Other</h2>
It is strictly prohibited to upload notes with illegal or pornographic
content. We will take strict measures to permanently ban violators.
<h2 className="mt-2 text-lg font-bold">Contact Us </h2>
If you have any questions or suggestions regarding my privacy policy,
please feel free to contact me at{" "}
<a className="text-blue-400" href="/feedback" target="_blank">
feedback
</a>
.
</div>
</>
);
}

View File

@ -3,7 +3,6 @@
import { Dispatch, ReactNode, SetStateAction, createContext } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { Toaster } from "sonner";
import { Analytics } from "@vercel/analytics/react";
import useLocalStorage from "@/lib/hooks/use-local-storage";
export const AppContext = createContext<{
@ -40,7 +39,6 @@ export default function Providers({ children }: { children: ReactNode }) {
>
<ToasterProvider />
{children}
<Analytics />
</AppContext.Provider>
</ThemeProvider>
);

View File

@ -0,0 +1,29 @@
import Providers from "@/app/providers";
import { siteConfig } from "@/config/site";
import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
// import Providers from "./providers";
export const metadata: Metadata = {
title: siteConfig.name,
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<Providers>{children}</Providers>
</>
);
}

View File

@ -0,0 +1,22 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Wrapper from "./wrapper";
import Nav from "@/ui/layout/nav";
import FooterPublish from "@/ui/layout/footer-publish";
// export async function generateMetadata({ params, searchParams }): Metadata {
// const data = await getDetail(params.slug);
// return { title: data.title };
// }
export default async function Page({ params }: { params: { id: string } }) {
const session = await getServerSession(authOptions);
return (
<div className="mt-16 flex flex-col items-center sm:mx-6 sm:px-3">
{/* @ts-expect-error Server Component */}
<Nav />
<Wrapper id={params.id} session={session} />
<FooterPublish />
</div>
);
}

View File

@ -0,0 +1,138 @@
"use client";
import {
useShareNoteByLocalId,
useUserInfoById,
} from "@/app/post/[id]/request";
import {
Content_Public_Storage_Key,
Default_Debounce_Duration,
} from "@/lib/consts";
import { Session } from "next-auth";
import { useEffect, useState } from "react";
import { Editor as InkeEditor } from "inkejs";
import { JSONContent } from "@tiptap/react";
import UINotFound from "../../../ui/layout/not-found";
import { LoadingCircle } from "@/ui/shared/icons";
import NewPostButton from "@/ui/new-post-button";
import Image from "next/image";
import { BadgeInfo } from "lucide-react";
import Tooltip from "@/ui/shared/tooltip";
import { fetcher, timeAgo } from "@/lib/utils";
import { ContentItem } from "@/lib/types/note";
export default function Wrapper({
id,
session,
}: {
id: string;
session: Session | null;
}) {
const { share, isLoading } = useShareNoteByLocalId(id);
const [canRenderGuide, setCanRenderGuide] = useState(false);
const [parseContent, setParseContent] = useState<ContentItem>();
const [currentContent, setCurrentContent] = useState<JSONContent>({});
const { user } = useUserInfoById(share?.data.userId);
useEffect(() => {
if (window) {
localStorage.removeItem(Content_Public_Storage_Key);
}
}, []);
useEffect(() => {
if (share && share.data && share.data.data) {
const parsed = JSON.parse(share.data.data || "{}");
setParseContent(parsed);
setCurrentContent(parsed.content);
setCanRenderGuide(true);
const title = parsed.title || "Untitled";
document.title = `${title} | Inke`;
}
}, [share]);
const handleUpdateKeeps = async () => {
if (share && share.data) {
await fetcher("/api/share/update/keep", {
method: "POST",
body: JSON.stringify({ id: share.data.id }),
});
}
};
return (
<div className="min-h-screen">
{isLoading && <LoadingCircle className="mx-auto h-6 w-6" />}
{!isLoading && share && share.data && canRenderGuide && (
<>
{user && (
<div className="mx-8 flex h-24 items-center justify-between">
<div className="flex items-center gap-2">
<Image
alt="avatar"
src={user && user.image ? user.image : "/cat.png"}
width={50}
height={50}
/>
<div className="flex flex-col justify-between gap-1">
<span className="cursor-pointer font-semibold text-slate-700">
{user.name}
</span>
<p className="flex text-xs text-slate-500">
<strong>{share.data.click}</strong>
&nbsp;clicks,&nbsp;
<strong>{share.data.keeps}</strong>&nbsp;keeps
{parseContent.created_at && (
<span className="ml-2 hidden border-l border-slate-300 pl-2 text-xs text-slate-500 sm:block">
Updated {timeAgo(parseContent.updated_at)}
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<NewPostButton
className="h-8 w-28 px-3 py-1"
text="Keep writing"
from="publish"
defaultContent={currentContent}
callback={handleUpdateKeeps}
/>
<Tooltip
content={
<div className="w-64 px-3 py-2 text-sm text-slate-400">
<h1 className="mb-2 font-semibold text-slate-500">
What&apos;s keep writing?
</h1>
<p>
Keep writing allows you to quickly create a note with
the same content as this note locally.
</p>
</div>
}
fullWidth={false}
>
<button className="hidden sm:block">
<BadgeInfo className="h-4 w-4 text-slate-400 hover:text-slate-500" />
</button>
</Tooltip>
</div>
</div>
)}
<InkeEditor
className="relative -mt-6 mb-3 w-screen max-w-screen-lg overflow-y-auto border-stone-200 bg-white"
storageKey={Content_Public_Storage_Key}
debounceDuration={Default_Debounce_Duration}
defaultValue={currentContent}
editable={false}
bot={true}
/>
</>
)}
{!isLoading && !share.data && <UINotFound />}
</div>
);
}

View File

@ -0,0 +1,11 @@
import { getServerSideSitemapIndex } from "next-sitemap";
export async function GET(request: Request) {
// Method to source urls from cms
// const urls = await fetch('https//example.com/api')
return getServerSideSitemapIndex([
"https://inke.app/publish.xml",
"https://inke.app/pricing.xml",
]);
}

View File

@ -0,0 +1,28 @@
import Providers from "@/app/providers";
import { siteConfig } from "@/config/site";
import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
export const metadata: Metadata = {
title: "Setting | Inke",
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<Providers>{children}</Providers>
</>
);
}

View File

@ -0,0 +1,19 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Nav from "@/ui/layout/nav";
import Wrapper from "./wrapper";
import Footer from "@/ui/layout/footer";
export default async function Page() {
const session = await getServerSession(authOptions);
return (
<>
<div className="pt-16">
{/* @ts-expect-error Server Component */}
<Nav />
<Wrapper session={session} />
<Footer />
</div>
</>
);
}

View File

@ -0,0 +1,23 @@
"use client";
import { Session } from "next-auth";
import Image from "next/image";
export default function Wrapper({ session }: { session: Session | null }) {
return (
<>
<div className="mx-auto h-screen max-w-3xl px-6">
<div className="flex flex-col items-center justify-center">
<Image
src="/cat.png"
alt="404"
width="250"
height="250"
className="ml-4 rounded-sm"
/>
<p className="mt-4">Coming soon...</p>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,28 @@
import Providers from "@/app/providers";
import { siteConfig } from "@/config/site";
import "@/styles/globals.css";
import { Metadata } from "next";
import { ReactNode } from "react";
export const metadata: Metadata = {
title: "Templates | Inke",
description: siteConfig.description,
keywords: siteConfig.keywords,
authors: siteConfig.authors,
creator: siteConfig.creator,
themeColor: siteConfig.themeColor,
icons: siteConfig.icons,
metadataBase: siteConfig.metadataBase,
openGraph: siteConfig.openGraph,
twitter: siteConfig.twitter,
manifest: siteConfig.manifest,
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<Providers>{children}</Providers>
</>
);
}

View File

@ -0,0 +1,19 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import Nav from "@/ui/layout/nav";
import Wrapper from "./wrapper";
import Footer from "@/ui/layout/footer";
export default async function Page() {
const session = await getServerSession(authOptions);
return (
<>
<div className="pt-16">
{/* @ts-expect-error Server Component */}
<Nav />
<Wrapper session={session} />
<Footer />
</div>
</>
);
}

View File

@ -0,0 +1,11 @@
"use client";
import { Session } from "next-auth";
export default function Wrapper({ session }: { session: Session | null }) {
return (
<>
<div className="mx-auto max-w-3xl px-6 py-6">working...</div>
</>
);
}

61
apps/web/config/site.ts Normal file
View File

@ -0,0 +1,61 @@
const baseSiteConfig = {
name: "Inke | Note",
description:
"Inke is a notebook with AI assisted writing and real-time collaboration",
url: "https://inke.app",
metadataBase: new URL("https://inke.app"),
keywords: [
"Editor",
"Notebook",
"Markdown",
"WYSIWYG",
"Collaboration",
"Openai",
"ChatGPT",
"AI",
"Next.js",
"note",
"writing",
"translate",
"AIGC",
],
authors: [
{
name: "yesmore",
url: "https://github.com/yesmore",
},
],
creator: "@yesmoree",
themeColor: "#fff",
// 生成所有平台的icohttps://realfavicongenerator.net/
icons: {
icon: "/favicon.ico",
shortcut: "/favicon-32x32.png",
apple: "/apple-touch-icon.png",
},
ogImage: "https://inke.app/opengraph-image.png",
links: {
twitter: "https://twitter.com/yesmoree",
github: "https://github.com/yesmore/inke",
},
manifest: "/manifest.json",
};
export const siteConfig = {
...baseSiteConfig,
openGraph: {
type: "website",
locale: "en_US",
url: baseSiteConfig.url,
title: baseSiteConfig.name,
description: baseSiteConfig.description,
siteName: baseSiteConfig.name,
},
twitter: {
card: "summary_large_image",
title: baseSiteConfig.name,
description: baseSiteConfig.description,
images: [`${baseSiteConfig.url}/opengraph-image.png`],
creator: baseSiteConfig.creator,
},
};

402
apps/web/lib/consts.ts Normal file
View File

@ -0,0 +1,402 @@
export const Note_Storage_Key = "note_storage_data";
export const Content_Storage_Key = "inke__content";
export const Content_Guide_Storage_Key = "inke__guide__content";
export const Content_Public_Storage_Key = "inke__public__content";
export const Default_Debounce_Duration = 750;
interface Plans {
ai_generate_day: number;
ai_generate_chars: number;
note_upload_count: number;
image_upload_size: number;
ai_bot_history_length: number;
space_user_count: number;
pay: number;
}
export const Account_Plans: Plans[] = [
{
// sign for free
ai_generate_day: 100,
ai_generate_chars: 300,
ai_bot_history_length: 10,
note_upload_count: 20,
image_upload_size: 1, // mb
space_user_count: 10,
pay: 0,
},
{
// basic
ai_generate_day: 300,
ai_generate_chars: 300,
ai_bot_history_length: 32,
note_upload_count: 10000,
image_upload_size: 1,
space_user_count: 10,
pay: 0,
},
{
// pro
ai_generate_day: 1000,
ai_generate_chars: 1000,
ai_bot_history_length: 100,
note_upload_count: 10000,
image_upload_size: 10,
space_user_count: 10,
pay: 0,
},
{
ai_generate_day: 10,
ai_generate_chars: 10,
ai_bot_history_length: 10,
note_upload_count: 0,
image_upload_size: 1,
space_user_count: 10,
pay: 0,
},
{
ai_generate_day: 10,
ai_generate_chars: 10,
ai_bot_history_length: 10,
note_upload_count: 0,
image_upload_size: 1,
space_user_count: 10,
pay: 0,
},
{
ai_generate_day: 50,
ai_generate_chars: 200,
ai_bot_history_length: 10,
note_upload_count: 0,
image_upload_size: 1,
space_user_count: 10,
pay: 0,
},
];
export const defaultEditorContent = {
type: "doc",
content: [{ type: "paragraph" }],
};
export const defaultEditorGuideContent = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2 },
content: [
{ type: "text", marks: [{ type: "bold" }], text: "🎉" },
{ type: "text", text: " " },
{
type: "text",
marks: [{ type: "bold" }],
text: "Introducing Inke",
},
],
},
{
type: "paragraph",
content: [
{
type: "text",
marks: [
{
type: "link",
attrs: {
href: "https://github.com/yesmore/inke",
target: "_blank",
rel: "noopener noreferrer nofollow",
class:
"novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer",
},
},
],
text: "Inke",
},
{
type: "text",
text: " is a simple-style editor with AI-powered autocompletion. With a clean and minimalist design, Inke offers a wide range of features to enhance your writing process.",
},
],
},
{ type: "paragraph", content: [{ type: "text", text: "Key Features:" }] },
{
type: "orderedList",
attrs: { tight: true, start: 1 },
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "😗 " },
{
type: "text",
marks: [{ type: "bold" }, { type: "italic" }],
text: "WYSIWYG Editing ",
},
{
type: "text",
text: "like markdown: Inke ensures that what you see is exactly what you get. Say goodbye to complicated formatting issues and enjoy a hassle-free editing experience. Inke offers full support for Markdown syntax with markdown shortcuts.",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "😄 " },
{
type: "text",
marks: [{ type: "bold" }, { type: "italic" }],
text: "Efficient Shortcut Inputs",
},
{
type: "text",
text: ": Inke understands the importance of speed and efficiency, so it support slash menu & bubble menu.",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "😍 " },
{
type: "text",
marks: [{ type: "bold" }, { type: "italic" }],
text: "AI-powered Text Autocomplete",
},
{
type: "text",
text: ": Boost your productivity with Inke's AI-powered autocomplete feature. Inke intelligently suggests completions for your text, making writing even faster and more efficient. (type ",
},
{ type: "text", marks: [{ type: "code" }], text: "??" },
{
type: "text",
text: " to activate, or select from slash menu)",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "🥰 " },
{
type: "text",
marks: [{ type: "bold" }, { type: "italic" }],
text: "Local Data Storage",
},
{
type: "text",
text: ": Rest easy knowing that your data is safe and secure. Inke saves all your notes and documents locally, ensuring your sensitive information remains private.",
},
],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "🥳 " },
{
type: "text",
marks: [{ type: "bold" }, { type: "italic" }],
text: "Image uploads",
},
{
type: "text",
text: ": drag & drop / copy & paste, or select from slash menu.",
},
],
},
],
},
],
},
{
type: "paragraph",
content: [{ type: "text", text: "Upcoming features" }],
},
{
type: "taskList",
content: [
{
type: "taskItem",
attrs: { checked: true },
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Cloud storage notes" }],
},
],
},
{
type: "taskItem",
attrs: { checked: true },
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Export notes as images",
},
],
},
],
},
{
type: "taskItem",
attrs: { checked: true },
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Export notes to a local file",
},
],
},
],
},
],
},
{
type: "heading",
attrs: { level: 2 },
content: [
{ type: "text", text: "🎊 " },
{ type: "text", marks: [{ type: "bold" }], text: "Try Inke here" },
],
},
{
type: "paragraph",
content: [
{ type: "text", text: "Now start a new line and type " },
{ type: "text", marks: [{ type: "code" }], text: "/" },
{ type: "text", text: ", then you will see a " },
{
type: "text",
marks: [
{ type: "underline" },
{ type: "textStyle", attrs: { color: "#2563EB" } },
],
text: "menu",
},
{ type: "text", text: " pop up, press the " },
{ type: "text", marks: [{ type: "code" }], text: "↑" },
{ type: "text", text: " or " },
{ type: "text", marks: [{ type: "code" }], text: "↓" },
{ type: "text", text: " to move the cursor and press " },
{ type: "text", marks: [{ type: "code" }], text: "Enter" },
{ type: "text", text: " to select it." },
],
},
{
type: "paragraph",
content: [
{
type: "text",
marks: [{ type: "underline" }],
text: "Markdown shortcuts",
},
{
type: "text",
text: " make it easy to format the text while typing.",
},
],
},
{
type: "paragraph",
content: [
{ type: "text", text: "To test that, start a new line and type " },
{ type: "text", marks: [{ type: "code" }], text: "#" },
{ type: "text", text: " followed by a space to get a heading. Try " },
{ type: "text", marks: [{ type: "code" }], text: "#" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: "##" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: "###" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: "####" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: "#####" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: "######" },
{ type: "text", text: " for different levels." },
],
},
{
type: "paragraph",
content: [
{ type: "text", text: "Try " },
{ type: "text", marks: [{ type: "code" }], text: ">" },
{ type: "text", text: " for blockquotes, " },
{ type: "text", marks: [{ type: "code" }], text: "*" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: "-" },
{ type: "text", text: " or " },
{ type: "text", marks: [{ type: "code" }], text: "+" },
{ type: "text", text: " for bullet lists, or " },
{ type: "text", marks: [{ type: "code" }], text: "`foobar`" },
{ type: "text", text: " to highlight code, " },
{ type: "text", marks: [{ type: "code" }], text: "~~tildes~~" },
{ type: "text", text: " to strike text, or " },
{ type: "text", marks: [{ type: "code" }], text: "==equal signs==" },
{ type: "text", text: " to highlight text." },
],
},
{
type: "paragraph",
content: [
{
type: "text",
text: "You can overwrite existing input rules or add your own to nodes, marks and extensions.",
},
],
},
{
type: "paragraph",
content: [
{ type: "text", text: "For example, we added the " },
{ type: "text", marks: [{ type: "code" }], text: "Typography" },
{ type: "text", text: " extension here. Try typing " },
{ type: "text", marks: [{ type: "code" }], text: "(c)" },
{
type: "text",
text: " to see how its converted to a proper © character. You can also try ",
},
{ type: "text", marks: [{ type: "code" }], text: "->" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: ">>" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: "1/2" },
{ type: "text", text: ", " },
{ type: "text", marks: [{ type: "code" }], text: "!=" },
{ type: "text", text: ", or " },
{ type: "text", marks: [{ type: "code" }], text: "--" },
{ type: "text", text: "." },
],
},
],
};

View File

@ -0,0 +1,140 @@
import { ContentItem } from "../types/note";
import prisma from "./prisma";
export async function createCollaboration(
userId: string,
localId: string,
roomId: string,
title: string,
) {
return await prisma.collaboration.create({
data: {
userId,
localId,
roomId,
title,
click: 0,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
expired: null,
},
});
}
export async function findCollaborationByRoomId(roomId: string, uid?: string) {
if (uid) {
return await prisma.collaboration.findFirst({
where: {
roomId,
userId: uid,
deletedAt: null,
expired: null,
},
});
} else {
return await prisma.collaboration.findFirst({
where: {
roomId,
// deletedAt: null,
// expired: null,
},
});
}
}
export async function findFirstCollaborationByRoomId(roomId: string) {
return await prisma.collaboration.findFirst({
where: {
roomId,
deletedAt: null,
expired: null,
},
// select: {
// userId: true,
// roomId: true,
// title: true,
// createdAt: true
// }
});
}
// 用户当前本地笔记是否已加入协作
export async function findCollaborationBylocalId(
localId: string,
userId: string,
) {
return await prisma.collaboration.findFirst({
where: {
userId,
localId,
deletedAt: null,
expired: null,
},
select: {
roomId: true,
},
});
}
// 邀请中转页调用
export async function findCollaborationByDBId(id: string) {
return await prisma.collaboration.findFirst({
where: {
id,
deletedAt: null,
expired: null,
},
});
}
// 该协作加入人数
export async function findCollaborationInviteCount(id: string) {
return await prisma.collaboration.count({
where: {
roomId: id,
deletedAt: null,
expired: null,
},
});
}
// 用户所有参与的协作分享
export async function findUserCollaborations(userId: string) {
return await prisma.collaboration.findMany({
where: {
userId,
deletedAt: null,
},
});
}
// export async function updateCollaboration(click: number, id: string) {
// return await prisma.collaboration.update({
// where: {
// id,
// },
// data: {
// click,
// updatedAt: new Date(),
// },
// });
// }
export async function updateCollaborationClick(id: string, pre: number) {
return await prisma.collaboration.update({
where: {
id,
},
data: {
click: pre + 1,
},
});
}
export async function deleteCollaborationNote(id: string) {
return await prisma.collaboration.update({
where: {
id,
},
data: {
deletedAt: new Date(),
},
});
}

11
apps/web/lib/db/prisma.ts Normal file
View File

@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV === "development") global.prisma = prisma;
export default prisma;

103
apps/web/lib/db/share.ts Normal file
View File

@ -0,0 +1,103 @@
import { ContentItem } from "../types/note";
import prisma from "./prisma";
export async function createShareNote(json: ContentItem, uid: string) {
return await prisma.shareNote.create({
data: {
userId: uid,
localId: json.id,
data: JSON.stringify(json),
click: 0,
keeps: 0,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
});
}
export async function findShareByLocalId(id: string, uid?: string) {
if (uid) {
return await prisma.shareNote.findFirst({
where: {
localId: id,
userId: uid,
deletedAt: null,
},
});
} else {
return await prisma.shareNote.findFirst({
where: {
localId: id,
deletedAt: null,
},
});
}
}
export async function findShareByDBId(id: string) {
return await prisma.shareNote.findFirst({
where: {
id,
deletedAt: null,
},
});
}
export async function findUserSharesCount(uid: string) {
return await prisma.shareNote.count({
where: {
userId: uid,
deletedAt: null,
},
});
}
export async function findUserShares(uid: string) {
return await prisma.shareNote.findMany({
where: {
userId: uid,
deletedAt: null,
},
});
}
export async function updateShareNote(json: ContentItem, id: string) {
return await prisma.shareNote.update({
where: {
id,
},
data: {
data: JSON.stringify(json),
updatedAt: new Date(),
},
});
}
export async function updateShareClick(id: string, pre: number) {
return await prisma.shareNote.update({
where: {
id,
},
data: {
click: pre + 1,
},
});
}
export async function updateShareKeeps(id: string, pre: number) {
return await prisma.shareNote.update({
where: {
id,
},
data: {
keeps: pre + 1,
},
});
}
export async function deleteShareNote(id: string) {
return await prisma.shareNote.update({
where: {
id,
},
data: {
deletedAt: new Date(),
},
});
}

23
apps/web/lib/db/user.ts Normal file
View File

@ -0,0 +1,23 @@
import prisma from "./prisma";
// get all users from user schema
export const getUsers = async () => {
return await prisma.user.findMany();
};
export const getUserByEmail = async (email: string) => {
return await prisma.user.findFirst({
where: { email },
});
};
export const getUserById = async (id: string) => {
return await prisma.user.findFirst({
where: { id },
select: { name: true, image: true },
});
};
export const updateUserName = async (id: string, name: string) => {
return await prisma.user.update({ data: { name }, where: { id } });
};

View File

@ -0,0 +1,16 @@
import { useCallback, useEffect, useState } from "react";
export default function useScroll(threshold: number) {
const [scrolled, setScrolled] = useState(false);
const onScroll = useCallback(() => {
setScrolled(window.pageYOffset > threshold);
}, [threshold]);
useEffect(() => {
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);
return scrolled;
}

View File

@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
export default function useWindowSize() {
const [windowSize, setWindowSize] = useState<{
width: number | undefined;
height: number | undefined;
}>({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return {
windowSize,
isMobile: typeof windowSize?.width === "number" && windowSize?.width < 768,
isDesktop:
typeof windowSize?.width === "number" && windowSize?.width >= 768,
};
}

View File

@ -0,0 +1,8 @@
export interface ActiveCodeItem {
id: string;
userId: string;
code: string;
expires: string;
createdAt: string;
updatedAt: string;
}

View File

@ -0,0 +1,21 @@
import { JSONContent } from "@tiptap/react";
export interface ContentItem {
id: string;
title: string;
content: JSONContent;
tag?: string;
created_at?: number;
updated_at?: number;
collapsed?: boolean;
}
export interface ShareNoteItem {
id: string;
userId: string;
localId: string;
data: string;
createdAt: number;
updatedAt: number;
deletedAt?: number;
}

View File

@ -0,0 +1,5 @@
export interface IResponse<T> {
code: number;
msg: string;
data: T;
}

View File

@ -0,0 +1,10 @@
export interface User {
id?: string;
name?: string;
email: string;
emailVerified: string;
image?: string;
credit: number;
active: number;
plan: string;
}

View File

@ -1,6 +1,166 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import ms from "ms";
import { ContentItem } from "./types/note";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const timeAgo = (timestamp: number, timeOnly?: boolean): string => {
if (!timestamp) return "never";
return `${ms(Date.now() - new Date(timestamp).getTime())}${
timeOnly ? "" : " ago"
}`;
};
export async function fetcher<JSON = any>(
input: RequestInfo,
init?: RequestInit,
): Promise<JSON> {
const res = await fetch(input, init);
if (!res.ok) {
const json = await res.json();
if (json.error) {
const error = new Error(json.error) as Error & {
status: number;
};
error.status = res.status;
throw error;
} else {
throw new Error("An unexpected error occurred");
}
}
return res.json();
}
export function nFormatter(num: number, digits?: number) {
if (!num) return "0";
const lookup = [
{ value: 1, symbol: "" },
{ value: 1e3, symbol: "K" },
{ value: 1e6, symbol: "M" },
{ value: 1e9, symbol: "G" },
{ value: 1e12, symbol: "T" },
{ value: 1e15, symbol: "P" },
{ value: 1e18, symbol: "E" },
];
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
var item = lookup
.slice()
.reverse()
.find(function (item) {
return num >= item.value;
});
return item
? (num / item.value).toFixed(digits || 1).replace(rx, "$1") + item.symbol
: "0";
}
export function isEmail(str: string) {
const reg = /^([a-zA-Z\d._%+-]+)@([a-zA-Z\d.-]+\.[a-zA-Z]{2,})$/;
return reg.test(str);
}
export function getAvatarById(id: string) {
return `https://avatars.dicebear.com/api/micah/${id}.svg`;
}
export function formatDate(dateString: string) {
if (!dateString) {
return `0秒前`;
}
const sourceDate = new Date(dateString).getTime();
const currentDate = new Date().getTime();
const timeDiff = currentDate - sourceDate;
const secondsDiff = Math.floor(timeDiff / 1000); // 计算秒数差
if (secondsDiff < 60) {
return `${secondsDiff}秒前`;
} else if (secondsDiff < 3600) {
// 不足1小时
const minutesDiff = Math.floor(secondsDiff / 60);
return `${minutesDiff}分钟前`;
} else if (secondsDiff < 86400) {
// 不足1天
const hoursDiff = Math.floor(secondsDiff / 3600);
return `${hoursDiff}小时前`;
} else {
const daysDiff = Math.floor(secondsDiff / 86400);
return `${daysDiff}天前`;
}
}
export function fomatTmpDate(dateNum: number) {
const date = new Date(dateNum);
return `${date.getMonth() + 1}/${date.getDate()}`;
}
export function generateName(id: string) {
return `u-${id.slice(-6)}`;
}
export function exportAsJson(data: any, filename: string) {
const dataStr = JSON.stringify(data);
const dataUri =
"data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", filename + ".json");
linkElement.style.display = "none";
document.body.appendChild(linkElement);
linkElement.click();
document.body.removeChild(linkElement);
}
export function exportAsMarkdownFile(data: string, fileName: string) {
const dataUri = "data:text/plain;charset=utf-8," + encodeURIComponent(data);
const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", fileName + ".md");
linkElement.style.display = "none";
document.body.appendChild(linkElement);
linkElement.click();
document.body.removeChild(linkElement);
}
export function getRandomElement(arr: string[]) {
let currentIndex = arr.length;
let temporaryValue;
let randomIndex;
// 当还有未洗牌的元素时
while (currentIndex !== 0) {
// 从剩余的元素中随机选择一个元素
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// 将当前元素与随机选择的元素进行交换
temporaryValue = arr[currentIndex];
arr[currentIndex] = arr[randomIndex];
arr[randomIndex] = temporaryValue;
}
// 返回洗牌后的数组的第一个元素
return arr[0];
}
export function greeting() {
var currentTime = new Date();
var currentHour = currentTime.getHours();
if (currentHour < 12) {
return "上午好";
} else if (currentHour < 18) {
return "下午好";
} else {
return "晚上好";
}
}

View File

@ -0,0 +1,39 @@
/**
* @type {import('next-sitemap').IConfig}
* @see https://github.com/iamvishnusankar/next-sitemap#readme
*/
const fs = require("fs");
const path = require("path");
module.exports = {
siteUrl: "https://inke.app",
changefreq: "daily",
priority: 0.7,
exclude: ["/server-sitemap-index.xml"],
generateRobotsTxt: true,
sitemapSize: 5000, // 站点超过5000个拆分到多个文件
robotsTxtOptions: {
additionalSitemaps: ["https://inke.app/server-sitemap-index.xml"],
policies: [
{
userAgent: "*",
allow: "/",
},
{
userAgent: "AhrefsBot",
disallow: ["/"],
},
{
userAgent: "SemrushBot",
disallow: ["/"],
},
{
userAgent: "MJ12bot",
disallow: ["/"],
},
{
userAgent: "DotBot",
disallow: ["/"],
},
],
},
};

View File

@ -1,10 +1,40 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "api.producthunt.com",
},
],
domains: [
"lh3.googleusercontent.com",
"avatars.githubusercontent.com",
"vercel.com",
"api.dicebear.com",
"api.producthunt.com",
"gcloud-1303456836.cos.ap-chengdu.myqcloud.com",
"img.shields.io",
],
},
redirects: async () => {
return [
{
source: "/github",
destination: "https://github.com/yesmore/inke",
source: "/document",
destination:
"https://inke.app/publish/0e1be533-ae66-4ffa-9725-bd6b84899e78",
permanent: true,
},
{
source: "/collaboration",
destination:
"https://inke.app/publish/5f099bdd-b2a1-4f88-bbcb-eb2292447d2c",
permanent: true,
},
{
source: "/shortcuts",
destination:
"https://inke.app/publish/b842b60f-29be-49b5-9c7e-44860c8d2df3",
permanent: true,
},
];

View File

@ -1,17 +1,23 @@
{
"name": "inke-web-app",
"version": "0.1.0",
"name": "inke-web",
"version": "0.3.4",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "prisma generate && next dev",
"build": "prisma generate && prisma db push && next build",
"start": "next start",
"format:write": "prettier --write \"**/*.{css,js,json,jsx,ts,tsx}\"",
"format": "prettier \"**/*.{css,js,json,jsx,ts,tsx}\"",
"lint": "next lint"
"lint": "next lint",
"postbuild": "next-sitemap"
},
"dependencies": {
"@giscus/react": "^2.3.0",
"@next-auth/mongodb-adapter": "^1.1.3",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^4.8.1",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.7",
"@tiptap/core": "^2.0.3",
"@tiptap/extension-color": "^2.0.3",
"@tiptap/extension-highlight": "^2.0.3",
@ -27,32 +33,51 @@
"@tiptap/react": "^2.0.3",
"@tiptap/starter-kit": "^2.0.0-beta.220",
"@tiptap/suggestion": "^2.0.3",
"@types/ms": "^0.7.32",
"@types/node": "18.15.3",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.4",
"@upstash/ratelimit": "^0.4.3",
"@vercel/analytics": "^1.0.1",
"@vercel/blob": "^0.9.2",
"@vercel/kv": "^0.2.1",
"ai": "^2.1.3",
"clsx": "^1.2.1",
"cos-nodejs-sdk-v5": "^2.12.4",
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"focus-trap-react": "^10.2.2",
"html-to-image": "^1.11.11",
"inkejs": "workspace:^",
"lucide-react": "^0.244.0",
"ms": "^2.1.3",
"next": "13.4.8-canary.14",
"inke": "workspace:^",
"next-auth": "4.22.1",
"next-sitemap": "^4.2.3",
"nodemailer": "^6.9.5",
"openai": "^4.3.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hot-toast": "^2.4.1",
"react-markdown": "^8.0.5",
"react-to-pdf": "^1.0.1",
"react-type-animation": "^3.2.0",
"shortid": "^2.2.16",
"sonner": "^0.7.0",
"swr": "^2.2.1",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.1",
"typescript": "4.9.5",
"use-debounce": "^9.0.3"
"use-debounce": "^9.0.3",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/nodemailer": "^6.4.11",
"@types/shortid": "^0.0.31",
"prisma": "^4.13.0",
"tailwind-config": "workspace:*"
}
}

View File

@ -0,0 +1,112 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(auto()) @map("_id") @db.ObjectId
sessionToken String @unique
userId String @db.ObjectId
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String? @unique
emailVerified DateTime?
credit Int @default(100)
active Boolean @default(false)
plan String @default("0")
name String?
image String?
accounts Account[]
sessions Session[]
ActiveCodeWithUser ActiveCodeWithUser[]
ShareNote ShareNote[]
Collaboration Collaboration[]
}
model VerificationToken {
id String @id @default(auto()) @map("_id") @db.ObjectId
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model ActiveCodeWithUser {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
code String
expires DateTime
createdAt DateTime
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model ShareNote {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
localId String
data String
click Int @default(0)
keeps Int @default(0)
createdAt DateTime
updatedAt DateTime @updatedAt
deletedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Collaboration {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
localId String
roomId String
title String
click Int @default(0)
expired DateTime?
createdAt DateTime
updatedAt DateTime @updatedAt
deletedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
apps/web/public/cat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
apps/web/public/desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

BIN
apps/web/public/e1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
apps/web/public/e2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
apps/web/public/e3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,7 @@
<svg width="86" height="32" viewBox="0 0 86 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="85" height="31" rx="3.5" fill="#FFFFFF"/>
<path d="M16 24C20.4183 24 24 20.4183 24 16C24 11.5817 20.4183 8 16 8C11.5817 8 8 11.5817 8 16C8 20.4183 11.5817 24 16 24Z" fill="#FF6154"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.0666 16V16.0001H14.8V13.6001H17.0666V13.6C17.7294 13.6 18.2666 14.1373 18.2666 14.8C18.2666 15.4627 17.7294 16 17.0666 16ZM17.0666 12V12.0001L13.2 12V20H14.8V17.6001H17.0666V17.6C18.613 17.6 19.8666 16.3464 19.8666 14.8C19.8666 13.2536 18.613 12 17.0666 12Z" fill="#FFFFFF"/>
<text x="30" y="21" font-family="Helvetica-Bold, Helvetica" font-size="14" line-height="14" font-weight="bold" letter-spacing="0.5px" fill="#FF6154">Follow</text>
<rect x="0.5" y="0.5" width="85" height="31" rx="3.5" stroke="#FF6154"/>
</svg>

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
apps/web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

3
apps/web/public/logo.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="128" height="128" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path fill="#000000" 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>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="250" height="54" viewBox="0 0 250 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-130.000000, -73.000000)">
<g transform="translate(130.000000, 73.000000)">
<rect stroke="#FF6154" stroke-width="1" fill="#FFFFFF" x="0.5" y="0.5" width="249" height="53" rx="10"></rect>
<text font-family="Helvetica-Bold, Helvetica" font-size="9" font-weight="bold" fill="#FF6154">
<tspan x="53" y="20">FEATURED ON</tspan>
</text>
<text font-family="Helvetica-Bold, Helvetica" font-size="21" font-weight="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 font-family="Helvetica-Bold, Helvetica" font-size="13" font-weight="bold" line-spacing="20">
<tspan x="12.4" y="27">52</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>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,26 @@
# *
User-agent: *
Allow: /
# AhrefsBot
User-agent: AhrefsBot
Disallow: /
# SemrushBot
User-agent: SemrushBot
Disallow: /
# MJ12bot
User-agent: MJ12bot
Disallow: /
# DotBot
User-agent: DotBot
Disallow: /
# Host
Host: https://inke.app
# Sitemaps
Sitemap: https://inke.app/sitemap.xml
Sitemap: https://inke.app/server-sitemap-index.xml

View File

@ -0,0 +1,27 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="128.000000pt" height="128.000000pt" viewBox="0 0 128.000000 128.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,128.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M690 1217 c65 -20 178 -82 168 -93 -2 -2 -26 3 -53 11 -96 29 -276
24 -357 -10 -29 -12 -21 1 15 25 28 18 29 20 7 15 -167 -45 -311 -162 -360
-292 -25 -68 -30 -96 -30 -168 -1 -91 7 -130 44 -240 15 -42 -29 43 -51 100
l-23 60 5 -63 c13 -146 75 -253 235 -406 103 -97 144 -117 208 -98 21 7 48 22
60 34 23 25 50 85 46 106 -1 6 -2 24 -3 38 -2 44 -57 88 -145 115 -154 47
-226 150 -227 326 0 76 34 180 81 243 18 25 30 36 25 25 -11 -31 -24 -89 -20
-93 2 -2 9 7 16 20 18 35 88 97 144 129 70 40 148 61 230 61 186 1 318 -91
383 -265 31 -83 27 -100 -8 -31 -39 79 -78 124 -144 168 -76 50 -101 57 -41
11 28 -21 56 -46 63 -54 38 -47 64 -97 80 -158 58 -215 -64 -421 -293 -493
-63 -20 -62 -28 2 -18 72 10 148 34 191 58 48 29 39 8 -21 -47 -48 -44 -168
-113 -195 -113 -6 0 -13 -4 -16 -9 -8 -12 69 4 137 27 73 26 150 78 212 144
28 29 39 43 25 32 -46 -38 -50 -36 -19 11 72 106 118 264 115 391 0 35 0 64 1
64 2 0 9 -15 17 -32 13 -29 14 -30 15 -9 1 13 -6 51 -15 85 -57 222 -252 386
-477 401 l-72 5 45 -13z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://inke.app/server-sitemap-index.xml</loc></sitemap>
</sitemapindex>

View File

@ -0,0 +1,26 @@
import { Note_Storage_Key } from "@/lib/consts";
import { ContentItem } from "@/lib/types/note";
import Dexie, { Table } from "dexie";
const db = new Dexie("database");
db.version(1).stores({
note_storage_data:
"id, title, content, tag, created_at, updated_at, collapsed",
});
export const noteTable = db.table(Note_Storage_Key);
export default db;
export const addNote = async (item: ContentItem) => {
await noteTable.add(item);
};
export const updateNote = async (item: ContentItem) => {
await noteTable.put(item);
};
export const deleteNote = async (id: string) => {
await noteTable.delete(id);
};
export const patchNote = async (data: ContentItem[]) => {
await noteTable.bulkAdd(data);
};

View File

@ -3,5 +3,29 @@
@tailwind utilities;
body {
background-color: var(--novel-white);
background-color: var(--inke-white);
}
*::-webkit-scrollbar {
width: 0px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: hsla(215, 10%, 77%, 0);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: hsla(0, 0%, 59%, 0.524);
}
.grids {
background-image: linear-gradient(
0deg,
rgba(0, 0, 0, 0.05) 1px,
transparent 0
),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 0);
background-position: 50%;
background-size: 24px 24px;
}

View File

@ -0,0 +1,66 @@
.search-label {
display: flex;
align-items: center;
box-sizing: border-box;
position: relative;
border: 1px solid transparent;
border-radius: 6px;
overflow: hidden;
background: #f5f5f5;
padding: 6px 9px;
cursor: text;
transition: all 0.5s;
}
.search-label:hover {
border-color: rgb(202, 202, 202);
}
.search-label:focus-within {
background: #eaeaea;
border-color: rgba(230, 230, 230, 0.779);
}
.search-label input {
outline: none;
width: 100%;
border: none;
background: none;
color: rgb(162, 162, 162);
margin-left: -20px;
font-size: 14px;
}
.search-label input:focus + .search-button {
display: none;
}
.search-label input:valid {
width: calc(100% - 22px);
transform: translateX(20px);
}
.search-button:active {
box-shadow: inset 0 1px 0 0 #c7c7c7, inset 0 0 1px 1px rgb(203, 203, 203),
0 1px 2px 0 rgba(28, 28, 29, 0.4);
text-shadow: 0 1px 0 #7e7e7e;
color: transparent;
}
.search-button {
position: absolute;
color: #939393;
right: 12px;
border: 1px solid #e7e7e7;
background: linear-gradient(-225deg, #eaeaea, #e1e1e1);
border-radius: 5px;
text-align: center;
box-shadow: inset 0 -2px 0 0 #cfcfcf, inset 0 0 1px 1px rgb(245, 245, 245),
1px 1px 1px 1px rgba(226, 226, 226, 0.4);
cursor: pointer;
font-size: 16px;
width: 30px;
height: 25px;
line-height: 22px;
z-index: 10;
}

Some files were not shown because too many files have changed in this diff Show More