🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "./globals.css";
import { Sidebar } from "@/components/layout/Sidebar";
import { BottomNav } from "@/components/layout/BottomNav";
export const metadata: Metadata = {
title: "NoteApp",
description: "一款支持多端同步、Markdown 编辑和标签管理的笔记应用。",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body className="bg-gray-50 min-h-screen">
<div className="flex">
{/* Desktop Sidebar */}
<Sidebar items={[{"name":"笔记列表","href":"/notes","icon":"🏠"},{"name":"笔记编辑","href":"/note/[id]","icon":"📋"},{"name":"标签管理","href":"/tags","icon":"📅"},{"name":"设置","href":"/settings","icon":"✏️"}] } projectName="NoteApp" />
{/* Main Content */}
<main className="flex-1 lg:pl-64 pb-20 lg:pb-0">
<div className="max-w-2xl mx-auto px-4 py-6">
{children}
</div>
</main>
</div>
{/* Mobile Bottom Nav */}
<BottomNav items={[{"name":"笔记列表","href":"/notes","icon":"🏠"},{"name":"笔记编辑","href":"/note/[id]","icon":"📋"},{"name":"标签管理","href":"/tags","icon":"📅"},{"name":"设置","href":"/settings","icon":"✏️"}] } />
</body>
</html>
);
}
@@ -0,0 +1,25 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
export default function NoteEditorPage() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800"></h1>
<Button variant="primary" size="sm"></Button>
</div>
<Input value={title} onChange={e => setTitle(e.target.value)} placeholder="笔记标题" className="text-lg font-medium border-0 px-0" />
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="开始记录..."
className="w-full min-h-[300px] text-sm text-gray-700 resize-none focus:outline-none"
/>
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
export default function NoteEditorPage() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800"></h1>
<Button variant="primary" size="sm"></Button>
</div>
<Input value={title} onChange={e => setTitle(e.target.value)} placeholder="笔记标题" className="text-lg font-medium border-0 px-0" />
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="开始记录..."
className="w-full min-h-[300px] text-sm text-gray-700 resize-none focus:outline-none"
/>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Link from "next/link";
export default function HomePage() {
return (
<div className="space-y-6">
{/* Hero Section */}
<section className="bg-gradient-to-br from-blue-500 to-blue-700 rounded-2xl p-6 text-white">
<h1 className="text-2xl font-bold">NoteApp</h1>
<p className="mt-2 text-blue-100 text-sm"></p>
</section>
{/* Quick Actions */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3"></h2>
<div className="grid grid-cols-2 gap-3">
<Link key="0" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📋</span>
<span className="text-sm text-gray-600"></span>
</Link>
<Link key="1" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📅</span>
<span className="text-sm text-gray-600">/</span>
</Link>
<Link key="2" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📝</span>
<span className="text-sm text-gray-600"></span>
</Link>
<Link key="3" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📸</span>
<span className="text-sm text-gray-600"></span>
</Link>
</div>
</section>
{/* Recent Activity / Stats */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3"></h2>
<div className="bg-white rounded-xl border border-gray-100 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">使 NoteApp</p>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<span className="text-3xl">👋</span>
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,14 @@
"use client";
import { Card } from "@/components/ui/Card";
const settings = ["账户安全", "通知设置", "主题偏好", "数据管理", "关于应用"];
export default function SettingsPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800"></h1>
<Card className="divide-y p-0">
{settings.map((s, i) => <div key={i} className="px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer flex items-center justify-between">{s} <span className="text-gray-300"></span></div>)}
</Card>
</div>
);
}
@@ -0,0 +1,14 @@
"use client";
import { Card } from "@/components/ui/Card";
const tags = ["工作", "个人", "灵感", "读书笔记", "代码片段", "日记"];
export default function TagPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800"></h1>
<div className="flex flex-wrap gap-2">
{tags.map(tag => <span key={tag} className="px-3 py-1.5 bg-gray-100 rounded-full text-sm text-gray-600 cursor-pointer hover:bg-gray-200">{tag}</span>)}
</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
name: string;
href: string;
icon: string;
}
export function BottomNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
if (!items.length) return null;
return (
<nav className="lg:hidden fixed bottom-0 inset-x-0 bg-white border-t border-gray-100 z-40">
<div className="flex items-center justify-around h-16">
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-0.5 px-3 py-1 ${
active ? "text-blue-600" : "text-gray-400"
}`}
>
<span className="text-xl">{item.icon}</span>
<span className="text-[10px]">{item.name}</span>
</Link>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
name: string;
href: string;
icon: string;
}
interface SidebarProps {
items: NavItem[];
projectName: string;
}
export function Sidebar({ items, projectName }: SidebarProps) {
const pathname = usePathname();
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0 bg-white border-r border-gray-100">
<div className="px-6 py-5 border-b border-gray-50">
<h1 className="text-lg font-bold text-gray-800">{projectName}</h1>
</div>
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
active ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-600 hover:bg-gray-50"
}`}
>
<span className="text-lg">{item.icon}</span>
<span>{item.name}</span>
</Link>
);
})}
</nav>
</aside>
);
}
@@ -0,0 +1,20 @@
import type { Note } from "@/types";
import { Card } from "@/components/ui/Card";
interface NoteCardProps {
note: Note;
onClick?: () => void;
}
export function NoteCard({ note, onClick }: NoteCardProps) {
return (
<Card onClick={onClick}>
<h3 className="font-medium text-gray-800 truncate">{note.title || "无标题"}</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{note.content?.slice(0, 120) || "空笔记"}</p>
<div className="flex items-center gap-2 mt-2">
{note.isMarkdown && <span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">MD</span>}
<span className="text-xs text-gray-400">{new Date(note.updatedAt).toLocaleDateString("zh-CN")}</span>
</div>
</Card>
);
}
@@ -0,0 +1,31 @@
"use client";
import { type ButtonHTMLAttributes } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
}
export function Button({ variant = "primary", size = "md", loading, children, className = "", disabled, ...props }: ButtonProps) {
const base = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-400",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
ghost: "text-gray-600 hover:bg-gray-100 focus:ring-gray-400",
};
const sizes = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
return (
<button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} disabled={disabled || loading} {...props}>
{loading && <svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
{children}
</button>
);
}
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
export function Card({ children, className = "", onClick }: CardProps) {
return (
<div
className={`bg-white rounded-xl shadow-sm border border-gray-100 p-4 ${onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""} ${className}`}
onClick={onClick}
>
{children}
</div>
);
}
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ icon = "📭", title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span className="text-5xl mb-4">{icon}</span>
<h3 className="text-lg font-medium text-gray-700 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{action}
</div>
);
}
@@ -0,0 +1,22 @@
"use client";
import { type InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function Input({ label, error, className = "", id, ...props }: InputProps) {
return (
<div className="w-full">
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<input
id={id}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${error ? "border-red-300" : "border-gray-300"} ${className}`}
{...props}
/>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useEffect, type ReactNode } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
return () => { document.body.style.overflow = ""; };
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
{children}
</div>
</div>
);
}
@@ -0,0 +1,14 @@
"use client";
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useState, useCallback } from "react";
interface UseFormOptions<T> {
initialValues: T;
onSubmit: (values: T) => Promise<void>;
}
export function useForm<T extends Record<string, unknown>>({ initialValues, onSubmit }: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
try {
setSubmitting(true);
setError(null);
await onSubmit(values);
} catch (err) {
setError(err instanceof Error ? err.message : "Submit failed");
} finally {
setSubmitting(false);
}
}, [values, onSubmit]);
return { values, setField, submitting, error, handleSubmit };
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for notes
type Note = Record<string, unknown>;
/**
* Fetch and manage notes data.
*/
export function useNotes() {
const [data, setData] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/notes`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for tags
type Tag = Record<string, unknown>;
/**
* Fetch and manage tags data.
*/
export function useTags() {
const [data, setData] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/tags`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+25
View File
@@ -0,0 +1,25 @@
{
"name": "noteapp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.6.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+47
View File
@@ -0,0 +1,47 @@
// Auto-generated API client for NoteApp
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
interface RequestOptions extends RequestInit {
params?: Record<string, string | number>;
}
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...init } = options;
let url = `${API_BASE}${endpoint}`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => searchParams.set(k, String(v)));
url += `?${searchParams.toString()}`;
}
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || `API Error: ${res.status}`);
}
return res.json();
}
export const api = {
get: <T>(url: string, params?: Record<string, string | number>) =>
request<T>(url, { method: "GET", params }),
post: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "POST", body: JSON.stringify(data) }),
put: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "PUT", body: JSON.stringify(data) }),
delete: <T>(url: string) =>
request<T>(url, { method: "DELETE" }),
};
@@ -0,0 +1,18 @@
// Auto-generated notes service
import { api } from "./api";
export function get() {
return api.get("/api/notes");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/notes", data);
}
export function put_id(id: string, data: Record<string, unknown>) {
return api.put(`/api/notes/${id}`, data);
}
export function delete_id(id: string) {
return api.delete(`/api/notes/${id}`);
}
@@ -0,0 +1,6 @@
// Auto-generated tags service
import { api } from "./api";
export function get() {
return api.get("/api/tags");
}
@@ -0,0 +1,10 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./hooks/**/*.{js,ts,jsx,tsx,mdx}", "./services/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;
+40
View File
@@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+46
View File
@@ -0,0 +1,46 @@
// Auto-generated types for NoteApp
// ─── Base ───────────────────────────────────────────
export interface User {
id: string;
phone: string;
nickname: string;
avatarUrl: string;
createdAt: string;
updatedAt: string;
}
export interface Note {
id: string;
userId: string;
title: string;
content: string;
isMarkdown: boolean;
folderId: string | null;
createdAt: string;
updatedAt: string;
}
export interface Tag {
id: string;
name: string;
color: string;
}
// ─── API Responses ──────────────────────────────────
export interface ApiResponse<T> {
data: T;
message: string;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
total: number;
page: number;
pageSize: number;
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}