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):
|
class TagTreeNode(SQLModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
children: List["TagTreeNode"] = []
|
children: List["TagTreeNode"] = []
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
from tkinter.constants import TOP
|
|
||||||
|
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Note,
|
|
||||||
NoteCreate,
|
|
||||||
NoteTag,
|
NoteTag,
|
||||||
NoteUpdate,
|
|
||||||
Tag,
|
Tag,
|
||||||
TagCreate,
|
TagCreate,
|
||||||
TagTreeNode,
|
TagTreeNode,
|
||||||
|
|
@ -19,7 +14,7 @@ from sqlmodel import Session, select
|
||||||
|
|
||||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/", response_model=list[Tag])
|
||||||
def list_tags(session: Session = Depends(get_session)):
|
def list_tags(session: Session = Depends(get_session)):
|
||||||
tags = session.exec(select(Tag)).all()
|
tags = session.exec(select(Tag)).all()
|
||||||
return tags
|
return tags
|
||||||
|
|
@ -44,12 +39,14 @@ def build_tag_tree_node(tag: Tag) -> TagTreeNode:
|
||||||
return TagTreeNode(
|
return TagTreeNode(
|
||||||
id= tag.id,
|
id= tag.id,
|
||||||
name = tag.name,
|
name = tag.name,
|
||||||
|
parent_id=tag.parent_id,
|
||||||
|
created_at=tag.created_at,
|
||||||
children = [build_tag_tree_node(child) for child in tag.children]
|
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)):
|
def get_tag_tree(session: Session = Depends(get_session)):
|
||||||
top_level_tags = session.exec(
|
top_level_tags = session.exec(
|
||||||
select(Tag)
|
select(Tag)
|
||||||
|
|
@ -61,7 +58,7 @@ def get_tag_tree(session: Session = Depends(get_session)):
|
||||||
return TagTreeResponse(tags=tree)
|
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(
|
def add_tag_to_note(
|
||||||
note_id: int,
|
note_id: int,
|
||||||
tag_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",
|
"@mdxeditor/editor": "^3.49.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@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/extension-placeholder": "^3.12.1",
|
||||||
"@tiptap/react": "^3.12.1",
|
"@tiptap/react": "^3.12.1",
|
||||||
"@tiptap/starter-kit": "^3.12.1",
|
"@tiptap/starter-kit": "^3.12.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"framer-motion": "^12.23.25",
|
"framer-motion": "^12.23.25",
|
||||||
|
"humps": "^2.0.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"openapi-fetch": "^0.15.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -32,11 +37,15 @@
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/humps": "^2.0.6",
|
||||||
"@types/react": "^19.2.6",
|
"@types/react": "^19.2.6",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"@vitest/ui": "^4.0.15",
|
"@vitest/ui": "^4.0.15",
|
||||||
"jsdom": "^27.3.0",
|
"jsdom": "^27.3.0",
|
||||||
|
"openapi-typescript": "^7.10.1",
|
||||||
|
"type-fest": "^5.3.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.4.21",
|
"vite": "^5.4.21",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"vitest": "^4.0.15"
|
"vitest": "^4.0.15"
|
||||||
|
|
@ -2951,6 +2960,52 @@
|
||||||
"react": ">=16.8"
|
"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": {
|
"node_modules/@remirror/core-constants": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
|
|
@ -3781,6 +3836,60 @@
|
||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|
@ -4425,6 +4534,13 @@
|
||||||
"@types/unist": "*"
|
"@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": {
|
"node_modules/@types/linkify-it": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
"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==",
|
"integrity": "sha512-QGY1oxYE7/kkeNmbtY/2ZjQ07BCG3zYdz+k/+sf69kMzEIxb93guHkPnIXITQ+BYi61oQwG74twMOX1tD4aesg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
|
@ -4738,6 +4864,13 @@
|
||||||
"proxy-from-env": "^1.1.0"
|
"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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
|
@ -4778,6 +4911,16 @@
|
||||||
"require-from-string": "^2.0.2"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.0",
|
"version": "4.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
|
||||||
|
|
@ -4914,6 +5057,13 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/character-entities": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||||
|
|
@ -4993,6 +5143,13 @@
|
||||||
"@codemirror/view": "^6.0.0"
|
"@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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
|
@ -5574,6 +5731,13 @@
|
||||||
"type": "^2.7.2"
|
"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": {
|
"node_modules/fast-equals": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz",
|
||||||
|
|
@ -5583,6 +5747,23 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/fault": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz",
|
||||||
|
|
@ -5876,6 +6057,12 @@
|
||||||
"node": ">= 14"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
|
@ -5942,6 +6129,19 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
|
@ -6038,6 +6238,16 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -6117,6 +6327,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
|
|
@ -7546,6 +7763,19 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/motion-dom": {
|
||||||
"version": "12.23.23",
|
"version": "12.23.23",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||||
|
|
@ -7648,6 +7878,73 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/orderedmap": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
|
@ -7785,6 +8082,16 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|
@ -8581,6 +8888,19 @@
|
||||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/svg-parser": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
|
||||||
|
|
@ -8601,6 +8921,19 @@
|
||||||
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
|
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.17",
|
"version": "4.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
"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==",
|
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/uc.micro": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
|
@ -8945,6 +9309,19 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/uvu": {
|
||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
|
||||||
|
|
@ -9058,6 +9435,7 @@
|
||||||
"integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==",
|
"integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.15",
|
"@vitest/expect": "4.0.15",
|
||||||
"@vitest/mocker": "4.0.15",
|
"@vitest/mocker": "4.0.15",
|
||||||
|
|
@ -9805,6 +10183,23 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yjs": {
|
||||||
"version": "13.6.27",
|
"version": "13.6.27",
|
||||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
|
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"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": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -14,17 +18,22 @@
|
||||||
"@mdxeditor/editor": "^3.49.3",
|
"@mdxeditor/editor": "^3.49.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@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/extension-placeholder": "^3.12.1",
|
||||||
"@tiptap/react": "^3.12.1",
|
"@tiptap/react": "^3.12.1",
|
||||||
"@tiptap/starter-kit": "^3.12.1",
|
"@tiptap/starter-kit": "^3.12.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"framer-motion": "^12.23.25",
|
"framer-motion": "^12.23.25",
|
||||||
|
"humps": "^2.0.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"openapi-fetch": "^0.15.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -32,18 +41,17 @@
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/humps": "^2.0.6",
|
||||||
"@types/react": "^19.2.6",
|
"@types/react": "^19.2.6",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"@vitest/ui": "^4.0.15",
|
"@vitest/ui": "^4.0.15",
|
||||||
"jsdom": "^27.3.0",
|
"jsdom": "^27.3.0",
|
||||||
|
"openapi-typescript": "^7.10.1",
|
||||||
|
"type-fest": "^5.3.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.4.21",
|
"vite": "^5.4.21",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"vitest": "^4.0.15"
|
"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 { components } from "@/types/api";
|
||||||
import { Tag } from "./tags";
|
// 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) {
|
export async function deriveKey(password: string, salt: string) {
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
|
@ -133,8 +151,8 @@ export async function decryptFolderTree(
|
||||||
folders: await Promise.all(
|
folders: await Promise.all(
|
||||||
tree.folders.map((folder) => decryptFolder(folder)),
|
tree.folders.map((folder) => decryptFolder(folder)),
|
||||||
),
|
),
|
||||||
orphaned_notes: await Promise.all(
|
orphanedNotes: await Promise.all(
|
||||||
tree.orphaned_notes.map(async (note) => ({
|
tree.orphanedNotes.map(async (note) => ({
|
||||||
...note,
|
...note,
|
||||||
title: await decryptString(note.title, encryptionKey),
|
title: await decryptString(note.title, encryptionKey),
|
||||||
content: await decryptString(note.content, encryptionKey),
|
content: await decryptString(note.content, encryptionKey),
|
||||||
|
|
@ -150,10 +168,10 @@ export async function decryptFolderTree(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const decryptTagTree = async (
|
export const decryptTagTree = async (
|
||||||
tags: Tag[],
|
tags: TagTreeNode[],
|
||||||
key: CryptoKey,
|
key: CryptoKey,
|
||||||
parentPath = "",
|
parentPath = "",
|
||||||
): Promise<Tag[]> => {
|
): Promise<DecryptedTagNode[]> => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
tags.map(async (tag) => {
|
tags.map(async (tag) => {
|
||||||
const decryptedName = await decryptString(tag.name, key);
|
const decryptedName = await decryptString(tag.name, key);
|
||||||
|
|
@ -164,7 +182,7 @@ export const decryptTagTree = async (
|
||||||
return {
|
return {
|
||||||
...tag,
|
...tag,
|
||||||
name: decryptedName,
|
name: decryptedName,
|
||||||
parent_path: parentPath,
|
parentPath: parentPath,
|
||||||
children: await decryptTagTree(tag.children, key, currentPath),
|
children: await decryptTagTree(tag.children, key, currentPath),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,34 @@
|
||||||
import axios from "axios";
|
|
||||||
import { decryptFolderTree } from "./encryption";
|
import { decryptFolderTree } from "./encryption";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
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
|
export type FolderTreeNode = CamelCasedPropertiesDeep<
|
||||||
? "/api"
|
components["schemas"]["FolderTreeNode"]
|
||||||
: "http://localhost:8000/api";
|
>;
|
||||||
|
|
||||||
export interface Folder {
|
export type FolderTreeResponse = CamelCasedPropertiesDeep<
|
||||||
id: number;
|
components["schemas"]["FolderTreeResponse"]
|
||||||
name: string;
|
>;
|
||||||
parent_id: number | null;
|
export type FolderCreate = CamelCasedPropertiesDeep<
|
||||||
created_at: string;
|
components["schemas"]["FolderCreate"]
|
||||||
}
|
>;
|
||||||
|
export type FolderUpdate = CamelCasedPropertiesDeep<
|
||||||
export interface NoteRead {
|
components["schemas"]["FolderUpdate"]
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFolderTree = async () => {
|
const getFolderTree = async () => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
if (!encryptionKey) throw new Error("Not authenticated");
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const { data } = await axios.get<FolderTreeResponse>(
|
const { data, error } = await client.GET("/api/folders/tree", {});
|
||||||
`${API_URL}/folders/tree`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const decryptedFolderTree = await decryptFolderTree(data, encryptionKey);
|
const newData = data as unknown as FolderTreeResponse;
|
||||||
|
|
||||||
|
const decryptedFolderTree = await decryptFolderTree(newData, encryptionKey);
|
||||||
|
|
||||||
return decryptedFolderTree;
|
return decryptedFolderTree;
|
||||||
};
|
};
|
||||||
|
|
@ -64,7 +36,10 @@ const getFolderTree = async () => {
|
||||||
const updateFolder = async (id: number, folder: FolderUpdate) => {
|
const updateFolder = async (id: number, folder: FolderUpdate) => {
|
||||||
console.log(`Updating folder ${id} with:`, folder);
|
console.log(`Updating folder ${id} with:`, folder);
|
||||||
try {
|
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);
|
console.log(`Folder ${id} update response:`, response.data);
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -75,10 +50,13 @@ const updateFolder = async (id: number, folder: FolderUpdate) => {
|
||||||
|
|
||||||
export const folderApi = {
|
export const folderApi = {
|
||||||
tree: () => getFolderTree(),
|
tree: () => getFolderTree(),
|
||||||
list: () => axios.get<Folder[]>(`${API_URL}/folders`),
|
list: () => client.GET("/api/folders/", {}),
|
||||||
create: (folder: FolderCreate) =>
|
create: (folder: FolderCreate) =>
|
||||||
axios.post<Folder>(`${API_URL}/folders/`, folder),
|
client.POST("/api/folders/", { body: folder }),
|
||||||
delete: (id: number) => axios.delete(`${API_URL}/folders/${id}`),
|
delete: (id: number) =>
|
||||||
|
client.DELETE("/api/folders/{folder_id}", {
|
||||||
|
params: { path: { folder_id: id } },
|
||||||
|
}),
|
||||||
update: (id: number, updateData: FolderUpdate) =>
|
update: (id: number, updateData: FolderUpdate) =>
|
||||||
updateFolder(id, updateData),
|
updateFolder(id, updateData),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,15 @@
|
||||||
import axios from "axios";
|
|
||||||
import { encryptString, decryptString } from "./encryption";
|
import { encryptString, decryptString } from "./encryption";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import { Tag } from "./tags";
|
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||||
axios.defaults.withCredentials = true;
|
import { components } from "@/types/api";
|
||||||
const API_URL = (import.meta as any).env.PROD
|
import client from "./client";
|
||||||
? "/api"
|
|
||||||
: "http://localhost:8000/api";
|
|
||||||
|
|
||||||
export interface Note {
|
export type NoteRead = CamelCasedPropertiesDeep<
|
||||||
id: number;
|
components["schemas"]["NoteRead"]
|
||||||
title: string;
|
>;
|
||||||
folder_id?: number;
|
export type NoteCreate = CamelCasedPropertiesDeep<
|
||||||
content: string;
|
components["schemas"]["NoteCreate"]
|
||||||
created_at: string;
|
>;
|
||||||
updated_at: string;
|
|
||||||
tags: Tag[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteCreate {
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
folder_id: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createNote = async (note: NoteCreate) => {
|
const createNote = async (note: NoteCreate) => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
|
|
@ -33,21 +21,21 @@ const createNote = async (note: NoteCreate) => {
|
||||||
var encryptedNote = {
|
var encryptedNote = {
|
||||||
title: noteTitle,
|
title: noteTitle,
|
||||||
content: noteContent,
|
content: noteContent,
|
||||||
folder_id: note.folder_id,
|
folderId: note.folderId,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(encryptedNote);
|
console.log(encryptedNote);
|
||||||
return axios.post(`${API_URL}/notes/`, encryptedNote);
|
return client.POST(`/api/notes/`, { body: encryptedNote });
|
||||||
};
|
};
|
||||||
const fetchNotes = async () => {
|
const fetchNotes = async () => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
if (!encryptionKey) throw new Error("Not authenticated");
|
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);
|
console.log(data);
|
||||||
const decryptedNotes = await Promise.all(
|
const decryptedNotes = await Promise.all(
|
||||||
data.map(async (note: Note) => ({
|
data.map(async (note: NoteRead) => ({
|
||||||
...note,
|
...note,
|
||||||
title: await decryptString(note.title, encryptionKey),
|
title: await decryptString(note.title, encryptionKey),
|
||||||
content: await decryptString(note.content, encryptionKey),
|
content: await decryptString(note.content, encryptionKey),
|
||||||
|
|
@ -62,28 +50,58 @@ const fetchNotes = async () => {
|
||||||
return decryptedNotes;
|
return decryptedNotes;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateNote = async (id: number, note: Partial<Note>) => {
|
const updateNote = async (id: number, note: Partial<NoteRead>) => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
if (!encryptionKey) throw new Error("Not authenticated");
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
var encryptedNote: Partial<Note> = {};
|
var encryptedNote: Partial<NoteRead> = {};
|
||||||
if (note.content) {
|
if (note.content) {
|
||||||
encryptedNote.content = await encryptString(note.content, encryptionKey);
|
encryptedNote.content = await encryptString(note.content, encryptionKey);
|
||||||
}
|
}
|
||||||
if (note.title) {
|
if (note.title) {
|
||||||
encryptedNote.title = await encryptString(note.title, encryptionKey);
|
encryptedNote.title = await encryptString(note.title, encryptionKey);
|
||||||
}
|
}
|
||||||
if (note.folder_id) {
|
if (note.folderId) {
|
||||||
encryptedNote.folder_id = note.folder_id;
|
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 = {
|
export const notesApi = {
|
||||||
list: () => fetchNotes(),
|
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),
|
create: (note: NoteCreate) => createNote(note),
|
||||||
update: (id: number, note: Partial<Note>) => updateNote(id, note),
|
update: (id: number, note: Partial<NoteRead>) => updateNote(id, note),
|
||||||
delete: (id: number) => axios.delete(`${API_URL}/notes/${id}`),
|
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 { encryptString, decryptTagTree } from "./encryption";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { useAuthStore } from "../stores/authStore";
|
||||||
axios.defaults.withCredentials = true;
|
import { CamelCasedPropertiesDeep } from "type-fest";
|
||||||
const API_URL = (import.meta as any).env.PROD
|
|
||||||
? "/api"
|
|
||||||
: "http://localhost:8000/api";
|
|
||||||
|
|
||||||
export interface Tag {
|
export type Tag = CamelCasedPropertiesDeep<components["schemas"]["Tag"]>;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parent_id?: number;
|
|
||||||
created_at: string;
|
|
||||||
children: Tag[];
|
|
||||||
parent_path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagCreate {
|
export type TagTreeNode = CamelCasedPropertiesDeep<
|
||||||
name: string;
|
components["schemas"]["TagTreeNode"]
|
||||||
parent_id?: number;
|
>;
|
||||||
}
|
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 fetchTags = async () => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
if (!encryptionKey) throw new Error("Not authenticated");
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const { data } = await axios.get(`${API_URL}/tags/tree`);
|
const response = await client.GET("/api/tags/tree", {});
|
||||||
const tags = decryptTagTree(data.tags, encryptionKey);
|
|
||||||
console.log(await tags);
|
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;
|
return tags;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTag = async (tag: TagCreate, noteId?: number) => {
|
const createTag = async (tag: TagCreate): Promise<TagTreeNode> => {
|
||||||
const encryptionKey = useAuthStore.getState().encryptionKey;
|
const encryptionKey = useAuthStore.getState().encryptionKey;
|
||||||
if (!encryptionKey) throw new Error("Not authenticated");
|
if (!encryptionKey) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const tagName = await encryptString(tag.name, encryptionKey);
|
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,
|
name: tagName,
|
||||||
parent_id: tag.parent_id,
|
parentId: tag.parentId || null,
|
||||||
};
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const r = await axios.post(`${API_URL}/tags/`, encryptedTag);
|
if (error) throw new Error("Failed to create tag");
|
||||||
console.log(r);
|
console.log(data);
|
||||||
|
return data as unknown as TagTreeNode;
|
||||||
if (noteId) {
|
|
||||||
return await addTagToNote(r.data.id, noteId);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addTagToNote = async (tagId: number, noteId: number) => {
|
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) => {
|
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 = {
|
export const tagsApi = {
|
||||||
list: async () => await fetchTags(),
|
list: fetchTags,
|
||||||
create: (tag: TagCreate, noteId?: number) => createTag(tag, noteId),
|
create: createTag,
|
||||||
delete: (tagId: number) => deleteTag(tagId),
|
addToNote: addTagToNote,
|
||||||
|
delete: deleteTag,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { FolderTreeNode } from "../../api/folders";
|
import { FolderTreeNode } from "../../api/folders";
|
||||||
import { useNoteStore } from "../../stores/notesStore";
|
import {
|
||||||
import { folderApi } from "../../api/folders";
|
useCreateFolder,
|
||||||
|
useUpdateFolder,
|
||||||
|
useDeleteFolder,
|
||||||
|
} from "../../hooks/useFolders";
|
||||||
|
|
||||||
interface FolderContextMenuProps {
|
interface FolderContextMenuProps {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -16,7 +19,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
folder,
|
folder,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { loadFolderTree, updateFolder } = useNoteStore();
|
const createFolderMutation = useCreateFolder();
|
||||||
|
const updateFolderMutation = useUpdateFolder();
|
||||||
|
const deleteFolderMutation = useDeleteFolder();
|
||||||
|
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const [newName, setNewName] = useState(folder.name);
|
const [newName, setNewName] = useState(folder.name);
|
||||||
|
|
||||||
|
|
@ -25,8 +31,7 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await folderApi.delete(folder.id);
|
await deleteFolderMutation.mutateAsync(folder.id);
|
||||||
await loadFolderTree();
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete folder:", error);
|
console.error("Failed to delete folder:", error);
|
||||||
|
|
@ -35,7 +40,14 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
|
|
||||||
const handleRename = async () => {
|
const handleRename = async () => {
|
||||||
if (newName.trim() && newName !== folder.name) {
|
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);
|
setIsRenaming(false);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -43,11 +55,10 @@ export const FolderContextMenu: React.FC<FolderContextMenuProps> = ({
|
||||||
|
|
||||||
const handleCreateSubfolder = async () => {
|
const handleCreateSubfolder = async () => {
|
||||||
try {
|
try {
|
||||||
await folderApi.create({
|
await createFolderMutation.mutateAsync({
|
||||||
name: "New Folder",
|
name: "New Folder",
|
||||||
parent_id: folder.id,
|
parent_id: folder.id,
|
||||||
});
|
});
|
||||||
await loadFolderTree();
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create subfolder:", error);
|
console.error("Failed to create subfolder:", error);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { NoteRead } from "../../api/folders";
|
import { Note } from "../../api/notes";
|
||||||
import { useNoteStore } from "../../stores/notesStore";
|
import { useCreateNote, useDeleteNote } from "../../hooks/useFolders";
|
||||||
import { notesApi } from "../../api/notes";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
|
|
||||||
interface NoteContextMenuProps {
|
interface NoteContextMenuProps {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
note: NoteRead;
|
note: Note;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,12 +16,15 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
||||||
note,
|
note,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { loadFolderTree, setSelectedNote } = useNoteStore();
|
const { setSelectedNote } = useUIStore();
|
||||||
|
const deleteNoteMutation = useDeleteNote();
|
||||||
|
const createNoteMutation = useCreateNote();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await notesApi.delete(note.id);
|
await deleteNoteMutation.mutateAsync(note.id);
|
||||||
await loadFolderTree();
|
// Clear selection if this note was selected
|
||||||
|
setSelectedNote(null);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete note:", error);
|
console.error("Failed to delete note:", error);
|
||||||
|
|
@ -30,12 +33,11 @@ export const NoteContextMenu: React.FC<NoteContextMenuProps> = ({
|
||||||
|
|
||||||
const handleDuplicate = async () => {
|
const handleDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
await notesApi.create({
|
await createNoteMutation.mutateAsync({
|
||||||
title: `${note.title} (Copy)`,
|
title: `${note.title} (Copy)`,
|
||||||
content: note.content,
|
content: note.content,
|
||||||
folder_id: note.folder_id,
|
folder_id: note.folder_id || null,
|
||||||
});
|
});
|
||||||
await loadFolderTree();
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to duplicate note:", 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 ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./main.css";
|
import "./main.css";
|
||||||
// import "./assets/fontawesome/js/fontawesome.min.js";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
// import "./assets/fontawesome/js/duotone-regular.js";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,64 +2,65 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import "../../main.css";
|
import "../../main.css";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useNoteStore } from "@/stores/notesStore";
|
|
||||||
import { useUIStore } from "@/stores/uiStore";
|
import { useUIStore } from "@/stores/uiStore";
|
||||||
import { Login } from "../Login";
|
import { Login } from "../Login";
|
||||||
import { TiptapEditor } from "../TipTap";
|
import { TiptapEditor } from "../TipTap";
|
||||||
import { Sidebar } from "./components/sidebar/SideBar";
|
import { Sidebar } from "./components/sidebar/SideBar";
|
||||||
import { StatusIndicator } from "./components/StatusIndicator";
|
import { StatusIndicator } from "./components/StatusIndicator";
|
||||||
|
import { useCreateTag, useTagTree } from "@/hooks/useTags";
|
||||||
import { Tag, tagsApi } from "@/api/tags";
|
import { useFolderTree, useUpdateNote } from "@/hooks/useFolders";
|
||||||
import { useTagStore } from "@/stores/tagStore";
|
import { Note } from "@/api/notes";
|
||||||
|
import { DecryptedTagNode } from "@/api/encryption";
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const [newFolder] = useState(false);
|
const [newFolder] = useState(false);
|
||||||
|
|
||||||
|
// Local state for editing the current note
|
||||||
|
const [editingNote, setEditingNote] = useState<Note | null>(null);
|
||||||
const [lastSavedNote, setLastSavedNote] = useState<{
|
const [lastSavedNote, setLastSavedNote] = useState<{
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const { loadFolderTree, updateNote, setContent, selectedNote, setTitle } =
|
|
||||||
useNoteStore();
|
|
||||||
|
|
||||||
const { encryptionKey } = useAuthStore();
|
const { encryptionKey } = useAuthStore();
|
||||||
|
const { showModal, setUpdating, selectedNote } = useUIStore();
|
||||||
const { showModal, setUpdating } = useUIStore();
|
|
||||||
|
|
||||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const folderTree = useFolderTree();
|
||||||
if (!encryptionKey) return;
|
const updateNoteMutation = useUpdateNote();
|
||||||
loadFolderTree();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (newFolder && newFolderRef.current) {
|
if (newFolder && newFolderRef.current) {
|
||||||
newFolderRef.current.focus();
|
newFolderRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [newFolder]);
|
}, [newFolder]);
|
||||||
|
|
||||||
|
// Auto-save effect - watches editingNote for changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedNote) return;
|
if (!editingNote) return;
|
||||||
if (!encryptionKey) return; // Don't try to save without encryption key
|
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 =
|
const hasChanges =
|
||||||
lastSavedNote &&
|
lastSavedNote &&
|
||||||
lastSavedNote.id === selectedNote.id &&
|
lastSavedNote.id === editingNote.id &&
|
||||||
(lastSavedNote.title !== selectedNote.title ||
|
(lastSavedNote.title !== editingNote.title ||
|
||||||
lastSavedNote.content !== selectedNote.content);
|
lastSavedNote.content !== editingNote.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasChanges) return;
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
|
@ -67,25 +68,30 @@ function Home() {
|
||||||
setUpdating(true);
|
setUpdating(true);
|
||||||
await handleUpdate();
|
await handleUpdate();
|
||||||
setLastSavedNote({
|
setLastSavedNote({
|
||||||
id: selectedNote.id,
|
id: editingNote.id,
|
||||||
title: selectedNote.title,
|
title: editingNote.title,
|
||||||
content: selectedNote.content,
|
content: editingNote.content,
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [selectedNote, encryptionKey]);
|
}, [editingNote?.title, editingNote?.content, encryptionKey]);
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
if (!selectedNote) return;
|
if (!editingNote) return;
|
||||||
if (!encryptionKey) {
|
if (!encryptionKey) {
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateNote(selectedNote.id);
|
await updateNoteMutation.mutateAsync({
|
||||||
console.log(selectedNote.id);
|
noteId: editingNote.id,
|
||||||
|
note: {
|
||||||
|
title: editingNote.title,
|
||||||
|
content: editingNote.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update note:", error);
|
console.error("Failed to update note:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -95,17 +101,24 @@ function Home() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getTagTree, tagTree } = useTagStore();
|
const setTitle = (title: string) => {
|
||||||
const getTags = () => {
|
if (editingNote) {
|
||||||
getTagTree();
|
setEditingNote({ ...editingNote, title });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setContent = (content: string) => {
|
||||||
|
if (editingNote) {
|
||||||
|
setEditingNote({ ...editingNote, content });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
|
<div className="flex bg-ctp-base h-screen text-ctp-text overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
{showModal && <Modal />}
|
{showModal && <Modal />}
|
||||||
|
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<button onClick={getTags}>create</button>
|
|
||||||
{/*<div className="flex flex-col">
|
{/*<div className="flex flex-col">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -125,27 +138,27 @@ function Home() {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Untitled note..."
|
placeholder="Untitled note..."
|
||||||
value={selectedNote?.title || ""}
|
value={editingNote?.title || ""}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
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"
|
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">
|
<div className="px-4 py-2 border-b border-ctp-surface2 flex items-center gap-2 flex-wrap">
|
||||||
{selectedNote?.tags &&
|
{editingNote?.tags &&
|
||||||
selectedNote.tags.map((tag) => (
|
editingNote.tags.map((tag) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => null}
|
onClick={() => null}
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="bg-ctp-surface0 px-1.5 text-sm rounded-full"
|
className="bg-ctp-surface0 px-1.5 text-sm rounded-full"
|
||||||
>
|
>
|
||||||
{tag.parent_id && "..."}
|
{tag.parentId && "..."}
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TiptapEditor
|
<TiptapEditor
|
||||||
key={selectedNote?.id}
|
key={editingNote?.id}
|
||||||
content={selectedNote?.content || ""}
|
content={editingNote?.content || ""}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -170,29 +183,45 @@ const Modal = () => {
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5"
|
className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5"
|
||||||
>
|
>
|
||||||
{/*<Login />*/}
|
<Login />
|
||||||
<TagSelector />
|
{/*<TagSelector />*/}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TagSelector = () => {
|
export const TagSelector = () => {
|
||||||
const { tagTree } = useTagStore();
|
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
const { data: tagTree, isLoading, error } = useTagTree();
|
||||||
|
const createTag = useCreateTag();
|
||||||
|
|
||||||
|
const handleEnter = async () => {
|
||||||
|
createTag.mutate({ name: value });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/*<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleEnter();
|
||||||
|
}}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
/>*/}
|
/>
|
||||||
{tagTree && tagTree.map((tag) => <TagTree tag={tag} />)}
|
{tagTree && tagTree.map((tag) => <TagTree tag={tag} />)}
|
||||||
</div>
|
</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);
|
const [collapse, setCollapse] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,14 @@ import {
|
||||||
import { FolderTree } from "./subcomponents/FolderTree.tsx";
|
import { FolderTree } from "./subcomponents/FolderTree.tsx";
|
||||||
import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
|
import { SidebarHeader } from "./subcomponents/SideBarHeader.tsx";
|
||||||
import { useAuthStore } from "@/stores/authStore.ts";
|
import { useAuthStore } from "@/stores/authStore.ts";
|
||||||
import { useNoteStore } from "@/stores/notesStore.ts";
|
|
||||||
import { useUIStore } from "@/stores/uiStore.ts";
|
import { useUIStore } from "@/stores/uiStore.ts";
|
||||||
import { TagSelector } from "../../Home.tsx";
|
import { TagSelector } from "../../Home.tsx";
|
||||||
|
import {
|
||||||
|
useCreateFolder,
|
||||||
|
useFolderTree,
|
||||||
|
useUpdateFolder,
|
||||||
|
useUpdateNote,
|
||||||
|
} from "@/hooks/useFolders.ts";
|
||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
const [newFolder, setNewFolder] = useState(false);
|
const [newFolder, setNewFolder] = useState(false);
|
||||||
|
|
@ -32,13 +37,8 @@ export const Sidebar = () => {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const newFolderRef = useRef<HTMLInputElement>(null);
|
const newFolderRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const {
|
const { data: folderTree, isLoading, error } = useFolderTree();
|
||||||
folderTree,
|
const createFolder = useCreateFolder();
|
||||||
loadFolderTree,
|
|
||||||
moveNoteToFolder,
|
|
||||||
moveFolderToFolder,
|
|
||||||
createFolder,
|
|
||||||
} = useNoteStore();
|
|
||||||
|
|
||||||
const { encryptionKey } = useAuthStore();
|
const { encryptionKey } = useAuthStore();
|
||||||
|
|
||||||
|
|
@ -52,17 +52,11 @@ export const Sidebar = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!encryptionKey) return;
|
if (!encryptionKey) return;
|
||||||
loadFolderTree();
|
|
||||||
}, [encryptionKey]);
|
}, [encryptionKey]);
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (!newFolderText.trim()) return;
|
if (!newFolderText.trim()) return;
|
||||||
await createFolder({
|
createFolder.mutate({ name: newFolderText, parentId: null });
|
||||||
name: newFolderText,
|
|
||||||
parent_id: null,
|
|
||||||
});
|
|
||||||
setNewFolderText("");
|
|
||||||
setNewFolder(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pointer = useSensor(PointerSensor, {
|
const pointer = useSensor(PointerSensor, {
|
||||||
|
|
@ -81,6 +75,9 @@ export const Sidebar = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateNote = useUpdateNote();
|
||||||
|
const updateFolder = useUpdateFolder();
|
||||||
|
|
||||||
const handleDragEnd = async (event: DragEndEvent) => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
setActiveItem(null);
|
setActiveItem(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
@ -95,8 +92,11 @@ export const Sidebar = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (active.data.current?.type === "note") {
|
if (active.data.current?.type === "note") {
|
||||||
console.log("Updating note", active.id, "to folder", over.id);
|
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") {
|
} else if (active.data.current?.type === "folder") {
|
||||||
// Prevent dropping folder into itself
|
// Prevent dropping folder into itself
|
||||||
if (active.data.current.folder.id === over.id) {
|
if (active.data.current.folder.id === over.id) {
|
||||||
|
|
@ -111,10 +111,10 @@ export const Sidebar = () => {
|
||||||
over.id,
|
over.id,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await moveFolderToFolder(
|
updateFolder.mutate({
|
||||||
active.data.current.folder.id,
|
folderId: active.data.current.folder.id,
|
||||||
over.id as number,
|
folder: { parentId: over.id as number },
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update folder:", error);
|
console.error("Failed to update folder:", error);
|
||||||
return;
|
return;
|
||||||
|
|
@ -163,20 +163,21 @@ export const Sidebar = () => {
|
||||||
autoScroll={false}
|
autoScroll={false}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
>
|
>
|
||||||
<div className="flex-row-reverse flex">
|
<div className="flex-row-reverse flex h-screen">
|
||||||
<div
|
<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}
|
onMouseDown={handleMouseDown}
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
className="flex flex-col min-h-full"
|
className="flex flex-col h-full"
|
||||||
style={{ width: `${sideBarResize}px` }}
|
style={{ width: `${sideBarResize}px` }}
|
||||||
>
|
>
|
||||||
<SidebarHeader setNewFolder={setNewFolder} />
|
<SidebarHeader setNewFolder={setNewFolder} />
|
||||||
|
<div className="flex-1 overflow-y-auto bg-ctp-mantle border-r border-ctp-surface2">
|
||||||
{sideBarView == "folders" ? (
|
{sideBarView == "folders" ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<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()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onTouchMove={(e) => e.preventDefault()}
|
onTouchMove={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
|
|
@ -203,22 +204,44 @@ export const Sidebar = () => {
|
||||||
</div>
|
</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 */}
|
{/* Folder tree */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{folderTree?.folders.map((folder) => (
|
{folderTree?.folders.map((folder) => (
|
||||||
<FolderTree key={folder.id} folder={folder} depth={0} />
|
<FolderTree
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
depth={0}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Orphaned notes */}
|
{/* Orphaned notes */}
|
||||||
{folderTree?.orphaned_notes &&
|
{folderTree?.orphanedNotes &&
|
||||||
folderTree.orphaned_notes.length > 0 && (
|
folderTree.orphanedNotes.length > 0 && (
|
||||||
<div className="mt-4 flex flex-col gap-1">
|
<div className="mt-4 flex flex-col gap-1">
|
||||||
{folderTree.orphaned_notes.map((note) => (
|
{folderTree.orphanedNotes.map((note) => (
|
||||||
<DraggableNote key={note.id} note={note} />
|
<DraggableNote key={note.id} note={note} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
|
|
@ -236,12 +259,13 @@ export const Sidebar = () => {
|
||||||
</DragOverlay>
|
</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 />
|
<TagSelector />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
||||||
import { useNoteStore } from "@/stores/notesStore";
|
import { useUIStore } from "@/stores/uiStore";
|
||||||
import { NoteRead } from "@/api/folders";
|
import { NoteRead } from "@/api/notes";
|
||||||
|
|
||||||
export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
export const DraggableNote = ({ note }: { note: NoteRead }) => {
|
||||||
const { selectedNote, setSelectedNote } = useNoteStore();
|
const { selectedNote, setSelectedNote } = useUIStore();
|
||||||
const { openContextMenu } = useContextMenu();
|
const { openContextMenu } = useContextMenu();
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
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";
|
import CaretRightIcon from "@/assets/fontawesome/svg/caret-right.svg?react";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
import FolderIcon from "@/assets/fontawesome/svg/folder.svg?react";
|
||||||
import { Folder } from "@/api/folders";
|
import { FolderTreeNode } from "@/api/folders";
|
||||||
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
import { useContextMenu } from "@/contexts/ContextMenuContext";
|
||||||
|
|
||||||
export const DroppableFolder = ({
|
export const DroppableFolder = ({
|
||||||
|
|
@ -12,7 +12,7 @@ export const DroppableFolder = ({
|
||||||
setCollapse,
|
setCollapse,
|
||||||
collapse,
|
collapse,
|
||||||
}: {
|
}: {
|
||||||
folder: Partial<Folder>;
|
folder: Partial<FolderTreeNode>;
|
||||||
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
|
setCollapse: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
collapse: boolean;
|
collapse: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -63,9 +63,11 @@ export const DroppableFolder = ({
|
||||||
{...listeners}
|
{...listeners}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
|
{(folder.notes?.length ?? 0) > 0 && (
|
||||||
<CaretRightIcon
|
<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`}
|
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" />
|
<FolderIcon className="w-4 h-4 min-h-4 min-w-4 fill-ctp-mauve mr-1" />
|
||||||
<span className="truncate">{folder.name}</span>
|
<span className="truncate">{folder.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,27 @@ import FolderPlusIcon from "@assets/fontawesome/svg/folder-plus.svg?react";
|
||||||
import TagsIcon from "@assets/fontawesome/svg/tags.svg?react";
|
import TagsIcon from "@assets/fontawesome/svg/tags.svg?react";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
|
import FileCirclePlusIcon from "@assets/fontawesome/svg/file-circle-plus.svg?react";
|
||||||
import { useNoteStore } from "@/stores/notesStore";
|
|
||||||
import { useUIStore } from "@/stores/uiStore";
|
import { useUIStore } from "@/stores/uiStore";
|
||||||
|
import { useCreateNote } from "@/hooks/useFolders";
|
||||||
|
import { NoteCreate } from "@/api/notes";
|
||||||
|
|
||||||
export const SidebarHeader = ({
|
export const SidebarHeader = ({
|
||||||
setNewFolder,
|
setNewFolder,
|
||||||
}: {
|
}: {
|
||||||
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
|
setNewFolder: React.Dispatch<SetStateAction<boolean>>;
|
||||||
}) => {
|
}) => {
|
||||||
const { createNote, selectedFolder } = useNoteStore();
|
const { setSideBarView, sideBarView, selectedFolder } = useUIStore();
|
||||||
const { setSideBarView, sideBarView } = useUIStore();
|
const createNote = useCreateNote();
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
await createNote({
|
createNote.mutate({
|
||||||
title: "Untitled",
|
title: "Untitled",
|
||||||
content: "",
|
content: "",
|
||||||
folder_id: selectedFolder,
|
folder_id: selectedFolder,
|
||||||
});
|
} as NoteCreate);
|
||||||
};
|
};
|
||||||
return (
|
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
|
<button
|
||||||
onClick={() => setNewFolder(true)}
|
onClick={() => setNewFolder(true)}
|
||||||
className="hover:bg-ctp-mauve group transition-colors rounded-sm p-1 m-1"
|
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" />
|
<FileCirclePlusIcon className="w-5 h-5 text-ctp-mauve group-hover:text-ctp-base transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
unwrapMasterKey,
|
unwrapMasterKey,
|
||||||
wrapMasterKey,
|
wrapMasterKey,
|
||||||
} from "../api/encryption";
|
} from "../api/encryption";
|
||||||
import { FolderTree } from "@/pages/Home/components/sidebar/subcomponents/FolderTree";
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -147,11 +146,6 @@ export const useAuthStore = create<AuthState>()(
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.clear();
|
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 { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
|
@ -13,6 +14,12 @@ interface UIState {
|
||||||
|
|
||||||
sideBarView: string;
|
sideBarView: string;
|
||||||
setSideBarView: (view: string) => void;
|
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>()(
|
export const useUIStore = create<UIState>()(
|
||||||
|
|
@ -34,7 +41,18 @@ export const useUIStore = create<UIState>()(
|
||||||
setSideBarView: (view) => {
|
setSideBarView: (view) => {
|
||||||
set({ sideBarView: 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",
|
name: "ui-store",
|
||||||
partialize: (state) => {
|
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 react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import svgr from "vite-plugin-svgr";
|
import svgr from "vite-plugin-svgr";
|
||||||
import path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), react(), svgr()],
|
plugins: [tailwindcss(), react(), svgr()],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue