Huge refactor adding in react query and general clean up. not finsihed
yet. more cleanup needs to be done. - added react query - moved to openapi instead of axios - added case translator from frontend to backend
This commit is contained in:
parent
b6afaf8606
commit
3fe4b9ea88
26 changed files with 12413 additions and 10638 deletions
|
|
@ -108,6 +108,8 @@ class TagUpdate(SQLModel):
|
|||
class TagTreeNode(SQLModel):
|
||||
id: int
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
children: List["TagTreeNode"] = []
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
from tkinter.constants import TOP
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.database import get_session
|
||||
from app.models import (
|
||||
Note,
|
||||
NoteCreate,
|
||||
NoteTag,
|
||||
NoteUpdate,
|
||||
Tag,
|
||||
TagCreate,
|
||||
TagTreeNode,
|
||||
|
|
@ -19,7 +14,7 @@ from sqlmodel import Session, select
|
|||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
@router.get("/")
|
||||
@router.get("/", response_model=list[Tag])
|
||||
def list_tags(session: Session = Depends(get_session)):
|
||||
tags = session.exec(select(Tag)).all()
|
||||
return tags
|
||||
|
|
@ -44,12 +39,14 @@ def build_tag_tree_node(tag: Tag) -> TagTreeNode:
|
|||
return TagTreeNode(
|
||||
id= tag.id,
|
||||
name = tag.name,
|
||||
parent_id=tag.parent_id,
|
||||
created_at=tag.created_at,
|
||||
children = [build_tag_tree_node(child) for child in tag.children]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
@router.get("/tree", response_model=TagTreeResponse)
|
||||
def get_tag_tree(session: Session = Depends(get_session)):
|
||||
top_level_tags = session.exec(
|
||||
select(Tag)
|
||||
|
|
@ -61,7 +58,7 @@ def get_tag_tree(session: Session = Depends(get_session)):
|
|||
return TagTreeResponse(tags=tree)
|
||||
|
||||
|
||||
@router.post("/note/{note_id}/tag/{tag_id}")
|
||||
@router.post("/note/{note_id}/tag/{tag_id}", response_model=NoteTag)
|
||||
def add_tag_to_note(
|
||||
note_id: int,
|
||||
tag_id: int,
|
||||
|
|
|
|||
395
frontend/package-lock.json
generated
395
frontend/package-lock.json
generated
|
|
@ -14,17 +14,22 @@
|
|||
"@mdxeditor/editor": "^3.49.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@tiptap/extension-placeholder": "^3.12.1",
|
||||
"@tiptap/react": "^3.12.1",
|
||||
"@tiptap/starter-kit": "^3.12.1",
|
||||
"axios": "^1.13.2",
|
||||
"framer-motion": "^12.23.25",
|
||||
"humps": "^2.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"openapi-fetch": "^0.15.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -32,11 +37,15 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/humps": "^2.0.6",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
"jsdom": "^27.3.0",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"type-fest": "^5.3.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vitest": "^4.0.15"
|
||||
|
|
@ -2951,6 +2960,52 @@
|
|||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/config": {
|
||||
"version": "0.22.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.2.tgz",
|
||||
"integrity": "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.6",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.6.tgz",
|
||||
"integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/ajv": "^8.11.2",
|
||||
"@redocly/config": "^0.22.0",
|
||||
"colorette": "^1.2.0",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"js-yaml": "^4.1.0",
|
||||
"minimatch": "^5.0.1",
|
||||
"pluralize": "^8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||
|
|
@ -3781,6 +3836,60 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
|
||||
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
|
||||
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.12",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
|
||||
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz",
|
||||
"integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.91.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
|
@ -4425,6 +4534,13 @@
|
|||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/humps": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/humps/-/humps-2.0.6.tgz",
|
||||
"integrity": "sha512-Fagm1/a/1J9gDKzGdtlPmmTN5eSw/aaTzHtj740oSfo+MODsSY2WglxMmhTdOglC8nxqUhGGQ+5HfVtBvxo3Kg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
|
|
@ -4660,6 +4776,16 @@
|
|||
"integrity": "sha512-QGY1oxYE7/kkeNmbtY/2ZjQ07BCG3zYdz+k/+sf69kMzEIxb93guHkPnIXITQ+BYi61oQwG74twMOX1tD4aesg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
|
|
@ -4738,6 +4864,13 @@
|
|||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
|
|
@ -4778,6 +4911,16 @@
|
|||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
|
||||
|
|
@ -4914,6 +5057,13 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/character-entities": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||
|
|
@ -4993,6 +5143,13 @@
|
|||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
|
|
@ -5574,6 +5731,13 @@
|
|||
"type": "^2.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz",
|
||||
|
|
@ -5583,6 +5747,23 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz",
|
||||
|
|
@ -5876,6 +6057,12 @@
|
|||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/humps": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz",
|
||||
"integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
|
|
@ -5942,6 +6129,19 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
|
|
@ -6038,6 +6238,16 @@
|
|||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -6117,6 +6327,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
|
|
@ -7546,6 +7763,19 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
|
|
@ -7648,6 +7878,73 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/openapi-fetch": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.15.0.tgz",
|
||||
"integrity": "sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"openapi-typescript-helpers": "^0.0.15"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.10.1.tgz",
|
||||
"integrity": "sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/openapi-core": "^1.34.5",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"change-case": "^5.4.4",
|
||||
"parse-json": "^8.3.0",
|
||||
"supports-color": "^10.2.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-typescript": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript-helpers": {
|
||||
"version": "0.0.15",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz",
|
||||
"integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"index-to-position": "^1.1.0",
|
||||
"type-fest": "^4.39.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
|
|
@ -7785,6 +8082,16 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
|
@ -8581,6 +8888,19 @@
|
|||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-parser": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
|
||||
|
|
@ -8601,6 +8921,19 @@
|
|||
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.17",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
||||
|
|
@ -8773,6 +9106,37 @@
|
|||
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.1.tgz",
|
||||
"integrity": "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
|
|
@ -8945,6 +9309,19 @@
|
|||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/uvu": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
|
||||
|
|
@ -9058,6 +9435,7 @@
|
|||
"integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.15",
|
||||
"@vitest/mocker": "4.0.15",
|
||||
|
|
@ -9805,6 +10183,23 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml-ast-parser": {
|
||||
"version": "0.0.43",
|
||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.27",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"generate-types": "openapi-typescript http://localhost:8000/openapi.json -o src/types/api.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -14,17 +18,22 @@
|
|||
"@mdxeditor/editor": "^3.49.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@tiptap/extension-placeholder": "^3.12.1",
|
||||
"@tiptap/react": "^3.12.1",
|
||||
"@tiptap/starter-kit": "^3.12.1",
|
||||
"axios": "^1.13.2",
|
||||
"framer-motion": "^12.23.25",
|
||||
"humps": "^2.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
"openapi-fetch": "^0.15.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -32,18 +41,17 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/humps": "^2.0.6",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitest/ui": "^4.0.15",
|
||||
"jsdom": "^27.3.0",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"type-fest": "^5.3.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vitest": "^4.0.15"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
frontend/src/api/client.ts
Normal file
72
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// frontend/src/api/client.ts
|
||||
import createClient from "openapi-fetch";
|
||||
import { camelizeKeys, decamelizeKeys } from "humps";
|
||||
import type { paths } from "@/types/api";
|
||||
|
||||
const API_URL = import.meta.env.PROD ? "/api" : "http://localhost:8000/";
|
||||
|
||||
// Create the base client with full type safety
|
||||
export const client = createClient<paths>({
|
||||
baseUrl: API_URL,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
// Add middleware to automatically transform requests and responses
|
||||
client.use({
|
||||
async onRequest({ request }) {
|
||||
// Transform request body from camelCase to snake_case
|
||||
if (request.body) {
|
||||
try {
|
||||
const bodyText = await request.text();
|
||||
if (bodyText) {
|
||||
const bodyJson = JSON.parse(bodyText);
|
||||
const transformedBody = decamelizeKeys(bodyJson);
|
||||
|
||||
// Preserve headers and ensure Content-Type is set
|
||||
const headers = new Headers(request.headers);
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
credentials: request.credentials,
|
||||
mode: request.mode,
|
||||
cache: request.cache,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
integrity: request.integrity,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// If not JSON, pass through unchanged
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
|
||||
async onResponse({ response }) {
|
||||
// Transform response body from snake_case to camelCase
|
||||
if (response.body) {
|
||||
try {
|
||||
const clonedResponse = response.clone();
|
||||
const json = await clonedResponse.json();
|
||||
const transformedData = camelizeKeys(json);
|
||||
|
||||
return new Response(JSON.stringify(transformedData), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} catch (e) {
|
||||
// If not JSON, return original response
|
||||
return response;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
export default client;
|
||||
|
|
@ -1,5 +1,23 @@
|
|||
import { FolderTreeResponse, FolderTreeNode } from "./folders";
|
||||
import { Tag } from "./tags";
|
||||
import { components } from "@/types/api";
|
||||
// encryption.tsx
|
||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||
import { FolderTreeResponse } from "./folders";
|
||||
export type Tag = CamelCasedPropertiesDeep<components["schemas"]["Tag"]>;
|
||||
export type FolderTreeNode = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderTreeNode"]
|
||||
>;
|
||||
export type TagTreeNode = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["TagTreeNode"]
|
||||
>;
|
||||
|
||||
export interface DecryptedTagNode {
|
||||
id?: number | null | undefined;
|
||||
name: string;
|
||||
parentId?: number | null;
|
||||
createdAt?: string;
|
||||
parentPath: string;
|
||||
children: DecryptedTagNode[];
|
||||
}
|
||||
|
||||
export async function deriveKey(password: string, salt: string) {
|
||||
const enc = new TextEncoder();
|
||||
|
|
@ -133,8 +151,8 @@ export async function decryptFolderTree(
|
|||
folders: await Promise.all(
|
||||
tree.folders.map((folder) => decryptFolder(folder)),
|
||||
),
|
||||
orphaned_notes: await Promise.all(
|
||||
tree.orphaned_notes.map(async (note) => ({
|
||||
orphanedNotes: await Promise.all(
|
||||
tree.orphanedNotes.map(async (note) => ({
|
||||
...note,
|
||||
title: await decryptString(note.title, encryptionKey),
|
||||
content: await decryptString(note.content, encryptionKey),
|
||||
|
|
@ -150,10 +168,10 @@ export async function decryptFolderTree(
|
|||
}
|
||||
|
||||
export const decryptTagTree = async (
|
||||
tags: Tag[],
|
||||
tags: TagTreeNode[],
|
||||
key: CryptoKey,
|
||||
parentPath = "",
|
||||
): Promise<Tag[]> => {
|
||||
): Promise<DecryptedTagNode[]> => {
|
||||
return Promise.all(
|
||||
tags.map(async (tag) => {
|
||||
const decryptedName = await decryptString(tag.name, key);
|
||||
|
|
@ -164,7 +182,7 @@ export const decryptTagTree = async (
|
|||
return {
|
||||
...tag,
|
||||
name: decryptedName,
|
||||
parent_path: parentPath,
|
||||
parentPath: parentPath,
|
||||
children: await decryptTagTree(tag.children, key, currentPath),
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,62 +1,34 @@
|
|||
import axios from "axios";
|
||||
import { decryptFolderTree } from "./encryption";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { Tag } from "./tags";
|
||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||
import { components } from "@/types/api";
|
||||
import client from "./client";
|
||||
|
||||
axios.defaults.withCredentials = true;
|
||||
export type Folder = CamelCasedPropertiesDeep<components["schemas"]["Folder"]>;
|
||||
|
||||
const API_URL = (import.meta as any).env.PROD
|
||||
? "/api"
|
||||
: "http://localhost:8000/api";
|
||||
export type FolderTreeNode = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderTreeNode"]
|
||||
>;
|
||||
|
||||
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;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
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 interface FolderUpdate {
|
||||
name?: string;
|
||||
parent_id?: number | null;
|
||||
}
|
||||
export type FolderTreeResponse = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderTreeResponse"]
|
||||
>;
|
||||
export type FolderCreate = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderCreate"]
|
||||
>;
|
||||
export type FolderUpdate = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["FolderUpdate"]
|
||||
>;
|
||||
|
||||
const getFolderTree = async () => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
const { data } = await axios.get<FolderTreeResponse>(
|
||||
`${API_URL}/folders/tree`,
|
||||
);
|
||||
const { data, error } = await client.GET("/api/folders/tree", {});
|
||||
|
||||
const decryptedFolderTree = await decryptFolderTree(data, encryptionKey);
|
||||
const newData = data as unknown as FolderTreeResponse;
|
||||
|
||||
const decryptedFolderTree = await decryptFolderTree(newData, encryptionKey);
|
||||
|
||||
return decryptedFolderTree;
|
||||
};
|
||||
|
|
@ -64,7 +36,10 @@ const getFolderTree = async () => {
|
|||
const updateFolder = async (id: number, folder: FolderUpdate) => {
|
||||
console.log(`Updating folder ${id} with:`, folder);
|
||||
try {
|
||||
const response = await axios.patch(`${API_URL}/folders/${id}`, folder);
|
||||
const response = await client.PATCH("/api/folders/{folder_id}", {
|
||||
params: { path: { folder_id: id } },
|
||||
body: folder,
|
||||
});
|
||||
console.log(`Folder ${id} update response:`, response.data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
|
@ -75,10 +50,13 @@ const updateFolder = async (id: number, folder: FolderUpdate) => {
|
|||
|
||||
export const folderApi = {
|
||||
tree: () => getFolderTree(),
|
||||
list: () => axios.get<Folder[]>(`${API_URL}/folders`),
|
||||
list: () => client.GET("/api/folders/", {}),
|
||||
create: (folder: FolderCreate) =>
|
||||
axios.post<Folder>(`${API_URL}/folders/`, folder),
|
||||
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`),
|
||||
client.POST("/api/folders/", { body: folder }),
|
||||
delete: (id: number) =>
|
||||
client.DELETE("/api/folders/{folder_id}", {
|
||||
params: { path: { folder_id: id } },
|
||||
}),
|
||||
update: (id: number, updateData: FolderUpdate) =>
|
||||
updateFolder(id, updateData),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,27 +1,15 @@
|
|||
import axios from "axios";
|
||||
import { encryptString, decryptString } from "./encryption";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import { Tag } from "./tags";
|
||||
axios.defaults.withCredentials = true;
|
||||
const API_URL = (import.meta as any).env.PROD
|
||||
? "/api"
|
||||
: "http://localhost:8000/api";
|
||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||
import { components } from "@/types/api";
|
||||
import client from "./client";
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
title: string;
|
||||
folder_id?: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface NoteCreate {
|
||||
title: string;
|
||||
content: string;
|
||||
folder_id: number | null;
|
||||
}
|
||||
export type NoteRead = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["NoteRead"]
|
||||
>;
|
||||
export type NoteCreate = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["NoteCreate"]
|
||||
>;
|
||||
|
||||
const createNote = async (note: NoteCreate) => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
|
|
@ -33,21 +21,21 @@ const createNote = async (note: NoteCreate) => {
|
|||
var encryptedNote = {
|
||||
title: noteTitle,
|
||||
content: noteContent,
|
||||
folder_id: note.folder_id,
|
||||
folderId: note.folderId,
|
||||
};
|
||||
|
||||
console.log(encryptedNote);
|
||||
return axios.post(`${API_URL}/notes/`, encryptedNote);
|
||||
return client.POST(`/api/notes/`, { body: encryptedNote });
|
||||
};
|
||||
const fetchNotes = async () => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
const { data } = await axios.get(`${API_URL}/notes/`);
|
||||
const { data } = await client.GET(`/api/notes/`);
|
||||
|
||||
console.log(data);
|
||||
const decryptedNotes = await Promise.all(
|
||||
data.map(async (note: Note) => ({
|
||||
data.map(async (note: NoteRead) => ({
|
||||
...note,
|
||||
title: await decryptString(note.title, encryptionKey),
|
||||
content: await decryptString(note.content, encryptionKey),
|
||||
|
|
@ -62,28 +50,58 @@ const fetchNotes = async () => {
|
|||
return decryptedNotes;
|
||||
};
|
||||
|
||||
const updateNote = async (id: number, note: Partial<Note>) => {
|
||||
const updateNote = async (id: number, note: Partial<NoteRead>) => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
var encryptedNote: Partial<Note> = {};
|
||||
var encryptedNote: Partial<NoteRead> = {};
|
||||
if (note.content) {
|
||||
encryptedNote.content = await encryptString(note.content, encryptionKey);
|
||||
}
|
||||
if (note.title) {
|
||||
encryptedNote.title = await encryptString(note.title, encryptionKey);
|
||||
}
|
||||
if (note.folder_id) {
|
||||
encryptedNote.folder_id = note.folder_id;
|
||||
if (note.folderId) {
|
||||
encryptedNote.folderId = note.folderId;
|
||||
}
|
||||
// if (!note.folderId){
|
||||
// throw new Error("Folder id missing from note.")
|
||||
// }
|
||||
const { data, error } = await client.PATCH(`/api/notes/{note_id}`, {
|
||||
body: encryptedNote,
|
||||
params: {
|
||||
path: {
|
||||
note_id: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return axios.patch(`${API_URL}/notes/${id}`, encryptedNote);
|
||||
if (data) {
|
||||
console.log(data);
|
||||
}
|
||||
if (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const notesApi = {
|
||||
list: () => fetchNotes(),
|
||||
get: (id: number) => axios.get(`${API_URL}/notes/${id}`),
|
||||
get: (id: number) =>
|
||||
client.GET(`/api/notes/{note_id}`, {
|
||||
params: {
|
||||
path: {
|
||||
note_id: id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
create: (note: NoteCreate) => createNote(note),
|
||||
update: (id: number, note: Partial<Note>) => updateNote(id, note),
|
||||
delete: (id: number) => axios.delete(`${API_URL}/notes/${id}`),
|
||||
update: (id: number, note: Partial<NoteRead>) => updateNote(id, note),
|
||||
delete: (id: number) =>
|
||||
client.DELETE(`/api/notes/{note_id}`, {
|
||||
params: {
|
||||
path: {
|
||||
note_id: id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,63 +1,90 @@
|
|||
import axios from "axios";
|
||||
import { client } from "./client";
|
||||
import { components } from "@/types/api";
|
||||
import { encryptString, decryptTagTree } from "./encryption";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
axios.defaults.withCredentials = true;
|
||||
const API_URL = (import.meta as any).env.PROD
|
||||
? "/api"
|
||||
: "http://localhost:8000/api";
|
||||
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id?: number;
|
||||
created_at: string;
|
||||
children: Tag[];
|
||||
parent_path: string;
|
||||
}
|
||||
export type Tag = CamelCasedPropertiesDeep<components["schemas"]["Tag"]>;
|
||||
|
||||
export interface TagCreate {
|
||||
name: string;
|
||||
parent_id?: number;
|
||||
}
|
||||
export type TagTreeNode = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["TagTreeNode"]
|
||||
>;
|
||||
export type TagCreate = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["TagCreate"]
|
||||
>;
|
||||
export type TagRead = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["TagRead"]
|
||||
>;
|
||||
export type TagTreeResponse = CamelCasedPropertiesDeep<
|
||||
components["schemas"]["TagTreeResponse"]
|
||||
>;
|
||||
|
||||
const fetchTags = async () => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
const { data } = await axios.get(`${API_URL}/tags/tree`);
|
||||
const tags = decryptTagTree(data.tags, encryptionKey);
|
||||
console.log(await tags);
|
||||
const response = await client.GET("/api/tags/tree", {});
|
||||
|
||||
if (response.error) throw new Error("Failed to fetch tags");
|
||||
if (!response.data) throw new Error("No data returned");
|
||||
|
||||
const data = response.data;
|
||||
|
||||
const tags = decryptTagTree(data.tags as any, encryptionKey);
|
||||
return tags;
|
||||
};
|
||||
|
||||
const createTag = async (tag: TagCreate, noteId?: number) => {
|
||||
const createTag = async (tag: TagCreate): Promise<TagTreeNode> => {
|
||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||
if (!encryptionKey) throw new Error("Not authenticated");
|
||||
|
||||
const tagName = await encryptString(tag.name, encryptionKey);
|
||||
const encryptedTag = {
|
||||
|
||||
// Use the exact structure from TagCreate schema
|
||||
const { data, error } = await client.POST("/api/tags/", {
|
||||
body: {
|
||||
name: tagName,
|
||||
parent_id: tag.parent_id,
|
||||
};
|
||||
parentId: tag.parentId || null,
|
||||
},
|
||||
});
|
||||
|
||||
const r = await axios.post(`${API_URL}/tags/`, encryptedTag);
|
||||
console.log(r);
|
||||
|
||||
if (noteId) {
|
||||
return await addTagToNote(r.data.id, noteId);
|
||||
}
|
||||
if (error) throw new Error("Failed to create tag");
|
||||
console.log(data);
|
||||
return data as unknown as TagTreeNode;
|
||||
};
|
||||
|
||||
const addTagToNote = async (tagId: number, noteId: number) => {
|
||||
return axios.post(`${API_URL}/tags/note/${noteId}/tag/${tagId}`);
|
||||
const { data, error } = await client.POST(
|
||||
"/api/tags/note/{note_id}/tag/{tag_id}",
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
note_id: noteId,
|
||||
tag_id: tagId,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error) throw new Error("Failed to add tag to note");
|
||||
return data;
|
||||
};
|
||||
|
||||
const deleteTag = async (tagId: number) => {
|
||||
return axios.delete(`${API_URL}/tags/${tagId}`);
|
||||
const { error } = await client.DELETE("/api/tags/{tag_id}", {
|
||||
params: {
|
||||
path: {
|
||||
tag_id: tagId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) throw new Error("Failed to delete tag");
|
||||
};
|
||||
|
||||
export const tagsApi = {
|
||||
list: async () => await fetchTags(),
|
||||
create: (tag: TagCreate, noteId?: number) => createTag(tag, noteId),
|
||||
delete: (tagId: number) => deleteTag(tagId),
|
||||
list: fetchTags,
|
||||
create: createTag,
|
||||
addToNote: addTagToNote,
|
||||
delete: deleteTag,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import React, { useState } from "react";
|
||||
import { FolderTreeNode } from "../../api/folders";
|
||||
import { useNoteStore } from "../../stores/notesStore";
|
||||
import { folderApi } from "../../api/folders";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useUpdateFolder,
|
||||
useDeleteFolder,
|
||||
} from "../../hooks/useFolders";
|
||||
|
||||
interface FolderContextMenuProps {
|
||||
x: number;
|
||||
|
|
@ -16,7 +19,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
|||
folder,
|
||||
onClose,
|
||||
}) => {
|
||||
const { loadFolderTree, updateFolder } = useNoteStore();
|
||||
const createFolderMutation = useCreateFolder();
|
||||
const updateFolderMutation = useUpdateFolder();
|
||||
const deleteFolderMutation = useDeleteFolder();
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState(folder.name);
|
||||
|
||||
|
|
@ -25,8 +31,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
|||
return;
|
||||
}
|
||||
try {
|
||||
await folderApi.delete(folder.id);
|
||||
await loadFolderTree();
|
||||
await deleteFolderMutation.mutateAsync(folder.id);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete folder:", error);
|
||||
|
|
@ -35,7 +40,14 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
|||
|
||||
const handleRename = async () => {
|
||||
if (newName.trim() && newName !== folder.name) {
|
||||
await updateFolder(folder.id, { name: newName });
|
||||
try {
|
||||
await updateFolderMutation.mutateAsync({
|
||||
folderId: folder.id,
|
||||
folder: { name: newName },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to rename folder:", error);
|
||||
}
|
||||
}
|
||||
setIsRenaming(false);
|
||||
onClose();
|
||||
|
|
@ -43,11 +55,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
|||
|
||||
const handleCreateSubfolder = async () => {
|
||||
try {
|
||||
await folderApi.create({
|
||||
await createFolderMutation.mutateAsync({
|
||||
name: "New Folder",
|
||||
parent_id: folder.id,
|
||||
});
|
||||
await loadFolderTree();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create subfolder:", error);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React from "react";
|
||||
import { NoteRead } from "../../api/folders";
|
||||
import { useNoteStore } from "../../stores/notesStore";
|
||||
import { notesApi } from "../../api/notes";
|
||||
import { Note } from "../../api/notes";
|
||||
import { useCreateNote, useDeleteNote } from "../../hooks/useFolders";
|
||||
import { useUIStore } from "../../stores/uiStore";
|
||||
|
||||
interface NoteContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
note: NoteRead;
|
||||
note: Note;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -16,12 +16,15 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
|||
note,
|
||||
onClose,
|
||||
}) => {
|
||||
const { loadFolderTree, setSelectedNote } = useNoteStore();
|
||||
const { setSelectedNote } = useUIStore();
|
||||
const deleteNoteMutation = useDeleteNote();
|
||||
const createNoteMutation = useCreateNote();
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await notesApi.delete(note.id);
|
||||
await loadFolderTree();
|
||||
await deleteNoteMutation.mutateAsync(note.id);
|
||||
// Clear selection if this note was selected
|
||||
setSelectedNote(null);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete note:", error);
|
||||
|
|
@ -30,12 +33,11 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
|||
|
||||
const handleDuplicate = async () => {
|
||||
try {
|
||||
await notesApi.create({
|
||||
await createNoteMutation.mutateAsync({
|
||||
title: `${note.title} (Copy)`,
|
||||
content: note.content,
|
||||
folder_id: note.folder_id,
|
||||
folder_id: note.folder_id || null,
|
||||
});
|
||||
await loadFolderTree();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate note:", error);
|
||||
|
|
|
|||
306
frontend/src/hooks/useFolders.ts
Normal file
306
frontend/src/hooks/useFolders.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
FolderCreate,
|
||||
FolderTreeNode,
|
||||
FolderTreeResponse,
|
||||
FolderUpdate,
|
||||
folderApi,
|
||||
} from "@/api/folders";
|
||||
import { NoteRead, NoteCreate, notesApi } from "@/api/notes";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
export const useFolderTree = () => {
|
||||
const { encryptionKey } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["folders", "tree"],
|
||||
queryFn: folderApi.tree,
|
||||
enabled: !!encryptionKey,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (folder: FolderCreate) => folderApi.create(folder),
|
||||
|
||||
onMutate: async (newFolder) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const tempFolder: FolderTreeNode = {
|
||||
id: -Date.now(),
|
||||
name: newFolder.name,
|
||||
notes: [],
|
||||
children: [],
|
||||
};
|
||||
if (!newFolder.parentId) {
|
||||
return {
|
||||
...prev,
|
||||
folders: [...prev.folders, tempFolder],
|
||||
};
|
||||
}
|
||||
|
||||
const addToParent = (folders: FolderTreeNode[]): FolderTreeNode[] => {
|
||||
return folders.map((folder) => {
|
||||
if (folder.id === newFolder.parentId) {
|
||||
return {
|
||||
...folder,
|
||||
children: [...folder.children, tempFolder],
|
||||
};
|
||||
}
|
||||
return { ...folder, children: addToParent(folder.children) };
|
||||
});
|
||||
};
|
||||
|
||||
return { ...prev, folders: addToParent(prev.folders) };
|
||||
},
|
||||
);
|
||||
return { previousFolderTree };
|
||||
},
|
||||
onError: (err, newFolder, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
folderId,
|
||||
folder,
|
||||
}: {
|
||||
folderId: number;
|
||||
folder: FolderUpdate;
|
||||
}) => folderApi.update(folderId, folder),
|
||||
|
||||
onMutate: async ({ folderId, folder }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const updateInTree = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders.map((f) => {
|
||||
if (f.id == folderId) {
|
||||
return {
|
||||
...f,
|
||||
...folder,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
children: updateInTree(f.children),
|
||||
};
|
||||
});
|
||||
};
|
||||
return { ...prev, folders: updateInTree(prev.folders) };
|
||||
},
|
||||
);
|
||||
return { previousFolderTree };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateNote = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
noteId,
|
||||
note,
|
||||
}: {
|
||||
noteId: number;
|
||||
note: Partial<NoteRead>;
|
||||
}) => notesApi.update(noteId, note),
|
||||
|
||||
onMutate: async ({ noteId, note }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const updateNoteInTree = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders.map((folder) => ({
|
||||
...folder,
|
||||
notes: folder.notes.map((n) =>
|
||||
n.id === noteId ? { ...n, ...note } : n,
|
||||
),
|
||||
children: updateNoteInTree(folder.children),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
folders: updateNoteInTree(prev.folders),
|
||||
orphanedNotes: prev.orphanedNotes.map((n) =>
|
||||
n.id === noteId ? { ...n, ...note } : n,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previousFolderTree };
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
console.log(err);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateNote = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (note: NoteCreate) => notesApi.create(note),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteNote = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (noteId: number) => notesApi.delete(noteId),
|
||||
|
||||
onMutate: async (noteId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const removeNoteFromTree = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders.map((folder) => ({
|
||||
...folder,
|
||||
notes: folder.notes.filter((n) => n.id !== noteId),
|
||||
children: removeNoteFromTree(folder.children),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
folders: removeNoteFromTree(prev.folders),
|
||||
orphanedNotes: prev.orphanedNotes.filter((n) => n.id !== noteId),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previousFolderTree };
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (folderId: number) => folderApi.delete(folderId),
|
||||
|
||||
onMutate: async (folderId) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["folders", "tree"] });
|
||||
|
||||
const previousFolderTree = queryClient.getQueryData(["folders", "tree"]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
(old: FolderTreeResponse | undefined) => {
|
||||
const prev = old || { folders: [], orphanedNotes: [] };
|
||||
|
||||
const removeFolderFromTree = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders
|
||||
.filter((folder) => folder.id !== folderId)
|
||||
.map((folder) => ({
|
||||
...folder,
|
||||
children: removeFolderFromTree(folder.children),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
folders: removeFolderFromTree(prev.folders),
|
||||
orphanedNotes: prev.orphanedNotes,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previousFolderTree };
|
||||
},
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
queryClient.setQueryData(
|
||||
["folders", "tree"],
|
||||
context?.previousFolderTree,
|
||||
);
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["folders", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
48
frontend/src/hooks/useTags.ts
Normal file
48
frontend/src/hooks/useTags.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Tag, TagCreate, tagsApi } from "@/api/tags";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { DecryptedTagNode } from "@/api/encryption";
|
||||
|
||||
export const useTagTree = () => {
|
||||
const { encryptionKey } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["tags", "tree"],
|
||||
queryFn: tagsApi.list,
|
||||
enabled: !!encryptionKey,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tag: TagCreate) => tagsApi.create(tag),
|
||||
|
||||
onMutate: async (newTag) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["tags", "tree"] });
|
||||
|
||||
const previousTags = queryClient.getQueryData(["tags", "tree"]);
|
||||
|
||||
queryClient.setQueryData(["tags", "tree"], (old: Tag[] | undefined) => {
|
||||
const tempTag: DecryptedTagNode = {
|
||||
id: -Date.now(),
|
||||
name: newTag.name,
|
||||
parentId: newTag.parentId,
|
||||
parentPath: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
children: [],
|
||||
};
|
||||
return [...(old || []), tempTag];
|
||||
});
|
||||
|
||||
return { previousTags };
|
||||
},
|
||||
|
||||
onError: (err, newTag, context) => {
|
||||
queryClient.setQueryData(["tags", "tree"], context?.previousTags);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tags", "tree"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -2,11 +2,16 @@ import React from "react";
|
|||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./main.css";
|
||||
// import "./assets/fontawesome/js/fontawesome.min.js";
|
||||
// import "./assets/fontawesome/js/duotone-regular.js";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,64 +2,65 @@ import { useEffect, useRef, useState } from "react";
|
|||
import "../../main.css";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useNoteStore } from "@/stores/notesStore";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
import { Login } from "../Login";
|
||||
import { TiptapEditor } from "../TipTap";
|
||||
import { Sidebar } from "./components/sidebar/SideBar";
|
||||
import { StatusIndicator } from "./components/StatusIndicator";
|
||||
|
||||
import { Tag, tagsApi } from "@/api/tags";
|
||||
import { useTagStore } from "@/stores/tagStore";
|
||||
import { useCreateTag, useTagTree } from "@/hooks/useTags";
|
||||
import { useFolderTree, useUpdateNote } from "@/hooks/useFolders";
|
||||
import { Note } from "@/api/notes";
|
||||
import { DecryptedTagNode } from "@/api/encryption";
|
||||
|
||||
function Home() {
|
||||
const [newFolder] = useState(false);
|
||||
|
||||
// Local state for editing the current note
|
||||
const [editingNote, setEditingNote] = useState<Note | null>(null);
|
||||
const [lastSavedNote, setLastSavedNote] = useState<{
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
const { loadFolderTree, updateNote, setContent, selectedNote, setTitle } =
|
||||
useNoteStore();
|
||||
|
||||
const { encryptionKey } = useAuthStore();
|
||||
|
||||
const { showModal, setUpdating } = useUIStore();
|
||||
|
||||
const { showModal, setUpdating, selectedNote } = useUIStore();
|
||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!encryptionKey) return;
|
||||
loadFolderTree();
|
||||
}, []);
|
||||
const folderTree = useFolderTree();
|
||||
const updateNoteMutation = useUpdateNote();
|
||||
|
||||
// Sync editingNote with selectedNote when selection changes
|
||||
useEffect(() => {
|
||||
if (selectedNote) {
|
||||
setEditingNote(selectedNote);
|
||||
setLastSavedNote({
|
||||
id: selectedNote.id,
|
||||
title: selectedNote.title,
|
||||
content: selectedNote.content,
|
||||
});
|
||||
} else {
|
||||
setEditingNote(null);
|
||||
setLastSavedNote(null);
|
||||
}
|
||||
}, [selectedNote?.id]);
|
||||
useEffect(() => {
|
||||
if (newFolder && newFolderRef.current) {
|
||||
newFolderRef.current.focus();
|
||||
}
|
||||
}, [newFolder]);
|
||||
|
||||
// Auto-save effect - watches editingNote for changes
|
||||
useEffect(() => {
|
||||
if (!selectedNote) return;
|
||||
if (!encryptionKey) return; // Don't try to save without encryption key
|
||||
if (!editingNote) return;
|
||||
if (!encryptionKey) return;
|
||||
|
||||
// Check if content or title actually changed (not just selecting a different note)
|
||||
// Check if content or title actually changed
|
||||
const hasChanges =
|
||||
lastSavedNote &&
|
||||
lastSavedNote.id === selectedNote.id &&
|
||||
(lastSavedNote.title !== selectedNote.title ||
|
||||
lastSavedNote.content !== selectedNote.content);
|
||||
|
||||
// If it's a new note selection, just update lastSavedNote without saving
|
||||
if (!lastSavedNote || lastSavedNote.id !== selectedNote.id) {
|
||||
setLastSavedNote({
|
||||
id: selectedNote.id,
|
||||
title: selectedNote.title,
|
||||
content: selectedNote.content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
lastSavedNote.id === editingNote.id &&
|
||||
(lastSavedNote.title !== editingNote.title ||
|
||||
lastSavedNote.content !== editingNote.content);
|
||||
|
||||
if (!hasChanges) return;
|
||||
|
||||
|
|
@ -67,25 +68,30 @@ function Home() {
|
|||
setUpdating(true);
|
||||
await handleUpdate();
|
||||
setLastSavedNote({
|
||||
id: selectedNote.id,
|
||||
title: selectedNote.title,
|
||||
content: selectedNote.content,
|
||||
id: editingNote.id,
|
||||
title: editingNote.title,
|
||||
content: editingNote.content,
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedNote, encryptionKey]);
|
||||
}, [editingNote?.title, editingNote?.content, encryptionKey]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedNote) return;
|
||||
if (!editingNote) return;
|
||||
if (!encryptionKey) {
|
||||
setUpdating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateNote(selectedNote.id);
|
||||
console.log(selectedNote.id);
|
||||
await updateNoteMutation.mutateAsync({
|
||||
noteId: editingNote.id,
|
||||
note: {
|
||||
title: editingNote.title,
|
||||
content: editingNote.content,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update note:", error);
|
||||
} finally {
|
||||
|
|
@ -95,17 +101,24 @@ function Home() {
|
|||
}
|
||||
};
|
||||
|
||||
const { getTagTree, tagTree } = useTagStore();
|
||||
const getTags = () => {
|
||||
getTagTree();
|
||||
const setTitle = (title: string) => {
|
||||
if (editingNote) {
|
||||
setEditingNote({ ...editingNote, title });
|
||||
}
|
||||
};
|
||||
|
||||
const setContent = (content: string) => {
|
||||
if (editingNote) {
|
||||
setEditingNote({ ...editingNote, content });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
{showModal && <Modal />}
|
||||
|
||||
<Sidebar />
|
||||
<button onClick={getTags}>create</button>
|
||||
{/*<div className="flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -125,27 +138,27 @@ function Home() {
|
|||
<input
|
||||
type="text"
|
||||
placeholder="Untitled note..."
|
||||
value={selectedNote?.title || ""}
|
||||
value={editingNote?.title || ""}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-4 py-3 text-3xl font-semibold bg-transparentfocus:outline-none focus:border-ctp-mauve transition-colors placeholder:text-ctp-overlay0 text-ctp-text"
|
||||
/>
|
||||
<div className="px-4 py-2 border-b border-ctp-surface2 flex items-center gap-2 flex-wrap">
|
||||
{selectedNote?.tags &&
|
||||
selectedNote.tags.map((tag) => (
|
||||
{editingNote?.tags &&
|
||||
editingNote.tags.map((tag) => (
|
||||
<button
|
||||
onClick={() => null}
|
||||
key={tag.id}
|
||||
className="bg-ctp-surface0 px-1.5 text-sm rounded-full"
|
||||
>
|
||||
{tag.parent_id && "..."}
|
||||
{tag.parentId && "..."}
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<TiptapEditor
|
||||
key={selectedNote?.id}
|
||||
content={selectedNote?.content || ""}
|
||||
key={editingNote?.id}
|
||||
content={editingNote?.content || ""}
|
||||
onChange={setContent}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -170,29 +183,45 @@ const Modal = () => {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5"
|
||||
>
|
||||
{/*<Login />*/}
|
||||
<TagSelector />
|
||||
<Login />
|
||||
{/*<TagSelector />*/}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagSelector = () => {
|
||||
const { tagTree } = useTagStore();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const { data: tagTree, isLoading, error } = useTagTree();
|
||||
const createTag = useCreateTag();
|
||||
|
||||
const handleEnter = async () => {
|
||||
createTag.mutate({ name: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/*<input
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEnter();
|
||||
}}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>*/}
|
||||
/>
|
||||
{tagTree && tagTree.map((tag) => <TagTree tag={tag} />)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagTree = ({ tag, depth = 0 }: { tag: Tag; depth?: number }) => {
|
||||
export const TagTree = ({
|
||||
tag,
|
||||
depth = 0,
|
||||
}: {
|
||||
tag: DecryptedTagNode;
|
||||
depth?: number;
|
||||
}) => {
|
||||
const [collapse, setCollapse] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -19,9 +19,14 @@ import {
|
|||
import { FolderTree } from "./subcomponents/FolderTree.tsx";
|
||||
import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
|
||||
import { useAuthStore } from "@/stores/authStore.ts";
|
||||
import { useNoteStore } from "@/stores/notesStore.ts";
|
||||
import { useUIStore } from "@/stores/uiStore.ts";
|
||||
import { TagSelector } from "../../Home.tsx";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useFolderTree,
|
||||
useUpdateFolder,
|
||||
useUpdateNote,
|
||||
} from "@/hooks/useFolders.ts";
|
||||
|
||||
export const Sidebar = () => {
|
||||
const [newFolder, setNewFolder] = useState(false);
|
||||
|
|
@ -32,13 +37,8 @@ export const Sidebar = () => {
|
|||
} | null>(null);
|
||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
folderTree,
|
||||
loadFolderTree,
|
||||
moveNoteToFolder,
|
||||
moveFolderToFolder,
|
||||
createFolder,
|
||||
} = useNoteStore();
|
||||
const { data: folderTree, isLoading, error } = useFolderTree();
|
||||
const createFolder = useCreateFolder();
|
||||
|
||||
const { encryptionKey } = useAuthStore();
|
||||
|
||||
|
|
@ -52,17 +52,11 @@ export const Sidebar = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (!encryptionKey) return;
|
||||
loadFolderTree();
|
||||
}, [encryptionKey]);
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderText.trim()) return;
|
||||
await createFolder({
|
||||
name: newFolderText,
|
||||
parent_id: null,
|
||||
});
|
||||
setNewFolderText("");
|
||||
setNewFolder(false);
|
||||
createFolder.mutate({ name: newFolderText, parentId: null });
|
||||
};
|
||||
|
||||
const pointer = useSensor(PointerSensor, {
|
||||
|
|
@ -81,6 +75,9 @@ export const Sidebar = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const updateNote = useUpdateNote();
|
||||
const updateFolder = useUpdateFolder();
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
setActiveItem(null);
|
||||
const { active, over } = event;
|
||||
|
|
@ -96,7 +93,10 @@ export const Sidebar = () => {
|
|||
|
||||
if (active.data.current?.type === "note") {
|
||||
console.log("Updating note ", active.id, "to folder", over.id);
|
||||
await moveNoteToFolder(active.id as number, over.id as number);
|
||||
updateNote.mutate({
|
||||
noteId: active.id as number,
|
||||
note: { folderId: over.id as number },
|
||||
});
|
||||
} else if (active.data.current?.type === "folder") {
|
||||
// Prevent dropping folder into itself
|
||||
if (active.data.current.folder.id === over.id) {
|
||||
|
|
@ -111,10 +111,10 @@ export const Sidebar = () => {
|
|||
over.id,
|
||||
);
|
||||
try {
|
||||
await moveFolderToFolder(
|
||||
active.data.current.folder.id,
|
||||
over.id as number,
|
||||
);
|
||||
updateFolder.mutate({
|
||||
folderId: active.data.current.folder.id,
|
||||
folder: { parentId: over.id as number },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update folder:", error);
|
||||
return;
|
||||
|
|
@ -163,20 +163,21 @@ export const Sidebar = () => {
|
|||
autoScroll={false}
|
||||
sensors={sensors}
|
||||
>
|
||||
<div className="flex-row-reverse flex">
|
||||
<div className="flex-row-reverse flex h-screen">
|
||||
<div
|
||||
className="h-screen bg-ctp-surface0 w-0.5 hover:cursor-ew-resize hover:bg-ctp-mauve transition-colors"
|
||||
className="h-full bg-ctp-surface0 w-0.5 hover:cursor-ew-resize hover:bg-ctp-mauve transition-colors"
|
||||
onMouseDown={handleMouseDown}
|
||||
></div>
|
||||
<div
|
||||
className="flex flex-col min-h-full"
|
||||
className="flex flex-col h-full"
|
||||
style={{ width: `${sideBarResize}px` }}
|
||||
>
|
||||
<SidebarHeader setNewFolder={setNewFolder} />
|
||||
<div className="flex-1 overflow-y-auto bg-ctp-mantle border-r border-ctp-surface2">
|
||||
{sideBarView == "folders" ? (
|
||||
<>
|
||||
<div
|
||||
className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3"
|
||||
className="w-full p-4 sm:block hidden"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onTouchMove={(e) => e.preventDefault()}
|
||||
>
|
||||
|
|
@ -203,22 +204,44 @@ export const Sidebar = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-ctp-subtext0">
|
||||
<div className="text-sm">Loading folders...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-8 text-ctp-red">
|
||||
<div className="text-sm">Failed to load folders</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder tree */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
{folderTree?.folders.map((folder) => (
|
||||
<FolderTree key={folder.id} folder={folder} depth={0} />
|
||||
<FolderTree
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Orphaned notes */}
|
||||
{folderTree?.orphaned_notes &&
|
||||
folderTree.orphaned_notes.length > 0 && (
|
||||
{folderTree?.orphanedNotes &&
|
||||
folderTree.orphanedNotes.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{folderTree.orphaned_notes.map((note) => (
|
||||
{folderTree.orphanedNotes.map((note) => (
|
||||
<DraggableNote key={note.id} note={note} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
|
|
@ -236,12 +259,13 @@ export const Sidebar = () => {
|
|||
</DragOverlay>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-ctp-mantle min-h-full border-r border-ctp-surface2 w-full p-4 overflow-y-auto sm:block hidden flex-col gap-3">
|
||||
<div className="w-full p-4 sm:block hidden">
|
||||
<TagSelector />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
||||
import { useNoteStore } from "@/stores/notesStore";
|
||||
import { NoteRead } from "@/api/folders";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
import { NoteRead } from "@/api/notes";
|
||||
|
||||
export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
||||
const { selectedNote, setSelectedNote } = useNoteStore();
|
||||
const { selectedNote, setSelectedNote } = useUIStore();
|
||||
const { openContextMenu } = useContextMenu();
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useDroppable, useDraggable } from "@dnd-kit/core";
|
|||
import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react";
|
||||
// @ts-ignore
|
||||
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
||||
import { Folder } from "@/api/folders";
|
||||
import { FolderTreeNode } from "@/api/folders";
|
||||
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
||||
|
||||
export const DroppableFolder = ({
|
||||
|
|
@ -12,7 +12,7 @@ export const DroppableFolder = ({
|
|||
setCollapse,
|
||||
collapse,
|
||||
}: {
|
||||
folder: Partial<Folder>;
|
||||
folder: Partial<FolderTreeNode>;
|
||||
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
collapse: boolean;
|
||||
}) => {
|
||||
|
|
@ -63,9 +63,11 @@ export const DroppableFolder = ({
|
|||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{(folder.notes?.length ?? 0) > 0 && (
|
||||
<CaretRightIcon
|
||||
className={`w-4 h-4 min-h-4 min-w-4 mr-1 transition-all duration-200 ease-in-out ${collapse ? "rotate-90" : ""} fill-ctp-mauve`}
|
||||
/>
|
||||
)}
|
||||
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-ctp-mauve mr-1" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,25 +5,27 @@ import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
|
|||
import TagsIcon from "@assets/fontawesome/svg/tags.svg?react";
|
||||
// @ts-ignore
|
||||
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
|
||||
import { useNoteStore } from "@/stores/notesStore";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
import { useCreateNote } from "@/hooks/useFolders";
|
||||
import { NoteCreate } from "@/api/notes";
|
||||
|
||||
export const SidebarHeader = ({
|
||||
setNewFolder,
|
||||
}: {
|
||||
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const { createNote, selectedFolder } = useNoteStore();
|
||||
const { setSideBarView, sideBarView } = useUIStore();
|
||||
const { setSideBarView, sideBarView, selectedFolder } = useUIStore();
|
||||
const createNote = useCreateNote();
|
||||
const handleCreate = async () => {
|
||||
await createNote({
|
||||
createNote.mutate({
|
||||
title: "Untitled",
|
||||
content: "",
|
||||
folder_id: selectedFolder,
|
||||
});
|
||||
} as NoteCreate);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full gap-2 bg-ctp-mantle border-b border-ctp-surface0 p-1">
|
||||
<div className="w-full p-2 border-b border-ctp-surface2 bg-ctp-mantle">
|
||||
<div className="flex items-center justify-around bg-ctp-surface0 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setNewFolder(true)}
|
||||
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
|
||||
|
|
@ -48,5 +50,6 @@ export const SidebarHeader = ({
|
|||
<FileCirclePlusIcon className="w-5 h-5 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
unwrapMasterKey,
|
||||
wrapMasterKey,
|
||||
} from "../api/encryption";
|
||||
import { FolderTree } from "@/pages/Home/components/sidebar/subcomponents/FolderTree";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
|
|
@ -147,11 +146,6 @@ export const useAuthStore = create<AuthState>()(
|
|||
});
|
||||
|
||||
localStorage.clear();
|
||||
useNoteStore.setState({
|
||||
folderTree: null,
|
||||
selectedFolder: null,
|
||||
selectedNote: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,352 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import {
|
||||
folderApi,
|
||||
FolderCreate,
|
||||
FolderTreeNode,
|
||||
FolderTreeResponse,
|
||||
FolderUpdate,
|
||||
NoteRead,
|
||||
} from "../api/folders";
|
||||
import { Note, NoteCreate, notesApi } from "../api/notes";
|
||||
|
||||
const updateNoteInTree = (
|
||||
tree: FolderTreeResponse | null,
|
||||
updatedNote: NoteRead,
|
||||
): FolderTreeResponse | null => {
|
||||
if (!tree) return null;
|
||||
|
||||
const updateNotesInFolder = (folder: FolderTreeNode): FolderTreeNode => ({
|
||||
...folder,
|
||||
notes: folder.notes.map((note) =>
|
||||
note.id === updatedNote.id ? updatedNote : note,
|
||||
),
|
||||
children: folder.children.map(updateNotesInFolder),
|
||||
});
|
||||
|
||||
return {
|
||||
folders: tree.folders.map(updateNotesInFolder),
|
||||
orphaned_notes: tree.orphaned_notes.map((note) =>
|
||||
note.id === updatedNote.id ? updatedNote : note,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const updateFolder = (
|
||||
id: number,
|
||||
folder: FolderTreeNode,
|
||||
newFolder: FolderUpdate,
|
||||
): FolderTreeNode => {
|
||||
if (folder.id === id) {
|
||||
return { ...folder, ...newFolder };
|
||||
}
|
||||
if (folder.children) {
|
||||
return {
|
||||
...folder,
|
||||
children: folder.children.map((child) =>
|
||||
updateFolder(id, child, newFolder),
|
||||
),
|
||||
};
|
||||
}
|
||||
return folder;
|
||||
};
|
||||
|
||||
interface NoteState {
|
||||
loadFolderTree: () => Promise<void>;
|
||||
folderTree: FolderTreeResponse | null;
|
||||
|
||||
selectedFolder: number | null;
|
||||
setSelectedFolder: (id: number | null) => void;
|
||||
|
||||
selectedNote: NoteRead | null;
|
||||
setSelectedNote: (id: NoteRead | null) => void;
|
||||
setContent: (content: string) => void;
|
||||
setTitle: (title: string) => void;
|
||||
|
||||
createNote: (note: NoteCreate) => Promise<void>;
|
||||
updateNote: (id: number) => Promise<void>;
|
||||
|
||||
createFolder: (folder: FolderCreate) => Promise<void>;
|
||||
updateFolder: (id: number, newFolder: FolderUpdate) => Promise<void>;
|
||||
|
||||
moveNoteToFolder: (noteId: number, folderId: number) => Promise<void>;
|
||||
moveFolderToFolder: (folderId: number, newParentId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useNoteStore = create<NoteState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
loadFolderTree: async () => {
|
||||
const data = await folderApi.tree();
|
||||
// console.log(data);
|
||||
set({ folderTree: data });
|
||||
},
|
||||
folderTree: null,
|
||||
|
||||
selectedFolder: null,
|
||||
|
||||
setSelectedFolder: (id: number | null) => {
|
||||
set({ selectedFolder: id });
|
||||
},
|
||||
|
||||
selectedNote: null,
|
||||
|
||||
setSelectedNote: (id: NoteRead | null) => {
|
||||
set({ selectedNote: id });
|
||||
},
|
||||
setContent: (content) => {
|
||||
const currentNote = get().selectedNote;
|
||||
if (currentNote) {
|
||||
const updatedNote = { ...currentNote, content: content };
|
||||
set({
|
||||
selectedNote: updatedNote,
|
||||
folderTree: updateNoteInTree(get().folderTree, updatedNote),
|
||||
});
|
||||
}
|
||||
},
|
||||
setTitle: (title) => {
|
||||
const currentNote = get().selectedNote;
|
||||
if (currentNote) {
|
||||
const updatedNote = { ...currentNote, title: title };
|
||||
set({
|
||||
selectedNote: updatedNote,
|
||||
folderTree: updateNoteInTree(get().folderTree, updatedNote),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createNote: async (note: Partial<NoteRead>) => {
|
||||
const response = await notesApi.create(note as NoteCreate);
|
||||
const newNote = response.data as NoteRead;
|
||||
console.log(newNote.id);
|
||||
const noteToAppend: NoteRead = {
|
||||
...newNote,
|
||||
title: note.title || "Untitled",
|
||||
content: note.content || "",
|
||||
};
|
||||
|
||||
const tree = get().folderTree;
|
||||
if (!tree) return;
|
||||
|
||||
if (note.folder_id) {
|
||||
const addNoteToFolder = (folder: FolderTreeNode): FolderTreeNode => {
|
||||
if (folder.id === note.folder_id) {
|
||||
return {
|
||||
...folder,
|
||||
notes: [...folder.notes, noteToAppend],
|
||||
children: folder.children.map(addNoteToFolder),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...folder,
|
||||
children: folder.children.map(addNoteToFolder),
|
||||
};
|
||||
};
|
||||
|
||||
set({
|
||||
folderTree: {
|
||||
folders: tree.folders.map(addNoteToFolder),
|
||||
orphaned_notes: tree.orphaned_notes,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Add to orphaned notes
|
||||
set({
|
||||
folderTree: {
|
||||
folders: tree.folders,
|
||||
orphaned_notes: [...tree.orphaned_notes, noteToAppend],
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateNote: async (id: number) => {
|
||||
const note = get().selectedNote as Partial<Note>;
|
||||
await notesApi.update(id, note);
|
||||
},
|
||||
|
||||
createFolder: async (folder: FolderCreate) => {
|
||||
const response = await folderApi.create(folder);
|
||||
const newFolder = response.data;
|
||||
const tree = get().folderTree;
|
||||
if (!tree) return;
|
||||
|
||||
const newFolderNode: FolderTreeNode = {
|
||||
id: newFolder.id,
|
||||
name: newFolder.name,
|
||||
notes: [],
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (folder.parent_id) {
|
||||
// Add as child of parent folder
|
||||
const addToParent = (f: FolderTreeNode): FolderTreeNode => {
|
||||
if (f.id === folder.parent_id) {
|
||||
return {
|
||||
...f,
|
||||
children: [...f.children, newFolderNode],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
children: f.children.map(addToParent),
|
||||
};
|
||||
};
|
||||
|
||||
set({
|
||||
folderTree: {
|
||||
folders: tree.folders.map(addToParent),
|
||||
orphaned_notes: tree.orphaned_notes,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Add as top-level folder
|
||||
set({
|
||||
folderTree: {
|
||||
folders: [...tree.folders, newFolderNode],
|
||||
orphaned_notes: tree.orphaned_notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
updateFolder: async (id: number, newFolder: FolderUpdate) => {
|
||||
const tree = get().folderTree as FolderTreeResponse;
|
||||
|
||||
const newFolders = tree.folders.map((folder) =>
|
||||
updateFolder(id, folder, newFolder),
|
||||
);
|
||||
|
||||
set({
|
||||
folderTree: {
|
||||
folders: newFolders,
|
||||
orphaned_notes: tree.orphaned_notes,
|
||||
},
|
||||
});
|
||||
|
||||
await folderApi.update(id, newFolder);
|
||||
},
|
||||
|
||||
moveNoteToFolder: async (noteId: number, folderId: number) => {
|
||||
const tree = get().folderTree;
|
||||
if (!tree) return;
|
||||
|
||||
// Find and remove the note from its current location
|
||||
let noteToMove: NoteRead | null = null;
|
||||
|
||||
// Check orphaned notes
|
||||
const orphanedIndex = tree.orphaned_notes.findIndex(
|
||||
(n) => n.id === noteId,
|
||||
);
|
||||
if (orphanedIndex !== -1) {
|
||||
noteToMove = tree.orphaned_notes[orphanedIndex];
|
||||
}
|
||||
|
||||
// Check folders recursively
|
||||
const findAndRemoveNote = (folder: FolderTreeNode): FolderTreeNode => {
|
||||
const noteIndex = folder.notes.findIndex((n) => n.id === noteId);
|
||||
if (noteIndex !== -1) {
|
||||
noteToMove = folder.notes[noteIndex];
|
||||
return {
|
||||
...folder,
|
||||
notes: folder.notes.filter((n) => n.id !== noteId),
|
||||
children: folder.children.map(findAndRemoveNote),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...folder,
|
||||
children: folder.children.map(findAndRemoveNote),
|
||||
};
|
||||
};
|
||||
|
||||
// Add note to target folder
|
||||
const addNoteToFolder = (folder: FolderTreeNode): FolderTreeNode => {
|
||||
if (folder.id === folderId && noteToMove) {
|
||||
return {
|
||||
...folder,
|
||||
notes: [...folder.notes, { ...noteToMove, folder_id: folderId }],
|
||||
children: folder.children.map(addNoteToFolder),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...folder,
|
||||
children: folder.children.map(addNoteToFolder),
|
||||
};
|
||||
};
|
||||
|
||||
// Update local tree
|
||||
let newFolders = tree.folders.map(findAndRemoveNote);
|
||||
let newOrphaned = tree.orphaned_notes.filter((n) => n.id !== noteId);
|
||||
newFolders = newFolders.map(addNoteToFolder);
|
||||
|
||||
set({
|
||||
folderTree: {
|
||||
folders: newFolders,
|
||||
orphaned_notes: newOrphaned,
|
||||
},
|
||||
});
|
||||
|
||||
// Update backend
|
||||
await notesApi.update(noteId, { folder_id: folderId });
|
||||
},
|
||||
|
||||
moveFolderToFolder: async (folderId: number, newParentId: number) => {
|
||||
const tree = get().folderTree;
|
||||
if (!tree) return;
|
||||
|
||||
let folderToMove: FolderTreeNode | null = null;
|
||||
|
||||
const findAndRemoveFolder = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders
|
||||
.filter((f) => {
|
||||
if (f.id === folderId) {
|
||||
folderToMove = f;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((f) => ({
|
||||
...f,
|
||||
children: findAndRemoveFolder(f.children),
|
||||
}));
|
||||
};
|
||||
|
||||
const addFolderToParent = (
|
||||
folders: FolderTreeNode[],
|
||||
): FolderTreeNode[] => {
|
||||
return folders.map((f) => {
|
||||
if (f.id === newParentId && folderToMove) {
|
||||
return {
|
||||
...f,
|
||||
children: [...f.children, folderToMove],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
children: addFolderToParent(f.children),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let newFolders = findAndRemoveFolder(tree.folders);
|
||||
newFolders = addFolderToParent(newFolders);
|
||||
|
||||
set({
|
||||
folderTree: {
|
||||
folders: newFolders,
|
||||
orphaned_notes: tree.orphaned_notes,
|
||||
},
|
||||
});
|
||||
|
||||
await folderApi.update(folderId, { parent_id: newParentId });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "notes-storage",
|
||||
partialize: (state) => ({
|
||||
folderTree: state.folderTree,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { tagsApi } from "@/api/tags";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id?: number;
|
||||
created_at: string;
|
||||
parent_path: string;
|
||||
children: Tag[];
|
||||
}
|
||||
|
||||
interface TagStore {
|
||||
tagTree: Tag[] | null;
|
||||
getTagTree: () => void;
|
||||
}
|
||||
|
||||
export const useTagStore = create<TagStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tagTree: null,
|
||||
|
||||
getTagTree: async () => {
|
||||
const tags = await tagsApi.list();
|
||||
set({ tagTree: tags });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "tags-storage",
|
||||
partialize: (state) => ({
|
||||
tagTree: state.tagTree,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { Note } from "@/api/notes";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
|
|
@ -13,6 +14,12 @@ interface UIState {
|
|||
|
||||
sideBarView: string;
|
||||
setSideBarView: (view: string) => void;
|
||||
|
||||
selectedNote: Note | null;
|
||||
setSelectedNote: (note: Note | null) => void;
|
||||
|
||||
selectedFolder: number | null;
|
||||
setSelectedFolder: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
|
|
@ -34,7 +41,18 @@ export const useUIStore = create<UIState>()(
|
|||
setSideBarView: (view) => {
|
||||
set({ sideBarView: view });
|
||||
},
|
||||
selectedNote: null,
|
||||
|
||||
setSelectedNote: (id: Note | null) => {
|
||||
set({ selectedNote: id });
|
||||
},
|
||||
selectedFolder: null,
|
||||
|
||||
setSelectedFolder: (id: number | null) => {
|
||||
set({ selectedFolder: id });
|
||||
},
|
||||
}),
|
||||
|
||||
{
|
||||
name: "ui-store",
|
||||
partialize: (state) => {
|
||||
|
|
|
|||
1192
frontend/src/types/api.d.ts
vendored
Normal file
1192
frontend/src/types/api.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load diff
14
frontend/src/vite-env.d.ts
vendored
Normal file
14
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly PROD: boolean;
|
||||
readonly DEV: boolean;
|
||||
readonly MODE: string;
|
||||
readonly BASE_URL: string;
|
||||
readonly SSR: boolean;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { defineConfig } from "vite";
|
|||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import path from "path";
|
||||
import * as path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), react(), svgr()],
|
||||
|
|
|
|||
Loading…
Reference in a new issue