重构大量文件
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
SESSION_SECRET="replace-with-a-long-random-secret"
|
||||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -1,36 +1,30 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# NoteAI
|
||||||
|
|
||||||
## Getting Started
|
基于 Next.js + Prisma + TipTap 的本地知识笔记应用。
|
||||||
|
|
||||||
First, run the development server:
|
## 环境变量
|
||||||
|
|
||||||
|
在项目根目录创建 `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
SESSION_SECRET="replace-with-a-long-random-secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `SESSION_SECRET` 必填,用于服务端签名登录会话。
|
||||||
|
- 生产环境请使用长度至少 32 的随机字符串。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
默认端口为 `3001`。
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## 代码检查
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
## Learn More
|
npm run lint
|
||||||
|
```
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import nextTs from "eslint-config-next/typescript";
|
|||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...nextTs,
|
||||||
|
{
|
||||||
|
files: ["src/components/editor/**/*.{ts,tsx}", "src/components/editor.tsx"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
// Override default ignores of eslint-config-next.
|
// Override default ignores of eslint-config-next.
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
// Default ignores of eslint-config-next:
|
// Default ignores of eslint-config-next:
|
||||||
|
|||||||
Binary file not shown.
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const { scrypt, randomBytes } = require('crypto');
|
const { scrypt, randomBytes } = require('crypto');
|
||||||
const { promisify } = require('util');
|
const { promisify } = require('util');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const { scrypt, randomBytes } = require('crypto');
|
const { scrypt, randomBytes } = require('crypto');
|
||||||
const { promisify } = require('util');
|
const { promisify } = require('util');
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { verifyPassword } from "@/lib/auth";
|
import { verifyPassword } from "@/lib/auth";
|
||||||
|
import { createSessionToken, getSessionCookieName, getSessionTtlSeconds } from "@/lib/session";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const { password } = await req.json();
|
const { password, rememberMe, durationDays } = await req.json();
|
||||||
|
|
||||||
// Ensure settings exist, if not, maybe we should init or fail?
|
|
||||||
// Ideally init should happen on app startup or manual trigger, but for simplicity:
|
|
||||||
// If no settings exist, check against "admin" (fallback) but DO NOT create DB entry implicitely here for security,
|
|
||||||
// unless we strictly define that "admin" is the default.
|
|
||||||
// Let's assume DB must be populated.
|
|
||||||
|
|
||||||
const settings = await prisma.globalSettings.findUnique({
|
const settings = await prisma.globalSettings.findUnique({
|
||||||
where: { id: "default" },
|
where: { id: "default" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const isValid = settings
|
if (!settings) {
|
||||||
? await verifyPassword(password, settings.password)
|
return NextResponse.json({ error: "Settings not initialized" }, { status: 503 });
|
||||||
: password === "admin"; // Fallback only if DB empty
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyPassword(password, settings.password);
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
return NextResponse.json({ success: true });
|
const days = rememberMe ? Number(durationDays) || 1 : 1;
|
||||||
|
const token = await createSessionToken(days);
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
response.cookies.set(getSessionCookieName(), token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: getSessionTtlSeconds(days),
|
||||||
|
});
|
||||||
|
return response;
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: "Login failed" }, { status: 500 });
|
return NextResponse.json({ error: "Login failed" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { getSessionCookieName } from "@/lib/session";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete("auth");
|
cookieStore.delete(getSessionCookieName());
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
|
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
|
||||||
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Error fetching page' }, { status: 500 });
|
return NextResponse.json({ error: 'Error fetching page' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +26,7 @@ export async function PUT(
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
// Separate update logic for flexibility (e.g. only updating title)
|
// Separate update logic for flexibility (e.g. only updating title)
|
||||||
const updateData: any = {};
|
const updateData: Prisma.PageUncheckedUpdateInput = {};
|
||||||
if (body.title !== undefined) updateData.title = body.title;
|
if (body.title !== undefined) updateData.title = body.title;
|
||||||
if (body.content !== undefined) updateData.content = body.content;
|
if (body.content !== undefined) updateData.content = body.content;
|
||||||
if (body.parentId !== undefined) updateData.parentId = body.parentId;
|
if (body.parentId !== undefined) updateData.parentId = body.parentId;
|
||||||
@@ -38,7 +39,7 @@ export async function PUT(
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Error updating page' }, { status: 500 });
|
return NextResponse.json({ error: 'Error updating page' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ export async function DELETE(
|
|||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Error deleting page' }, { status: 500 });
|
return NextResponse.json({ error: 'Error deleting page' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function PUT(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Error reordering pages' }, { status: 500 });
|
return NextResponse.json({ error: 'Error reordering pages' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
tags: JSON.parse(p.tags || "[]")
|
tags: JSON.parse(p.tags || "[]")
|
||||||
}));
|
}));
|
||||||
return NextResponse.json(parsedPages);
|
return NextResponse.json(parsedPages);
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Error fetching pages' }, { status: 500 });
|
return NextResponse.json({ error: 'Error fetching pages' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Error creating page' }, { status: 500 });
|
return NextResponse.json({ error: 'Error creating page' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ export async function PUT(req: Request) {
|
|||||||
try {
|
try {
|
||||||
const { currentPassword, newPassword } = await req.json();
|
const { currentPassword, newPassword } = await req.json();
|
||||||
|
|
||||||
// Cast to any to bypass build error until server restart allows prisma generate to run
|
const settings = await prisma.globalSettings.findUnique({
|
||||||
const settings = await (prisma as any).globalSettings.findUnique({
|
|
||||||
where: { id: "default" },
|
where: { id: "default" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,13 +21,13 @@ export async function PUT(req: Request) {
|
|||||||
|
|
||||||
const hashedPassword = await hashPassword(newPassword);
|
const hashedPassword = await hashPassword(newPassword);
|
||||||
|
|
||||||
await (prisma as any).globalSettings.update({
|
await prisma.globalSettings.update({
|
||||||
where: { id: "default" },
|
where: { id: "default" },
|
||||||
data: { password: hashedPassword },
|
data: { password: hashedPassword },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: "Failed to change password" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to change password" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-21
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -8,7 +8,7 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [duration, setDuration] = useState("1"); // days
|
const [duration, setDuration] = useState("1");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
@@ -19,23 +19,21 @@ export default function LoginPage() {
|
|||||||
const res = await fetch("/api/auth/login", {
|
const res = await fetch("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({
|
||||||
|
password,
|
||||||
|
rememberMe,
|
||||||
|
durationDays: parseInt(duration, 10),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
let cookieString = "auth=true; path=/";
|
|
||||||
if (rememberMe) {
|
|
||||||
const seconds = parseInt(duration) * 24 * 60 * 60;
|
|
||||||
cookieString += `; max-age=${seconds}`;
|
|
||||||
}
|
|
||||||
document.cookie = cookieString;
|
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setError(data.error || "登录失败");
|
setError(data.error || "Login failed");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("发生错误,请重试");
|
setError("Something went wrong. Please try again.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,8 +44,8 @@ export default function LoginPage() {
|
|||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl text-primary-foreground mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl text-primary-foreground mb-4">
|
||||||
<Sparkles size={32} />
|
<Sparkles size={32} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">欢迎回来</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
|
||||||
<p className="text-muted-foreground">请输入访问密码以进入您的个人空间</p>
|
<p className="text-muted-foreground">Enter your access password to continue.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
@@ -56,7 +54,7 @@ export default function LoginPage() {
|
|||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="访问密码"
|
placeholder="Access password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-3 bg-muted/50 border rounded-xl focus:ring-2 focus:ring-primary outline-none transition-all"
|
className="w-full pl-10 pr-4 py-3 bg-muted/50 border rounded-xl focus:ring-2 focus:ring-primary outline-none transition-all"
|
||||||
@@ -74,7 +72,7 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setRememberMe(e.target.checked)}
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50"
|
className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50"
|
||||||
/>
|
/>
|
||||||
记住我
|
Remember me
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{rememberMe && (
|
{rememberMe && (
|
||||||
@@ -83,9 +81,9 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setDuration(e.target.value)}
|
onChange={(e) => setDuration(e.target.value)}
|
||||||
className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs"
|
className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs"
|
||||||
>
|
>
|
||||||
<option value="1">1天</option>
|
<option value="1">1 day</option>
|
||||||
<option value="7">7天</option>
|
<option value="7">7 days</option>
|
||||||
<option value="30">30天</option>
|
<option value="30">30 days</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -94,12 +92,12 @@ export default function LoginPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="w-full py-3 bg-primary text-primary-foreground font-semibold rounded-xl hover:opacity-90 active:scale-[0.98] transition-all shadow-lg"
|
className="w-full py-3 bg-primary text-primary-foreground font-semibold rounded-xl hover:opacity-90 active:scale-[0.98] transition-all shadow-lg"
|
||||||
>
|
>
|
||||||
开启灵感
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="text-center text-xs text-muted-foreground">
|
<div className="text-center text-xs text-muted-foreground">
|
||||||
NoteAI • 您的私人第二大脑
|
NoteAI - Your private second brain
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-9
@@ -3,7 +3,7 @@
|
|||||||
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
|
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
|
||||||
import { Editor } from "@/components/editor";
|
import { Editor } from "@/components/editor";
|
||||||
import { useEditorStore } from "@/lib/store";
|
import { useEditorStore } from "@/lib/store";
|
||||||
import { FileDown, Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock } from "lucide-react";
|
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock } from "lucide-react";
|
||||||
import { useSettingsStore } from "@/lib/settings-store";
|
import { useSettingsStore } from "@/lib/settings-store";
|
||||||
import { exportPageAsMarkdown } from "@/lib/export";
|
import { exportPageAsMarkdown } from "@/lib/export";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -46,14 +46,6 @@ export default function Home() {
|
|||||||
<div className="flex-1 overflow-y-auto scroll-smooth">
|
<div className="flex-1 overflow-y-auto scroll-smooth">
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-16 py-6 min-h-screen content-start">
|
<div className="max-w-7xl mx-auto px-4 md:px-16 py-6 min-h-screen content-start">
|
||||||
|
|
||||||
{/* Mobile Back Button */}
|
|
||||||
<div className="md:hidden mb-4 flex items-center text-muted-foreground" onClick={() => updatePage(null as any, {} as any)}>
|
|
||||||
{/* Note: updatePage isn't the right way to clear selection. We need setPageId(null).
|
|
||||||
But store only exposes updatePage. Let's fix store usage or use a store action if available.
|
|
||||||
Actually, looking at store.ts, we need `setActivePageId`.
|
|
||||||
*/}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="group mb-8 relative">
|
<div className="group mb-8 relative">
|
||||||
{/* Mobile Back Button Integration in Header */}
|
{/* Mobile Back Button Integration in Header */}
|
||||||
<div className="md:hidden absolute -top-12 left-0 flex items-center gap-1 py-2 text-muted-foreground hover:text-foreground cursor-pointer"
|
<div className="md:hidden absolute -top-12 left-0 flex items-center gap-1 py-2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSettingsStore, defaultPrompts, fontOptions, timezoneOptions } from "@/lib/settings-store";
|
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store";
|
||||||
import { Lock, Type, Save, Sparkles } from "lucide-react";
|
import { Lock, Type, Save, Sparkles } from "lucide-react";
|
||||||
import { ResizableSidebar } from "@/components/sidebar";
|
import { ResizableSidebar } from "@/components/sidebar";
|
||||||
import { PromptManagement } from "@/components/settings/prompt-management";
|
import { PromptManagement } from "@/components/settings/prompt-management";
|
||||||
@@ -16,7 +16,6 @@ export default function SettingsPage() {
|
|||||||
tableLineHeight, setTableLineHeight,
|
tableLineHeight, setTableLineHeight,
|
||||||
timezone, setTimezone,
|
timezone, setTimezone,
|
||||||
aiConfig, setAIConfig,
|
aiConfig, setAIConfig,
|
||||||
prompts, resetPrompts, updatePrompt, deletePrompt, addPrompt
|
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
@@ -50,7 +49,7 @@ export default function SettingsPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setMsg({ type: 'error', text: data.error || "修改失败" });
|
setMsg({ type: 'error', text: data.error || "修改失败" });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
setMsg({ type: 'error', text: "系统错误,请重试" });
|
setMsg({ type: 'error', text: "系统错误,请重试" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Send, User, Bot, Copy, FileText, X, Sparkles, Loader2, Eraser } from "lucide-react";
|
import { Send, User, Bot, Copy, FileText, X, Sparkles, Eraser } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSettingsStore } from "@/lib/settings-store";
|
import { useSettingsStore } from "@/lib/settings-store";
|
||||||
import { Editor } from "@tiptap/react";
|
import { Editor } from "@tiptap/react";
|
||||||
@@ -94,7 +94,7 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
|
|||||||
|
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let assistantMsg: Message = { id: (Date.now() + 1).toString(), role: "assistant", content: "" };
|
const assistantMsg: Message = { id: (Date.now() + 1).toString(), role: "assistant", content: "" };
|
||||||
|
|
||||||
setMessages(prev => [...prev, assistantMsg]);
|
setMessages(prev => [...prev, assistantMsg]);
|
||||||
|
|
||||||
@@ -122,8 +122,9 @@ export function AIChatPanel({ editor, isOpen, onClose }: AIChatPanelProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
if (e.name === 'AbortError') {
|
const isAbortError = e instanceof Error && e.name === "AbortError";
|
||||||
|
if (isAbortError) {
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: "Genertion stopped by user." }]);
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: "Genertion stopped by user." }]);
|
||||||
} else {
|
} else {
|
||||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: `Error: ${e instanceof Error ? e.message : "Unknown error"}` }]);
|
setMessages(prev => [...prev, { id: Date.now().toString(), role: "system", content: `Error: ${e instanceof Error ? e.message : "Unknown error"}` }]);
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
|
|||||||
if (content) {
|
if (content) {
|
||||||
editor.commands.insertContent(content);
|
editor.commands.insertContent(content);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { NodeViewWrapper, NodeViewContent, NodeViewProps } from '@tiptap/react'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export const TaskItemComponent: React.FC<NodeViewProps> = ({ node, updateAttributes, extension }) => {
|
export const TaskItemComponent: React.FC<NodeViewProps> = ({ node, updateAttributes }) => {
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper as="li" data-type="taskItem" className="relative !pl-7 !my-1 task-item-custom group">
|
<NodeViewWrapper as="li" data-type="taskItem" className="relative !pl-7 !my-1 task-item-custom group">
|
||||||
{/* Absolute Checkbox Wrapper */}
|
{/* Absolute Checkbox Wrapper */}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface LanguageSelectorProps {
|
|||||||
export function LanguageSelector({ language, onChange, languages }: LanguageSelectorProps) {
|
export function LanguageSelector({ language, onChange, languages }: LanguageSelectorProps) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false)
|
||||||
const [search, setSearch] = React.useState("")
|
const [search, setSearch] = React.useState("")
|
||||||
|
const listboxId = React.useId()
|
||||||
|
|
||||||
const filteredLanguages = languages.filter((lang) =>
|
const filteredLanguages = languages.filter((lang) =>
|
||||||
lang.toLowerCase().includes(search.toLowerCase())
|
lang.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -52,6 +53,7 @@ export function LanguageSelector({ language, onChange, languages }: LanguageSele
|
|||||||
<button
|
<button
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-controls={listboxId}
|
||||||
className="flex items-center justify-between gap-2 px-2 py-1 text-xs font-medium text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/50 rounded transition-colors outline-none"
|
className="flex items-center justify-between gap-2 px-2 py-1 text-xs font-medium text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/50 rounded transition-colors outline-none"
|
||||||
onClick={(e) => e.stopPropagation()} // Prevent editor focus loss if possible, though specific to Tiptap needs
|
onClick={(e) => e.stopPropagation()} // Prevent editor focus loss if possible, though specific to Tiptap needs
|
||||||
>
|
>
|
||||||
@@ -70,7 +72,7 @@ export function LanguageSelector({ language, onChange, languages }: LanguageSele
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[200px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-zinc-700">
|
<div id={listboxId} className="max-h-[200px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-zinc-700">
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<div className="py-6 text-center text-sm">No language found.</div>
|
<div className="py-6 text-center text-sm">No language found.</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Heading1, Heading2, Heading3,
|
Heading1, Heading2, Heading3,
|
||||||
List, ListOrdered, Quote,
|
List, ListOrdered, Quote,
|
||||||
Undo, Redo, Minus, RemoveFormatting,
|
Undo, Redo, Minus, RemoveFormatting,
|
||||||
AlignLeft, AlignCenter, AlignRight, CheckSquare, Link as LinkIcon, Underline as UnderlineIcon, Image as ImageIcon,
|
AlignLeft, AlignCenter, AlignRight, CheckSquare, Link as LinkIcon, Image as ImageIcon,
|
||||||
Table as TableIcon, Sparkles, FileDown, SquareCode
|
Table as TableIcon, Sparkles, FileDown, SquareCode
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchStore } from "@/lib/search-store";
|
import { useSearchStore } from "@/lib/search-store";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useEditorStore } from "@/lib/store";
|
import { useEditorStore } from "@/lib/store";
|
||||||
import { Command } from "cmdk";
|
import { Command } from "cmdk";
|
||||||
import { Search, FileText, Folder } from "lucide-react";
|
import { Search, FileText, Folder } from "lucide-react";
|
||||||
@@ -10,8 +9,6 @@ import { Search, FileText, Folder } from "lucide-react";
|
|||||||
export function SearchCommand() {
|
export function SearchCommand() {
|
||||||
const { isOpen, setOpen, query, setQuery } = useSearchStore();
|
const { isOpen, setOpen, query, setQuery } = useSearchStore();
|
||||||
const { pages, setActivePageId } = useEditorStore();
|
const { pages, setActivePageId } = useEditorStore();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
|||||||
+16
-19
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useRef, useEffect, useState } from "react";
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
import { useEditorStore, Page } from "@/lib/store";
|
import { useEditorStore, Page } from "@/lib/store";
|
||||||
import { Plus, Search, PanelLeftClose, Sun, Moon, Upload, FolderPlus, FilePlus, Settings, Hash, Tag as TagIcon, FileText, Folder, Menu, X, LogOut, ChevronRight, ChevronDown, Layers, PanelLeftOpen } from "lucide-react";
|
import { Plus, Search, PanelLeftClose, Sun, Moon, Upload, FolderPlus, FilePlus, Settings, Hash, Tag as TagIcon, FileText, Folder, LogOut, ChevronRight, ChevronDown, Layers, PanelLeftOpen } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { TreeView } from "./sidebar/tree-view";
|
import { TreeView } from "./sidebar/tree-view";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -10,8 +10,8 @@ import { useRouter } from "next/navigation";
|
|||||||
import { SearchCommand } from "@/components/search-command";
|
import { SearchCommand } from "@/components/search-command";
|
||||||
import { cn, getTagColor } from "@/lib/utils";
|
import { cn, getTagColor } from "@/lib/utils";
|
||||||
import { useSearchStore } from "@/lib/search-store";
|
import { useSearchStore } from "@/lib/search-store";
|
||||||
import { ImportProvider, useImport } from "@/components/import-context";
|
import { useImport } from "@/components/import-context";
|
||||||
import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor, pointerWithin, DragStartEvent, DragOverEvent, useDroppable } from "@dnd-kit/core";
|
import { DndContext, DragEndEvent, DragOverlay, useSensor, useSensors, PointerSensor, pointerWithin, DragStartEvent, useDroppable } from "@dnd-kit/core";
|
||||||
import { arrayMove } from "@dnd-kit/sortable";
|
import { arrayMove } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
function RootDropZone() {
|
function RootDropZone() {
|
||||||
@@ -34,19 +34,18 @@ function RootDropZone() {
|
|||||||
|
|
||||||
export function ResizableSidebar() {
|
export function ResizableSidebar() {
|
||||||
// State
|
// State
|
||||||
const [width, setWidth] = useState(256);
|
const [width, setWidth] = useState(() => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
if (typeof window === "undefined") return 256;
|
||||||
|
const savedWidth = localStorage.getItem("sidebar-width");
|
||||||
|
return savedWidth ? parseInt(savedWidth, 10) : 256;
|
||||||
|
});
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return localStorage.getItem("sidebar-collapsed") === "true";
|
||||||
|
});
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef<HTMLElement>(null);
|
const sidebarRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
// Load state from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const savedWidth = localStorage.getItem('sidebar-width');
|
|
||||||
const savedCollapsed = localStorage.getItem('sidebar-collapsed');
|
|
||||||
if (savedWidth) setWidth(parseInt(savedWidth));
|
|
||||||
if (savedCollapsed) setIsCollapsed(savedCollapsed === 'true');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save state
|
// Save state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('sidebar-width', width.toString());
|
localStorage.setItem('sidebar-width', width.toString());
|
||||||
@@ -122,7 +121,6 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
const { setOpen, openSearchWithTag } = useSearchStore();
|
const { setOpen, openSearchWithTag } = useSearchStore();
|
||||||
const { triggerImport, isImporting } = useImport();
|
const { triggerImport, isImporting } = useImport();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [mounted, setMounted] = React.useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Collapsible State
|
// Collapsible State
|
||||||
@@ -214,15 +212,14 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
|
|
||||||
const [activeDragItem, setActiveDragItem] = React.useState<Page | null>(null);
|
const [activeDragItem, setActiveDragItem] = React.useState<Page | null>(null);
|
||||||
|
|
||||||
const onDragStart = (event: any) => {
|
const onDragStart = (event: DragStartEvent) => {
|
||||||
const item = pages.find(p => p.id === event.active.id);
|
const item = pages.find(p => p.id === event.active.id);
|
||||||
if (item) setActiveDragItem(item);
|
if (item) setActiveDragItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
|
||||||
fetchPages();
|
fetchPages();
|
||||||
}, []);
|
}, [fetchPages]);
|
||||||
|
|
||||||
// Handle clicks that should close mobile sidebar
|
// Handle clicks that should close mobile sidebar
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
@@ -416,9 +413,9 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
|
|||||||
<button
|
<button
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
className="p-2 text-muted-foreground hover:text-foreground hover:bg-background rounded-md transition-all shadow-sm border border-transparent hover:border-border"
|
className="p-2 text-muted-foreground hover:text-foreground hover:bg-background rounded-md transition-all shadow-sm border border-transparent hover:border-border"
|
||||||
title={mounted ? (theme === 'dark' ? '切换到明亮模式' : '切换到暗黑模式') : '切换主题'}
|
title={theme === 'dark' ? '切换到明亮模式' : '切换到暗黑模式'}
|
||||||
>
|
>
|
||||||
{mounted ? (theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />) : <Sun size={16} className="opacity-0" />}
|
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Page, useEditorStore } from "@/lib/store";
|
import { Page, useEditorStore } from "@/lib/store";
|
||||||
import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal, File, Plus, Trash2, FilePlus, FolderPlus, Download } from "lucide-react";
|
import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal, Trash2, FilePlus, FolderPlus, Download, Upload } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
import { exportPageAsMarkdown, exportFolderAsZip } from "@/lib/export";
|
import { exportPageAsMarkdown, exportFolderAsZip } from "@/lib/export";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { useImport } from "@/components/import-context";
|
import { useImport } from "@/components/import-context";
|
||||||
import { Upload } from "lucide-react";
|
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
|
||||||
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
@@ -24,7 +22,7 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
|
|||||||
pages: Page[],
|
pages: Page[],
|
||||||
level: number,
|
level: number,
|
||||||
expanded: Record<string, boolean>,
|
expanded: Record<string, boolean>,
|
||||||
toggleExpand: (id: string, e: React.MouseEvent) => void
|
toggleExpand: (id: string) => void
|
||||||
}) {
|
}) {
|
||||||
const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
|
const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -75,7 +73,7 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
|
|||||||
onPointerDown={(e) => e.stopPropagation()} // Prevent drag start on expand button
|
onPointerDown={(e) => e.stopPropagation()} // Prevent drag start on expand button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleExpand(node.id, e);
|
toggleExpand(node.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-0.5 rounded-sm hover:bg-muted-foreground/20 transition-colors",
|
"p-0.5 rounded-sm hover:bg-muted-foreground/20 transition-colors",
|
||||||
@@ -122,7 +120,7 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
|
|||||||
// Ideally we'd find the mid-point, but simpler logic: just +1 and let sort handle basic "after"
|
// Ideally we'd find the mid-point, but simpler logic: just +1 and let sort handle basic "after"
|
||||||
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
|
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
|
||||||
addPage(targetParentId, 'file', undefined, order);
|
addPage(targetParentId, 'file', undefined, order);
|
||||||
if (node.type === 'folder') toggleExpand(node.id, e);
|
if (node.type === 'folder') toggleExpand(node.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FilePlus size={14} />
|
<FilePlus size={14} />
|
||||||
@@ -135,7 +133,7 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
|
|||||||
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
|
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
|
||||||
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
|
const order = node.type === 'folder' ? undefined : (node.order || 0) + 1;
|
||||||
addPage(targetParentId, 'folder', undefined, order);
|
addPage(targetParentId, 'folder', undefined, order);
|
||||||
if (node.type === 'folder') toggleExpand(node.id, e);
|
if (node.type === 'folder') toggleExpand(node.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FolderPlus size={14} />
|
<FolderPlus size={14} />
|
||||||
@@ -147,7 +145,7 @@ function TreeNode({ node, pages, level, expanded, toggleExpand }: {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
|
const targetParentId = node.type === 'folder' ? node.id : node.parentId;
|
||||||
triggerImport(targetParentId);
|
triggerImport(targetParentId);
|
||||||
if (node.type === 'folder') toggleExpand(node.id, e);
|
if (node.type === 'folder') toggleExpand(node.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload size={14} />
|
<Upload size={14} />
|
||||||
@@ -201,7 +199,7 @@ export function TreeView({ pages, parentId, level = 0 }: TreeViewProps) {
|
|||||||
// Simple state for expansion
|
// Simple state for expansion
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const toggleExpand = (id: string, e: React.MouseEvent) => {
|
const toggleExpand = (id: string) => {
|
||||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -88,7 +88,7 @@ export function pageToMarkdown(page: Page): string {
|
|||||||
const parts = new Intl.DateTimeFormat('sv-SE', options).formatToParts(dateObj);
|
const parts = new Intl.DateTimeFormat('sv-SE', options).formatToParts(dateObj);
|
||||||
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "";
|
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "";
|
||||||
localDate = `${getPart('year')}-${getPart('month')}-${getPart('day')}T${getPart('hour')}:${getPart('minute')}:${getPart('second')}`;
|
localDate = `${getPart('year')}-${getPart('month')}-${getPart('day')}T${getPart('hour')}:${getPart('minute')}:${getPart('second')}`;
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Fallback if timezone is invalid
|
// Fallback if timezone is invalid
|
||||||
localDate = dateObj.toISOString().slice(0, 19);
|
localDate = dateObj.toISOString().slice(0, 19);
|
||||||
}
|
}
|
||||||
@@ -184,3 +184,4 @@ function addPagesToZipRecursive(currentFolder: JSZip, parentId: string | null, a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
export function exportToMarkdown(title: string, htmlContent: string) {
|
export function exportToMarkdown(title: string, htmlContent: string) {
|
||||||
// Simple HTML to MD conversion (basic elements)
|
// Simple HTML to MD conversion (basic elements)
|
||||||
let md = htmlContent
|
const md = htmlContent
|
||||||
.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n')
|
.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n')
|
||||||
.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n')
|
.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n')
|
||||||
.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n')
|
.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n')
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const SESSION_COOKIE_NAME = "auth";
|
||||||
|
const SESSION_TTL_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
|
type SessionPayload = {
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toBase64Url(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(value: string): Uint8Array {
|
||||||
|
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4));
|
||||||
|
const binary = atob(base64 + pad);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecretBytes(): Uint8Array | null {
|
||||||
|
const secret = process.env.SESSION_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSigningKey(secretBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
const normalizedSecret = Uint8Array.from(secretBytes);
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
normalizedSecret,
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign", "verify"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sign(data: string): Promise<string> {
|
||||||
|
const secretBytes = getSecretBytes();
|
||||||
|
if (!secretBytes) {
|
||||||
|
throw new Error("SESSION_SECRET is required");
|
||||||
|
}
|
||||||
|
const key = await importSigningKey(secretBytes);
|
||||||
|
const payload = Uint8Array.from(new TextEncoder().encode(data));
|
||||||
|
const signature = await crypto.subtle.sign("HMAC", key, payload);
|
||||||
|
return toBase64Url(new Uint8Array(signature));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify(data: string, signature: string): Promise<boolean> {
|
||||||
|
const secretBytes = getSecretBytes();
|
||||||
|
if (!secretBytes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const key = await importSigningKey(secretBytes);
|
||||||
|
const payload = Uint8Array.from(new TextEncoder().encode(data));
|
||||||
|
const signatureBytes = Uint8Array.from(fromBase64Url(signature));
|
||||||
|
return crypto.subtle.verify(
|
||||||
|
"HMAC",
|
||||||
|
key,
|
||||||
|
signatureBytes,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionCookieName(): string {
|
||||||
|
return SESSION_COOKIE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionTtlSeconds(days?: number): number {
|
||||||
|
if (!days || Number.isNaN(days) || days <= 0) {
|
||||||
|
return SESSION_TTL_SECONDS;
|
||||||
|
}
|
||||||
|
return Math.floor(days * 24 * 60 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionToken(days?: number): Promise<string> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const ttl = getSessionTtlSeconds(days);
|
||||||
|
const payload: SessionPayload = {
|
||||||
|
exp: now + ttl,
|
||||||
|
};
|
||||||
|
const encodedPayload = toBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||||
|
const signature = await sign(encodedPayload);
|
||||||
|
return `${encodedPayload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySessionToken(token: string): Promise<boolean> {
|
||||||
|
const [payloadPart, signaturePart] = token.split(".");
|
||||||
|
if (!payloadPart || !signaturePart) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidSignature = await verify(payloadPart, signaturePart);
|
||||||
|
if (!isValidSignature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payloadText = new TextDecoder().decode(fromBase64Url(payloadPart));
|
||||||
|
const payload = JSON.parse(payloadText) as SessionPayload;
|
||||||
|
if (!payload.exp || typeof payload.exp !== "number") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return payload.exp > now;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-5
@@ -1,15 +1,25 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { getSessionCookieName, verifySessionToken } from '@/lib/session';
|
||||||
|
|
||||||
export default function proxy(request: NextRequest) {
|
const PUBLIC_PATHS = new Set(['/login']);
|
||||||
const authCookie = request.cookies.get('auth');
|
const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/settings/init']);
|
||||||
|
|
||||||
|
export default async function proxy(request: NextRequest) {
|
||||||
|
const authCookie = request.cookies.get(getSessionCookieName());
|
||||||
const isLoginPage = request.nextUrl.pathname === '/login';
|
const isLoginPage = request.nextUrl.pathname === '/login';
|
||||||
|
const isPublicPath = PUBLIC_PATHS.has(request.nextUrl.pathname);
|
||||||
|
const isPublicApiPath = PUBLIC_API_PATHS.has(request.nextUrl.pathname);
|
||||||
|
const isAuthenticated = authCookie ? await verifySessionToken(authCookie.value) : false;
|
||||||
|
|
||||||
if (!authCookie && !isLoginPage) {
|
if (!isAuthenticated && !isPublicPath && !isPublicApiPath) {
|
||||||
|
if (request.nextUrl.pathname.startsWith('/api')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
return NextResponse.redirect(new URL('/login', request.url));
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCookie && isLoginPage) {
|
if (isAuthenticated && isLoginPage) {
|
||||||
return NextResponse.redirect(new URL('/', request.url));
|
return NextResponse.redirect(new URL('/', request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,5 +27,5 @@ export default function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-2
@@ -1,4 +1,6 @@
|
|||||||
import { type Config } from "tailwindcss";
|
import { type Config } from "tailwindcss";
|
||||||
|
import typography from "@tailwindcss/typography";
|
||||||
|
import tailwindcssAnimate from "tailwindcss-animate";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
@@ -53,7 +55,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
require("@tailwindcss/typography"),
|
typography,
|
||||||
require("tailwindcss-animate"),
|
tailwindcssAnimate,
|
||||||
],
|
],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
Reference in New Issue
Block a user