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:
james fitzsimons 2025-12-22 15:23:40 +00:00
parent b6afaf8606
commit 3fe4b9ea88
26 changed files with 12413 additions and 10638 deletions

View file

@ -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"] = []

View file

@ -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,

View file

@ -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",

View file

@ -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"
}
}

View 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;

View file

@ -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),
};
}),

View file

@ -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),
};

View file

@ -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,
},
},
}),
};

View file

@ -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,
};

View file

@ -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);

View file

@ -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);

View 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"] });
},
});
};

View 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"] });
},
});
};

View file

@ -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>,
);

View file

@ -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 (

View file

@ -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;
@ -95,8 +92,11 @@ 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);
console.log("Updating note ", active.id, "to folder", over.id);
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>
);
};

View file

@ -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 } =

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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,
});
},
}),
{

View file

@ -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,
}),
},
),
);

View file

@ -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,
}),
},
),
);

View file

@ -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

File diff suppressed because it is too large Load diff

14
frontend/src/vite-env.d.ts vendored Normal file
View 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;
}

View file

@ -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()],