From c01a1fc90836d350f0dc080d251b4e3eb7a364b6 Mon Sep 17 00:00:00 2001 From: james fitzsimons Date: Mon, 15 Dec 2025 21:33:00 +0000 Subject: [PATCH] Add Tag Support With Backend Models And UI --- .gitignore | 1 + backend/app/__pycache__/auth.cpython-314.pyc | Bin 4178 -> 4173 bytes backend/app/__pycache__/main.cpython-314.pyc | Bin 1343 -> 1439 bytes .../app/__pycache__/models.cpython-314.pyc | Bin 8608 -> 12742 bytes backend/app/auth.py | 9 +- backend/app/main.py | 3 +- backend/app/models.py | 71 ++++++++++++-- .../__pycache__/folders.cpython-314.pyc | Bin 6229 -> 6568 bytes .../routes/__pycache__/notes.cpython-314.pyc | Bin 4416 -> 4416 bytes .../routes/__pycache__/tags.cpython-314.pyc | Bin 0 -> 3789 bytes backend/app/routes/folders.py | 11 +-- backend/app/routes/notes.py | 6 +- backend/app/routes/tags.py | 60 ++++++++++++ frontend/src/api/encryption.tsx | 12 +++ frontend/src/api/folders.tsx | 2 + frontend/src/api/notes.tsx | 8 +- frontend/src/api/tags.tsx | 92 ++++++++++++++++++ frontend/src/pages/Home/Home.tsx | 84 +++++++++++++++- frontend/src/stores/authStore.ts | 12 +-- frontend/src/stores/notesStore.ts | 4 +- frontend/src/stores/tagStore.ts | 36 +++++++ frontend/src/stores/uiStore.ts | 2 +- 22 files changed, 374 insertions(+), 39 deletions(-) create mode 100644 backend/app/routes/__pycache__/tags.cpython-314.pyc create mode 100644 backend/app/routes/tags.py create mode 100644 frontend/src/api/tags.tsx create mode 100644 frontend/src/stores/tagStore.ts diff --git a/.gitignore b/.gitignore index 2d5f545..a1a24f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules frontend/src/assets/fontawesome/svg/* frontend/src/assets/fontawesome/svg/0.svg +*.db diff --git a/backend/app/__pycache__/auth.cpython-314.pyc b/backend/app/__pycache__/auth.cpython-314.pyc index 4e8d24ee0b8b87c03fd296308bd0c9e3941515c3..a45ea6f02ea121cca15b1d3acc73010af977c94e 100644 GIT binary patch delta 190 zcmcbla8`j=n~#@^0SHXeZ8Hlt@&+?8@@-CJieO~qo4kwp6Qk(nwJhR{KsJHnj*9%5A zJ&>~G$(wlsIc!$hthT!%W;vOccL}4-=1shIj6mumpFX1z8w;b-S3YJ&i|;%FjACEa lxEUor$}uRaFDO~ev(x>6_hp-a@5~&G0v{QGM3FeqOaL3LG#dZ_ delta 192 zcmX@Ba7lqzn~#@^0SK0G=w<%j$Q#VW$iF#}DT0xafATKoPmE%l*RqH+0;!{{f{cu^ zo3FF6GqJFhmLyNU&oR+Z7O1hf5=b;Kd|_h{lDsacc1cieLD6MFtp=|TE>H`giD?wmQj%*h&71KoDrmifuV#u zmODUlVwFBKcYyT7HJZ|VK)yUg4w;PQ3{aT(K~WOBBnLi8c6^dtU`e3m%9A@7H5r8_ zZ(?+mVY$VUSXz>Ci#;trCndG0_!etkeo1QaEtZnR^v#@1%#7mVK(`44aWMywXkd84 zB5{pHsz`LQGP9c_H&CcZ1Wt&8SYklp7E4ineo2uGkSPbm#k@eGf#Cs<=!EJE+{zbN zl#4)0{WOIquVRj3D&m^V$fC?BK3SDT(H^7+VkE*e4x8Nkl+v73yCQX#cN78T!O&C4%I-R#T6%*ZGD^df> qf`X#>B#`(3w3+cPgY;bnqo)kI_ZhS=GH6de$1-zr9jh!0NH+kGwLpUa diff --git a/backend/app/__pycache__/models.cpython-314.pyc b/backend/app/__pycache__/models.cpython-314.pyc index 00bb4e2660a9d90c9dd697157262b2330ca0bc27..ec9635d6dbc82031ac0c856661277a2e1a296ad3 100644 GIT binary patch literal 12742 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 D!YnyP delta 2867 zcma)8YfO_@7(R!Vw$M`gQ3|cv3oQr*D#*p_m>>*94VsFbZU~}nyhP~swN4XFXmknu zSvGvL#B9lCvOi;4=8V}T`(YMCwm(Z25|*gTGM8*|+b?k??$0jgIo~X;Ln_Jl@P6kz zm-l(z%js7~uXI}*tR=-v{QW)Oe&Q3$wAINsP1nvi+jJOYENOADDi*d?vaoW<*ruDl z|B4~JCcuJjG8DYPXKA$HYI%gU^029roiZg$!$nD3ge4W)pgYUIp-%4>vKCorQ8%d< ztE}{v{sPq&zRfSu4VRO?FYMu6x7x$0y@N?m>fM&pYfC1nHbO4?O$g^8uN!FdPx&V^D9abx# z4bTqQARt?V00seqpxfO3i2nhz#~>XF&>owM-ZU%RF9bMAise1qR;*C~n*m#B)>6ZR z*MGM>$8FNBDh%%e?4WJdRR&Eg8nHUsS5U~_8$T6|sW}26dWqL$@H-q1>#hZ(h{=M9 zDXuwv7yWFFWXV>ZN45btv|`nC3I4_yA8Kpjnk<0e+e-%?S8U=}z8AJ8hn_ zU&W_UmSTrkl80HcV7NsHd2^Sn0*kByTeu{th$<)xb0od$6)Oe?RWn@Z0rUd;09mTz z)dwgiZ;6Y4nkk-(&X-X<#Z>Ov7?s?1V;aZ=4Qe6m$Ff&aKsBtK!pcBA8OsR|QZI2q zgr|3+Yr%}rShUbAl;Fk6tje6eX`vJ^Wvs+89r@0(W=`L|V8=)~GndZk+aEZ@i@KEl z_B?Ui2L}5AS#gw+i(qgmni<;piU5L3)g_0)% zDb@Frx8d`R^WN@M?`_5X{`2!nd#dLln4n|G{b;Y>Meq0&-bo*XYRF&h}q!!x$a(+vr|E z;r$d{`*pXhPM;W8hXFeTuPqMP$x()Sr*!=GKf(yR|ZH;K_1|&F@qqnYz`OxUF*1DMGr2)=-9xIwU6l?`}DU zK>$)YK_RzOlkb@H^1}v&57CW=o4Zel&%!V1lDtyUo}R+5{Vzoy_iKp0Z0tCY@he!o ztjM{NE=aE8xD4bN&N!|H!KDv6ea3k~Ua) z51-m}xowfbN_&d<=BeYCql-)!X=hyt-!ygL^57y9M%q`*H)wN(kzQlquBo$WCjO<% L=-m1~Jp%eKr(P}c diff --git a/backend/app/auth.py b/backend/app/auth.py index fec6785..b7498ca 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -2,7 +2,7 @@ import secrets from datetime import datetime, timedelta from typing import Optional -import bcrypt # Use bcrypt directly instead of passlib +import bcrypt from fastapi import Cookie, Depends, HTTPException, Request, status from sqlmodel import Session, select @@ -11,7 +11,6 @@ from app.models import Session as SessionModel from app.models import User -# Password hashing with bcrypt directly def hash_password(password: str) -> str: password_bytes = password.encode("utf-8") salt = bcrypt.gensalt() @@ -25,12 +24,11 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return bcrypt.checkpw(password_bytes, hashed_bytes) -# Session management def create_session( user_id: int, request: Request, db: Session, expires_in_days: int = 30 ) -> str: session_id = secrets.token_urlsafe(32) - expires_at = datetime.utcnow() + timedelta(days=expires_in_days) + expires_at = datetime.now() + timedelta(days=expires_in_days) db_session = SessionModel( session_id=session_id, @@ -53,13 +51,12 @@ def get_session_user(session_id: Optional[str], db: Session) -> Optional[User]: select(SessionModel).where(SessionModel.session_id == session_id) ).first() - if not session or session.expires_at < datetime.utcnow(): + if not session or session.expires_at < datetime.now(): return None return session.user -# Dependency for protected routes async def require_auth( session_id: Optional[str] = Cookie(None), db: Session = Depends(get_session) ) -> User: diff --git a/backend/app/main.py b/backend/app/main.py index 728376a..9471f10 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI # type: ignore from fastapi.middleware.cors import CORSMiddleware # type:ignore from app.database import create_db_and_tables -from app.routes import auth, folders, notes +from app.routes import auth, folders, notes, tags app = FastAPI(title="Notes API") @@ -24,6 +24,7 @@ def on_startup(): app.include_router(notes.router, prefix="/api") app.include_router(folders.router, prefix="/api") app.include_router(auth.router, prefix="/api") +app.include_router(tags.router, prefix="/api") @app.get("/") diff --git a/backend/app/models.py b/backend/app/models.py index afeafa9..2f13376 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,26 +4,27 @@ from typing import List, Optional from sqlmodel import Field, Relationship, SQLModel # type: ignore -class User(SQLModel, table=True): +class User(SQLModel, table=True): # type: ignore id: Optional[int] = Field(default=None, primary_key=True) username: str = Field(unique=True, index=True) email: str = Field(unique=True, index=True) hashed_password: str salt: str wrapped_master_key: str - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=datetime.now) # Add relationships to existing models notes: List["Note"] = Relationship(back_populates="user") folders: List["Folder"] = Relationship(back_populates="user") sessions: List["Session"] = Relationship(back_populates="user") + tags: List["Tag"] = Relationship(back_populates="user") -class Session(SQLModel, table=True): +class Session(SQLModel, table=True): # type: ignore id: Optional[int] = Field(default=None, primary_key=True) session_id: str = Field(unique=True, index=True) user_id: int = Field(foreign_key="user.id") - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=datetime.now) expires_at: datetime ip_address: Optional[str] = None user_agent: Optional[str] = None @@ -35,7 +36,7 @@ class Folder(SQLModel, table=True): # type: ignore id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(max_length=255) parent_id: Optional[int] = Field(default=None, foreign_key="folder.id") - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=datetime.now) user_id: int = Field(foreign_key="user.id") # Relationships @@ -47,20 +48,73 @@ class Folder(SQLModel, table=True): # type: ignore user: User = Relationship(back_populates="folders") +class NoteTag(SQLModel, table=True): #type: ignore + note_id: int = Field(foreign_key="note.id", primary_key=True) + tag_id: int = Field(foreign_key="tag.id", primary_key=True) + + +class Tag(SQLModel, table=True): # type: ignore + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(max_length=255) + parent_id: Optional[int] = Field(default=None, foreign_key="tag.id") + user_id: int = Field(foreign_key="user.id") + created_at: datetime = Field(default_factory=datetime.now) + + # Relationships + user: User = Relationship(back_populates="tags") + parent: Optional["Tag"] = Relationship( + back_populates="children", + sa_relationship_kwargs={"remote_side": "Tag.id"} + ) + children: List["Tag"] = Relationship(back_populates="parent") + notes: List["Note"] = Relationship(back_populates="tags", link_model=NoteTag) + + + class Note(SQLModel, table=True): # type: ignore id: Optional[int] = Field(default=None, primary_key=True) title: str = Field(max_length=255) content: str folder_id: Optional[int] = Field(default=None, foreign_key="folder.id") - created_at: datetime = Field(default_factory=datetime.utcnow) - updated_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) user_id: int = Field(foreign_key="user.id") + #Relationships folder: Optional[Folder] = Relationship(back_populates="notes") user: User = Relationship(back_populates="notes") + tags: List[Tag] = Relationship(back_populates="notes", link_model=NoteTag) + + + +# API Response models +class TagRead(SQLModel): + id: int + name: str + parent_id: Optional[int] = None + created_at: datetime + + +class TagCreate(SQLModel): + name: str + parent_id: Optional[int] = None + + +class TagUpdate(SQLModel): + name: Optional[str] = None + parent_id: Optional[int] = None + + +class TagTreeNode(SQLModel): + id: int + name: str + children: List["TagTreeNode"] = [] + + +class TagTreeResponse(SQLModel): + tags: List[TagTreeNode] -# API Response models (what gets sent to frontend) class NoteRead(SQLModel): id: int title: str @@ -68,6 +122,7 @@ class NoteRead(SQLModel): folder_id: Optional[int] = None created_at: datetime updated_at: datetime + tags: List[TagRead] = [] class FolderTreeNode(SQLModel): diff --git a/backend/app/routes/__pycache__/folders.cpython-314.pyc b/backend/app/routes/__pycache__/folders.cpython-314.pyc index 5075343fd2e2a6e1ae67f28c6601b5beab40915c..d4377a933bb46511403503c2a5bfe6d18cb6c464 100644 GIT binary patch delta 2627 zcmb7GO>7%Q6rT0kUa$Y{ICh*L$N6({L&Hy#HbDBPmTFx}vD~T$6xVg_O=H;DW_Fx5 z7aY_Bs#>Y41_>2a6x0K)1V~jcfRI2)72;S$2x)0map(bYan(akNX(n{#*qaC_VDJ- zoAvkrg*_D<0xeyu_>ch)?koKi6|)0!oksl@JLjVG`ytXQoGq zkO=3cOs^6pQ6)y2v0;=Cj-Z??Cr=4E_ipA6e55buiJ_b~hO{7w=lilVrhGHa`SR|Z z-?GSuMo?ay7EBF3b6B&Twp@Ur1p!SHrrRK7Am7*gTi{S$$V<=mcQ)(VrUNYG!VI{l z3wV$@{7T2#;&P$9)P>aiXOANcxVH;9!GQN!aGfz1wIc0`Tgb&2N?#XBlA#xQlps^LD|OU5rL4?Ay1sdcl7Csh#dSu@(i*cN`n@zne{ziqoQp*|x)yg5Wur7H zZxiD5SfrmWyT$}B{Zg8EePj3kyQhWR8Rxz9D|wK%q`*w5Bel3q_*?sTM(!1c=y`cW z7^WY|L!P9Ck{zW#$mjM0g5j^>`eL!FmJ4v^dOB>_)$?iz_pn6_)nrJuI$>p|WAMkU z9}jR*I`1BSAqgsCQPT{0p{A8pT&R@IDuJZ%O2gxfOG|h=2qx(rcdWSsShLg!R#lv0 zb6sL2#VU<`yiSb3S(Oyps9bF~(@28qB&()X)bgTu@`m@C_kqiQdHIt6ksQ1j`(W_; z!VT@3wicTFB{X|KH2ZDjyZG1fwa~#e`OqWTf3f%SGM(_GW@Ja)x$Z`i`@PZywdFyM zXv>eHLm$pxod-N}$!p=2Z&a>T)^?tl>3g9-*1n&f38^_0Rj5>7r zR6A{6t)g3b(=oKoIr^?|hmfG3`{oaD#~}6X^I%+B#E|>{c4rUKjQ=?yad*Wp3yBxO zS~A>bD=9aY>Np8VybX=hPXdFUO>zapi_FKx;Hll* z$7ulK8MGNW&LeGo3KbXV0rzS-Ob9J&s4SC z$})@N^inu$rtY;3w+vsqDjRj)I=Po3KFstn{0(gB>S=nRhaJe>J3R%VsenZW$Pq?P zu+HyO&zH1DSuHfGx=PYAhF8T*DujghGGaI?L|xLM=aM2J7{fEhgTx0I$L<@CGzU`f z&%ACP)8hhjyJ(1St1E_sUg-_DPo=$R28Z8dRt5S^@5N?{H ztkouM5 za>H+c5Kh=|)QhCF$iuyjFvM!MAJ-g%ZfNS>IlRDxtmPm*&S*;%1mPiyJw_uB(fGPe y7HsQ?k*7e`BS_eFAI&^O+a96u-{de=W77flj6D^RZMJ1&Tx%D-6}xSRjs61F#_=2g delta 2280 zcma)6PmB{)7=LeO+G(fL>9*VLZejnGg*62hK_D#3g2;li5y(0s@vx=Tc3>OJw!V4Y zz^Q8_QG*wJ2jfK#T;Spb4|>tWe+MtdgO>qgVnT?ChZ3S2Vq*NhH|_4ygV?0seD8bT z_j}*}n|puW9p7Qa4FbPoiCxu4*W=mNPKA&aa*jB3j@XpbG31pw#a6j$Yh1HKJS1i6 zT-esRZbx`Tp0&BCZE!>Kp}ClCa#Ql*xwvg{%TDlw-N*avBv0BYp0fLSzn$i3dw>s& zk%F2dj_yQuwWt$4050H*Irk%>14}zL7d+A?d`s76mOw3dTMfXvmY< zJ?&K-3(yjXR-^kmAUyjxZmSFK^c85q+PSHFCZy+?bztfw0XWqIya|90tXp?Ez_ogi zHagrQg>_)X=?CC+5AZMmKi7p@|2hNRNPFU@P6kj0dr(FJ<;c2qmpk?!Fhc;7?SUD0 zHcgQH=HJmiPkrrV)#IYAOePgyf5r8@YJJHMdv47wi-xiyrqy@CF;Hc3@ts|7E|+T6N{PE{i&zbfZX3cYZK=*(KU7|<)+(L~lF9LL zwizwAN+O;O-=l+KTpwwrP@n_JQ-<~Qus^m0%ENev1RwQ~Ss|+=OT!P4+)ZWLDt-?> z{{X*i1Qg1h_5_xYDE#4d?cr59H)rX5r}^ZqBqCvrij&cSnAVleN5o1bC+_HJ@kk%h z&XRdkSjJN{A^wTX&^~b}x((=ZWCyAM_oFuaxYB0vvXQ0(;)0Qb7~eHU=#cm{IxN06 z+=+g8#kcCLu~=GiD@9C`m$!V)z3i4*1TI-jT#SviFyFpfs?}H@a?uO*TE%6>Y9&aZ zg_>9dHUT8Bvpn+Kf%r*YZxn0pGE^oo^4Lzi4C=y}>#}Wd_Aup>P!{;fWc!JWF7KEW zKQKdM`$1tGHU9##LfRI|j(=i)WZsQf?=N4o#0hh1i>_tEs}Z6bZDg)R+vM2nj5rlPJ~arh$Oxbhp41M9#aHoR zIwJ1IKRz;rc0uNl!!49oBoM=7d)NQKKyu@ zxiCx}C)o=in-xcsCGhb^^4tOG<1^?I7CW!_YITV}F<-BW>C^;xwvfuvIq5+d-wMO{ zx-hth5lUriu(DTzvF&Cq9;kNRIt>vRTk)2_#P{ zZApBYhJxJwBVDAe6KL@w5*x@FSYdJZa=F&5xW(p@=kk1%oy6O@~1DFaFHD z*TR)^I-m2y{7R#`bdlk%^YsfQkCz%%hC79YkjQ*Tyfh=u!mG}sD}L-2I7<{oBcP{Z})?H||0lBl vSF-gU$-#M5iBe^i0C|YyL53Lp;`5<>3E0mMRiaF{72w*_;_snb8Yb^wySBN% diff --git a/backend/app/routes/__pycache__/notes.cpython-314.pyc b/backend/app/routes/__pycache__/notes.cpython-314.pyc index 9098d404fc74718aa505bbf67ba277a1e68f6702..5592dd87b6f658423a3c8926ad590a0e39017f17 100644 GIT binary patch delta 463 zcmZvZPfNov7{-$}P5-4`1wGe|-2_2&Y&{4H3K{5S-h?UDZKzD0Z9Mo5+(jAh#}EaX zA7DqXIzb%DK=9&6*vY0UikQPo-d~>Q$s5wk^>X%<>sTmRr_K3}U0m_e2CArKl@meI zJt=FP3d(h%b3+*16lTh`vc+v-b0!#fgp)E_c6ml*xFd-r&X0U>M;N>?unyqmS6oqH4J%UhDO+G6~Z>(j!YqKA!{KIH^aCdiH{gs p0;RDyN_P=!!&7E|>J1fdQE`OwuPFPB@+0IAkPjQ~9JT=5iw_QLX9@rS diff --git a/backend/app/routes/__pycache__/tags.cpython-314.pyc b/backend/app/routes/__pycache__/tags.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b35bedb230ee5e4c74af59e575ac0055f332ea6e GIT binary patch literal 3789 zcmb7H&2JmW6`%becbCgANtPH(iY2+WELx4$f$hWxJ4InNpkP#HH#Um`l8H6BqS#U- zGrNo&IUf{Kz!n-nKKS6G1}YRixVIj9=%IgvRI0E%a3B};B{!-n>}%hf<&qMs)a?MA zd9(B8&Aj)1@6F?6G!i1vw*GRZNP~p@11Ek9w21vb353+iEn*5YBqg}Q2-4z=n37zH z)6$Hb3b=t(&<&;(S4pX^n$lb?6>>u<-PJjdJQGeu+(;_wMpH32#=iw-;;A0DC)Mlr zj*$7t7%_vUG9{Sm`=WQY+c)2rPWl(>jHY%0?u2bVI!5Lte|BClLkVK)2~vuegbN-E zar;5aJFUrq!PMq8Gwh$mB?-QyF1ex^0lCpGxry(P8#^TByJf~fW>1&Qw?O9k1D9-( zX73@H2Q}^kDaX2`44VDlBguhpad%Q+VQPO|D^fda)!apx>jm4*I=15!E9IoZ-IsciwRAbrCscpq<8^Y~H14;pkWCi$jg)1<_-AEsx_O3k$?Q>N{zUCui!lDcst z{i8cMyXxW@B2zx{j$=W`F4;MEC1AK#!C^A?upX>Zd!cxT_P~vqVC2xhEgus^g9N|* zQ_$5(hTIY_{gRM8d>bY|5f%kgn1dCs83Jf7ev5mU;_E2T7|cQQ;6jBiS+0YkPqHh< zVr9uTer_!l-4$cn{=_a-s!Mj+H5M$#ErSgl4s`@DlXR}rr zxWG8sEIke)F`Aq{=<4J(5rz03b`O{Nun(UJ*9!@37-3wmTR@;*X~m(jlKhw()eo%tiippPJ$v-i$X>>e#`MKr@;4!vd4wrYxMRQ40Rc)&QDZ zucSoLDOAVK0|89{?ID0`%00(BTzAHyL-&P+-aYJ|YrRbg#+SVV%>a!53X*95o&prt zGuQSH>_eJ+bejt~+M{jHNEspB)(bYYT1GMnUZSIC8m-mU#z>}}1*&pJOrwLkT1NH} zGD3g@ZP3;HyYTf$*CI?0NstjoOhMabi+M;KA@0{~zP40lVlnT5D(Zdw#3~iNv|;7* zESRe-L69*8q8w1Mm<-S{5Kj?M+)qpe|7Br=#e(_U9CjEU^z!9(G`#rUc{5aI6`C5#ZgIXevml2y_i759o61#yGUY7gH9bF6sYGT}P#?j`C?V zXIIOROJG|c&~Mn!%bLRiGQz?AZ8=AP-VX{0EBQ=INFLBQOiU<(@PV20izD(=!4&-x zq8b1@U=HbOnSg&9gqf7aOxxA`6Jb37ZkL#{1ka^auvdC+ScOrm1klfaItr0w=OJMm zu$`hKlNudHnx}Mp1Xy5s(WZ8Xg9R3f)N$zqvL$g#u(Q~~5mW6uknW4+0#iBI0B8<6 z83M2!fiXBA!!PSrvdBa)0Rz4b&MI^ehEWnvtbexl*>g4e_42P`yIORu|Lf)TFW0`@ zII|T?Hng+;Cy3RJfvwn?hBmRU$noH7l_=p=`>R4zBZ0{4Fj3<>@#C9AQ=2hkQ#D@3 z1|BVM$KHeBZiTP9Eg$cM?+d-Je%%XM?dbSK@bdWd@c+j6Y{#S_qC9!pIeeqZ1-FSgpp0dKo3_#NK}kHk@BL3>ArTxii%F(dkT zEkJr4K7dn1ZU?E0P!t>$K^gG?QiY?Qlnzo&$&`7q6(~N03$8>EsQ`=7g-WdqC}p|> zl~K*f=HT&%q;9)bu|%=9UC{wVqfimpF2Fg!Bq&Gu+d(m>BP*_WXq7rf=P!nXP6SAL zHHz-BdT9Istl;&WtI$D}66#r>T$_BDd-Pr-bh3VRSC2lA4n2|nczruMy{S&)LjcMD z!TG}vLAd|H=bQTJP5CtU%xrRqsZje(Vd+fqa2CcXzErm@cnJ|$EQFuD{su%-5(MEJGWHEQwM)#W#M~n0F1ffxF7A?3 zf7f~+oq00wMBUcjtp{HS%DutW!TVRg8hJK0@nonmcJUR~v-=VedzvCpO_hl1y`j~i z`yXxe)rT6w>AfHs9NQRf9G`lYIPvIiBQg03D<#}A-V}jqo@i~^j7&C!@9(Mq!H*9P q^1ss*foh&b{6x#?FJK*%)wPZ21t_P+rJ2DHHd literal 0 HcmV?d00001 diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index f77ea22..09b393d 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -1,8 +1,5 @@ from typing import List -from fastapi import APIRouter, Depends, HTTPException # type: ignore -from sqlmodel import Session, select # type: ignore - from app.auth import require_auth from app.database import get_session from app.models import ( @@ -15,6 +12,9 @@ from app.models import ( NoteRead, User, ) +from fastapi import APIRouter, Depends, HTTPException # type: ignore +from sqlalchemy.orm import selectinload +from sqlmodel import Session, select # type: ignore router = APIRouter(prefix="/folders", tags=["folders"]) @@ -35,21 +35,20 @@ def get_folder_tree( ): """Get complete folder tree with notes""" - # Get all top-level folders (parent_id is None) for current user top_level_folders = session.exec( select(Folder) + .options(selectinload(Folder.notes).selectinload(Note.tags)) .where(Folder.parent_id == None) .where(Folder.user_id == current_user.id) ).all() - # Get all orphaned notes (folder_id is None) for current user orphaned_notes = session.exec( select(Note) + .options(selectinload(Note.tags)) .where(Note.folder_id == None) .where(Note.user_id == current_user.id) ).all() - # Build tree recursively tree = [build_folder_tree_node(folder) for folder in top_level_folders] return FolderTreeResponse( diff --git a/backend/app/routes/notes.py b/backend/app/routes/notes.py index 283168a..ba90306 100644 --- a/backend/app/routes/notes.py +++ b/backend/app/routes/notes.py @@ -1,11 +1,10 @@ from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException -from sqlmodel import Session, select - from app.auth import require_auth from app.database import get_session from app.models import Note, NoteCreate, NoteUpdate, User +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select router = APIRouter(prefix="/notes", tags=["notes"]) @@ -16,6 +15,7 @@ def list_notes(session: Session = Depends(get_session)): return notes + @router.post("/", response_model=Note) def create_note( note: NoteCreate, diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py new file mode 100644 index 0000000..8e103ed --- /dev/null +++ b/backend/app/routes/tags.py @@ -0,0 +1,60 @@ +from app.auth import require_auth +from app.database import get_session +from app.models import Note, NoteCreate, NoteTag, NoteUpdate, Tag, TagCreate, User +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +router = APIRouter(prefix="/tags", tags=["tags"]) + +@router.get("/") +def list_tags(session: Session = Depends(get_session)): + tags = session.exec(select(Tag)).all() + return tags + +@router.post('/', response_model=Tag) +def create_tag( + tag: TagCreate, + current_user: User = Depends(require_auth), + session: Session = Depends(get_session) +): + tag_data = tag.model_dump() + tag_data["user_id"] = current_user.id + db_tag = Tag.model_validate(tag_data) + + session.add(db_tag) + session.commit() + session.refresh(db_tag) + return db_tag + + +@router.post("/note/{note_id}/tag/{tag_id}") +def add_tag_to_note( + note_id: int, + tag_id: int, + current_user: User = Depends(require_auth), + session: Session = Depends(get_session) +): + existing = session.exec( + select(NoteTag) + .where(NoteTag.note_id == note_id) + .where(NoteTag.tag_id == tag_id) + ).first() + + if existing: + return {"message": "Tag already added"} + + note_tag = NoteTag(note_id=note_id, tag_id=tag_id) + session.add(note_tag) + session.commit() + + return note_tag + +@router.delete("/{tag_id}") +def delete_note(tag_id: int, session: Session = Depends(get_session)): + tag = session.get(Tag, tag_id) + if not tag: + raise HTTPException(status_code=404, detail="Tag not found") + + session.delete(tag) + session.commit() + return {"message": "tag deleted"} diff --git a/frontend/src/api/encryption.tsx b/frontend/src/api/encryption.tsx index 3f094b9..3929f72 100644 --- a/frontend/src/api/encryption.tsx +++ b/frontend/src/api/encryption.tsx @@ -114,6 +114,12 @@ export async function decryptFolderTree( ...note, title: await decryptString(note.title, encryptionKey), content: await decryptString(note.content, encryptionKey), + tags: await Promise.all( + note.tags.map(async (tag) => ({ + ...tag, + name: await decryptString(tag.name, encryptionKey), + })), + ), })), ), children: await Promise.all( @@ -131,6 +137,12 @@ export async function decryptFolderTree( ...note, title: await decryptString(note.title, encryptionKey), content: await decryptString(note.content, encryptionKey), + tags: await Promise.all( + note.tags.map(async (tag) => ({ + ...tag, + name: await decryptString(tag.name, encryptionKey), + })), + ), })), ), }; diff --git a/frontend/src/api/folders.tsx b/frontend/src/api/folders.tsx index 7f09b9d..ec82412 100644 --- a/frontend/src/api/folders.tsx +++ b/frontend/src/api/folders.tsx @@ -1,6 +1,7 @@ import axios from "axios"; import { decryptFolderTree } from "./encryption"; import { useAuthStore } from "../stores/authStore"; +import { Tag } from "./tags"; axios.defaults.withCredentials = true; @@ -22,6 +23,7 @@ export interface NoteRead { folder_id: number | null; created_at: string; updated_at: string; + tags: Tag[]; } export interface FolderTreeNode { diff --git a/frontend/src/api/notes.tsx b/frontend/src/api/notes.tsx index 3407a21..2f4a481 100644 --- a/frontend/src/api/notes.tsx +++ b/frontend/src/api/notes.tsx @@ -1,7 +1,7 @@ import axios from "axios"; -import { NoteRead } from "./folders"; import { encryptString, decryptString } from "./encryption"; import { useAuthStore } from "../stores/authStore"; +import { Tag } from "./tags"; axios.defaults.withCredentials = true; const API_URL = (import.meta as any).env.PROD ? "/api" @@ -14,6 +14,7 @@ export interface Note { content: string; created_at: string; updated_at: string; + tags: Tag[]; } export interface NoteCreate { @@ -50,9 +51,12 @@ const fetchNotes = async () => { ...note, title: await decryptString(note.title, encryptionKey), content: await decryptString(note.content, encryptionKey), + tags: note.tags.map(async (tag) => ({ + ...tag, + name: await decryptString(tag.name, encryptionKey), + })), })), ); - return decryptedNotes; }; diff --git a/frontend/src/api/tags.tsx b/frontend/src/api/tags.tsx new file mode 100644 index 0000000..d4422ef --- /dev/null +++ b/frontend/src/api/tags.tsx @@ -0,0 +1,92 @@ +import axios from "axios"; +import { encryptString, decryptString } from "./encryption"; +import { useAuthStore } from "../stores/authStore"; +axios.defaults.withCredentials = true; +const API_URL = (import.meta as any).env.PROD + ? "/api" + : "http://localhost:8000/api"; + +export interface Tag { + id: string; + name: string; + parent_id?: number; + created_at: string; + children: Tag[]; + parent_path: string; +} + +export interface TagCreate { + name: string; + parent_id?: number; +} + +const buildTagTree = ( + tags: Tag[], + parent_id: string | number | null = null, + parentPath = "", +): Tag[] => { + const result: Tag[] = []; + for (const tag of tags) { + if (tag.parent_id == parent_id) { + tag.parent_path = parentPath; + + const currentPath = parentPath ? `${parentPath} › ${tag.name}` : tag.name; + + tag.children = buildTagTree(tags, tag.id, currentPath); + result.push(tag); + } + } + return result; +}; + +const fetchTags = async () => { + const encryptionKey = useAuthStore.getState().encryptionKey; + if (!encryptionKey) throw new Error("Not authenticated"); + + const { data } = await axios.get(`${API_URL}/tags/`); + + const decryptedTags = await Promise.all( + data.map(async (tag: Tag) => ({ + ...tag, + name: await decryptString(tag.name, encryptionKey), + })), + ); + + const tags = buildTagTree(decryptedTags); + + console.log(tags); + + return tags; +}; + +const createTag = async (tag: TagCreate, noteId?: number) => { + const encryptionKey = useAuthStore.getState().encryptionKey; + if (!encryptionKey) throw new Error("Not authenticated"); + + const tagName = await encryptString(tag.name, encryptionKey); + const encryptedTag = { + name: tagName, + parent_id: tag.parent_id, + }; + + const r = await axios.post(`${API_URL}/tags/`, encryptedTag); + console.log(r); + + if (noteId) { + return await addTagToNote(r.data.id, noteId); + } +}; + +const addTagToNote = async (tagId: number, noteId: number) => { + return axios.post(`${API_URL}/tags/note/${noteId}/tag/${tagId}`); +}; + +const deleteTag = async (tagId: number) => { + return axios.delete(`${API_URL}/tags/${tagId}`); +}; + +export const tagsApi = { + list: async () => await fetchTags(), + create: (tag: TagCreate, noteId?: number) => createTag(tag, noteId), + delete: (tagId: number) => deleteTag(tagId), +}; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 4dc8cfa..a4fdd51 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import "../../main.css"; -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { useAuthStore } from "@/stores/authStore"; import { useNoteStore } from "@/stores/notesStore"; import { useUIStore } from "@/stores/uiStore"; @@ -9,6 +9,9 @@ import { TiptapEditor } from "../TipTap"; import { Sidebar } from "./components/sidebar/SideBar"; import { StatusIndicator } from "./components/StatusIndicator"; +import { Tag, tagsApi } from "@/api/tags"; +import { useTagStore } from "@/stores/tagStore"; + function Home() { const [newFolder] = useState(false); const [lastSavedNote, setLastSavedNote] = useState<{ @@ -92,12 +95,30 @@ function Home() { } }; + const { getTagTree, tagTree } = useTagStore(); + const getTags = () => { + getTagTree(); + }; return (
{/* Sidebar */} {showModal && } + {/*
+ setTagName(e.target.value)} + /> + + {tags.map((tag) => ( + + ))} +
*/} + {/* Main editor area */}
@@ -109,6 +130,20 @@ function Home() { 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" /> +
+ {selectedNote?.tags && + selectedNote.tags.map((tag) => ( + + ))} +
+ { onClick={(e) => e.stopPropagation()} className="w-2/3 h-2/3 bg-ctp-base rounded-xl border-ctp-surface2 border p-5" > - + {/**/} +
); }; + +const TagSelector = () => { + const { tagTree } = useTagStore(); + const [value, setValue] = useState(""); + return ( +
+ setValue(e.target.value)} + /> + {tagTree && tagTree.map((tag) => )} +
+ ); +}; + +export const TagTree = ({ tag, depth = 0 }: { tag: Tag; depth?: number }) => { + const [collapse, setCollapse] = useState(false); + + return ( +
+
setCollapse(!collapse)}>{tag.name}
+ + {collapse && ( + + {/* The line container */} +
+ {/* Child tags */} + {tag.children.map((child) => ( + + ))} +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 5be5e8a..7087e57 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -11,15 +11,15 @@ interface User { id: number; username: string; email: string; - salt: string; // For key derivation + salt: string; } interface AuthState { user: User | null; - encryptionKey: CryptoKey | null; // Memory only! + encryptionKey: CryptoKey | null; isAuthenticated: boolean; rememberMe: boolean; - setRememberMe: (boolean) => void; + setRememberMe: (remember: boolean) => void; login: (username: string, password: string) => Promise; register: ( @@ -45,7 +45,6 @@ export const useAuthStore = create()( set({ rememberMe: bool }); }, initEncryptionKey: async (password: string, salt: string) => { - // Use user-specific salt instead of hardcoded const key = await deriveKey(password, salt); set({ encryptionKey: key }); }, @@ -76,7 +75,6 @@ export const useAuthStore = create()( const data = await response.json(); - // Store the master key directly (not derived from password) set({ user: data.user, isAuthenticated: true, @@ -99,11 +97,9 @@ export const useAuthStore = create()( const { user } = await response.json(); - // Derive KEK and unwrap master key const kek = await deriveKey(password, user.salt); const masterKey = await unwrapMasterKey(user.wrapped_master_key, kek); - // Store master key in memory set({ encryptionKey: masterKey, user, isAuthenticated: true }); }, @@ -115,7 +111,7 @@ export const useAuthStore = create()( set({ user: null, - encryptionKey: null, // Wipe from memory + encryptionKey: null, isAuthenticated: false, }); }, diff --git a/frontend/src/stores/notesStore.ts b/frontend/src/stores/notesStore.ts index 038971c..37dfd4e 100644 --- a/frontend/src/stores/notesStore.ts +++ b/frontend/src/stores/notesStore.ts @@ -36,7 +36,7 @@ const updateFolder = ( id: number, folder: FolderTreeNode, newFolder: FolderUpdate, -) => { +): FolderTreeNode => { if (folder.id === id) { return { ...folder, ...newFolder }; } @@ -78,7 +78,7 @@ export const useNoteStore = create()( (set, get) => ({ loadFolderTree: async () => { const data = await folderApi.tree(); - console.log("getting tree"); + console.log(data); set({ folderTree: data }); }, folderTree: null, diff --git a/frontend/src/stores/tagStore.ts b/frontend/src/stores/tagStore.ts new file mode 100644 index 0000000..ef7fc77 --- /dev/null +++ b/frontend/src/stores/tagStore.ts @@ -0,0 +1,36 @@ +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()( + persist( + (set, get) => ({ + tagTree: null, + + getTagTree: async () => { + const tags = await tagsApi.list(); + set({ tagTree: tags }); + }, + }), + { + name: "tags-storage", + partialize: (state) => ({ + tagTree: state.tagTree, + }), + }, + ), +); diff --git a/frontend/src/stores/uiStore.ts b/frontend/src/stores/uiStore.ts index 399c4cc..9f36006 100644 --- a/frontend/src/stores/uiStore.ts +++ b/frontend/src/stores/uiStore.ts @@ -19,7 +19,7 @@ export const useUIStore = create()( setUpdating: (update) => { set({ updating: update }); }, - showModal: false, + showModal: true, setShowModal: (show) => { set({ showModal: show }); },