Bulk update
This commit is contained in:
parent
c79ac06b58
commit
9645f411f3
29 changed files with 12284 additions and 215 deletions
|
|
@ -1,13 +1,10 @@
|
|||
# ---- Build stage (optional) ----
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||
FROM python:3.11-slim
|
||||
|
||||
# ---- Runtime stage ----
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||
COPY app ./app
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--port", "8000"]
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY ./app ./app
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
|||
BIN
backend/app/__pycache__/database.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
16
backend/app/database.py
Normal file
16
backend/app/database.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from sqlmodel import Session, SQLModel, create_engine # type: ignore
|
||||
|
||||
DATABASE_URL = "sqlite:///./notes.db"
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL, echo=True, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
|
||||
def create_db_and_tables():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
|
@ -1,34 +1,30 @@
|
|||
# backend/app/main.py
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import FastAPI # type: ignore
|
||||
from fastapi.middleware.cors import CORSMiddleware # type:ignore
|
||||
|
||||
from .models import add_note, get_all_notes
|
||||
from app.database import create_db_and_tables
|
||||
from app.routes import folders, notes
|
||||
|
||||
app = FastAPI(title="Simple Note API")
|
||||
app = FastAPI(title="Notes API")
|
||||
|
||||
# CORS - adjust origins for production
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"], # Vite dev server
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class NoteIn(BaseModel):
|
||||
title: str
|
||||
body: str
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
create_db_and_tables()
|
||||
|
||||
|
||||
class NoteOut(NoteIn):
|
||||
id: int
|
||||
app.include_router(notes.router, prefix="/api")
|
||||
app.include_router(folders.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/notes", response_model=list[NoteOut])
|
||||
def list_notes():
|
||||
return get_all_notes()
|
||||
|
||||
|
||||
@app.post("/notes", response_model=NoteOut, status_code=201)
|
||||
def create_note(note: NoteIn):
|
||||
# Very tiny validation – you can expand later
|
||||
if not note.title.strip():
|
||||
raise HTTPException(status_code=400, detail="Title cannot be empty")
|
||||
return add_note(note.title, note.body)
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Notes API"}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,69 @@
|
|||
# backend/app/models.py
|
||||
from typing import Dict, List
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
# In‑memory “database”
|
||||
_notes: List[Dict] = [] # each note is a dict with id, title, body
|
||||
from sqlmodel import Field, Relationship, SQLModel # type: ignore
|
||||
|
||||
|
||||
def add_note(title: str, body: str) -> Dict:
|
||||
note_id = len(_notes) + 1
|
||||
note = {"id": note_id, "title": title, "body": body}
|
||||
_notes.append(note)
|
||||
return note
|
||||
class Folder(SQLModel, table=True): # type: ignore
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(max_length=255)
|
||||
parent_id: Optional[int] = Field(default=None, foreign_key="folder.id")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
parent: Optional["Folder"] = Relationship(
|
||||
back_populates="children", sa_relationship_kwargs={"remote_side": "Folder.id"}
|
||||
)
|
||||
children: List["Folder"] = Relationship(back_populates="parent")
|
||||
notes: List["Note"] = Relationship(back_populates="folder")
|
||||
|
||||
|
||||
def get_all_notes() -> List[Dict]:
|
||||
return _notes
|
||||
class Note(SQLModel, table=True): # type: ignore
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
title: str = Field(max_length=255)
|
||||
content: str
|
||||
folder_id: Optional[int] = Field(default=None, foreign_key="folder.id")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
folder: Optional[Folder] = Relationship(back_populates="notes")
|
||||
|
||||
|
||||
# API Response models (what gets sent to frontend)
|
||||
class NoteRead(SQLModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
folder_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FolderTreeNode(SQLModel):
|
||||
id: int
|
||||
name: str
|
||||
notes: List[NoteRead] = []
|
||||
children: List["FolderTreeNode"] = []
|
||||
|
||||
|
||||
class FolderTreeResponse(SQLModel):
|
||||
folders: List[FolderTreeNode]
|
||||
orphaned_notes: List[NoteRead]
|
||||
|
||||
|
||||
# Create/Update models
|
||||
class NoteCreate(SQLModel):
|
||||
title: str
|
||||
content: str
|
||||
folder_id: Optional[int] = None
|
||||
|
||||
|
||||
class NoteUpdate(SQLModel):
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
folder_id: Optional[int] = None
|
||||
|
||||
|
||||
class FolderCreate(SQLModel):
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
|
|
|
|||
BIN
backend/app/routes/__pycache__/folders.cpython-314.pyc
Normal file
BIN
backend/app/routes/__pycache__/folders.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/routes/__pycache__/notes.cpython-314.pyc
Normal file
BIN
backend/app/routes/__pycache__/notes.cpython-314.pyc
Normal file
Binary file not shown.
76
backend/app/routes/folders.py
Normal file
76
backend/app/routes/folders.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException # type: ignore
|
||||
from sqlmodel import Session, select # type: ignore
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import (
|
||||
Folder,
|
||||
FolderCreate,
|
||||
FolderTreeNode,
|
||||
FolderTreeResponse,
|
||||
Note,
|
||||
NoteRead,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/folders", tags=["folders"])
|
||||
|
||||
|
||||
def build_folder_tree_node(folder: Folder) -> FolderTreeNode:
|
||||
"""Recursively build a folder tree node with notes and children"""
|
||||
return FolderTreeNode(
|
||||
id=folder.id,
|
||||
name=folder.name,
|
||||
notes=[NoteRead.model_validate(note) for note in folder.notes],
|
||||
children=[build_folder_tree_node(child) for child in folder.children],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tree", response_model=FolderTreeResponse)
|
||||
def get_folder_tree(session: Session = Depends(get_session)):
|
||||
"""Get complete folder tree with notes"""
|
||||
|
||||
# Get all top-level folders (parent_id is None)
|
||||
top_level_folders = session.exec(
|
||||
select(Folder).where(Folder.parent_id == None)
|
||||
).all()
|
||||
|
||||
# Get all orphaned notes (folder_id is None)
|
||||
orphaned_notes = session.exec(select(Note).where(Note.folder_id == None)).all()
|
||||
|
||||
# Build tree recursively
|
||||
tree = [build_folder_tree_node(folder) for folder in top_level_folders]
|
||||
|
||||
return FolderTreeResponse(
|
||||
folders=tree,
|
||||
orphaned_notes=[NoteRead.model_validate(note) for note in orphaned_notes],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Folder])
|
||||
def list_folders(session: Session = Depends(get_session)):
|
||||
"""Get flat list of all folders"""
|
||||
folders = session.exec(select(Folder)).all()
|
||||
return folders
|
||||
|
||||
|
||||
@router.post("/", response_model=Folder)
|
||||
def create_folder(folder: FolderCreate, session: Session = Depends(get_session)):
|
||||
"""Create a new folder"""
|
||||
db_folder = Folder.model_validate(folder)
|
||||
session.add(db_folder)
|
||||
session.commit()
|
||||
session.refresh(db_folder)
|
||||
return db_folder
|
||||
|
||||
|
||||
@router.delete("/{folder_id}")
|
||||
def delete_folder(folder_id: int, session: Session = Depends(get_session)):
|
||||
"""Delete a folder"""
|
||||
folder = session.get(Folder, folder_id)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
session.delete(folder)
|
||||
session.commit()
|
||||
return {"message": "Folder deleted"}
|
||||
61
backend/app/routes/notes.py
Normal file
61
backend/app/routes/notes.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from datetime import datetime
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import Note, NoteCreate, NoteUpdate
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_notes(session: Session = Depends(get_session)):
|
||||
notes = session.exec(select(Note).order_by(Note.updated_at.desc())).all()
|
||||
return notes
|
||||
|
||||
|
||||
@router.post("/", response_model=Note)
|
||||
def create_note(note: NoteCreate, session: Session = Depends(get_session)):
|
||||
db_note = Note.model_validate(note)
|
||||
session.add(db_note)
|
||||
session.commit()
|
||||
session.refresh(db_note)
|
||||
return db_note
|
||||
|
||||
|
||||
@router.get("/{note_id}", response_model=Note)
|
||||
def get_note(note_id: int, session: Session = Depends(get_session)):
|
||||
note = session.get(Note, note_id)
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
return note
|
||||
|
||||
|
||||
@router.patch("/{note_id}", response_model=Note)
|
||||
def update_note(
|
||||
note_id: int, note_update: NoteUpdate, session: Session = Depends(get_session)
|
||||
):
|
||||
note = session.get(Note, note_id)
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
update_data = note_update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(note, key, value)
|
||||
|
||||
note.updated_at = datetime.utcnow()
|
||||
session.add(note)
|
||||
session.commit()
|
||||
session.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
def delete_note(note_id: int, session: Session = Depends(get_session)):
|
||||
note = session.get(Note, note_id)
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
session.delete(note)
|
||||
session.commit()
|
||||
return {"message": "Note deleted"}
|
||||
BIN
backend/notes.db
Normal file
BIN
backend/notes.db
Normal file
Binary file not shown.
3
backend/pyproject.toml
Normal file
3
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"reportGeneralTypeIssues": "warning"
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>FastAPI + Vite Note Demo</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notes App</title>
|
||||
</head>
|
||||
<body>
|
||||
<body style="margin: 0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
|
|
|||
4750
frontend/package-lock.json
generated
4750
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,11 +8,22 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@mdxeditor/editor": "^3.49.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"tailwindcss": "^4.1.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@catppuccin/tailwindcss": "^1.0.0",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +1,19 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
// src/App.tsx
|
||||
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
|
||||
import Home from "./pages/Home"; // existing home page
|
||||
import { MarkdownPage } from "./pages/Markdown";
|
||||
const App = () => (
|
||||
<BrowserRouter>
|
||||
{/* Simple nav – you can replace with your own UI later */}
|
||||
{/*<nav style={{ marginBottom: "1rem" }}>
|
||||
<Link to="/">Home</Link> | <Link to="/markdown">MD</Link>
|
||||
</nav>*/}
|
||||
|
||||
type Note = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/markdown" element={<MarkdownPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
export default function App() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [saved, setSaved] = useState<Note | null>(null);
|
||||
const [allNotes, setAllNotes] = useState<Note[]>([]);
|
||||
|
||||
// Load existing notes on mount
|
||||
useEffect(() => {
|
||||
axios.get<Note[]>("/api/notes").then((res) => setAllNotes(res.data));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const resp = await axios.post<Note>("/api/notes", { title, body });
|
||||
setSaved(resp.data);
|
||||
setAllNotes((prev) => [...prev, resp.data]);
|
||||
setTitle("");
|
||||
setBody("");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to save note – check console.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "600px",
|
||||
margin: "2rem auto",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
<h1>📝 Simple Note</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ marginBottom: "2rem" }}>
|
||||
<div>
|
||||
<input
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
style={{ width: "100%", padding: "0.5rem", fontSize: "1.1rem" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<textarea
|
||||
placeholder="Body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={4}
|
||||
style={{ width: "100%", padding: "0.5rem" }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ marginTop: "0.5rem", padding: "0.5rem 1rem" }}
|
||||
>
|
||||
Save note
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{saved && (
|
||||
<div
|
||||
style={{
|
||||
background: "#f0f8ff",
|
||||
padding: "1rem",
|
||||
marginBottom: "2rem",
|
||||
}}
|
||||
>
|
||||
<strong>Saved:</strong> #{saved.id} – {saved.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>All notes</h2>
|
||||
{allNotes.length === 0 ? (
|
||||
<p>No notes yet.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{allNotes.map((n) => (
|
||||
<li key={n.id}>
|
||||
<strong>{n.title}</strong>: {n.body}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default App;
|
||||
|
|
|
|||
44
frontend/src/api/folders.tsx
Normal file
44
frontend/src/api/folders.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import axios from "axios";
|
||||
|
||||
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/api";
|
||||
|
||||
export interface Folder {
|
||||
id: number;
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NoteRead {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
folder_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FolderTreeNode {
|
||||
id: number;
|
||||
name: string;
|
||||
notes: NoteRead[];
|
||||
children: FolderTreeNode[];
|
||||
}
|
||||
|
||||
export interface FolderTreeResponse {
|
||||
folders: FolderTreeNode[];
|
||||
orphaned_notes: NoteRead[];
|
||||
}
|
||||
|
||||
export interface FolderCreate {
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
}
|
||||
|
||||
export const folderApi = {
|
||||
tree: () => axios.get<FolderTreeResponse>(`${API_URL}/folders/tree`),
|
||||
list: () => axios.get<Folder[]>(`${API_URL}/folders`),
|
||||
create: (folder: FolderCreate) =>
|
||||
axios.post<Folder>(`${API_URL}/folders`, folder),
|
||||
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`),
|
||||
};
|
||||
27
frontend/src/api/notes.tsx
Normal file
27
frontend/src/api/notes.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import axios from "axios";
|
||||
|
||||
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/api";
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
title: string;
|
||||
folder_id?: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NoteCreate {
|
||||
title: string;
|
||||
content: string;
|
||||
folder_id: number | null;
|
||||
}
|
||||
|
||||
export const notesApi = {
|
||||
list: () => axios.get(`${API_URL}/notes`),
|
||||
get: (id: number) => axios.get(`${API_URL}/notes/${id}`),
|
||||
create: (note: NoteCreate) => axios.post(`${API_URL}/notes`, note),
|
||||
update: (id: number, note: Partial) =>
|
||||
axios.patch(`${API_URL}/notes/${id}`, note),
|
||||
delete: (id: number) => axios.delete(`${API_URL}/notes/${id}`),
|
||||
};
|
||||
3690
frontend/src/assets/fontawesome/js/duotone-regular.js
Normal file
3690
frontend/src/assets/fontawesome/js/duotone-regular.js
Normal file
File diff suppressed because one or more lines are too long
3051
frontend/src/assets/fontawesome/js/fontawesome.js
Normal file
3051
frontend/src/assets/fontawesome/js/fontawesome.js
Normal file
File diff suppressed because one or more lines are too long
40
frontend/src/components/sidebar/DraggableNote.tsx
Normal file
40
frontend/src/components/sidebar/DraggableNote.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { Note } from "../../api/notes";
|
||||
import { NoteRead } from "../../api/folders";
|
||||
|
||||
export const DraggableNote = ({
|
||||
note,
|
||||
selectNote,
|
||||
selectedNote,
|
||||
}: {
|
||||
note: NoteRead;
|
||||
selectNote: (note: NoteRead) => void;
|
||||
selectedNote: NoteRead | null;
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: note.id,
|
||||
data: { type: "note", note },
|
||||
});
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||
<div
|
||||
key={note.id}
|
||||
onClick={() => selectNote(note)}
|
||||
className={`ml-5 rounded-md px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
|
||||
selectedNote?.id === note.id
|
||||
? "bg-ctp-mauve text-ctp-base"
|
||||
: "hover:bg-ctp-surface1"
|
||||
}`}
|
||||
>
|
||||
<span>{note.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
40
frontend/src/components/sidebar/DroppableFolder.tsx
Normal file
40
frontend/src/components/sidebar/DroppableFolder.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { Folder, NoteRead } from "../../api/folders";
|
||||
|
||||
export const DroppableFolder = ({
|
||||
folder,
|
||||
setSelectedFolder,
|
||||
selectedFolder,
|
||||
selectedNote,
|
||||
}: {
|
||||
folder: Partial<Folder>;
|
||||
setSelectedFolder: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
selectedFolder: number | null;
|
||||
selectedNote: NoteRead | null;
|
||||
}) => {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: folder.id!,
|
||||
data: { type: "folder", folder },
|
||||
});
|
||||
const style = {
|
||||
color: isOver ? "green" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<div
|
||||
onClick={() => setSelectedFolder(folder.id as number)}
|
||||
className={`font-semibold mb-1 flex items-center gap-1 px-2 py-1 rounded cursor-pointer ${
|
||||
selectedFolder === folder.id &&
|
||||
(selectedNote?.folder_id == folder.id || selectedNote == null)
|
||||
? "bg-ctp-surface1"
|
||||
: "hover:bg-ctp-surface0"
|
||||
}`}
|
||||
>
|
||||
<i className="fadr fa-folder text-sm"></i>
|
||||
{folder.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
39
frontend/src/main.css
Normal file
39
frontend/src/main.css
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "@catppuccin/tailwindcss/macchiato.css";
|
||||
|
||||
/* Override MDXEditor and all its children */
|
||||
[class*="mdxeditor"],
|
||||
._mdxeditor-root-content-editable,
|
||||
.mdxeditor-root-contenteditable,
|
||||
div[contenteditable="true"] {
|
||||
color: var(--ctp-text) !important;
|
||||
}
|
||||
|
||||
/* Override prose specifically */
|
||||
.prose,
|
||||
.prose * {
|
||||
color: var(--ctp-text) !important;
|
||||
}
|
||||
|
||||
/* Override list markers */
|
||||
.prose ul li::marker,
|
||||
.prose ol li::marker,
|
||||
ul li::marker,
|
||||
ol li::marker {
|
||||
color: var(--ctp-text) !important;
|
||||
}
|
||||
|
||||
.my-class {
|
||||
background-color: var(--ctp-mantle) !important;
|
||||
border-bottom: 1px solid var(--ctp-surface2) !important;
|
||||
}
|
||||
|
||||
.my-class button {
|
||||
color: var(--ctp-text) !important;
|
||||
background-color: var(--ctp-surface0) !important;
|
||||
}
|
||||
|
||||
.mdxeditor-popup-container > * {
|
||||
background-color: var(--ctp-base) !important;
|
||||
}
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./main.css";
|
||||
import "./assets/fontawesome/js/duotone-regular.js";
|
||||
import "./assets/fontawesome/js/fontawesome.js ";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
302
frontend/src/pages/Home.tsx
Normal file
302
frontend/src/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import {
|
||||
codeBlockPlugin,
|
||||
codeMirrorPlugin,
|
||||
headingsPlugin,
|
||||
linkPlugin,
|
||||
listsPlugin,
|
||||
markdownShortcutPlugin,
|
||||
MDXEditor,
|
||||
quotePlugin,
|
||||
SandpackConfig,
|
||||
sandpackPlugin,
|
||||
thematicBreakPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
folderApi,
|
||||
FolderCreate,
|
||||
FolderTreeNode,
|
||||
FolderTreeResponse,
|
||||
NoteRead,
|
||||
} from "../api/folders";
|
||||
import { NoteCreate, notesApi } from "../api/notes";
|
||||
import "../main.css";
|
||||
import { DndContext, DragEndEvent } from "@dnd-kit/core";
|
||||
|
||||
import "@mdxeditor/editor/style.css";
|
||||
import { DroppableFolder } from "../components/sidebar/DroppableFolder";
|
||||
import { DraggableNote } from "../components/sidebar/DraggableNote";
|
||||
|
||||
const simpleSandpackConfig: SandpackConfig = {
|
||||
defaultPreset: "react",
|
||||
presets: [
|
||||
{
|
||||
label: "React",
|
||||
name: "react",
|
||||
meta: "live react",
|
||||
sandpackTemplate: "react",
|
||||
sandpackTheme: "dark",
|
||||
snippetFileName: "/App.js",
|
||||
snippetLanguage: "jsx",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function Home() {
|
||||
const [folderTree, setFolderTree] = useState<FolderTreeResponse | null>(null);
|
||||
const [selectedNote, setSelectedNote] = useState<NoteRead | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("#");
|
||||
const [newFolder, setNewFolder] = useState(false);
|
||||
const [newFolderText, setNewFolderText] = useState("");
|
||||
const [selectedFolder, setSelectedFolder] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadFolderTree();
|
||||
}, []);
|
||||
|
||||
const loadFolderTree = async () => {
|
||||
const { data } = await folderApi.tree();
|
||||
setFolderTree(data);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
const newNote: NoteCreate = { title, content, folder_id: selectedFolder };
|
||||
await notesApi.create(newNote);
|
||||
setTitle("");
|
||||
setContent("#");
|
||||
loadFolderTree();
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderText.trim()) return;
|
||||
const newFolderData: FolderCreate = {
|
||||
name: newFolderText,
|
||||
parent_id: null,
|
||||
};
|
||||
await folderApi.create(newFolderData);
|
||||
setNewFolderText("");
|
||||
loadFolderTree();
|
||||
setNewFolder(false);
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedNote) return;
|
||||
await notesApi.update(selectedNote.id, { title, content });
|
||||
setSelectedNote(null);
|
||||
setTitle("");
|
||||
setContent("#");
|
||||
loadFolderTree();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await notesApi.delete(id);
|
||||
loadFolderTree();
|
||||
};
|
||||
|
||||
const selectNote = (note: NoteRead) => {
|
||||
setSelectedNote(note);
|
||||
setTitle(note.title);
|
||||
setContent(note.content);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedNote(null);
|
||||
setTitle("");
|
||||
setContent("#");
|
||||
};
|
||||
|
||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (newFolder && newFolderRef.current) {
|
||||
newFolderRef.current.focus();
|
||||
}
|
||||
}, [newFolder]);
|
||||
|
||||
const renderFolder = (folder: FolderTreeNode, depth: number = 0) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
style={{ marginLeft: depth > 0 ? "1rem" : "0" }}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<DroppableFolder
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
setSelectedFolder={setSelectedFolder}
|
||||
selectedFolder={selectedFolder}
|
||||
selectedNote={selectedNote}
|
||||
/>
|
||||
{folder.notes.map((note) => (
|
||||
<DraggableNote
|
||||
key={note.id}
|
||||
note={note}
|
||||
selectNote={selectNote}
|
||||
selectedNote={selectedNote}
|
||||
/>
|
||||
))}
|
||||
{folder.children.map((child) => renderFolder(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
console.log(active.data);
|
||||
console.log(over.data);
|
||||
|
||||
await notesApi.update(active.id as number, {
|
||||
folder_id: over.id as number,
|
||||
});
|
||||
|
||||
loadFolderTree();
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext onDragEnd={handleDragEnd} autoScroll={false}>
|
||||
<div className="flex bg-ctp-base min-h-screen text-ctp-text">
|
||||
<div
|
||||
className="bg-ctp-mantle border-r-ctp-surface2 border-r overflow-hidden"
|
||||
style={{
|
||||
width: "300px",
|
||||
padding: "1rem",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()} // Add this
|
||||
onTouchMove={(e) => e.preventDefault()} // And this for touch devices
|
||||
>
|
||||
<h2>Notes</h2>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
style={{ marginBottom: "1rem", width: "100%" }}
|
||||
>
|
||||
New Note
|
||||
</button>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newFolder && newFolderRef.current) {
|
||||
newFolderRef.current.focus();
|
||||
}
|
||||
setNewFolder(true);
|
||||
}}
|
||||
className="hover:bg-ctp-mauve group transition-colors rounded-md p-1 text-center flex"
|
||||
>
|
||||
<i className="fadr fa-folder-plus text-xl text-ctp-mauve group-hover:text-ctp-base transition-colors"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="hover:bg-ctp-mauve group transition-colors rounded-md p-1 text-center flex"
|
||||
>
|
||||
<i className="fadr fa-file-circle-plus text-xl text-ctp-mauve group-hover:text-ctp-base transition-colors"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{newFolder && (
|
||||
<div className="px-1 mb-1">
|
||||
<input
|
||||
onBlur={() => setNewFolder(false)}
|
||||
onChange={(e) => setNewFolderText(e.target.value)}
|
||||
value={newFolderText}
|
||||
type="text"
|
||||
placeholder="new folder"
|
||||
className="border-ctp-mauve border rounded-md px-2 w-full focus:outline-none focus:ring-1 focus:ring-ctp-mauve focus:border-ctp-mauve bg-ctp-base"
|
||||
ref={newFolderRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateFolder();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render folder tree */}
|
||||
{folderTree?.folders.map((folder) => renderFolder(folder))}
|
||||
|
||||
{/* Render orphaned notes */}
|
||||
{folderTree?.orphaned_notes &&
|
||||
folderTree.orphaned_notes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-ctp-subtext0 text-sm mb-1">Unsorted</div>
|
||||
{folderTree.orphaned_notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
onClick={() => selectNote(note)}
|
||||
className={`rounded-md px-2 mb-0.5 select-none cursor-pointer font-light transition-all duration-150 flex items-center gap-1 ${
|
||||
selectedNote?.id === note.id
|
||||
? "bg-ctp-mauve text-ctp-base"
|
||||
: "hover:bg-ctp-surface1"
|
||||
}`}
|
||||
>
|
||||
<i className="fadr fa-file text-xs"></i>
|
||||
<span>{note.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Note title..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
marginBottom: "1rem",
|
||||
fontSize: "1.5rem",
|
||||
border: "1px solid #ccc",
|
||||
}}
|
||||
/>
|
||||
<MDXEditor
|
||||
markdown={content}
|
||||
key={selectedNote?.id || "new"}
|
||||
onChange={setContent}
|
||||
className="prose text-ctp-text"
|
||||
plugins={[
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
thematicBreakPlugin(),
|
||||
linkPlugin(),
|
||||
codeBlockPlugin({ defaultCodeBlockLanguage: "js" }),
|
||||
sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
|
||||
codeMirrorPlugin({
|
||||
codeBlockLanguages: { js: "JavaScript", css: "CSS" },
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
]}
|
||||
/>
|
||||
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
|
||||
{selectedNote ? (
|
||||
<>
|
||||
<button onClick={handleUpdate}>Update Note</button>
|
||||
<button
|
||||
onClick={() => handleDelete(selectedNote.id)}
|
||||
className="bg-ctp-red rounded-md px-1 text-ctp-crust"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button onClick={clearSelection}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={handleCreate}>Create Note</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
68
frontend/src/pages/Markdown.tsx
Normal file
68
frontend/src/pages/Markdown.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// src/pages/TestPage.tsx
|
||||
import { FC } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { MDXEditor, SandpackConfig } from "@mdxeditor/editor";
|
||||
import {
|
||||
headingsPlugin,
|
||||
listsPlugin,
|
||||
quotePlugin,
|
||||
thematicBreakPlugin,
|
||||
linkPlugin,
|
||||
codeBlockPlugin,
|
||||
codeMirrorPlugin,
|
||||
sandpackPlugin,
|
||||
markdownShortcutPlugin,
|
||||
toolbarPlugin,
|
||||
BoldItalicUnderlineToggles,
|
||||
} from "@mdxeditor/editor";
|
||||
|
||||
import "@mdxeditor/editor/style.css";
|
||||
|
||||
const simpleSandpackConfig: SandpackConfig = {
|
||||
defaultPreset: "react",
|
||||
presets: [
|
||||
{
|
||||
label: "React",
|
||||
name: "react",
|
||||
meta: "live react",
|
||||
sandpackTemplate: "react",
|
||||
sandpackTheme: "dark",
|
||||
snippetFileName: "/App.js",
|
||||
snippetLanguage: "jsx",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MarkdownPage: FC = () => {
|
||||
const markdown = `
|
||||
# This is *perfect*!
|
||||
- TestPage
|
||||
- te
|
||||
`;
|
||||
return (
|
||||
<MDXEditor
|
||||
markdown={markdown}
|
||||
plugins={[
|
||||
toolbarPlugin({
|
||||
toolbarClassName: "my-class",
|
||||
toolbarContents: () => (
|
||||
<>
|
||||
<BoldItalicUnderlineToggles />
|
||||
</>
|
||||
),
|
||||
}),
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
thematicBreakPlugin(),
|
||||
linkPlugin(),
|
||||
codeBlockPlugin({ defaultCodeBlockLanguage: "js" }),
|
||||
sandpackPlugin({ sandpackConfig: simpleSandpackConfig }),
|
||||
codeMirrorPlugin({
|
||||
codeBlockLanguages: { js: "JavaScript", css: "CSS" },
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [tailwindcss(), react()],
|
||||
server: {
|
||||
// Proxy API calls to the FastAPI container (or local dev server)
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
Loading…
Reference in a new issue