重构大量文件

This commit is contained in:
2026-02-24 10:50:11 +08:00
parent dd97cf6a2c
commit 9efdf060f5
29 changed files with 264 additions and 133 deletions
+2
View File
@@ -0,0 +1,2 @@
DATABASE_URL="file:./dev.db"
SESSION_SECRET="replace-with-a-long-random-secret"
+1
View File
@@ -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
+21 -27
View File
@@ -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.
+7
View File
@@ -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:
BIN
View File
Binary file not shown.
+1
View File
@@ -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
View File
@@ -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');
+19 -12
View File
@@ -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 });
} }
} }
+2 -1
View File
@@ -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 });
} }
+5 -4
View File
@@ -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 });
} }
} }
+1 -1
View File
@@ -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 });
} }
} }
+2 -2
View File
@@ -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 });
} }
} }
+3 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+2 -3
View File
@@ -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: "系统错误,请重试" });
} }
}; };
+5 -4
View File
@@ -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"}` }]);
+1 -1
View File
@@ -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 */}
+3 -1
View File
@@ -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>
)} )}
+1 -1
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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}
+7 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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')
+120
View File
@@ -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
View File
@@ -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
View File
@@ -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;