From 312093ae2e90dc2782e268c7039db5e94dd9e6fa Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Fri, 25 Jul 2025 14:15:23 +0200 Subject: [PATCH] Story Collections Feature --- ...5-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg | Bin 0 -> 37911 bytes .../controller/AuthorController.java | 34 +- .../controller/CollectionController.java | 421 +++++++++++++++++ .../storycove/controller/StoryController.java | 113 ++++- .../com/storycove/dto/AuthorSummaryDto.java | 106 +++++ .../java/com/storycove/dto/CollectionDto.java | 141 ++++++ .../com/storycove/dto/CollectionStoryDto.java | 46 ++ .../com/storycove/dto/StorySummaryDto.java | 172 +++++++ .../java/com/storycove/entity/Author.java | 2 + .../java/com/storycove/entity/Collection.java | 233 ++++++++++ .../com/storycove/entity/CollectionStory.java | 114 +++++ .../storycove/entity/CollectionStoryId.java | 61 +++ .../java/com/storycove/entity/Series.java | 2 + .../main/java/com/storycove/entity/Story.java | 5 + .../main/java/com/storycove/entity/Tag.java | 2 + .../repository/CollectionRepository.java | 48 ++ .../repository/CollectionStoryRepository.java | 93 ++++ .../service/CollectionSearchResult.java | 56 +++ .../storycove/service/CollectionService.java | 423 ++++++++++++++++++ .../storycove/service/TypesenseService.java | 294 +++++++++++- .../src/app/collections/[id]/edit/page.tsx | 142 ++++++ frontend/src/app/collections/[id]/page.tsx | 85 ++++ .../collections/[id]/read/[storyId]/page.tsx | 82 ++++ frontend/src/app/collections/new/page.tsx | 84 ++++ frontend/src/app/collections/page.tsx | 286 ++++++++++++ frontend/src/app/library/page.tsx | 22 +- frontend/src/app/stories/[id]/detail/page.tsx | 58 ++- .../collections/AddToCollectionModal.tsx | 201 +++++++++ .../components/collections/CollectionCard.tsx | 203 +++++++++ .../collections/CollectionDetailView.tsx | 360 +++++++++++++++ .../components/collections/CollectionForm.tsx | 415 +++++++++++++++++ .../components/collections/CollectionGrid.tsx | 42 ++ .../collections/CollectionReadingView.tsx | 218 +++++++++ .../collections/StoryReorderList.tsx | 264 +++++++++++ frontend/src/components/layout/Header.tsx | 13 + frontend/src/components/stories/StoryCard.tsx | 16 +- .../components/stories/StoryMultiSelect.tsx | 131 ++++++ .../stories/StorySelectionToolbar.tsx | 251 +++++++++++ frontend/src/lib/api.ts | 142 +++++- frontend/src/types/api.ts | 60 +++ frontend/tsconfig.tsbuildinfo | 2 +- pinch-and-twist.epub | Bin 0 -> 1291969 bytes 42 files changed, 5398 insertions(+), 45 deletions(-) create mode 100644 0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg create mode 100644 backend/src/main/java/com/storycove/controller/CollectionController.java create mode 100644 backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java create mode 100644 backend/src/main/java/com/storycove/dto/CollectionDto.java create mode 100644 backend/src/main/java/com/storycove/dto/CollectionStoryDto.java create mode 100644 backend/src/main/java/com/storycove/dto/StorySummaryDto.java create mode 100644 backend/src/main/java/com/storycove/entity/Collection.java create mode 100644 backend/src/main/java/com/storycove/entity/CollectionStory.java create mode 100644 backend/src/main/java/com/storycove/entity/CollectionStoryId.java create mode 100644 backend/src/main/java/com/storycove/repository/CollectionRepository.java create mode 100644 backend/src/main/java/com/storycove/repository/CollectionStoryRepository.java create mode 100644 backend/src/main/java/com/storycove/service/CollectionSearchResult.java create mode 100644 backend/src/main/java/com/storycove/service/CollectionService.java create mode 100644 frontend/src/app/collections/[id]/edit/page.tsx create mode 100644 frontend/src/app/collections/[id]/page.tsx create mode 100644 frontend/src/app/collections/[id]/read/[storyId]/page.tsx create mode 100644 frontend/src/app/collections/new/page.tsx create mode 100644 frontend/src/app/collections/page.tsx create mode 100644 frontend/src/components/collections/AddToCollectionModal.tsx create mode 100644 frontend/src/components/collections/CollectionCard.tsx create mode 100644 frontend/src/components/collections/CollectionDetailView.tsx create mode 100644 frontend/src/components/collections/CollectionForm.tsx create mode 100644 frontend/src/components/collections/CollectionGrid.tsx create mode 100644 frontend/src/components/collections/CollectionReadingView.tsx create mode 100644 frontend/src/components/collections/StoryReorderList.tsx create mode 100644 frontend/src/components/stories/StoryMultiSelect.tsx create mode 100644 frontend/src/components/stories/StorySelectionToolbar.tsx create mode 100644 pinch-and-twist.epub diff --git a/0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg b/0271785-1d039172-cbf9-498c-bd54-2fff2c0c2c75.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7e395d84a416fe975ec060a1ad826b9ae7107562 GIT binary patch literal 37911 zcmbsQ2RxhM8$Sw1QKN`Wt+Yj{Sz4o3=rF2isXbCft)i$=4NTcDGpr@H_;#YIQYMR(Fk_Ynl5W2F1f z0s0>Y-6>#vrfs2v*yprB&o_o$r7yNmZU!;Cu z7QbHE#%Df6khtRUqJAzkmN-v=}R(KRaDi~uW8)4c}w5G(CD_seM>8A8(TXU zSGPy*9-dx-PlJL(LZ5}bd=(uN8yBCDmi{IqGwbcU?2n&57ZetKDgIhjT~k|E|LuE2 zdq-zich8UBpTi@gW8)K(Q&=2+ap}+U%HP#B;?C~g{=p&X=$H;f|6g-}=l>e?f3SxO zu;&y513d%NKYQp-g#bVFTnvool}>Z(-D7h0=eeN#f|>Vv>W9iU7V#_Q1ips>L#+G~ zS8#2Ladl&5+{@}qZE;rNr?VoT2qp^CEIZ4I~ z3R$1v`lRQ+PthD})}PEU!wfNoKFd$tQw)u{uV2<@42%q3e3<|u@#*Z_Ad9~aQP-*= z7P|c0rBZg~8O|DmjB&Jm+r%;}x?&hT5-DWwLZcNyavE#n%-LjOGvGQRcd zbY!%SDZMoaoH!nf^J)tB>by$#P_dC@`D)b?PsT*sn7*NVs8jn$CwA5H)4G+*-w5Du zXrq-lxz_1FziQ~5H7w)S8lIML4Klv@>(w5PEzf1QF>Cxc9s{aZgJs+as0;+WVa1dG zb@kAz{V6YqB7Dg)GW@ z#bnHewI5YB3Zpr)ZT&Zv!evY|73iKnkgca>wCRdLPe6>&yC#K;}9O>d$Hx8Vus70P1!1Z1Y@V2}8U_>d2-($PEtwP~wB zU&5*nvWTWVUEX+M&+|3kMz8L=EUDB4e#K=R))C6riq1U=+_ZsK#%{o%n_e{Ox1<8C zD=r@d#UK5TvFn~`*1gNQwDJv)F{8qVO*l2Q+`Crbo=Y zclY&APa3}vRA!d4qZu&{;1w4VrqNw1D#D1T3n znC8S5{MrXEuA>80erA+sHphsOcvNL^w=UL5Wa&|}EMASmjqa$Y@qZts@rVB2I=mNs z_G8VMTTH0JTSTAIRj)?Dy9XV$G@3fA2iJ}w;z`_ z1WN{4SGnt2Kl^U%pmvAFC}*kF!q{PF6m^|K1X) zYpl!okbU;Fkea#W8{}O*-mk$QbY7}dlXcgW?PwDxAc4KE6A;phsPOS`Zfetkl-RrF zO2nV^aB2E=SOTOXW1s^$^}7dODyCp!XyoSh+`ph1nNg{rRT-WBZ07zCjTf)MrWwCz zy^yNto=v?kq!=YUFJUt?S>&QoMBhQY#53bfx)5m)!{hhI{;&rTShn615X1M}jD%F& zCe!ODh;giS8n8BaR?-$bpj_FuLi%9W{L0>LU-f)&8cmWUO;IBy;t{MlnEDgY!!;fo zYDwSJ^3h}KV8B_T%HDJu_hAtC42;hSD3RGM07ZDccEi&JxnGu$q46J3czX;futJvE zB0n78Wmp5Ok7`*WWKnpmU|b||qj6LZ%B=}gk+8e*(s-t_x?AMp_EnywMRkfS?#II` z8S(md*Ezuf{;)ZpkAX{GFT$FLBB9U5I!)gO{HgO=R!~M%>2nuQ6%j`XNVpXVtHR zeylWy{{bd*^#qh2fuePUz>XO#iO`K+7k|P!jTMETKW!{dO%Ce6y*w`U@Va=9Ts-nl zj|}a4j|?tm)I$f2=t#`iJx*NXOf-y*Gc0@3E;r*Mw? zg$9wZ=o5l~r*pq7u~Dlc{Ew_9Fricr`c>G}`hgxS#px7+8=7FRKmANHzs{aZZH6_( zcdj|)X^+>Qi9L;_|2cbNUDjf#uR`ljgOH-PL&y(q!Q$&L9k(~6Z_|DYr7}dR2)8qz zfXY2#xe$UeDh|RKgvRz5)gc7e>ce;p{KX22=I8sbhA=;PQ^*%DoH1sv#f1#i6-6ad zL`y>HJL?-xfA@O4ucZ?F6EhHl10j)CexZk<1Tf z_WbsW2Mx6a*#c+Stl3^NiM{z31YT`gjuhCKj?w{u9=&QgQfuS%hVJMiDhzWIx{v_d zdD5bK0;+Vx?3J@pw#}MhA0huRQynIZm@J<^wq^u9W1{dL(#(o_5GDYm8|I@26-l!0 zly?eQCm`oswG+@SW8mVp>k*8Uo1{eY)6gn2_E1QtP=75Y8*X)&nf-Xcck{rIm$s+S zO2@!$dYU85dJ;jfUjOkFHMxCr#MkX+Gs>wlLA^fU&-u4?+PXb!()7e?Q(yyo&@ztE zz+x~z0lil18aO^0f$p72pnMs@EMlPr5hcJD|&bKQvVI3Gfwyw$=PJxM+>U9nNz)&cHp(40< z0hR!*fxaqWgc*$b_^_gE3Y(B8AUAY{St0_*NkDDvY?056DazHnyS8xDVA7^{0zz=f z{s1o7W=6ELlf!AF{9ZZdMn4S)g;owV?WY+)52u?!5qIFhzzVM>Ri1!cQ~^T@%&jaC zC5R6+%M#)=&2#|YLrub>X&FYgCbTkI?9pAOb%RYH{7CoskxzR-k2SDOpJ>dVg(i#33%mbBpr1`>S%sT(Zf73itt4RL>NA6=XF^y0<0$|yN*@HU-@ z%gcTc97n!&0_xp>+X9Or-Qol^)PEle0H#b)m!IG&j1%F7iF1-1+!9(h7^^KI_g%xP zCiU|C=(6_)>FbGbWdkrKm$Rx1DW0#(qI{e~kOgWWRAEmi1HgMmC!kaq9!h;$MGN8E zg4LS6s>O%^u2ZqTN!Ut#UcN5i1R10xvg@4AeiqzZqB?rls6EP5litZ2_;3`-c2O}# zM~t|qqev*P#KrqM4Yp?2zRB!%EDWE%@#4KNwK{Ody`5&KvvB~G+vCw1oaOxtG zCmcm%H6ipF%+sn{vr&Dh?HO6bCI*<8OqAeW8*X44gZ~pJ%vz#k~pDkj$~(wLmZ5O=-a0|9Gqg2pm+p^L0N(^z3CG&Usho^IzBOod`W zm?nwS&L|Sd_fwz>5FT5D07F5xh#u|6h-)uQod)6HdDEt7!L*cfmiQ^}rV|(p@^+V)%B=i=>8Fr*fHKVnwp^oKccim|$ionP3 zfvJJ%C!m}Cu+Q@qXi8255L%pt2dMlRN7aC10rW`!5=;9IM9zfZgesq1j5vEd$gnzGD&wy_hc%ymGg_`<6=&%7LULJ&YZU`|{yrG}Ya1JQP(wW2^u(8# zn01Y`vFFCQj_ieNv)W-#cjM_KszuluxlNgLm7G!m>Z`kQuuR-0*FlbNFv14}+ZGc* znZr;S3DX7ZwW5DqS$AFly1sJf1T?51yVI<*AMpk8{}RopY1B%%Ih=_s3uh+khQGW? zgvR(yHS=BYna@sx3LR=owST%zt*D*nyovcXp~d6LqyBf;e$E*165I^(2d0c@GdjPE zSa{#U?{~C}6+Zzji6HEbM-D~(F7x#uG+m96Zk$N_i?DbcC5ST%Ex8>@PrS1@lq~gZw1kVC!pzh z+P#$d1GYiLUQq2b0}7Wg?bbj%YI_o)nGGVDbzxYtaIj7Ur$DHVb*b6> zK@UqU&WVWlzrKXp>21lVCL65Pr$?GZpEcH8boxcsDz9Hoh z>n*6c2xmtr{Apwn)+;+E9Y=-q)wIZ6KwP%(4DmTf5Xl4Ib3jmdrVplx(9O6xAR4}k z?P#!i)xcbRSyZV&$1iuqceWX6CjE3@C7tYv_$j{vz|nIcN(|LyEFlOtQAd0Yp=ilo6}i%Wtwg@cDn48}`) z2C35Ya~Nmp6|%(vig0~%=3FLzf&94sHFAP)Yn}&g@dsc$Is#a~BXL4armK5nNBBjk zCHe}m8v<5i$Oo^Z;I6iwYJu(}n!rG&))0xWy<=%-_;A`z@{oI3M%CX&Hj;PjemuB) zVB?1pe!})yEZl?sCt~4CHsU~PbMwR8CRT-A$}A@YJs<}qpDUNq$yf(6FWYb^S@R&8 zWL>uCkz=%D9Cl>=c%FRU`gKtlhCm=9l!wHB~!&B>7u%?LGQAC?7 zW@)uW3;>sAKL9AIClvm}0Qc+<_y9ln0ik3={22gH!x&Y>0>J2Zf&dVixCY(3Gj?o3 zW(DF){Tkwcs|j$YCoBg_FhU$@#39z-qmD0nfwnOVRj8f76A(M_HFRKFX8(Om64h&) z!f!z{Mc^>hNC2sZr@m$pQDq?0e_;0YKez^}_y=bH5^=@bAj;7Hf|Esr0BLwVbm7hX zSqcxqV9_sOA0`@_GSXq^Gari`Ka(@tCI+jyK1~POt+m-*)fGWe_~>b7IX#%INyI7} z->yaw`Z2YUb}2M)QE}6I(Tp$DluS0$ZAin)dbcJes%7}_-uWp=Umu{#B9h|WIZ_6) z1%~nUKh*I5m`I*;i@F1`z>PlCP|M>ZhN!H>&$**iIQt0=!!?J z_cVjxL1Ys|?=;*F*aIjF!1%0kCCnIfa6*sR*NJschI%T-9UmkH*JJgI&}#z0S%%EE zAD_*pww@E=)LX7#zNku{rk4+%K#-*0>-bT4+;96Gt#G`W|Lk%+0l0~wnD~MS+koNJ z#mtm_gw}TNL_DK=ZR7B)1lF!z{s4{6)IaGTpc&AwB5-HY{7_xbZDC{t-kVd{eTDy6BYk2MQ z^?sfiP4T42CwfdMJFnyprVjvfwZ-@$j`Xkn?~wQtx)3+NqW}jYL`28Y%*01H2laCV z8*O;LS{v1lObn}yiGRlaO2~1uyf|2H`0tJS-aM9GR^cW6?f^kC=Jg>GwEF~9HBaGL z#BF_tL^ruOf0>y}eR2?d+!U7bA#4qtCCL0FK}}6US~y-J8$|g={RklbfsP!o9eRsT zK!2CNLjFNzPyeF*eEbRMEX4><{yV0yUozc%`>9z&vRrf>(z%gulJ4yfQ2@ODe{w(Q z=1cNjit;#SA#X5L>JHJQt^l9dtY}avvH3kqdZ1qU9P% ze1Vp{sOfjsyW%gsh3dAq_HcdH`Q!qQ#z=K&llgxudRcb|q_&Qc{DJ&)R%X(OHf5D9ItL%%Z2^Bw>7y|9%ha=_w3!ySK|3%bD9U#I) z!8}_KT_}DSSE|p)xEFhh8W3uscM0*3!gm@N6r#3~A_6;QJL|cM7j|T?%^ZE_dde!< z&P=gST*g()cg63X1B}Achet>0@XnByce~~;R1X$3rVPtkHq5@Wyi;(nAodEq0^{_W z=YBeWmNuucjeNSC7Ji;`&;P4^<3-U0BcmHNdy@B`c+hV^NeUdPRghO2!^rGwDzXm| zXF?!9CVs01JR2t(YG|CMbbJ4WkYr;4U&q1(A~;AK&@-VQ2du_6_hY`P-YY3Brq{*2 zdnw&5sL!mmG|@lD`;82Xsd-nOdyoo%+NnsW3C39+K(HwoERlRTv3}Gc3I_-bgD&yL zbZ7@28GDwHWuH^49namnD1(=J(pHaHK~;imo8*heK$jQk3n+Jc@3kKYnq z4*g{m^_8wy(~y3=1whfL2XKp#79L7O<9MxYa(SY^-+|`$y7WH=7dK0Fds`p8TCDS+ zA2|X2FCn^So+N|nQe=f&Pz#&RhTyaVrXA`khsrj6-D+p!3dr}-6-yk!4PwxyOMUBr9YUbX>qu-UM{L4VKKEJf`1t+9;AUYyQ8)eJgID$PE;5>G3aa z<{bU@S_}I6_Zh|8y(3R}jAH+#EGGK%ymv@{ql|3Vpn zm8z|yLmK;}6w2WvbnWyF@2q>abxIR`k9MI%?7k0_#==Kz*<#Gtrbzl4_8KX!09oq~ zUe=XPt1wrq>Z33A{(?)yelIDz;}cLP5ZizpOGEUNnY$lu_SqZPHs`NCuc=z2ddCwl zD7UG3I+B#Lh6O})RITGF>|NGb6VA|U>Y$&|uSJM8ufxBbU9rx7YWL>hC1XbRZ$9eR zx*SF}#j9R8$8jc(Xyw&R{--n!vK-kH_7&}Y-I2Xk(DbC2jHZ(Lefe1DmVmu@lJAnNL- zlufwl^l#gokRQKU*=L_9p~f3tt=^s1-)*?XeKqYnYg~fu+q_7+QL^#lvMqDg9qKDK+iMqOL!6s3c9+Im&D96kZDtA^azLx0jyqP5O*(l|oR*E^Gg zibAt)SD!YnE)?LN?p7jq8{EqfJxGsK4()|pGx$NdX%ON>`4fXygZQZFMN}1 z$}ujePFWXjgK*TKPK6iLY-=xl$BJ%=D7F3uwbD0-!^&x`Q2sspN11a2x5tB%-fs9s zB3ccLQNNq6bm|C>QAEG?7kD?gji-O^ z@DGvy>m4gEy$%L-d0FsHCZ=M(-NChp3^E;Im&Te(@LoV}3=vlJhxev8y*umdw3`bi zV)y#|stZbq`L=dO4h$XN9{psJ3N~CCT9$e~xzz%cM%ym5a8tBW&I||*JR=HNJ6w^R zQmvDj8$92KXq|nrSH69hT6F>f(t^nP;5l}JYo<_P_0@F%f#yM;@tS=~npWCF(TV=P z*Wk+-b&Q81NhY>yJDID$Ff`yk$s2dX!De^$*TgVK=<_^5uq(@UHjsBl=~JW|h-RC7 z(B#6}1V>2wc!2Ns?+(jb3xar7M2kTG;{*E9gxj0(Q;_N*uRg#0WV8FpPlJpRHM5eP zUvE{M`QGtOGr}oAuZQWq^`d*{Hc4i?+r+w?b{^R#^gG=JXSltByq)dHubOB4V5QG} z?w1;0Ptl2a19dXxQ z|AKD9Wl$?`W@$5WSaA}0&_LQH>(ho?gb6eZ{<-EXEB4a%K40SP818e)%-wf6Y-a_e z)Mq!H4uQ%;DA70iGws)1kC5Zfnb~tCrS?VzQ|&Bj`U0nYCjTZN-=px8niIK*Henr} z@Wc)$RZ?mQ3%3_fVd8@g=$|(>X%Dp%Yg}kk*+(ZJ8fzQTF!Tg;@TH#6Rnzhawa~95 z|4#2GwH$7I>3Bm)aqQBh!H;`EB}zf%FL5Cq5N?Vyu>cvZC0?jgR_=7#qiie#Wa;?p z*Dqa5P>|GM2;T>y?80HP4x4xCs+cNab0rpCV5ZF1aIea3QBu^%n*Irsr@EGy=Zn4` zEAcg%rl5y9A0t(@l!CO8aeIk5R&EV3J1owwYoe~^-wuE99p@6kcw{Rw(vAazABYQk ztRpqO>&Ay`1ihQNl>HDiEs)tF6@2H=w7HX^IN!-TKke5$D`Z>0B zX$!|UgpaDyH@JivP}q|0A=2DB?t6TiCFZ3S(RD}Mm*a3^=gokYa;dXs_ z!W)P`0d1ZkdUuGOCIv3^3%52nm4u6aZx~gBId(nP@sway?QV3jQkO9h^_~W@<#|yB z8aQHL9Pr`XC!H)jT41ga<*D+%@EWfEI$A5CHA;Q*oe1rqCE_1xBI(QxbPJ)A6&~<- zNm?5ZyZA@b3R@K--^xZgmQI#_WY>+yE|`r3s=|Sd5W#~Y8gfP z)2=#wn(P{amE_XlBt~*Xl+CRTlMHYQ>Cj`R1_FATqWA3VgNKF^5Y0?-;^BEL3)|7o zko=wjf~*=!uS>ENX;@D7Eb_u;ymAUZ0VUNg@5t5ciEF+_?aS!>Ou9PECi*2wtSUkU zu=l~SIsH)Q;@HhJ$?YVSKJdjhr8(@mDd!2sW00~)n)e@YGD>tGjlK9hTw$)0 zq=_@lIUg?Do>{K}PXwM=n$=*(ARCylga=Gy}@dw+}nes}r zTKsy%5O7w*=Nd`sAe@@1zvwq=DGue;?l2=s^|S+- zL1sI-GcP1b(b+;G-3$50?&39x>qUZ)&CG(FgC!Ez0-Dovj*-An0<3Ayx!-kl*#a*f z^WXPmY5n!-@i~rydIFtPr1yeZGPLerqJb5a8Yvq6Nb>&O+4HNVsvSa?+$9cMnbu)xD>POCBDtr`zxZ#2)X-CQ ze*1T~64YWBpZz+e&sDwsNI2oedl7e)RSe-i207s*PtEkODDswsdpp1koAfa()9pa1Z?j3gMZtuqZ6mPRLvTqViQ*BUc%TJQZ z{-m7|3wbd!G0;0W+~ek3o%4d>`a)!(&&y|G@75E7xx!^S5#uEVHpt_Vv(7r3Hl|S} zMYAO=fZF5D=0D2sRq=n_xGU9{X$W7d6a|?w-!+LCr~Q~?A40ezqOf8wN(Cx>1PmdZ zO`hSexg;_irtNxXGHmKM2Tso-Ft99GWqo^Gi!yC!j(t#(Qx(ZRaFAJO126IR)tX@6 z=(2<^1RbhTcop%r+a23eb@Fr>r4(CYpmmnzsLSKhx?@*KhRYRNQR4PCKUPzvw>;I{ zs0bLLb8BD#ngrzwQcOYcQ!E!ZZBd(cv+MmLrc*oTAaeJ>uDU>ji>~~k;8OOj6bVna zVAi;ol5Yv1!;4?9f4KZ9^0e#>#sx|T?KH#Mw;m&2`^uMY>zDWo(j@Ase_H}HNb zTE>g=mWf_E=SJN}B?k!G0oRcaIG8GcpaJYUAyR7|J2i&_bAH&zx1AVdNKq+zdpF6p z6M@(b8Aw|oJ1CY#I8$T^*F)#Ku))Zy%LBKQoz~5p>1o=JKirV)#1I6BD%YkB!XAVJ zy$l8_!F7&@nDsK~;xnHhQQu`z{Z0IXDh1TCmR07FHp5QoNPTIr|BJ(}5D39G(GY_> zN4i#g^Tt)8SLUy^fU^culMy#jzbl$}79SC;>KB@?8{e-Q)AC2(D4d#9!v7YHKO2ph zikjXSU)bl^QM3CE1c_9yRlH)x7VZ~lpS-lu)Cn%CqWR16cB zdZpe~T)|nIxm^y0K zh++DXLx$Z1A_BY$^t&*gy_<6WJ|Ab+jhwxP-7+DbJppa% zLIP>Opmg|A8fc32@-F7(O~N=jFz9P&mW{itTv&J_?xDHP-q*-Qm3C9o^dgq4xw-_{#sa5hRBz zd}OcKO5%~VOu>Zu!)uV+;c`0l;uS(I#U)-&7l^9Gf#sn^Hd&iODIbE)hlJ3!!kO-H zsDmDWWSx}Sjx9sK{_7yT?NcRc{i^JYF%pfsApHl}l;{y8!yE~5;74){^81p7G$wg| zO!kAlvtei&D|b<-TTF4}O+rj~$l~XxnA$Ffb}Tw&1tHED11sy2N?&Wqw~R>NTlC+R zA1}7BhX8GWyD&kRCyc!bOh~ihBqerZU*#zhAAZpMgp62jY#6GzI~Ljg*u6hpLuO$+gZJte`5OsQypI#tpm=WIbO2 zX&eg{cnxGdG7bL{ph)Odi|v1!x$w0L)NrH@K&7L+;p#aS1T=F0M@{iB5Jb|uvixbB(ADOtm5v}-E%+QAWOTQo+nvP z0CnB%sg|>}-WCY4bPzqY=Lqf0yPRev0j}vvv@vM8`$Kl8-FQ9Od7=r0pa!F405rs5SCEF(;l4R^F)3<=|E8?P&?{Ko%n4CNa5@F6( zK&Q=;>SM4714+%H$LC2>E3YtB2v;P>DYS4Xy3>5-Nee-G)}1~i)7pF;Y_kpTqozM2KB09(>-wtxpPCV$ z>Au~FL{1YF(*u*u+D3j-r`eGFd!SV_6Yjn zYFU> z1*^*HkT=CTKX<{+3&2*nX1a4MKV+0^74|&-eEB_OJAp)RT4Qf~Kk_L(!*Skn%U(r; zUka=Ok?{2SHU1T=;Z6L6&Tk#k_e!PG1&*aQ5nrXz+2=waQi@JSN&esZ7K!m1s<_Fk znKg~*&fv^Jv~|X$47fAZ_VVSYY(+BqdI#vw2sc<{#1-Oxc@ch|!?Scir0=6=Skk-% z|3nBxB1;kpyCC%twDK#IIJiLn7t9q}WJvNRBzB8ktjeS-3^lleY(M7dW!Y7bsGVmu zeMj$0z5cwIgX4B<6PtA`u;z4X7?3^I*|nqI;D)s(KvsuZoLW1;)nRx9AEwS8BSe0)zX3?WVOyuKllBBGqIY+xpzH}p7;Oc z6j?X1b10+E&5cNb<}{v1$5HffK4$G|nZXTZm?>X{QlEXn*YDFAi18O(Rx{4wtH)JR zpZsM0p>?30EZ?<^yh~~%Ym;^t^gsK+9Y6GWKi;fjH=RGM3xRC?R39MA#~hTi>SSy~ znNC1HU@_8#HZ3K*^dbqh(Dz#Y&!Cnr+b*v<-TAjMkB?YS%(bq_m`6w)oBEDR?Ah&EvPWY6Bak8!ToLz z2Y>1#iPZD-d&L<3HhJFMd&7)*tpmrjkq9U{lkP>Q>k{N?pL~=w{lgLdRTs3|kPw0l z=}eoD^RSj|;dC8*K-%cMwsHNhDT;}ixmC|i04#$XHLL@12k&ouA|t*%CPFGoJoi^i>fHWDoE@$G<6cvDkx~75JhNfeT2B(0!~5Zi-r!T| zwy~z`IXyNTi$E#4ks-56EdDvC zI8jEA${srYZo#|R$t_{-;_JN692sRL6d9k>qb3o{;z0#@nEvG`<$|3Tj~ zig%-5{nJic|C3SQWqkEdJN>Wh7Iv3uIROYS8IFKNJS7rp+QQB2TeFS35_7u8bT^}S`=?IN%kq_;`WfV4g;t-s3p8R2(Z?Yqb zJkOkt*<#Yyw-tY2nKJ%B_^iWwE{D!D6*Bv+r?TwhJxc9?-YptIFh<6;2$RwY%8GQ& zKVrLRT%L29YOjkkSsp3(Jo)s^>Orh1^U5|W<&{~pBlxA3JDQ?Hsg~_ZG^$9Iwe0cD01g9At%8@8Pv{SZ;XQ^NfpG{CAv~+7}6~?mrS?Tnk))Dqp}3L}exg z0i?a3Fwq_EYsOE~#G8DgNMXK{^-+8UjXEEA+57B`FsIa#D5%R)Anxz@=GcP@>K8aY znHzp(BtnRy8Z~l{xY=}?UG`3V=KW#i&%%vK{>!4jTGf7S7^#aQWUg+y+#k+}$t_v4 zb6TF6pE^vl-_lkNzC0jSdniER@jif(!ilgOM5#_QK|5po^M19v-i9^a@rV#{=3z5s zb3+B2mQ$5ycG3bT=@$n8yk~22C!StDSie0~>&LqmVxiJcrEqF}D0pa_Z4^eIW@@uz zgdQ50f*w4Y*g&`g_2A5~%~3J%M6DxtxR7kg*iYLo#rN&Fx17wKzb=D?!*-pT%SeHX zSxJ7lp(YtEKu6G)`H9PM5{vL&yWEqsrT3?xR093sq59H9FSM>_QyAyRK)Ey;K@Zm= z27dy^J$LRMtjkG$6y3j_nb4N4bnb|rJO6PK!>u3WUmo;HO@MaB9UUXY967A z`%fKN7<{ld&|wzYse(pKZabjjv;+tOcdcM76p!b&dNL(=`Ag&1zuPe+(BDE|^LfijbOIDGMaz;+MK%~V+r$f`2$HZ?X z^FEMa18GMKZ(S|5M;4b{8l?Vfa{o8-bdx@PL9}}UJD`5lw+27#U~5qD_c)>Oije)9 z?(64^u3zT$(P=J@B`*g3tz3w(xH2t1JmidL!~WL0kL!cN;n#<`g)Y5WOiN-OpkEgB z)fiLPOW_pUI>%OzZdNFZ_Xvi9DJmR|#m0f*JFBY9QVrLDve8yIxCAmNG%}%33y#w$ zHoV{SsL1OA8|YIcXhX>wy$R7H25>vE|(4`1>v#DIPmJ+or zoHJR~jXuSUPbslP_Nl?@GaddEShP*)jp{6GlK7&JecPpc8~)Vb@1k7+Vc=+2qF6Iu zleFV3u@xOe{rn-(aKcya;rZLDGjvXgWHyR$Y?sp|N4o>ggG~2+&6pcpgAqYFMdaTW z8{T;F=ua~Jf0245f3lJ%jEyGfK&tLkI+uHX*X2czc*+$R3I?Hs(d3C1HnyokTG3CM zd97CmHp#^ET9SnRr*B6k?HoYBa41#8QKWqHbCM$N) z)~-%9t;Bj$vta5mG9U3!Q3M^;qWBjZXx~1rHjGkPa;Pp|pwFA!+;%+N#1NV&%%mP~ z{5D&s{9eCqVn(t-Zw&dbL6MU-JA8%EXH9D-RetzUhLkaErCA>HkZmV{J3xuT0_kmau8As>c z6KuK9K>F!DwQ&M+Meun$DG*waxXr+hJc$vwna{Jp&!mKI;)EG$ zBlT()m!@F?(eTAH>wC#R!$guGaS-~gCYFUVNY}WNG~qA_uKC5Ow@~FUb|v#xUDHkO ztK2N&H*%A|DMyuFd8sQF4w(b`GH3_Fc5AMH6*aqdKE7T|ZMam$1ZtVkdo@1KA>;Bd zngtyTl@fE#g|5zL_FHjliN?f6EnX44h82s#9>$`Z+{#trN17B}f+XszZv_d4{_&fQ zTeM_ao?D3g9BIuE`HVMcJX8uFl0*~M9w9-y8U~`>8bGGUw-)u57Yi5EAG_|0t~UUR z;Yaq(YHecDMAOto3=^r|lrYkE`b)Ot(y$TRh-t+~5K-FyJ93-%jix=5INt~Sz|;*# z!ebrfdQ?{g-zfYRPS^!e5*lLGlxhFNht6c=lnd8;CaX)0p$yziw?Vrweu^Q`4Da+1 z0`4#2g4^O{4FtXIsZQ3>EycN*0%y-KxAJ#qjXk&}wVzyrTFf=y=$t<#MX*8%984s^ zuM~Ns9#uDe8}xP0RmhK8aZWJlR#b41i`z)mtSXjbViNmK^g9S2+T8a$6?&%Cy}drW z=~%m^vUuI)Zj-6cxQC{}osd4hX@t|;-zHTR;hNi)q z^<-lQx8{ro!^13Tm&|RG?9aY{(>5xOY~0lu==*@1|IVY`Yp9uu3Vsw7kq9Wkvx<1z zrK7$-M5C}oVORKOob#@@49R@>tF+uFiT76ESla1%M~|Jgh;u}-mkT*JicLOYMZ@qn zy{CT|@)!v92gAN$5M(ZywF`Et?`%vrlawU!tt%Ah#w$2n*paO^FJn zp{fTDu~%rLWq>R>r6eLqIoKpOR0Cq$Y4M5DFP1@w)kERyyDc@1KiBKLXB;3|uO$Ur z%?S{`*T?f~KDQ6J%#^Dv*^*Sy{|K(m5xsO@FE5qp)el>i$8KE_BjGN;+J^&b+!ucm{y1#m4%uJzXXYm z;E~)tXXFP9Hl*>bQoVO&mT~(?vE-g-&oE^X{oYoZezU~^ znkc?L&s9EJw>^hiaYFE=rgy%!UyS2dKLJUer3T*AUdz%!D&*&XV&14B$CVX&D~e2+ zaxl9-rl}k|;--WyiETUvz=zrJN zPLj<3ztj#!eng7}zvLESxcE?$zd&;7K8oIR*WMwQdk$-;a`kzyF9WDo=4}g!x&@wh zuK&Y8sl$Y|j1lT%;pg4Ryrh7U4gnLj6s>1?;aqNmCPH&TXO^0idp* zjBE2P55?BPj8sj&M>6gvP^{|)Yvf_3RYWHwu1xVx%>H6JYSBC@ucw$0 zcyPZ{iI|QS2!U^LMti>FD5$Ry#{D-6KZ)pkS8k5e`zH1KP1JO#jZJbvYZu6wG=Go< zvG@=pHmy$KSh0}|7}=^!uqu~ zBniO4MMFVhbgc8lyeXHrCC`KDSY7@AT^EX&o+BOWAT`~ zJgS1`?0r`1@noGp>mVD9`kg3L&AJbSnBpae zwcH9ww5+tM>hjI$m@pU7wVsVbg3j(@QT)EKdSO%D@ScyViM@G_NyYPZjHK;d z1I7kUw~W~_%K#>cG?2>16@l|kj@=rs@Hdky|Qj~#L|6mIj zTx;y9Pe^xF=m4yiBSMG=pNn0L;;G*}M(jNEOcVt?|dK8a0<9gWdEK@tzR6LC6}<*AQdd*98`S|sPy z>{(jcxu&T0pMn?D@PE<_<`^E|ZpS=LQxly!I|m^d5q9wc@$&Lo!i!}tWSQ~dLyHM? z(~S>jT%28e1XIh!ZI%&gp_*1&CBEJM*`U7^d*We-5UOp{xLvhgN1AxSVRG7T)y_J> zXHyEDtH#I#e{X?o$^C)oZ8)FzLcdeLtKpXG zD_hB?;r1yRL5xQ&n+bEgf-;+F zTwfn^O;q=kkzk{`+>^_UPa;@I8Sc17NCJz$OQ0;N{?_*qA1h_dw>0~2h3x00wxW|? zbesA+1+v>D=I4VQsz{i^4z)J7Kt?21cFqh zh=737A}U?FbRh~NCDL1fKBGP;BT|jyXz4w|>1B86G~opBgWk@^MmU<_7WwgyO{{EDdeAhzZvElD zg-MiyX+P!bcdXE;r~-Yp;LYdsW)~GR`if8X0~unDpnZxw3(-+qYt=CPu1oDsqe80} zoout`b`Ur*YSZ8iRy}iZ(gwHY-nBRrmjzf;5D z@DG75BF<6MHB82@;%@)Uy!i}n7n^c_ypgZo;J0TWq%RjjZgknyfubh-Vh+?JDXjBo ze!<_y4X{5;RlitOK`3v^&~N_SHmI?!V~g8DZ|#=N_gFb`_e1r9W%wh6hBUnWnlymT zotGqDAh0aDYf=x{+9c%ixfh0QCm|d|@LqnvGW_)NU)aqL2V(y#$mNX`u8Yor=+1%3 zPW8G=^*@lQ9vYJNykH^%rAz40lgY-R#oZgW`|o^^8N&Y%C?6J04Lb<7ONJu`UK;NQ zQ@~jkTRkoTft0nhBY26Gk}7Wja5gmHKPLW%=rK?G@Wp?>5s*3d2u$c=F?x-)SQxmq zIbJgP76zESSh>o^bh^I`us72wQ~BJP4(fLwP8%$5_>hHmg0R8~V|{;4bI^Kb*G3FRaL7TxvwN6oac_Lr_-1TWio%d;-;(qmc-m zI`88e-Zu>7e)`LFOVMMW&qE20=b7NvLlsSd%fRj-u%5(bk>w^-b34NRJhST5Nx4{w zVv^HELysD$ZdsbFh7<{h73hUk=!XGLsw=XK4(tl@a^*(W56bHr&wu%xp8YS^CC=nu z%*%6nj=x-&IKzK2FTn9d#V|If7tS^`13K12-Sn!_zJw+M$%`gGWD0rH1?q69WDbuF z1gz&Hpgz85J6=2#qZ)s5<_k~@p7T4gOFp%)#~06t#rS?)=7~0mx7x7oIg-OMXps&_ zn9S1nnwIx~@D9Ry3v7w}Oa=2-(y4-{L6Ydk81dU*FWp<9TpR9`t-rWKlUJ!NE);qwwpX3L)_Kj*89YUx|*R z{`D_(eg`90rLiw<#ALdwR8s*p$&b+d|Rwq536o_!_1C=?pG z%)Rs;`oz!%CSIBmqbp~fZ-c}^$~pt!+8EpJYj%f^zE+Jq6FrZ@475|h+!oy{d+o(rjoiSKcx!meeY2A?_GJ3u|@bo0OozLglNmJ7lwyy`WEMJdFaD1arroV|kQRjZc)wnruBR6&2V(%OC z2fksQ`}rT>P3CBCe9v+}b-;t^AXwqjrOTz$B0j^@f~~G%M9=0=uJ=Meq3As901q{C z7hw3{#z4j<-+ZRDEV>G2eBy-m=zvRSW8--6qh$vB0`I(L-EU6p&(jV5=wo>F`J7HZ zo;xgd5Yu$q#On)`5s|pUZ@_ZUj$AdtogaCCnseCXZ=X{4+Ryw$;7OH@m$-}av6{O( zo+#wR`rU=)m*kWI`#T9QzDx{BC`t?MRT+CLn%ki2HH-ai-@w}l5$tfls8#hTcX$>V z?M8?7W`hqFzp*($R*!f4vTa9?|A|1@<8B@fD44$N=$F$o+n?@uOEpT&mV+0)5MPWqbfWFL-Dj$ zZi#f$8&ilA?;NX|abh>SCxUa~dpv8+oFL7}qoRVysk_S6p-wsKJ9hIcus?qvfV>xu zPP(Ssub2K$JP21BqpnkEDca3HV@!Z^?AYCK;6-3%ya{Iwuz})V#urR)E7dU#XGt7h zb;;kLD=KqbTsS?Tby9`d8JD2XDLI(9^q?HK2zm8^NKDe+GYrLTFr!ZReiC&luxhcXm{< z%ocl~R2M;nVs(y2I*mSCB9E>1*1bCKUykt{i~6JM7C%YYMc4_7-g93a-r4yx#GjU4 z_mo}i+AWHm7@HEi*6iL!w z0&RQ{w&I{W5;Hg+^aw&1_0_rI<7t~mruezb%s9izZmndP+XFypz9ovF?q1FmPoTLz zMmQ^xs%v-BSTO{j}4rfi%wJCQU%~7B&;L15-@OvUQ z4rj=I^reKxuCBk7Jmhu2!7|vBgP8)BU9L`CK>KUxcQ#q%O^z*cSW!?Tk2Nc^i-FmF z*_E^@1n;pi-||{iL8j}}sER!a;o3c8@5pDMo-CCFd2gd6`&oCL6c34cZJVnQuWj`5 z=eA~uv(akx0QloqU!DaI@eOOXtb5Rhy4rr3@B9saen;#4dehb$lmDsv3d<8`d`0Ma z-F#*F*5v@8Y`N{u0KsQ0U|VOTKwrJ#IsKJB?L4s%=>b6F28p+X`8EZEQ~B(uVIC+A zcM${;sZm)fe2h_v@?s0TpXG5@!&imXSrQ$DYs4Qm_L3`-8gy?2AZNRQ0uTQW?f#`%5`V+A*+Q-wa z;#58#J~^s-zwjQPlc~Z8wtK1W_GD_kEk5%kLrpZ5L%I-Hd7QWtV@>-2qmJ>+N+_-Y z<60zEm#gBu^EQFIDmMn6r#;+*7H;X$vF=TD8bhjVn@A7x^Se-zZ{FdDI%G}}aish? zc;!Ze%n+wL>@Uhuwa67Y(38$J;D1CBXXbv<@%@|kX|nHd?HjbIH7|RNIkj(ih1qAl z#KegUDeb_s-HfUg19Wf{dFE%bZ!P_|ihjy4!i{|4<<<7vbB$U}SPMjUX*cT*h3ppP z;Mgo!W7Jvr#`yB^SDv0DsrgwalcY0Hwl`S`dOoTk+Cu+Z6U%i&0OX~f0gLBO>{pCM zJbe!#g$rTNJQu?@UMl}4Tvh7unn(MeBv*~`vs3q!{vcvY4XQg^HZb6FYWrigoOb#1 zw%dV4AD990Zr83&mu9Zbx+)$ZzELoF-8auphX9CID!YIrqQBj&<4drGROzGh-HR;_>n0Y|Shk{Fwy z3Y0`vzw#zW1S~a(v>@(qp&oGzRUFfw|2nhTn~!>OqHul!4pu%NI1w9#lY;#pEn`?R z#kHDle$cRQ*j}5T8fcxOM=nh#4N#c!d0l*=xSiNCVlCCZmI*K9N$A^z{!)xLOy7{!? zZkJ{WjFen4ynu%4)vj#=`k~{e@T6%aF~tViy}#5m)&++Y3i<%ft=UU?v-{J6@V5j@iOz8LUef42%B0Ds;ua2cid^$yFfv zn_k_|#az{)u7CcwPNes5D7oV6q0AQY^qs38!BydD)A}rxzh}9sI~7I+=VY*jX7VFE z5=#=!xVu&=+ zPTO!iBgo^u0u`&0n_+tY4|I=g zyl>2sOop_=;Akoff*QuV-&bP58HG*v4k}I3!6dYC-a?pP=KN&UT@$J!8hyNb+~;D# zf-Onko`_VqqYS%`Z8^;|9y%`HeR?<&6SEhY_z%HyP^ojU(vxnp*t=}36^GzR7n_&* z<15vcnJ@n6`%s8LG}>4S(1foXZ*2Z%8`sTS+?C;rnwv~dt)Z(DJ(Ya7u-k7xPh4;3 z`Ew%a8T)sA3kt#x$9Wbw)X4$COac~SowfEq&se%~prw0uL|E5(wK1ijt-XH;T;^FW+dGId#nZ>a- z4H&40gkNV<)KoR$$;ft&qN&JNDP;kSbJ*gB}n;3qw7IqPR2msrq`=9DGR!> zn^IwNrVf#E_9a4L22+TpaJ_OYq9uWqeME#7ZN=Rz zuPV|Ok_H=+!NZa#G+{dfT$qHwH8dm)kj$AHbLkSB?LITXd%Ej~_hdzbfzO zMEh(#&slVlzWIdE`fr8jDV-rVu^GqHJfVk-2Lr`1*06Mfs+N zi&x8Rj`}BbQ02j&V|MV#gM*rGp5nsI_fcw*=< zh^GW^Y8y7nD-V2E!mBD|P&WDGl943OJrh?&drxb>y$UFgVSe|fd*X2W&8nK}SAxj% z1C^wpNsCY7zvt%s=Znu#;9u61xh6`E%a)&rWrt{;HIL}*+8KJOAAPrVqE-F=4Z)m> zl*JtyD4jw%Ymu=Zt5IPEy$pNltvUht3gau2$1e=Iw4`3$NXA3rt{%Gv(ukp|F?Bi> z9W*isr~Ep0N-PTI@z&swQD%G8n|LI4Z!BQrp;69kQ0LNP$F(0~P3)=Bi(YQe?XU@3 zLR-3RX_WC@7unIq{Mz&VX>X_c3#By@2Q(;nM{S$UoXoZr8fMP*9c0+&>;3HZ_@yJ- z{huDZIkr-pA1M%sh-ggS<$M;u+CpyMc9-Or=JYmdQ!`s??CY*}uh#(Iz2qf_khaCB z=>JyoiL>y#0xIWGjXD1*^w-%bACH)MYL@N;=u>}>xc?X{!FT1_X_ zi8fVf&nYUAk<=*g;E7DQhCh$kF7XB2g4!yNdu`x4PAvXI$R{@Q zno#*e7i!n`wvrbfr$zD@i%!xMr~c~E<$#&F_nEr)K0kNNiN#4{W)7Tuqhv-Kd3x()@)*l1Cm9fXt2v((&7$Em zD%%mKY5Yhl(iirVZSnO#K{ zG_k&Hx|iF|+lkV3JLZ=mat&G+QR$B==7aIfS~hU;AJ5xr^?z{H}+2K*{;2v(qPuNm4jGXK}Q9wK6$^A7kwCsCZ&SdxJ%h@=d z$sCuPNrW_Zx}dTAjY&D0mUSEpfZ8_Pkk7Es;@3tc>K3q%H1hmdDnWUoA0GNUrmHPs zJXm*l*5+g7HHT|8maEhKgSUQ5c`Q#gM4dEal@^Px^mWIxz+%Gt&u6hq zbv90&i3)+Av{Zu3BkFt?jPmY7A+@tyV&nGCLX>ebDc)X@kEYt% zW$4iPa}0ImsV6SFp=}yfZ~3h!&q!&KKP0nG%8Y>n;1EF0$z;>-yTtP7X?{@>wO2aZ zL1(7p#^iu{Yc6bG4HN8F#6%ia6VNklD87x%a+;^Yi7u(~lCvKgCQ?^b@_F8rC;mDc z4nkOu$6B8#Y(6t#S$rtr93SHL4}l0tyZ3_xjr!C#@H}cgy1t5?zSx>b=(HW&6TRK; zO;=ooAnSOaD|<6ye_>$%_r@OyqV3T2BY-lqskeKNpHp6)ysF~k#)ULZ&4})!lp0YJ zg@lmIk6YHq5l4enJ!*(J&&C9F-Rs21L#0*4n!0;3)SEZOmEvSFuBFfRa~XGRMbR+j z?+|c~@%2uJ$ZA%f6gm4ZdI$5F{W0|t*B#4ljo`Y;n=MoGtD=?ymiimh6g*2f<-M^geMso8T{5|g%B@Le9HzX8>4gJ!6Rsa%SxD5 zdGJn6s%l5H!tDD|&yRIQl-RX@2vo307rV^O6TTDO1FgL?lITy8jY8c3%BJ0f1>Nq= zK3a5a{0inXqtfuLV>OQz%kmr-A>Moa&s_dq@`J&jxlWz~*=NY7FpDMff#6i5r{+Sf z5Kf}D2T~TB#`6*0GFv7hjg{4nVyD-}XJpAF2_l|$?ofyrr(tCm=c!>%CMf4_{$Bmc z!=E+0c!Pn5{c7OWdI{9xac5SZk!-dK83g+ECF$FD@|--k^Sky|7CvCMwX#K7BvPGn zqFM$U#IhJxKSpO;EluZTD~_Ad%2hp6yLCdhAw!2T9?!|`vQ3J89efF&{7rO`F&35O zUKG+ui>+&6YM-(T;kP}K+~^+}XoEJq&|t~bw93pj_to`9()+N6`AmG^#|QFViG zw-msUmZym|YoT8MPb z=K<1B?F#+B&|1J9^gnS5m9YHH;(;LY-=+6?V~tFPFBdMaAsCDwh#&4(jZiiDILEx- zACm=I6I1oQ3L_uKU7*r3^Os(u-M2mI*({EWR50T|$UBQzzndLw);dF>*C>+Sn1QuN zFJkpaZ7S$S&*A>WB~KG{tuevtBe(%Tw+O#3-XX|pm^DpZs#L2WO-EvQ8HSb{2=u0f z-54KH3*vK$l)9644vW0;^QY1Q8y1m#x zX%w7FZ;xa%Ib^saOcdsp9}9KK^2S(;*HuB-t3JaO766EA1=#`i3p>{Kp}`)tjCde3 z{$t}i>}tWLHy@_5bV?uQe3KLA{9At|^EnzE_hEA?=vRVX;tw*qxR&UP_@yNC#U=@T z6IUMCT#u?5qm^rvT7-I@Y~OB06mL~c?8_$o?1(?MZVFuh=d{2$B|Gx^cOfO_p<+D! zi<|j^)Y18{9!HAD+p zsq#NVFs}`t5+-}!sJ&P8S$NCApQ(=7RolcK&s|&L_h(1pW9+B)G?3u0PQE70qwk<# zoFFv6b>Eq@TK_}HXAPo%7A#kJ3vfK;D=uhc?Sl6*xd6mjGs&b9S;-a4~eOL;b zGySCil-TIcUA-+Ba#Cq2g(RE$PJ&UD1Rv3P;u&;*woz@Izk+VA0BzXGZ63ZOnU6(O zi>c9J79|_0+@4t93C)Wfx;loi)#0?bNxgS3>+Jh)@U72djcVKOXpN``HkgG3|>9&1@`?< z!#3Ja1vja~iRw5Sc;AfpCr6nHb>@?UZ5|w2M`LH57E2cAX`~}&_AQ8iWVo`B++{o| zdWI258%&&=dqmu|&Jza~6T#)}6iw$X2AGgya0gRerNir|B@502nh+sa)hlC*X7nF_ z@pyCJ_uahikgbPuPpfDqf0&5oc-DzJd);@(Ftsi7v^gx;(Ve*^c0_!s9uj@_T)6Lc z_}wrU{_3vkYmG_`=uzzfF(j`yo?Avcb$M_+HEM}8Y=FdYOKg~SwEW??IosiCDl>w& z=+}0)L!lfz%_iFa@%eGV*W{Skv*ah%yh6%T0*rwR5N2xxIb80<-5?^zE9Mn0hXpI0 zH+5{JqLQR28o`$$w(WoS*#YO4-iAk1yK5oolDe;aUiPCb%cNv^&Yq~o#tO>jeWrYE z^~G7$?y_1|gV{0lkE{y|qe_lV94-B&zk9as?K7-S>}qELvs_eb%VO1Q6Q|yXuzO{* zP*$HvTXmmWf<>nt|BvicSO!{Q1ejN-9pPG+WE9)En3&+!0-i2AZ8HX1bxK)B28eUC zHE@rsEQY1@*eQ{}{qA@XDJlV~UpWsq9~{$?xzPPLQ{qB<&JeQ#`oYT4se9WP6@=rU1wDp*BaD~cc3tWdpv8pW z-mZ+7X{AzPru^)DQ%_07T*KdvB;(;8Ax;E;tueV_kS%gB!auG{8IAO%ClwD#iw^E3 zHvfl!)vxjM*vISFBIC+*6?go~+3TA`Itt-J(_Q2K~u3m^}_=Z61u=PVwv%UK)zTRfmtE@P0@ z@csZOL?FWh=ZZvm4+$;+^+drNKo??@`S^DvIKW?ND`Ml#iRcgQGed+E8@giJ%0y^R z>29nmg6T5sGO>{(bB=x_BVM*tks4>tzJz7X?^x`u_%Bt;z(M4 zZc?f{Jv{!oMCiG~N$mKIx&_bAq~@q~#v;PY=z16EqF~thC-I%1ookxJxwDUFv~y^4 zCx*YuWUEmb9%QV0-i`8wBiu9R?$f(*r90aO8u{b4NsUSqUW{>aflO#lg-=a`o#olwa`%AfKPvvxR&MY9%KZ&|M1M zfn?x5I&$d2XB-!f$?X5g&!~v%Jv6)>dA~Y?U1O5$FA7x?k*Apqq64L-kHlu<4qEcxbk?sa(41$a9o1Fl*U zs!K|d9%b5oG}BD`4?))$0F*GEX8R1Zcf7g1_9;iTG8ZRPGzz4CW@l%tQ2)0sq$cCx8y1AG3B~O8SL7@ls4F}Vx*achs>ko=~2gt50?+>u5HVJ^J5aJOT zmOCvmeHKBCu0AuM1jV_6%0QixNCIrnoLIHxLW-YwUpQ-Lx$lu^{CoOK>K0^GgGSD>iKyCtfp`% z8ak}-+rrsSR{adJrWj`m-qslg#-c%AE`|O7md5X{#Y{OT@(8KiBT+EPZC;_mqNc$V zI6KOA%3Fq87&?aMH|^eo4*K6cTOMjepU8}1*uA!PyzjwZG+`ZzylMx%(#JJ8>PpQ5 zQ^D#VS!8#-LNYH>Qz79nUO<3kzUS4HZXE`7V?@-YLbW?jN;-!Ih)94|QKnv!CF`SM zxN%ltR>thD#ixhtwp_kVwc8UHN74~ca#*+BiW2v#iO)$vhK#w{F|P2M{_ z)$*M?nGi%0XFRleyjxl`87-Au4@7_|*~sqPqo{HAwr$?Jxykw$TSMf?NXUCz0f zZXE0K3^Nnc7^CU?UHTyLpz<0~L7JI<|2tDllR_K*py3AUqYkKDFk3O+WndHK%1S*# zKhF4Sgg~W7o!=u~blv0$*5|zPLh_+fM0Je06xHR}$+7gNvH~))ORIVKG-c(S7H6}h zS-2(hiahD*-G#`=*<0tXps0d(9piZ~ghByB{~ugqfq~wyEp^v_<&~B}at45Q{ddWg zNI>sOW$7dOghlnpMr-`|#_LCSi%t_VDBC~iHBGA1%_w4uVY4qYD7NHfae3>C_|0>@ zpSUL&hVP%8Cn2ZB*9OFX+?`4+X)1q043H%zZ9LFXU9WX2Noiw3AK>T+R$@O>v*PQRWvsY& zC^z{X0`g{+bA#d8hZt;HQw|hB2nS>F7D1LNxG|b#pe@4sRhagi#ZaO7(*xV4^;C7a z1mB$rJSJDf2ukm2qE*uLnT&i!O!^Yq23Kd<52}|JRk_PmQmru4>vzK|SBAmaz2aV$ z%m-%(jq}4DQ{fr;L1WqbL6+a%$Bi!dpXphl*LLQy)bG?@i*Qi3Q(Z#+fKrmRU5GBk zuGTU^Gfwl-7{}Cb^;`FF&yp^l@Z)V`Wb0=W)FN*80q;oPT@9}r>PPu0HRV$IQx2(2 z8T)qin=yaK68^j9jpB9y!4;4v>x^7g>>pg~am~ARHM&aqKP%AwhdBv zml^BXwimX7IW@0-H=Q)f*1Xf%FsF{?O4u2KNBo?gvetz7`kmm};|#UOeh5vx(cmCZuA3OjJ3B4$z^=ZUnw!!%SGM_CKXeN5 z&TfKqv1^y=LS`P@)=6z0v(0dd64Q+`cYbfg6?U8X!Qw;-=l<``8xk>uXQvrZhAHwb z<4u&~S{dkQR-wBV**U!f8MSZSHAP5x1HFZ{h z!ykJ>cEZqffKhEf(oitqqt)V&kLRrJ(#+ygMby3x=N-mx=|mHao;t=m9>V<&rMl}? z-K=JA1t!+d^GB!{x5Api5J+c6_`A@Hd-&U;7mP5(33NnCXql3*#gj6~&XywS*Yw*N z(dNqJ!i^N;#&^toez(}@?ruZw89j~G34W=FS%3B>;mPd$ciX=Tpd+A3adBdguG(e6 z2pX4Q-0NbMhK^QiFSs&0X{4gKn;E8;T%~NEjG?qiM zcAnOclHk$&B|$>*c!BJv(hlK=vLxT-blIhDUtF9pT!zTpgIi*`Q-v^b4fq^{rfVy}bg>Cjw%&5>9F*86S#gr(ooq%T|}8o0=Y zHu0ptoL1(HD&aL515cl_`Tu6~ANF}{3h2R&i|Z29>w?xuCZDL*sGsdZ-g-ATl>&$W znT=H5@%j%52_>5-%}Jxl>9^%KZ>jF=bO-T#A?UwB`4)3@G+zw#jG!WIqGfHoECtM| zY~Kykf80=ez+@wtu_#CZ#cb(5p6zG_lYL(W)gljv>-tzs$G*x{^H(oRZ*R+9FyRLb zZy>!m#hWI-l)vh=pJTTvArNU=cg$c;j*c?Kq0tuYq+aTJWA8`y5#>)VR3_6ZnlybK zsGr^9saz#&VsdaDLjG7aD2HY#XLp>XM(0~aSc&kFQH_`UQAzbHe2e`u-`NNadf%yP zuKG*9Ml#US>!nqa?)XC&R(k2KshU1So0wkcro%Pxs5TcyV)B}nR`vu+JQp@m@~$BE zICIBLvvulQRTF8w^!0(%@vZ7-Y|KPP!~Cw}@h8P;k)ro{A}2S`5fXhBsYY!lVaS>> z4sa);wIEoS`j2D#N@5G=>=4?Z^AYuB2lzWME)~eG{?_vIWc2@6!Ve?|f|jTBiD&vc z$BV7WH4^9;3kSe7wa=m*?>qYLaY0Th>n$nYy>%<jnyOxnXmuFWOdHHJC?)MG>S}5@ler+Xe69RBMWpI$3$grPR3sWaRR!QKfJ1($p3n z*i+o_asavwXeHUG34F5$)G!zyPKW&hj1ja2FT~6rSki+}?u~+v-lb7?LV(!OEr+qY z3m6pn5}7e7;BlecS=a{P`cR z_@)m{p9TWFfo6oc3L+46y_~pCTyuJpX0JYI)M#_)*Nh0qqooTf3Q3VQACTD+m;%wF z05%hV89c8PD3Eiqj6W!LD{l0AF-Q;A0W1`=aJ?m$z=pZ+PA)&KA6FD+n2Aiyw6L`) z@CxZ^@7-i?0$8bu;Ib;fq7-F<;SOH5BX0v7E!Nj-XkO+_51e>Tpwo5~cqjt=o^ORf z9wA!Py>B4{Amud})rh9JH0&CO_w8_A2sizzcfr@#Z4Q78l?S-S*_u{zaL)l&pg~_?0GUy8|#DEBDDjiL-oOmaJ0a1s8~bbPt<^zm;Q_B zNDzN3;vWL;qzuG_+CM;nQK7<{3lM&Z*yUr9CRRZXh^Ih}I|5RPM88IyJ?XoLZ`A{) zxk={@1UG{xOnUH`>2?#E&@H8ub;@N0l0W<^AG|UmojM5Mb-)c<>TiHX?t<;IH&7yl zVL4U_kg4&|uAUFCW|_kq#QBp5Y@433Y}W%P+BJ}E1lPvC9uy|GD!&-3A=>vs_J#Ef zRPIXxK(Y(}69=q0TBW7ZqdyP*Zo}g%iuZnP_KKN_9`ush`B8`Y9cD_dU~2DsCnjWpw6B7r|FG)pWS~k=Tqv7wBD*`Awb7GF5z3PnvV$5!8a{KjJ!vY=r@#v zzO4$S%qSUuB&#CTuC1G> zg(DN(6)@6#jtf%w6dy+mjD)Q2Wb74T$J+C#hOIBYnTo*WTzVSDRt&U^ zIFl2v6z_kjnj=y#{rA8j3)6o=P2mU&KdY-Y# zBJ9D@{4H#$Quzb?URa*|qg|Rwx<<>cQejm6ETYBS`{_XTxBG)q_iUWPy>j?%??&1G zbbLTG0RAL6Vb%^VIZ0Yf(bmMUM1?r_Su0cuPc;udVYpovO7~InYoUE}RIMWk<0Tt# z=E}_Yt`i=7#@}FhMu%AP z+zo;!@XI%|^Q7__coqAFMLNj|JlAnR*=nJ5+nXDR=+F%!!_AP( z=qyPJvV#X!vxwPnpprMOn}zKH7|fqBkRTa;Y~FLUUVs!hA8V)57}GPN(cu0kfnB5| z^Ng81%b%|KqPpk{p7Ls}GhHJQoEs%YxUj3-KOjGrs${5C@N#CV{xh|F(CdiI+{hor z^77=clfa)|F~X2|X{9vEdJ-JH(^3-ztCvd4G2%7w4*_!#5NV+Uw>N=Nr8@$4%Mgm^ zTw?Q-oKyLBESg-y3 z@U`#fretS;9UK99juX5rX?z5~ftJZwgj~0-RLV4Y5+k25?q^XJ-N~eA#6f)AX8ZOq zQVp)&I`yWKs!pwAEjp}DsN2+?6aR$pgaJogyEM`QE z*qM`8DXMF{61tN;nY5GHu+%q!DGr-VW)zvgLuuQoNS}fpcZG7A~V)Uw{ zR;824<+JbJM>%u7uX`LmeXW6v(ogRaT&&}7J#E1=VRs^f{|)5zM$05P8l$E4NQ9;O zXBm+ab&AG_!fl#)!&}Gn2Cr-xEh#bwI2d5(@D)tC8-`+_1CA^Nw*ztFpElvl^ z@w10%NzV8V+J=)M9k0Vt_wIS-1&}{b&J^J43>s71sOT^2}Y=*_immPrQrB1a+1QPFAZ1sj!7p|*Osg(43${IBtec! z;0OYjlFO+Q&}=T|?~lKN6F?T81pp@b{|4~?qE`Woga5V81&G4ErixRp>-{@0hVY9ee(KR92Yb8@Yak{2|WSa?&c*6C< z0QT_6JQffi;efpeebaUquoW3fpe1ZA!cifY^}z>1Fu{Kah-?vOtYx^Dz_s(k?x$M~ z8yl!E+IYNSyx@w@#Pr~M%tIK!x^z3BDhQ5Na+#r7a0=R^m;H{JHy11Y4v;ixcJcBmA6M6CEAlgvOCrYV(d#Dtnbn;N1Fs) zP6N| z4!ijDpAal3NvF;~1m8~m5oob>K-6^wkw$CnPX%`&fT1|PQjED+>ZQ)PL!HspifZw3 zKYLBnna8&l)*4CFfsr)3Obei05T(;U1RB6|zx0c!?=N{~GPtv;wCVFv3SXzndtvLr z`f#R2*L1@Y@Cog{Ite1qCRROOFNwSDdu`UgbS|8!Aq7YZ$jtE>s7(Q^#Tx5F{d#qA9V0nes=vp#6*UD$=D;U} zXEY3a+s5L)QbbFj5EHiGSLntFmqP2(@G#F;-8y@Jx&kyW*Vy##FM4{Zycv`C8+Rmk zw*{JPiS1FzZ|x3nO@(;A6Ty}_CbZxV$%Qy%duo}z+w4=7VgCc(rYa@^i>ZB+x1zlKAz7NI9L!z4eeFj9!8*gcxEQajVn<7dP{V{;~GWCys)=HL0 z*LTjeJv-wc3Kd>@7eFf6^5Pf~#>!icrtA=q=u*S5Z1DYrGC6+R-Ty$u&B(#I^TkK` zi)*EjkHZ(rfh4})g^QFl!CJyMMYq7fq!(Dm^7`k!ayv4v+-@t9DO=m3ETOjBv4PzL zF-jW*(v(>qdN^9U6Idx00K@Ptkpv0QceOV1)Ie=sbY7pP%fgceKYywd_cA5J=l8@# zvt9;pLj0@L*paS$3fYkYO8l!B9~o6_*g^wz5fo)t4_XS7HOrq0g!9=(T29Bsh~c@U z4!3STWee>OU(wrA-_5?@H^dJkC_DP2R;FQxFDV~IdnjHr*@to<959{U+wac(&r~u^ z-rm}CzLcLbBG>BqSXozAZCnZzck;t2U)*H(k*-=?-x*j&hBQ%#BAGUHPXPH>qvgSR zawdvfbe1i|UbcC8je`fE7-ejstxxhq>w+VKWa-XjFKB_j7@24O;XecdF6x)Ws8xpM z?#zTXtA;Y)IUi}&|4kLsr8E$gK|t0ykAdRro%b&p9U$M7PVr?LzMr4(LpFU}jf9|?Vf=qQ2i0qA>YGOgWXX`qt_`gcK5_c%qH$1W(WY6*=Mxv5^ zYf2ch7b23BC~4A!gp3ShNW-yH^ee}dElZ4LP?jcZ%F);-#xf{b24kJU%yi!KH=IA< z`>yZ0-uJto_qpHazHgSx>IFkqoRlXMZVZW`aR)GUYaQ@$5t{+xZ%9(c?v6=#@AWal zPV(67rRIw#dz;fnW-*La{FuK4_EtFrjO_|(u`xfqHiB8&dB=hiFrw}n!L0m?dEic~ zw>8G4V`eyvuNx?`!}6Yt)b1EL#tj0ebtc+?-R2iPpA=CwqSZ{neecSC zu-s_;#b&gYZp3=`y`0fex?pn;*a4_#bFA^JFF9qL_oSc7=7xYe;Ky;(8VOwi@L>n; zB4M0BeFFZkxJXnF#KD9g$IES^0-~Grl=MSUZm^;7^gAEX3Z1{?Pkfr&FKLjBYXFfX zZOax=BAnsuWIpZ8$!IxlT^)n7mqrbBdd-*gohpnf zowz<>&#trIFw}=98Sj;WQ$PW0$iEyVeU0T8^`K^H&!%_DL$WT^xTq+Yx?Jm+y`oC_ zGTo3)Pnv~~F=v6rG1{Sc5eAzU3t#@AyAr%|%Rv1}mOi5K74(6?aip?v<~PejJhX*) z`Oh+Y0Q~u04hK!oz*55~cTSyZqGe4e|9KlLf@PJ$Q)2>In(dFLJI#Wqc8qV&BdV4n z4$7WIo=8b0ho&8RT=Pk(FQ$#n$HElFbotb&|0E~tzQ)oEUR|S94PY%bTwnBqW6psh zBxi1D%|-Cn8qO%tM4Y8*TT!gjb_;!*g0l#)AHL$^0@NmoZ>kQv1nbA!X@W-2OEARmyokaH!%06 zNBc(yhX$)g|IAeLxCEEbJUM4Gik8Oc;AW$bZFcO96h?+#y(cOE z7w}JWU9h#JJ4IXV%g6MNuXPa;6~KCV8${{yEy9E%wMYK;m))~Ik|kfq#QKWp(IB=D zZ6MJ;ppM#BH+%%nh1WtK%G{}K8d|OVvQ*2dQlFbfR)o5UTN||WyXmFnfQG#0hM0vacG~_i zqy;#}+s_~*G5G!F^tj)UMno3@f~uwCaeIx4o^7F;w*z27nW?*Gs-dwR1$i)L-3X@5 z8smS)_{2P`j}`?tyi81lN(SuoW=GvGOfKeqmsz@7!^}XGZ>~dEw1aYe8c5ypMJ`{* zI}V6*9-iNSwFBXlJHJtCY=uakqF=a83=hu!H%j)oc?um4*22T6u8UFl=5L|9;Z`H6MnRgs%327>7U4EjRyU@ zRv4Dx3`Zv*4Cy0@l=D>A)()$WHGDKNXcB9-yLV+a%lgBjTaifqGoCH+mB{^jsNS9S zb*1WIUvJ)ag|LJz&Vi|OODU*Lf3&!PV@Mu^M};TrqBKKrR*BR`N{JC(FCNJQmVaVp zqV%2q3}yRacALZ8Bcbl7*%q#v@B@A?XVBk|2ViKG(7NK`fSu&NkTk>EK*{fA>>lzU zD9AWMOdo!86SVuZq~&$ASNCUi8^;{IY$iV>+5vpO0O#$;!=UC$wrH24&kU?;Q=IE^ zl&m~H5M{}ztIxV$*{Rv`qWj2ee;gLx{U#PyN*%XTM3?j5NRGFVHpodD4p;e;gJ8he zGNH7Dx3OEAx>7nHN|vgxTJcY2435tlWlCrUhM)HD=`jl#7G}Rn!__LQtvqZvGY^L*+yS4R|W1FxOIi=UFLN4+uV=j6fqN^Mna zhyuUzFpqLVkzMha)=3jF6FjsUbD5#7q^-3lAkMt^Aaw^er5(p!^eRqyP}z+a%pY)2%z>0x9*I}BJ11CWN6siH@P!DRL^NJV z-069f+uzSGE{9rzr$C9Y^p+?;zmS_H%P;eCPxVhi)IF5a&rc?Mwxp0S5-5eFOT3<;&=oq#=ctyaLW!v^WU|zfZhUBYMaZJ5{zd4rUW%) zHxl$wwF+;XRh&?Is_99Buv)hx(8P(vojx-ze&C}ZOTUC%!itpjKWV>!tid}rw5aq+ beKfPVQQwd@u2)lfdFv2Q_> getAllAuthors( + public ResponseEntity> getAllAuthors( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "name") String sortBy, @@ -54,7 +52,7 @@ public class AuthorController { Pageable pageable = PageRequest.of(page, size, sort); Page authors = authorService.findAll(pageable); - Page authorDtos = authors.map(this::convertToDto); + Page authorDtos = authors.map(this::convertToSummaryDto); return ResponseEntity.ok(authorDtos); } @@ -255,14 +253,14 @@ public class AuthorController { } @GetMapping("/search") - public ResponseEntity> searchAuthors( + public ResponseEntity> searchAuthors( @RequestParam String query, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size); Page authors = authorService.searchByName(query, pageable); - Page authorDtos = authors.map(this::convertToDto); + Page authorDtos = authors.map(this::convertToSummaryDto); return ResponseEntity.ok(authorDtos); } @@ -353,10 +351,10 @@ public class AuthorController { } @GetMapping("/top-rated") - public ResponseEntity> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) { + public ResponseEntity> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) { Pageable pageable = PageRequest.of(0, limit); List authors = authorService.findTopRated(pageable); - List authorDtos = authors.stream().map(this::convertToDto).collect(Collectors.toList()); + List authorDtos = authors.stream().map(this::convertToSummaryDto).collect(Collectors.toList()); return ResponseEntity.ok(authorDtos); } @@ -422,6 +420,24 @@ public class AuthorController { return dto; } + private AuthorSummaryDto convertToSummaryDto(Author author) { + AuthorSummaryDto dto = new AuthorSummaryDto(); + dto.setId(author.getId()); + dto.setName(author.getName()); + dto.setNotes(author.getNotes()); + dto.setAvatarImagePath(author.getAvatarImagePath()); + dto.setAuthorRating(author.getAuthorRating()); + dto.setUrls(author.getUrls()); + dto.setStoryCount(author.getStories() != null ? author.getStories().size() : 0); + dto.setCreatedAt(author.getCreatedAt()); + dto.setUpdatedAt(author.getUpdatedAt()); + + // Calculate and set average story rating without loading all stories + dto.setAverageStoryRating(authorService.calculateAverageStoryRating(author.getId())); + + return dto; + } + private AuthorDto convertSearchDtoToDto(AuthorSearchDto searchDto) { AuthorDto dto = new AuthorDto(); dto.setId(searchDto.getId()); diff --git a/backend/src/main/java/com/storycove/controller/CollectionController.java b/backend/src/main/java/com/storycove/controller/CollectionController.java new file mode 100644 index 0000000..0bb7e70 --- /dev/null +++ b/backend/src/main/java/com/storycove/controller/CollectionController.java @@ -0,0 +1,421 @@ +package com.storycove.controller; + +import com.storycove.dto.*; +import com.storycove.entity.Collection; +import com.storycove.entity.CollectionStory; +import com.storycove.entity.Story; +import com.storycove.entity.Tag; +import com.storycove.service.CollectionService; +import com.storycove.service.ImageService; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/collections") +public class CollectionController { + + private static final Logger logger = LoggerFactory.getLogger(CollectionController.class); + + private final CollectionService collectionService; + private final ImageService imageService; + + @Autowired + public CollectionController(CollectionService collectionService, + ImageService imageService) { + this.collectionService = collectionService; + this.imageService = imageService; + } + + /** + * GET /api/collections - Search and list collections with pagination + * IMPORTANT: Uses Typesense for all search/filter operations + */ + @GetMapping + public ResponseEntity> getCollections( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int limit, + @RequestParam(required = false) String search, + @RequestParam(required = false) List tags, + @RequestParam(defaultValue = "false") boolean archived) { + + logger.info("COLLECTIONS: Search request - search='{}', tags={}, archived={}, page={}, limit={}", + search, tags, archived, page, limit); + + // MANDATORY: Use Typesense for all search/filter operations + SearchResultDto results = collectionService.searchCollections(search, tags, archived, page, limit); + + // Convert to lightweight DTOs + SearchResultDto optimizedResults = new SearchResultDto<>(); + optimizedResults.setQuery(results.getQuery()); + optimizedResults.setPage(results.getPage()); + optimizedResults.setPerPage(results.getPerPage()); + optimizedResults.setTotalHits(results.getTotalHits()); + optimizedResults.setSearchTimeMs(results.getSearchTimeMs()); + + if (results.getResults() != null) { + optimizedResults.setResults(results.getResults().stream() + .map(this::mapToCollectionDto) + .toList()); + } + + return ResponseEntity.ok(optimizedResults); + } + + /** + * GET /api/collections/{id} - Get collection with lightweight details (no story content) + */ + @GetMapping("/{id}") + public ResponseEntity getCollectionById(@PathVariable UUID id) { + Collection collection = collectionService.findById(id); + CollectionDto dto = mapToCollectionDto(collection); + return ResponseEntity.ok(dto); + } + + /** + * POST /api/collections - Create new collection + */ + @PostMapping + public ResponseEntity createCollection(@Valid @RequestBody CreateCollectionRequest request) { + Collection collection = collectionService.createCollection( + request.getName(), + request.getDescription(), + request.getTagNames(), + request.getStoryIds() + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(collection); + } + + /** + * POST /api/collections (multipart) - Create new collection with cover image + */ + @PostMapping(consumes = "multipart/form-data") + public ResponseEntity createCollectionWithImage( + @RequestParam String name, + @RequestParam(required = false) String description, + @RequestParam(required = false) List tags, + @RequestParam(required = false) List storyIds, + @RequestParam(required = false, name = "coverImage") MultipartFile coverImage) { + + try { + // Create collection first + Collection collection = collectionService.createCollection(name, description, tags, storyIds); + + // Upload cover image if provided + if (coverImage != null && !coverImage.isEmpty()) { + String imagePath = imageService.uploadImage(coverImage, ImageService.ImageType.COVER); + collection.setCoverImagePath(imagePath); + collection = collectionService.updateCollection( + collection.getId(), null, null, null, null + ); + } + + return ResponseEntity.status(HttpStatus.CREATED).body(collection); + + } catch (Exception e) { + logger.error("Failed to create collection with image", e); + return ResponseEntity.badRequest().build(); + } + } + + /** + * PUT /api/collections/{id} - Update collection metadata + */ + @PutMapping("/{id}") + public ResponseEntity updateCollection( + @PathVariable UUID id, + @Valid @RequestBody UpdateCollectionRequest request) { + + Collection collection = collectionService.updateCollection( + id, + request.getName(), + request.getDescription(), + request.getTagNames(), + request.getRating() + ); + + return ResponseEntity.ok(collection); + } + + /** + * DELETE /api/collections/{id} - Delete collection + */ + @DeleteMapping("/{id}") + public ResponseEntity> deleteCollection(@PathVariable UUID id) { + collectionService.deleteCollection(id); + return ResponseEntity.ok(Map.of("message", "Collection deleted successfully")); + } + + /** + * PUT /api/collections/{id}/archive - Archive/unarchive collection + */ + @PutMapping("/{id}/archive") + public ResponseEntity archiveCollection( + @PathVariable UUID id, + @RequestBody ArchiveRequest request) { + + Collection collection = collectionService.archiveCollection(id, request.getArchived()); + return ResponseEntity.ok(collection); + } + + /** + * POST /api/collections/{id}/stories - Add stories to collection + */ + @PostMapping("/{id}/stories") + public ResponseEntity> addStoriesToCollection( + @PathVariable UUID id, + @RequestBody AddStoriesRequest request) { + + Map result = collectionService.addStoriesToCollection( + id, + request.getStoryIds(), + request.getPosition() + ); + + return ResponseEntity.ok(result); + } + + /** + * DELETE /api/collections/{id}/stories/{storyId} - Remove story from collection + */ + @DeleteMapping("/{id}/stories/{storyId}") + public ResponseEntity> removeStoryFromCollection( + @PathVariable UUID id, + @PathVariable UUID storyId) { + + collectionService.removeStoryFromCollection(id, storyId); + return ResponseEntity.ok(Map.of("message", "Story removed from collection")); + } + + /** + * PUT /api/collections/{id}/stories/order - Reorder stories in collection + */ + @PutMapping("/{id}/stories/order") + public ResponseEntity> reorderStories( + @PathVariable UUID id, + @RequestBody ReorderStoriesRequest request) { + + collectionService.reorderStories(id, request.getStoryOrders()); + return ResponseEntity.ok(Map.of("message", "Stories reordered successfully")); + } + + /** + * GET /api/collections/{id}/read/{storyId} - Get story with collection context + */ + @GetMapping("/{id}/read/{storyId}") + public ResponseEntity> getStoryWithCollectionContext( + @PathVariable UUID id, + @PathVariable UUID storyId) { + + Map result = collectionService.getStoryWithCollectionContext(id, storyId); + return ResponseEntity.ok(result); + } + + /** + * GET /api/collections/{id}/stats - Get collection statistics + */ + @GetMapping("/{id}/stats") + public ResponseEntity> getCollectionStatistics(@PathVariable UUID id) { + Map stats = collectionService.getCollectionStatistics(id); + return ResponseEntity.ok(stats); + } + + /** + * POST /api/collections/{id}/cover - Upload cover image + */ + @PostMapping("/{id}/cover") + public ResponseEntity> uploadCoverImage( + @PathVariable UUID id, + @RequestParam("file") MultipartFile file) { + + try { + String imagePath = imageService.uploadImage(file, ImageService.ImageType.COVER); + + // Update collection with new cover path + collectionService.updateCollection(id, null, null, null, null); + Collection collection = collectionService.findByIdBasic(id); + collection.setCoverImagePath(imagePath); + + return ResponseEntity.ok(Map.of( + "message", "Cover uploaded successfully", + "coverPath", imagePath, + "coverUrl", "/api/files/images/" + imagePath + )); + + } catch (Exception e) { + logger.error("Failed to upload collection cover", e); + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + /** + * DELETE /api/collections/{id}/cover - Remove cover image + */ + @DeleteMapping("/{id}/cover") + public ResponseEntity> removeCoverImage(@PathVariable UUID id) { + Collection collection = collectionService.findByIdBasic(id); + collection.setCoverImagePath(null); + collectionService.updateCollection(id, null, null, null, null); + + return ResponseEntity.ok(Map.of("message", "Cover removed successfully")); + } + + // Mapper methods + + private CollectionDto mapToCollectionDto(Collection collection) { + CollectionDto dto = new CollectionDto(); + dto.setId(collection.getId()); + dto.setName(collection.getName()); + dto.setDescription(collection.getDescription()); + dto.setRating(collection.getRating()); + dto.setCoverImagePath(collection.getCoverImagePath()); + dto.setIsArchived(collection.getIsArchived()); + dto.setCreatedAt(collection.getCreatedAt()); + dto.setUpdatedAt(collection.getUpdatedAt()); + + // Map tags + if (collection.getTags() != null) { + dto.setTags(collection.getTags().stream() + .map(this::mapToTagDto) + .toList()); + } + + // Map collection stories (lightweight) + if (collection.getCollectionStories() != null) { + dto.setCollectionStories(collection.getCollectionStories().stream() + .map(this::mapToCollectionStoryDto) + .toList()); + } + + // Set calculated properties + dto.setStoryCount(collection.getStoryCount()); + dto.setTotalWordCount(collection.getTotalWordCount()); + dto.setEstimatedReadingTime(collection.getEstimatedReadingTime()); + dto.setAverageStoryRating(collection.getAverageStoryRating()); + + return dto; + } + + private CollectionStoryDto mapToCollectionStoryDto(CollectionStory collectionStory) { + CollectionStoryDto dto = new CollectionStoryDto(); + dto.setPosition(collectionStory.getPosition()); + dto.setAddedAt(collectionStory.getAddedAt()); + dto.setStory(mapToStorySummaryDto(collectionStory.getStory())); + return dto; + } + + private StorySummaryDto mapToStorySummaryDto(Story story) { + StorySummaryDto dto = new StorySummaryDto(); + dto.setId(story.getId()); + dto.setTitle(story.getTitle()); + dto.setSummary(story.getSummary()); + dto.setDescription(story.getDescription()); + dto.setSourceUrl(story.getSourceUrl()); + dto.setCoverPath(story.getCoverPath()); + dto.setWordCount(story.getWordCount()); + dto.setRating(story.getRating()); + dto.setVolume(story.getVolume()); + dto.setCreatedAt(story.getCreatedAt()); + dto.setUpdatedAt(story.getUpdatedAt()); + dto.setPartOfSeries(story.isPartOfSeries()); + + // Map author info + if (story.getAuthor() != null) { + dto.setAuthorId(story.getAuthor().getId()); + dto.setAuthorName(story.getAuthor().getName()); + } + + // Map series info + if (story.getSeries() != null) { + dto.setSeriesId(story.getSeries().getId()); + dto.setSeriesName(story.getSeries().getName()); + } + + // Map tags + if (story.getTags() != null) { + dto.setTags(story.getTags().stream() + .map(this::mapToTagDto) + .toList()); + } + + return dto; + } + + private TagDto mapToTagDto(Tag tag) { + TagDto dto = new TagDto(); + dto.setId(tag.getId()); + dto.setName(tag.getName()); + dto.setCreatedAt(tag.getCreatedAt()); + return dto; + } + + // Request DTOs + + public static class CreateCollectionRequest { + private String name; + private String description; + private List tagNames; + private List storyIds; + + // Getters and setters + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public List getTagNames() { return tagNames; } + public void setTagNames(List tagNames) { this.tagNames = tagNames; } + public List getStoryIds() { return storyIds; } + public void setStoryIds(List storyIds) { this.storyIds = storyIds; } + } + + public static class UpdateCollectionRequest { + private String name; + private String description; + private List tagNames; + private Integer rating; + + // Getters and setters + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public List getTagNames() { return tagNames; } + public void setTagNames(List tagNames) { this.tagNames = tagNames; } + public Integer getRating() { return rating; } + public void setRating(Integer rating) { this.rating = rating; } + } + + public static class ArchiveRequest { + private Boolean archived; + + public Boolean getArchived() { return archived; } + public void setArchived(Boolean archived) { this.archived = archived; } + } + + public static class AddStoriesRequest { + private List storyIds; + private Integer position; + + public List getStoryIds() { return storyIds; } + public void setStoryIds(List storyIds) { this.storyIds = storyIds; } + public Integer getPosition() { return position; } + public void setPosition(Integer position) { this.position = position; } + } + + public static class ReorderStoriesRequest { + private List> storyOrders; + + public List> getStoryOrders() { return storyOrders; } + public void setStoryOrders(List> storyOrders) { this.storyOrders = storyOrders; } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 0193b04..107c124 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -1,8 +1,8 @@ package com.storycove.controller; -import com.storycove.dto.StoryDto; -import com.storycove.dto.TagDto; +import com.storycove.dto.*; import com.storycove.entity.Author; +import com.storycove.entity.Collection; import com.storycove.entity.Series; import com.storycove.entity.Story; import com.storycove.entity.Tag; @@ -40,23 +40,26 @@ public class StoryController { private final HtmlSanitizationService sanitizationService; private final ImageService imageService; private final TypesenseService typesenseService; + private final CollectionService collectionService; public StoryController(StoryService storyService, AuthorService authorService, SeriesService seriesService, HtmlSanitizationService sanitizationService, ImageService imageService, + CollectionService collectionService, @Autowired(required = false) TypesenseService typesenseService) { this.storyService = storyService; this.authorService = authorService; this.seriesService = seriesService; this.sanitizationService = sanitizationService; this.imageService = imageService; + this.collectionService = collectionService; this.typesenseService = typesenseService; } @GetMapping - public ResponseEntity> getAllStories( + public ResponseEntity> getAllStories( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "createdAt") String sortBy, @@ -67,7 +70,7 @@ public class StoryController { Pageable pageable = PageRequest.of(page, size, sort); Page stories = storyService.findAll(pageable); - Page storyDtos = stories.map(this::convertToDto); + Page storyDtos = stories.map(this::convertToSummaryDto); return ResponseEntity.ok(storyDtos); } @@ -232,57 +235,73 @@ public class StoryController { } @GetMapping("/author/{authorId}") - public ResponseEntity> getStoriesByAuthor( + public ResponseEntity> getStoriesByAuthor( @PathVariable UUID authorId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size); Page stories = storyService.findByAuthor(authorId, pageable); - Page storyDtos = stories.map(this::convertToDto); + Page storyDtos = stories.map(this::convertToSummaryDto); return ResponseEntity.ok(storyDtos); } @GetMapping("/series/{seriesId}") - public ResponseEntity> getStoriesBySeries(@PathVariable UUID seriesId) { + public ResponseEntity> getStoriesBySeries(@PathVariable UUID seriesId) { List stories = storyService.findBySeriesOrderByVolume(seriesId); - List storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); + List storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList()); return ResponseEntity.ok(storyDtos); } @GetMapping("/tags/{tagName}") - public ResponseEntity> getStoriesByTag( + public ResponseEntity> getStoriesByTag( @PathVariable String tagName, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size); Page stories = storyService.findByTagNames(List.of(tagName), pageable); - Page storyDtos = stories.map(this::convertToDto); + Page storyDtos = stories.map(this::convertToSummaryDto); return ResponseEntity.ok(storyDtos); } @GetMapping("/recent") - public ResponseEntity> getRecentStories(@RequestParam(defaultValue = "10") int limit) { + public ResponseEntity> getRecentStories(@RequestParam(defaultValue = "10") int limit) { Pageable pageable = PageRequest.of(0, limit, Sort.by("createdAt").descending()); List stories = storyService.findRecentlyAddedLimited(pageable); - List storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); + List storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList()); return ResponseEntity.ok(storyDtos); } @GetMapping("/top-rated") - public ResponseEntity> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) { + public ResponseEntity> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) { Pageable pageable = PageRequest.of(0, limit); List stories = storyService.findTopRatedStoriesLimited(pageable); - List storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList()); + List storyDtos = stories.stream().map(this::convertToSummaryDto).collect(Collectors.toList()); return ResponseEntity.ok(storyDtos); } + @GetMapping("/{id}/collections") + public ResponseEntity> getStoryCollections(@PathVariable UUID id) { + List collections = collectionService.getCollectionsForStory(id); + List collectionDtos = collections.stream() + .map(this::convertToCollectionDto) + .collect(Collectors.toList()); + + return ResponseEntity.ok(collectionDtos); + } + + @PostMapping("/batch/add-to-collection") + public ResponseEntity> addStoriesToCollection(@RequestBody BatchAddToCollectionRequest request) { + // This endpoint will be implemented once we have the complete collection service + return ResponseEntity.ok(Map.of("message", "Batch add to collection endpoint - to be implemented")); + } + private Author findOrCreateAuthor(String authorName) { // First try to find existing author by name try { @@ -392,6 +411,38 @@ public class StoryController { return dto; } + private StorySummaryDto convertToSummaryDto(Story story) { + StorySummaryDto dto = new StorySummaryDto(); + dto.setId(story.getId()); + dto.setTitle(story.getTitle()); + dto.setSummary(story.getSummary()); + dto.setDescription(story.getDescription()); + dto.setSourceUrl(story.getSourceUrl()); + dto.setCoverPath(story.getCoverPath()); + dto.setWordCount(story.getWordCount()); + dto.setRating(story.getRating()); + dto.setVolume(story.getVolume()); + dto.setCreatedAt(story.getCreatedAt()); + dto.setUpdatedAt(story.getUpdatedAt()); + dto.setPartOfSeries(story.isPartOfSeries()); + + if (story.getAuthor() != null) { + dto.setAuthorId(story.getAuthor().getId()); + dto.setAuthorName(story.getAuthor().getName()); + } + + if (story.getSeries() != null) { + dto.setSeriesId(story.getSeries().getId()); + dto.setSeriesName(story.getSeries().getName()); + } + + dto.setTags(story.getTags().stream() + .map(this::convertTagToDto) + .collect(Collectors.toList())); + + return dto; + } + private TagDto convertTagToDto(Tag tag) { TagDto tagDto = new TagDto(); tagDto.setId(tag.getId()); @@ -401,6 +452,27 @@ public class StoryController { return tagDto; } + private CollectionDto convertToCollectionDto(Collection collection) { + CollectionDto dto = new CollectionDto(); + dto.setId(collection.getId()); + dto.setName(collection.getName()); + dto.setDescription(collection.getDescription()); + dto.setRating(collection.getRating()); + dto.setCoverImagePath(collection.getCoverImagePath()); + dto.setIsArchived(collection.getIsArchived()); + dto.setCreatedAt(collection.getCreatedAt()); + dto.setUpdatedAt(collection.getUpdatedAt()); + + // For story collections endpoint, we don't need to map the stories themselves + // to avoid circular references and keep it lightweight + dto.setStoryCount(collection.getStoryCount()); + dto.setTotalWordCount(collection.getTotalWordCount()); + dto.setEstimatedReadingTime(collection.getEstimatedReadingTime()); + dto.setAverageStoryRating(collection.getAverageStoryRating()); + + return dto; + } + // Request DTOs public static class CreateStoryRequest { private String title; @@ -481,4 +553,17 @@ public class StoryController { public Integer getRating() { return rating; } public void setRating(Integer rating) { this.rating = rating; } } + + public static class BatchAddToCollectionRequest { + private List storyIds; + private UUID collectionId; + private String newCollectionName; + + public List getStoryIds() { return storyIds; } + public void setStoryIds(List storyIds) { this.storyIds = storyIds; } + public UUID getCollectionId() { return collectionId; } + public void setCollectionId(UUID collectionId) { this.collectionId = collectionId; } + public String getNewCollectionName() { return newCollectionName; } + public void setNewCollectionName(String newCollectionName) { this.newCollectionName = newCollectionName; } + } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java b/backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java new file mode 100644 index 0000000..a2affe1 --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/AuthorSummaryDto.java @@ -0,0 +1,106 @@ +package com.storycove.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Lightweight Author DTO for listings. + * Excludes story collections to reduce payload size. + */ +public class AuthorSummaryDto { + + private UUID id; + private String name; + private String notes; + private String avatarImagePath; + private Integer authorRating; + private Double averageStoryRating; + private Integer storyCount; + private List urls; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public AuthorSummaryDto() {} + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public String getAvatarImagePath() { + return avatarImagePath; + } + + public void setAvatarImagePath(String avatarImagePath) { + this.avatarImagePath = avatarImagePath; + } + + public Integer getAuthorRating() { + return authorRating; + } + + public void setAuthorRating(Integer authorRating) { + this.authorRating = authorRating; + } + + public Double getAverageStoryRating() { + return averageStoryRating; + } + + public void setAverageStoryRating(Double averageStoryRating) { + this.averageStoryRating = averageStoryRating; + } + + public Integer getStoryCount() { + return storyCount; + } + + public void setStoryCount(Integer storyCount) { + this.storyCount = storyCount; + } + + public List getUrls() { + return urls; + } + + public void setUrls(List urls) { + this.urls = urls; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/CollectionDto.java b/backend/src/main/java/com/storycove/dto/CollectionDto.java new file mode 100644 index 0000000..290305d --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/CollectionDto.java @@ -0,0 +1,141 @@ +package com.storycove.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * DTO for Collection with lightweight story references + */ +public class CollectionDto { + + private UUID id; + private String name; + private String description; + private Integer rating; + private String coverImagePath; + private Boolean isArchived; + private List tags; + private List collectionStories; + private Integer storyCount; + private Integer totalWordCount; + private Integer estimatedReadingTime; + private Double averageStoryRating; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public CollectionDto() {} + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + public String getCoverImagePath() { + return coverImagePath; + } + + public void setCoverImagePath(String coverImagePath) { + this.coverImagePath = coverImagePath; + } + + public Boolean getIsArchived() { + return isArchived; + } + + public void setIsArchived(Boolean isArchived) { + this.isArchived = isArchived; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public List getCollectionStories() { + return collectionStories; + } + + public void setCollectionStories(List collectionStories) { + this.collectionStories = collectionStories; + } + + public Integer getStoryCount() { + return storyCount; + } + + public void setStoryCount(Integer storyCount) { + this.storyCount = storyCount; + } + + public Integer getTotalWordCount() { + return totalWordCount; + } + + public void setTotalWordCount(Integer totalWordCount) { + this.totalWordCount = totalWordCount; + } + + public Integer getEstimatedReadingTime() { + return estimatedReadingTime; + } + + public void setEstimatedReadingTime(Integer estimatedReadingTime) { + this.estimatedReadingTime = estimatedReadingTime; + } + + public Double getAverageStoryRating() { + return averageStoryRating; + } + + public void setAverageStoryRating(Double averageStoryRating) { + this.averageStoryRating = averageStoryRating; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/CollectionStoryDto.java b/backend/src/main/java/com/storycove/dto/CollectionStoryDto.java new file mode 100644 index 0000000..ae735aa --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/CollectionStoryDto.java @@ -0,0 +1,46 @@ +package com.storycove.dto; + +import java.time.LocalDateTime; + +/** + * DTO for CollectionStory with lightweight story reference + */ +public class CollectionStoryDto { + + private StorySummaryDto story; + private Integer position; + private LocalDateTime addedAt; + + public CollectionStoryDto() {} + + public CollectionStoryDto(StorySummaryDto story, Integer position, LocalDateTime addedAt) { + this.story = story; + this.position = position; + this.addedAt = addedAt; + } + + // Getters and Setters + public StorySummaryDto getStory() { + return story; + } + + public void setStory(StorySummaryDto story) { + this.story = story; + } + + public Integer getPosition() { + return position; + } + + public void setPosition(Integer position) { + this.position = position; + } + + public LocalDateTime getAddedAt() { + return addedAt; + } + + public void setAddedAt(LocalDateTime addedAt) { + this.addedAt = addedAt; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/StorySummaryDto.java b/backend/src/main/java/com/storycove/dto/StorySummaryDto.java new file mode 100644 index 0000000..3ab012a --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/StorySummaryDto.java @@ -0,0 +1,172 @@ +package com.storycove.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Lightweight Story DTO for listings and collection views. + * Excludes contentHtml and contentPlain to reduce payload size. + */ +public class StorySummaryDto { + + private UUID id; + private String title; + private String summary; + private String description; + private String sourceUrl; + private String coverPath; + private Integer wordCount; + private Integer rating; + private Integer volume; + + // Related entities as simple references + private UUID authorId; + private String authorName; + private UUID seriesId; + private String seriesName; + private List tags; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private boolean partOfSeries; + + public StorySummaryDto() {} + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSourceUrl() { + return sourceUrl; + } + + public void setSourceUrl(String sourceUrl) { + this.sourceUrl = sourceUrl; + } + + public String getCoverPath() { + return coverPath; + } + + public void setCoverPath(String coverPath) { + this.coverPath = coverPath; + } + + public Integer getWordCount() { + return wordCount; + } + + public void setWordCount(Integer wordCount) { + this.wordCount = wordCount; + } + + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + public Integer getVolume() { + return volume; + } + + public void setVolume(Integer volume) { + this.volume = volume; + } + + public UUID getAuthorId() { + return authorId; + } + + public void setAuthorId(UUID authorId) { + this.authorId = authorId; + } + + public String getAuthorName() { + return authorName; + } + + public void setAuthorName(String authorName) { + this.authorName = authorName; + } + + public UUID getSeriesId() { + return seriesId; + } + + public void setSeriesId(UUID seriesId) { + this.seriesId = seriesId; + } + + public String getSeriesName() { + return seriesName; + } + + public void setSeriesName(String seriesName) { + this.seriesName = seriesName; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public boolean isPartOfSeries() { + return partOfSeries; + } + + public void setPartOfSeries(boolean partOfSeries) { + this.partOfSeries = partOfSeries; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/entity/Author.java b/backend/src/main/java/com/storycove/entity/Author.java index 55d4c8a..c820244 100644 --- a/backend/src/main/java/com/storycove/entity/Author.java +++ b/backend/src/main/java/com/storycove/entity/Author.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; +import com.fasterxml.jackson.annotation.JsonManagedReference; import java.time.LocalDateTime; import java.util.ArrayList; @@ -40,6 +41,7 @@ public class Author { private List urls = new ArrayList<>(); @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JsonManagedReference("author-stories") private List stories = new ArrayList<>(); @CreationTimestamp diff --git a/backend/src/main/java/com/storycove/entity/Collection.java b/backend/src/main/java/com/storycove/entity/Collection.java new file mode 100644 index 0000000..efd4251 --- /dev/null +++ b/backend/src/main/java/com/storycove/entity/Collection.java @@ -0,0 +1,233 @@ +package com.storycove.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import com.fasterxml.jackson.annotation.JsonManagedReference; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "collections") +public class Collection { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @NotBlank(message = "Collection name is required") + @Size(max = 500, message = "Collection name must not exceed 500 characters") + @Column(nullable = false, length = 500) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "rating") + private Integer rating; + + @Column(name = "cover_image_path", length = 500) + private String coverImagePath; + + @Column(name = "is_archived", nullable = false) + private Boolean isArchived = false; + + @OneToMany(mappedBy = "collection", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("position ASC") + @JsonManagedReference("collection-stories") + private List collectionStories = new ArrayList<>(); + + @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + @JoinTable( + name = "collection_tags", + joinColumns = @JoinColumn(name = "collection_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + private Set tags = new HashSet<>(); + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public Collection() {} + + public Collection(String name) { + this.name = name; + } + + public Collection(String name, String description) { + this.name = name; + this.description = description; + } + + // Helper methods for managing collection stories + public void addStory(Story story, int position) { + CollectionStory collectionStory = new CollectionStory(); + collectionStory.setCollection(this); + collectionStory.setStory(story); + collectionStory.setPosition(position); + collectionStories.add(collectionStory); + } + + public void removeStory(UUID storyId) { + collectionStories.removeIf(cs -> cs.getStory().getId().equals(storyId)); + } + + public void reorderStories(List storyIds) { + for (int i = 0; i < storyIds.size(); i++) { + UUID storyId = storyIds.get(i); + final int position = (i + 1) * 1000; // Gap-based positioning + collectionStories.stream() + .filter(cs -> cs.getStory().getId().equals(storyId)) + .findFirst() + .ifPresent(cs -> cs.setPosition(position)); + } + } + + public void addTag(Tag tag) { + tags.add(tag); + } + + public void removeTag(Tag tag) { + tags.remove(tag); + } + + // Calculated properties + public int getStoryCount() { + return collectionStories.size(); + } + + public int getTotalWordCount() { + return collectionStories.stream() + .mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0) + .sum(); + } + + public int getEstimatedReadingTime() { + // Assuming 200 words per minute reading speed + return Math.max(1, getTotalWordCount() / 200); + } + + public Double getAverageStoryRating() { + return collectionStories.stream() + .filter(cs -> cs.getStory().getRating() != null) + .mapToInt(cs -> cs.getStory().getRating()) + .average() + .orElse(0.0); + } + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + public String getCoverImagePath() { + return coverImagePath; + } + + public void setCoverImagePath(String coverImagePath) { + this.coverImagePath = coverImagePath; + } + + public Boolean getIsArchived() { + return isArchived; + } + + public void setIsArchived(Boolean isArchived) { + this.isArchived = isArchived; + } + + public List getCollectionStories() { + return collectionStories; + } + + public void setCollectionStories(List collectionStories) { + this.collectionStories = collectionStories; + } + + public Set getTags() { + return tags; + } + + public void setTags(Set tags) { + this.tags = tags; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Collection)) return false; + Collection collection = (Collection) o; + return id != null && id.equals(collection.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + @Override + public String toString() { + return "Collection{" + + "id=" + id + + ", name='" + name + '\'' + + ", storyCount=" + getStoryCount() + + ", isArchived=" + isArchived + + '}'; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/entity/CollectionStory.java b/backend/src/main/java/com/storycove/entity/CollectionStory.java new file mode 100644 index 0000000..5665fd7 --- /dev/null +++ b/backend/src/main/java/com/storycove/entity/CollectionStory.java @@ -0,0 +1,114 @@ +package com.storycove.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; +import com.fasterxml.jackson.annotation.JsonBackReference; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "collection_stories", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"collection_id", "position"}) + }) +public class CollectionStory { + + @EmbeddedId + private CollectionStoryId id; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("collectionId") + @JoinColumn(name = "collection_id") + @JsonBackReference("collection-stories") + private Collection collection; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("storyId") + @JoinColumn(name = "story_id") + private Story story; + + @Column(nullable = false) + private Integer position; + + @CreationTimestamp + @Column(name = "added_at", nullable = false, updatable = false) + private LocalDateTime addedAt; + + public CollectionStory() {} + + public CollectionStory(Collection collection, Story story, Integer position) { + this.id = new CollectionStoryId(collection.getId(), story.getId()); + this.collection = collection; + this.story = story; + this.position = position; + } + + // Getters and Setters + public CollectionStoryId getId() { + return id; + } + + public void setId(CollectionStoryId id) { + this.id = id; + } + + public Collection getCollection() { + return collection; + } + + public void setCollection(Collection collection) { + this.collection = collection; + if (this.story != null) { + this.id = new CollectionStoryId(collection.getId(), this.story.getId()); + } + } + + public Story getStory() { + return story; + } + + public void setStory(Story story) { + this.story = story; + if (this.collection != null) { + this.id = new CollectionStoryId(this.collection.getId(), story.getId()); + } + } + + public Integer getPosition() { + return position; + } + + public void setPosition(Integer position) { + this.position = position; + } + + public LocalDateTime getAddedAt() { + return addedAt; + } + + public void setAddedAt(LocalDateTime addedAt) { + this.addedAt = addedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CollectionStory)) return false; + CollectionStory that = (CollectionStory) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + @Override + public String toString() { + return "CollectionStory{" + + "collectionId=" + (collection != null ? collection.getId() : null) + + ", storyId=" + (story != null ? story.getId() : null) + + ", position=" + position + + '}'; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/entity/CollectionStoryId.java b/backend/src/main/java/com/storycove/entity/CollectionStoryId.java new file mode 100644 index 0000000..80620dc --- /dev/null +++ b/backend/src/main/java/com/storycove/entity/CollectionStoryId.java @@ -0,0 +1,61 @@ +package com.storycove.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.UUID; + +@Embeddable +public class CollectionStoryId implements java.io.Serializable { + + @Column(name = "collection_id") + private UUID collectionId; + + @Column(name = "story_id") + private UUID storyId; + + public CollectionStoryId() {} + + public CollectionStoryId(UUID collectionId, UUID storyId) { + this.collectionId = collectionId; + this.storyId = storyId; + } + + // Getters and Setters + public UUID getCollectionId() { + return collectionId; + } + + public void setCollectionId(UUID collectionId) { + this.collectionId = collectionId; + } + + public UUID getStoryId() { + return storyId; + } + + public void setStoryId(UUID storyId) { + this.storyId = storyId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CollectionStoryId)) return false; + CollectionStoryId that = (CollectionStoryId) o; + return collectionId != null && collectionId.equals(that.collectionId) && + storyId != null && storyId.equals(that.storyId); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(collectionId, storyId); + } + + @Override + public String toString() { + return "CollectionStoryId{" + + "collectionId=" + collectionId + + ", storyId=" + storyId + + '}'; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/entity/Series.java b/backend/src/main/java/com/storycove/entity/Series.java index a2a46b5..6b2e037 100644 --- a/backend/src/main/java/com/storycove/entity/Series.java +++ b/backend/src/main/java/com/storycove/entity/Series.java @@ -4,6 +4,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.hibernate.annotations.CreationTimestamp; +import com.fasterxml.jackson.annotation.JsonManagedReference; import java.time.LocalDateTime; import java.util.ArrayList; @@ -29,6 +30,7 @@ public class Series { @OneToMany(mappedBy = "series", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OrderBy("volume ASC") + @JsonManagedReference("series-stories") private List stories = new ArrayList<>(); @CreationTimestamp diff --git a/backend/src/main/java/com/storycove/entity/Story.java b/backend/src/main/java/com/storycove/entity/Story.java index 706c6be..ffb5d71 100644 --- a/backend/src/main/java/com/storycove/entity/Story.java +++ b/backend/src/main/java/com/storycove/entity/Story.java @@ -6,6 +6,8 @@ import jakarta.validation.constraints.Size; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import org.jsoup.Jsoup; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonBackReference; import java.time.LocalDateTime; import java.util.HashSet; @@ -55,10 +57,12 @@ public class Story { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "author_id") + @JsonBackReference("author-stories") private Author author; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "series_id") + @JsonBackReference("series-stories") private Series series; @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) @@ -67,6 +71,7 @@ public class Story { joinColumns = @JoinColumn(name = "story_id"), inverseJoinColumns = @JoinColumn(name = "tag_id") ) + @JsonManagedReference("story-tags") private Set tags = new HashSet<>(); @CreationTimestamp diff --git a/backend/src/main/java/com/storycove/entity/Tag.java b/backend/src/main/java/com/storycove/entity/Tag.java index a5e61e5..4f5867e 100644 --- a/backend/src/main/java/com/storycove/entity/Tag.java +++ b/backend/src/main/java/com/storycove/entity/Tag.java @@ -4,6 +4,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import org.hibernate.annotations.CreationTimestamp; +import com.fasterxml.jackson.annotation.JsonBackReference; import java.time.LocalDateTime; import java.util.HashSet; @@ -25,6 +26,7 @@ public class Tag { @ManyToMany(mappedBy = "tags") + @JsonBackReference("story-tags") private Set stories = new HashSet<>(); @CreationTimestamp diff --git a/backend/src/main/java/com/storycove/repository/CollectionRepository.java b/backend/src/main/java/com/storycove/repository/CollectionRepository.java new file mode 100644 index 0000000..30ffa38 --- /dev/null +++ b/backend/src/main/java/com/storycove/repository/CollectionRepository.java @@ -0,0 +1,48 @@ +package com.storycove.repository; + +import com.storycove.entity.Collection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface CollectionRepository extends JpaRepository { + + /** + * Find collection by ID with tags eagerly loaded + * Used for detailed collection retrieval + */ + @Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags WHERE c.id = :id") + Optional findByIdWithTags(@Param("id") UUID id); + + /** + * Find collection by ID with full story details + * Used for collection detail view with story list + */ + @Query("SELECT c FROM Collection c " + + "LEFT JOIN FETCH c.collectionStories cs " + + "LEFT JOIN FETCH cs.story s " + + "LEFT JOIN FETCH s.author " + + "LEFT JOIN FETCH c.tags " + + "WHERE c.id = :id " + + "ORDER BY cs.position ASC") + Optional findByIdWithStoriesAndTags(@Param("id") UUID id); + + /** + * Count all collections for statistics + */ + long countByIsArchivedFalse(); + + /** + * Find all collections with basic info (for batch operations) + * NOTE: This method should only be used for operations that require all collections + * For search/filter/list operations, use TypesenseService instead + */ + @Query("SELECT c FROM Collection c WHERE c.isArchived = false ORDER BY c.updatedAt DESC") + List findAllActiveCollections(); +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/CollectionStoryRepository.java b/backend/src/main/java/com/storycove/repository/CollectionStoryRepository.java new file mode 100644 index 0000000..382292c --- /dev/null +++ b/backend/src/main/java/com/storycove/repository/CollectionStoryRepository.java @@ -0,0 +1,93 @@ +package com.storycove.repository; + +import com.storycove.entity.CollectionStory; +import com.storycove.entity.CollectionStoryId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface CollectionStoryRepository extends JpaRepository { + + /** + * Find all stories in a collection ordered by position + */ + @Query("SELECT cs FROM CollectionStory cs " + + "LEFT JOIN FETCH cs.story s " + + "LEFT JOIN FETCH s.author " + + "WHERE cs.collection.id = :collectionId " + + "ORDER BY cs.position ASC") + List findByCollectionIdOrderByPosition(@Param("collectionId") UUID collectionId); + + /** + * Find story by collection and story ID + */ + @Query("SELECT cs FROM CollectionStory cs " + + "WHERE cs.collection.id = :collectionId AND cs.story.id = :storyId") + CollectionStory findByCollectionIdAndStoryId(@Param("collectionId") UUID collectionId, @Param("storyId") UUID storyId); + + /** + * Get next available position in collection + */ + @Query("SELECT COALESCE(MAX(cs.position), 0) + 1000 FROM CollectionStory cs WHERE cs.collection.id = :collectionId") + Integer getNextPosition(@Param("collectionId") UUID collectionId); + + /** + * Remove all stories from a collection (used when deleting collection) + */ + @Modifying + @Query("DELETE FROM CollectionStory cs WHERE cs.collection.id = :collectionId") + void deleteByCollectionId(@Param("collectionId") UUID collectionId); + + /** + * Update positions for stories in a collection + * Used for bulk position updates during reordering + */ + @Modifying + @Query("UPDATE CollectionStory cs SET cs.position = :position " + + "WHERE cs.collection.id = :collectionId AND cs.story.id = :storyId") + void updatePosition(@Param("collectionId") UUID collectionId, + @Param("storyId") UUID storyId, + @Param("position") Integer position); + + /** + * Check if a story already exists in a collection + */ + boolean existsByCollectionIdAndStoryId(UUID collectionId, UUID storyId); + + /** + * Count stories in a collection + */ + long countByCollectionId(UUID collectionId); + + /** + * Find all collections that contain a specific story + */ + @Query("SELECT cs FROM CollectionStory cs " + + "LEFT JOIN FETCH cs.collection c " + + "WHERE cs.story.id = :storyId " + + "ORDER BY c.name ASC") + List findByStoryId(@Param("storyId") UUID storyId); + + /** + * Find previous and next stories for reading navigation + */ + @Query("SELECT cs FROM CollectionStory cs " + + "WHERE cs.collection.id = :collectionId " + + "AND cs.position < (SELECT current.position FROM CollectionStory current " + + " WHERE current.collection.id = :collectionId AND current.story.id = :currentStoryId) " + + "ORDER BY cs.position DESC") + List findPreviousStory(@Param("collectionId") UUID collectionId, @Param("currentStoryId") UUID currentStoryId); + + @Query("SELECT cs FROM CollectionStory cs " + + "WHERE cs.collection.id = :collectionId " + + "AND cs.position > (SELECT current.position FROM CollectionStory current " + + " WHERE current.collection.id = :collectionId AND current.story.id = :currentStoryId) " + + "ORDER BY cs.position ASC") + List findNextStory(@Param("collectionId") UUID collectionId, @Param("currentStoryId") UUID currentStoryId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/CollectionSearchResult.java b/backend/src/main/java/com/storycove/service/CollectionSearchResult.java new file mode 100644 index 0000000..a9a1eb9 --- /dev/null +++ b/backend/src/main/java/com/storycove/service/CollectionSearchResult.java @@ -0,0 +1,56 @@ +package com.storycove.service; + +import com.storycove.entity.Collection; + +/** + * Special Collection subclass for search results that provides pre-calculated statistics + * to avoid lazy loading issues when displaying collection lists. + */ +public class CollectionSearchResult extends Collection { + + private Integer storedStoryCount; + private Integer storedTotalWordCount; + + public CollectionSearchResult(Collection collection) { + this.setId(collection.getId()); + this.setName(collection.getName()); + this.setDescription(collection.getDescription()); + this.setRating(collection.getRating()); + this.setIsArchived(collection.getIsArchived()); + this.setCreatedAt(collection.getCreatedAt()); + this.setUpdatedAt(collection.getUpdatedAt()); + this.setCoverImagePath(collection.getCoverImagePath()); + // Note: don't copy collectionStories or tags to avoid lazy loading issues + } + + public void setStoredStoryCount(Integer storyCount) { + this.storedStoryCount = storyCount; + } + + public void setStoredTotalWordCount(Integer totalWordCount) { + this.storedTotalWordCount = totalWordCount; + } + + @Override + public int getStoryCount() { + return storedStoryCount != null ? storedStoryCount : 0; + } + + @Override + public int getTotalWordCount() { + return storedTotalWordCount != null ? storedTotalWordCount : 0; + } + + @Override + public int getEstimatedReadingTime() { + // Assuming 200 words per minute reading speed + return Math.max(1, getTotalWordCount() / 200); + } + + @Override + public Double getAverageStoryRating() { + // For search results, we don't calculate average rating to avoid complexity + // This would require loading all stories. Can be enhanced later if needed. + return null; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/CollectionService.java b/backend/src/main/java/com/storycove/service/CollectionService.java new file mode 100644 index 0000000..6c408cd --- /dev/null +++ b/backend/src/main/java/com/storycove/service/CollectionService.java @@ -0,0 +1,423 @@ +package com.storycove.service; + +import com.storycove.dto.SearchResultDto; +import com.storycove.entity.Collection; +import com.storycove.entity.CollectionStory; +import com.storycove.entity.Story; +import com.storycove.entity.Tag; +import com.storycove.repository.CollectionRepository; +import com.storycove.repository.CollectionStoryRepository; +import com.storycove.repository.StoryRepository; +import com.storycove.repository.TagRepository; +import com.storycove.service.exception.DuplicateResourceException; +import com.storycove.service.exception.ResourceNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional +public class CollectionService { + + private static final Logger logger = LoggerFactory.getLogger(CollectionService.class); + + private final CollectionRepository collectionRepository; + private final CollectionStoryRepository collectionStoryRepository; + private final StoryRepository storyRepository; + private final TagRepository tagRepository; + private final TypesenseService typesenseService; + + @Autowired + public CollectionService(CollectionRepository collectionRepository, + CollectionStoryRepository collectionStoryRepository, + StoryRepository storyRepository, + TagRepository tagRepository, + @Autowired(required = false) TypesenseService typesenseService) { + this.collectionRepository = collectionRepository; + this.collectionStoryRepository = collectionStoryRepository; + this.storyRepository = storyRepository; + this.tagRepository = tagRepository; + this.typesenseService = typesenseService; + } + + /** + * Search collections using Typesense (MANDATORY for all search/filter operations) + * This method MUST be used instead of JPA queries for listing collections + */ + public SearchResultDto searchCollections(String query, List tags, boolean includeArchived, int page, int limit) { + if (typesenseService == null) { + logger.warn("Typesense service not available, returning empty results"); + return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0); + } + + // Delegate to TypesenseService for all search operations + return typesenseService.searchCollections(query, tags, includeArchived, page, limit); + } + + /** + * Find collection by ID with full details + */ + public Collection findById(UUID id) { + return collectionRepository.findByIdWithStoriesAndTags(id) + .orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id)); + } + + /** + * Find collection by ID with basic info only + */ + public Collection findByIdBasic(UUID id) { + return collectionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id)); + } + + /** + * Create a new collection with optional initial stories + */ + public Collection createCollection(String name, String description, List tagNames, List initialStoryIds) { + Collection collection = new Collection(name, description); + + // Add tags if provided + if (tagNames != null && !tagNames.isEmpty()) { + Set tags = findOrCreateTags(tagNames); + collection.setTags(tags); + } + + Collection savedCollection = collectionRepository.save(collection); + + // Add initial stories if provided + if (initialStoryIds != null && !initialStoryIds.isEmpty()) { + addStoriesToCollection(savedCollection.getId(), initialStoryIds, null); + // Reload to get updated collection with stories + savedCollection = findById(savedCollection.getId()); + } + + // Index in Typesense + if (typesenseService != null) { + typesenseService.indexCollection(savedCollection); + } + + logger.info("Created collection: {} with {} stories", name, initialStoryIds != null ? initialStoryIds.size() : 0); + return savedCollection; + } + + /** + * Update collection metadata + */ + public Collection updateCollection(UUID id, String name, String description, List tagNames, Integer rating) { + Collection collection = findByIdBasic(id); + + if (name != null) { + collection.setName(name); + } + if (description != null) { + collection.setDescription(description); + } + if (rating != null) { + collection.setRating(rating); + } + + // Update tags if provided + if (tagNames != null) { + Set tags = findOrCreateTags(tagNames); + collection.setTags(tags); + } + + Collection savedCollection = collectionRepository.save(collection); + + // Update in Typesense + if (typesenseService != null) { + typesenseService.indexCollection(savedCollection); + } + + logger.info("Updated collection: {}", id); + return savedCollection; + } + + /** + * Delete a collection (stories remain in the system) + */ + public void deleteCollection(UUID id) { + Collection collection = findByIdBasic(id); + + // Remove from Typesense first + if (typesenseService != null) { + typesenseService.removeCollection(id); + } + + collectionRepository.delete(collection); + logger.info("Deleted collection: {}", id); + } + + /** + * Archive or unarchive a collection + */ + public Collection archiveCollection(UUID id, boolean archived) { + Collection collection = findByIdBasic(id); + collection.setIsArchived(archived); + + Collection savedCollection = collectionRepository.save(collection); + + // Update in Typesense + if (typesenseService != null) { + typesenseService.indexCollection(savedCollection); + } + + logger.info("{} collection: {}", archived ? "Archived" : "Unarchived", id); + return savedCollection; + } + + /** + * Add stories to a collection + */ + public Map addStoriesToCollection(UUID collectionId, List storyIds, Integer startPosition) { + Collection collection = findByIdBasic(collectionId); + + // Validate stories exist + List stories = storyRepository.findAllById(storyIds); + if (stories.size() != storyIds.size()) { + throw new ResourceNotFoundException("One or more stories not found"); + } + + int added = 0; + int skipped = 0; + + // Get starting position + int position = startPosition != null ? startPosition : collectionStoryRepository.getNextPosition(collectionId); + + for (UUID storyId : storyIds) { + // Check if story is already in collection + if (collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId)) { + skipped++; + continue; + } + + // Add story to collection + Story story = stories.stream() + .filter(s -> s.getId().equals(storyId)) + .findFirst() + .orElseThrow(); + + CollectionStory collectionStory = new CollectionStory(collection, story, position); + collectionStoryRepository.save(collectionStory); + + added++; + position += 1000; // Gap-based positioning + } + + // Update collection in Typesense + if (typesenseService != null) { + Collection updatedCollection = findById(collectionId); + typesenseService.indexCollection(updatedCollection); + } + + long totalStories = collectionStoryRepository.countByCollectionId(collectionId); + + logger.info("Added {} stories to collection {}, skipped {} duplicates", added, collectionId, skipped); + + return Map.of( + "added", added, + "skipped", skipped, + "totalStories", totalStories + ); + } + + /** + * Remove a story from a collection + */ + public void removeStoryFromCollection(UUID collectionId, UUID storyId) { + if (!collectionStoryRepository.existsByCollectionIdAndStoryId(collectionId, storyId)) { + throw new ResourceNotFoundException("Story not found in collection"); + } + + CollectionStory collectionStory = collectionStoryRepository.findByCollectionIdAndStoryId(collectionId, storyId); + collectionStoryRepository.delete(collectionStory); + + // Update collection in Typesense + if (typesenseService != null) { + Collection updatedCollection = findById(collectionId); + typesenseService.indexCollection(updatedCollection); + } + + logger.info("Removed story {} from collection {}", storyId, collectionId); + } + + /** + * Reorder stories in a collection + */ + @Transactional + public void reorderStories(UUID collectionId, List> storyOrders) { + Collection collection = findByIdBasic(collectionId); + + // Two-phase update to avoid unique constraint violations: + // Phase 1: Set all positions to negative values (temporary) + logger.debug("Phase 1: Setting temporary negative positions for collection {}", collectionId); + for (int i = 0; i < storyOrders.size(); i++) { + Map order = storyOrders.get(i); + UUID storyId = UUID.fromString(String.valueOf(order.get("storyId"))); + + // Set temporary negative position to avoid conflicts + collectionStoryRepository.updatePosition(collectionId, storyId, -(i + 1)); + } + + // Phase 2: Set final positions + logger.debug("Phase 2: Setting final positions for collection {}", collectionId); + for (Map order : storyOrders) { + UUID storyId = UUID.fromString(String.valueOf(order.get("storyId"))); + Integer position = (Integer) order.get("position"); + + collectionStoryRepository.updatePosition(collectionId, storyId, position * 1000); // Gap-based positioning + } + + // Update collection in Typesense + if (typesenseService != null) { + Collection updatedCollection = findById(collectionId); + typesenseService.indexCollection(updatedCollection); + } + + logger.info("Reordered {} stories in collection {}", storyOrders.size(), collectionId); + } + + /** + * Get story with collection reading context + */ + public Map getStoryWithCollectionContext(UUID collectionId, UUID storyId) { + Collection collection = findByIdBasic(collectionId); + Story story = storyRepository.findById(storyId) + .orElseThrow(() -> new ResourceNotFoundException("Story not found: " + storyId)); + + // Find current position + CollectionStory currentStory = collectionStoryRepository.findByCollectionIdAndStoryId(collectionId, storyId); + if (currentStory == null) { + throw new ResourceNotFoundException("Story not found in collection"); + } + + // Find previous and next stories + List previousStories = collectionStoryRepository.findPreviousStory(collectionId, storyId); + List nextStories = collectionStoryRepository.findNextStory(collectionId, storyId); + + UUID previousStoryId = previousStories.isEmpty() ? null : previousStories.get(0).getStory().getId(); + UUID nextStoryId = nextStories.isEmpty() ? null : nextStories.get(0).getStory().getId(); + + // Get current position in collection + List allStories = collectionStoryRepository.findByCollectionIdOrderByPosition(collectionId); + int currentPosition = 0; + for (int i = 0; i < allStories.size(); i++) { + if (allStories.get(i).getStory().getId().equals(storyId)) { + currentPosition = i + 1; + break; + } + } + + Map collectionContext = Map.of( + "id", collection.getId(), + "name", collection.getName(), + "currentPosition", currentPosition, + "totalStories", allStories.size(), + "previousStoryId", previousStoryId != null ? previousStoryId : "", + "nextStoryId", nextStoryId != null ? nextStoryId : "" + ); + + return Map.of( + "story", story, + "collection", collectionContext + ); + } + + /** + * Get collection statistics + */ + public Map getCollectionStatistics(UUID collectionId) { + Collection collection = findById(collectionId); + + List collectionStories = collection.getCollectionStories(); + + // Calculate statistics + int totalStories = collectionStories.size(); + int totalWordCount = collectionStories.stream() + .mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0) + .sum(); + int estimatedReadingTime = Math.max(1, totalWordCount / 200); // 200 words per minute + + double averageStoryRating = collectionStories.stream() + .filter(cs -> cs.getStory().getRating() != null) + .mapToInt(cs -> cs.getStory().getRating()) + .average() + .orElse(0.0); + + double averageWordCount = totalStories > 0 ? (double) totalWordCount / totalStories : 0.0; + + // Tag frequency + Map tagFrequency = collectionStories.stream() + .flatMap(cs -> cs.getStory().getTags().stream()) + .collect(Collectors.groupingBy(Tag::getName, Collectors.counting())); + + // Author distribution + List> authorDistribution = collectionStories.stream() + .filter(cs -> cs.getStory().getAuthor() != null) + .collect(Collectors.groupingBy(cs -> cs.getStory().getAuthor().getName(), Collectors.counting())) + .entrySet().stream() + .map(entry -> Map.of( + "authorName", entry.getKey(), + "storyCount", entry.getValue() + )) + .sorted((a, b) -> Long.compare((Long) b.get("storyCount"), (Long) a.get("storyCount"))) + .collect(Collectors.toList()); + + return Map.of( + "totalStories", totalStories, + "totalWordCount", totalWordCount, + "estimatedReadingTime", estimatedReadingTime, + "averageStoryRating", Math.round(averageStoryRating * 100.0) / 100.0, + "averageWordCount", Math.round(averageWordCount), + "tagFrequency", tagFrequency, + "authorDistribution", authorDistribution + ); + } + + /** + * Find or create tags by names + */ + private Set findOrCreateTags(List tagNames) { + Set tags = new HashSet<>(); + + for (String tagName : tagNames) { + String trimmedName = tagName.trim(); + if (!trimmedName.isEmpty()) { + Tag tag = tagRepository.findByName(trimmedName) + .orElseGet(() -> { + Tag newTag = new Tag(); + newTag.setName(trimmedName); + return tagRepository.save(newTag); + }); + tags.add(tag); + } + } + + return tags; + } + + /** + * Get collections that contain a specific story + */ + public List getCollectionsForStory(UUID storyId) { + List collectionStories = collectionStoryRepository.findByStoryId(storyId); + return collectionStories.stream() + .map(CollectionStory::getCollection) + .collect(Collectors.toList()); + } + + /** + * Get all collections for indexing (used by TypesenseService) + */ + public List findAllForIndexing() { + return collectionRepository.findAllActiveCollections(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/TypesenseService.java b/backend/src/main/java/com/storycove/service/TypesenseService.java index 2c4079c..e5e99d5 100644 --- a/backend/src/main/java/com/storycove/service/TypesenseService.java +++ b/backend/src/main/java/com/storycove/service/TypesenseService.java @@ -4,7 +4,10 @@ import com.storycove.dto.AuthorSearchDto; import com.storycove.dto.SearchResultDto; import com.storycove.dto.StorySearchDto; import com.storycove.entity.Author; +import com.storycove.entity.Collection; +import com.storycove.entity.CollectionStory; import com.storycove.entity.Story; +import com.storycove.repository.CollectionStoryRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +17,7 @@ import org.typesense.api.Client; import org.typesense.model.*; import jakarta.annotation.PostConstruct; +import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -24,12 +28,16 @@ public class TypesenseService { private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class); private static final String STORIES_COLLECTION = "stories"; private static final String AUTHORS_COLLECTION = "authors"; + private static final String COLLECTIONS_COLLECTION = "collections"; private final Client typesenseClient; + private final CollectionStoryRepository collectionStoryRepository; @Autowired - public TypesenseService(Client typesenseClient) { + public TypesenseService(Client typesenseClient, + @Autowired(required = false) CollectionStoryRepository collectionStoryRepository) { this.typesenseClient = typesenseClient; + this.collectionStoryRepository = collectionStoryRepository; } @PostConstruct @@ -37,6 +45,7 @@ public class TypesenseService { try { createStoriesCollectionIfNotExists(); createAuthorsCollectionIfNotExists(); + createCollectionsCollectionIfNotExists(); } catch (Exception e) { logger.error("Failed to initialize Typesense collections", e); } @@ -936,4 +945,287 @@ public class TypesenseService { return value; } + + // Collections support methods + + private void createCollectionsCollectionIfNotExists() throws Exception { + try { + // Check if collection already exists + typesenseClient.collections(COLLECTIONS_COLLECTION).retrieve(); + logger.info("Collections collection already exists"); + } catch (Exception e) { + logger.info("Creating collections collection..."); + createCollectionsCollection(); + } + } + + private void createCollectionsCollection() throws Exception { + List fields = Arrays.asList( + new Field().name("id").type("string").facet(false), + new Field().name("name").type("string").facet(false), + new Field().name("description").type("string").facet(false).optional(true), + new Field().name("tags").type("string[]").facet(true).optional(true), + new Field().name("story_count").type("int32").facet(true), + new Field().name("total_word_count").type("int32").facet(true), + new Field().name("rating").type("int32").facet(true).optional(true), + new Field().name("is_archived").type("bool").facet(true), + new Field().name("created_at").type("int64").facet(false), + new Field().name("updated_at").type("int64").facet(false) + ); + + CollectionSchema collectionSchema = new CollectionSchema() + .name(COLLECTIONS_COLLECTION) + .fields(fields) + .defaultSortingField("updated_at"); + + typesenseClient.collections().create(collectionSchema); + logger.info("Collections collection created successfully"); + } + + /** + * Search collections using Typesense + * This is the MANDATORY method for all collection search/filter operations + */ + public SearchResultDto searchCollections(String query, List tags, boolean includeArchived, int page, int limit) { + long startTime = System.currentTimeMillis(); + + try { + String normalizedQuery = (query == null || query.trim().isEmpty()) ? "*" : query.trim(); + + SearchParameters searchParameters = new SearchParameters() + .q(normalizedQuery) + .queryBy("name,description") + .page(page + 1) // Typesense uses 1-based pagination + .perPage(limit) + .sortBy("updated_at:desc"); + + // Add filters + List filterConditions = new ArrayList<>(); + + if (!includeArchived) { + filterConditions.add("is_archived:=false"); + } + + if (tags != null && !tags.isEmpty()) { + String tagFilter = tags.stream() + .map(tag -> "tags:=" + escapeTypesenseValue(tag)) + .collect(Collectors.joining(" || ")); + filterConditions.add("(" + tagFilter + ")"); + } + + if (!filterConditions.isEmpty()) { + String finalFilter = String.join(" && ", filterConditions); + searchParameters.filterBy(finalFilter); + } + + SearchResult searchResult = typesenseClient.collections(COLLECTIONS_COLLECTION) + .documents() + .search(searchParameters); + + List results = convertCollectionSearchResult(searchResult); + long searchTime = System.currentTimeMillis() - startTime; + + return new SearchResultDto<>( + results, + searchResult.getFound(), + page, + limit, + query != null ? query : "", + searchTime + ); + + } catch (Exception e) { + logger.error("Collection search failed for query: " + query, e); + return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0); + } + } + + /** + * Index a collection in Typesense + */ + public void indexCollection(Collection collection) { + try { + Map document = createCollectionDocument(collection); + typesenseClient.collections(COLLECTIONS_COLLECTION).documents().upsert(document); + logger.debug("Indexed collection: {}", collection.getName()); + } catch (Exception e) { + logger.error("Failed to index collection: " + collection.getId(), e); + } + } + + /** + * Remove a collection from Typesense index + */ + public void removeCollection(UUID collectionId) { + try { + typesenseClient.collections(COLLECTIONS_COLLECTION).documents(collectionId.toString()).delete(); + logger.debug("Removed collection from index: {}", collectionId); + } catch (Exception e) { + logger.error("Failed to remove collection from index: " + collectionId, e); + } + } + + /** + * Bulk index collections + */ + public void bulkIndexCollections(List collections) { + if (collections == null || collections.isEmpty()) { + return; + } + + try { + List> documents = collections.stream() + .map(this::createCollectionDocument) + .collect(Collectors.toList()); + + for (Map document : documents) { + typesenseClient.collections(COLLECTIONS_COLLECTION).documents().create(document); + } + logger.info("Bulk indexed {} collections", collections.size()); + + } catch (Exception e) { + logger.error("Failed to bulk index collections", e); + } + } + + /** + * Reindex all collections + */ + public void reindexAllCollections(List collections) { + try { + // Clear existing collection + try { + typesenseClient.collections(COLLECTIONS_COLLECTION).delete(); + } catch (Exception e) { + logger.debug("Collection didn't exist for deletion: {}", e.getMessage()); + } + + // Recreate collection + createCollectionsCollection(); + + // Bulk index all collections + bulkIndexCollections(collections); + + logger.info("Reindexed {} collections", collections.size()); + } catch (Exception e) { + logger.error("Failed to reindex collections", e); + } + } + + /** + * Create Typesense document from Collection entity + */ + private Map createCollectionDocument(Collection collection) { + Map document = new HashMap<>(); + + document.put("id", collection.getId().toString()); + document.put("name", collection.getName()); + document.put("description", collection.getDescription() != null ? collection.getDescription() : ""); + + // Tags - safely get tag names without triggering lazy loading issues + List tagNames = new ArrayList<>(); + if (collection.getTags() != null) { + try { + tagNames = collection.getTags().stream() + .map(tag -> tag.getName()) + .collect(Collectors.toList()); + } catch (Exception e) { + logger.warn("Failed to load tags for collection {}, using empty list", collection.getId()); + tagNames = new ArrayList<>(); + } + } + document.put("tags", tagNames); + + // Statistics - calculate safely using repository queries to avoid lazy loading issues + int storyCount = 0; + int totalWordCount = 0; + + try { + if (collectionStoryRepository != null) { + // Use repository count instead of accessing entity collection + storyCount = (int) collectionStoryRepository.countByCollectionId(collection.getId()); + + // For word count, we'll calculate it via a repository query to avoid lazy loading + List collectionStories = collectionStoryRepository.findByCollectionIdOrderByPosition(collection.getId()); + totalWordCount = collectionStories.stream() + .mapToInt(cs -> { + try { + Integer wordCount = cs.getStory().getWordCount(); + return wordCount != null ? wordCount : 0; + } catch (Exception e) { + logger.debug("Failed to get word count for story in collection {}", collection.getId()); + return 0; + } + }) + .sum(); + } + } catch (Exception e) { + logger.warn("Failed to calculate statistics for collection {}, using defaults: {}", collection.getId(), e.getMessage()); + storyCount = 0; + totalWordCount = 0; + } + + document.put("story_count", storyCount); + document.put("total_word_count", totalWordCount); + document.put("rating", collection.getRating()); + document.put("cover_image_path", collection.getCoverImagePath()); + document.put("is_archived", collection.getIsArchived() != null ? collection.getIsArchived() : false); + + // Timestamps + document.put("created_at", collection.getCreatedAt().toEpochSecond(java.time.ZoneOffset.UTC)); + document.put("updated_at", collection.getUpdatedAt().toEpochSecond(java.time.ZoneOffset.UTC)); + + return document; + } + + /** + * Convert Typesense search result to Collection entities + */ + private List convertCollectionSearchResult(SearchResult searchResult) { + List collections = new ArrayList<>(); + + if (searchResult.getHits() != null) { + for (SearchResultHit hit : searchResult.getHits()) { + try { + Map doc = hit.getDocument(); + + Collection collection = new Collection(); + collection.setId(UUID.fromString((String) doc.get("id"))); + collection.setName((String) doc.get("name")); + collection.setDescription((String) doc.get("description")); + collection.setRating(doc.get("rating") != null ? ((Number) doc.get("rating")).intValue() : null); + collection.setCoverImagePath((String) doc.get("cover_image_path")); + collection.setIsArchived((Boolean) doc.get("is_archived")); + + // Set timestamps + if (doc.get("created_at") != null) { + long createdAtSeconds = ((Number) doc.get("created_at")).longValue(); + collection.setCreatedAt(LocalDateTime.ofEpochSecond(createdAtSeconds, 0, java.time.ZoneOffset.UTC)); + } + if (doc.get("updated_at") != null) { + long updatedAtSeconds = ((Number) doc.get("updated_at")).longValue(); + collection.setUpdatedAt(LocalDateTime.ofEpochSecond(updatedAtSeconds, 0, java.time.ZoneOffset.UTC)); + } + + // For list/search views, we create a special lightweight collection that stores + // the calculated values directly to avoid lazy loading issues + CollectionSearchResult searchCollection = new CollectionSearchResult(collection); + + // Set the calculated statistics from the Typesense document + if (doc.get("story_count") != null) { + searchCollection.setStoredStoryCount(((Number) doc.get("story_count")).intValue()); + } + if (doc.get("total_word_count") != null) { + searchCollection.setStoredTotalWordCount(((Number) doc.get("total_word_count")).intValue()); + } + + collections.add(searchCollection); + } catch (Exception e) { + logger.error("Error converting collection search result", e); + } + } + } + + return collections; + } } \ No newline at end of file diff --git a/frontend/src/app/collections/[id]/edit/page.tsx b/frontend/src/app/collections/[id]/edit/page.tsx new file mode 100644 index 0000000..3c9312f --- /dev/null +++ b/frontend/src/app/collections/[id]/edit/page.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { collectionApi } from '../../../../lib/api'; +import { Collection } from '../../../../types/api'; +import AppLayout from '../../../../components/layout/AppLayout'; +import CollectionForm from '../../../../components/collections/CollectionForm'; +import LoadingSpinner from '../../../../components/ui/LoadingSpinner'; + +export default function EditCollectionPage() { + const params = useParams(); + const router = useRouter(); + const collectionId = params.id as string; + + const [collection, setCollection] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const loadCollection = async () => { + try { + setLoading(true); + setError(null); + const data = await collectionApi.getCollection(collectionId); + setCollection(data); + } catch (err: any) { + console.error('Failed to load collection:', err); + setError(err.response?.data?.message || 'Failed to load collection'); + } finally { + setLoading(false); + } + }; + + if (collectionId) { + loadCollection(); + } + }, [collectionId]); + + const handleSubmit = async (formData: { + name: string; + description?: string; + tags?: string[]; + storyIds?: string[]; + coverImage?: File; + }) => { + if (!collection) return; + + try { + setSaving(true); + setError(null); + + // Update basic info + await collectionApi.updateCollection(collection.id, { + name: formData.name, + description: formData.description, + tagNames: formData.tags, + }); + + // Upload cover image if provided + if (formData.coverImage) { + await collectionApi.uploadCover(collection.id, formData.coverImage); + } + + // Redirect back to collection detail + router.push(`/collections/${collection.id}`); + } catch (err: any) { + console.error('Failed to update collection:', err); + setError(err.response?.data?.message || 'Failed to update collection'); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + router.push(`/collections/${collectionId}`); + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (error || !collection) { + return ( + +
+
+ {error || 'Collection not found'} +
+ +
+
+ ); + } + + const initialData = { + name: collection.name, + description: collection.description, + tags: collection.tags?.map(tag => tag.name) || [], + storyIds: collection.collectionStories?.map(cs => cs.story.id) || [], + coverImagePath: collection.coverImagePath, + }; + + return ( + +
+
+

Edit Collection

+

+ Update your collection details and organization. +

+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/collections/[id]/page.tsx b/frontend/src/app/collections/[id]/page.tsx new file mode 100644 index 0000000..be42e37 --- /dev/null +++ b/frontend/src/app/collections/[id]/page.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { collectionApi } from '../../../lib/api'; +import { Collection } from '../../../types/api'; +import AppLayout from '../../../components/layout/AppLayout'; +import CollectionDetailView from '../../../components/collections/CollectionDetailView'; +import LoadingSpinner from '../../../components/ui/LoadingSpinner'; + +export default function CollectionDetailPage() { + const params = useParams(); + const router = useRouter(); + const collectionId = params.id as string; + + const [collection, setCollection] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadCollection = async () => { + try { + setLoading(true); + setError(null); + const data = await collectionApi.getCollection(collectionId); + setCollection(data); + } catch (err: any) { + console.error('Failed to load collection:', err); + setError(err.response?.data?.message || 'Failed to load collection'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (collectionId) { + loadCollection(); + } + }, [collectionId]); + + const handleCollectionUpdate = () => { + loadCollection(); + }; + + const handleCollectionDelete = () => { + router.push('/collections'); + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (error || !collection) { + return ( + +
+
+ {error || 'Collection not found'} +
+ +
+
+ ); + } + + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/collections/[id]/read/[storyId]/page.tsx b/frontend/src/app/collections/[id]/read/[storyId]/page.tsx new file mode 100644 index 0000000..15f5c12 --- /dev/null +++ b/frontend/src/app/collections/[id]/read/[storyId]/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { collectionApi } from '../../../../../lib/api'; +import { StoryWithCollectionContext } from '../../../../../types/api'; +import AppLayout from '../../../../../components/layout/AppLayout'; +import CollectionReadingView from '../../../../../components/collections/CollectionReadingView'; +import LoadingSpinner from '../../../../../components/ui/LoadingSpinner'; + +export default function CollectionReadingPage() { + const params = useParams(); + const router = useRouter(); + const collectionId = params.id as string; + const storyId = params.storyId as string; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadStoryWithContext = async () => { + if (!collectionId || !storyId) return; + + try { + setLoading(true); + setError(null); + const result = await collectionApi.getStoryWithCollectionContext(collectionId, storyId); + setData(result); + } catch (err: any) { + console.error('Failed to load story with collection context:', err); + setError(err.response?.data?.message || 'Failed to load story'); + } finally { + setLoading(false); + } + }; + + loadStoryWithContext(); + }, [collectionId, storyId]); + + const handleNavigate = (newStoryId: string) => { + router.push(`/collections/${collectionId}/read/${newStoryId}`); + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (error || !data) { + return ( + +
+
+ {error || 'Story not found'} +
+ +
+
+ ); + } + + return ( + + router.push(`/collections/${collectionId}`)} + /> + + ); +} \ No newline at end of file diff --git a/frontend/src/app/collections/new/page.tsx b/frontend/src/app/collections/new/page.tsx new file mode 100644 index 0000000..a0bf08a --- /dev/null +++ b/frontend/src/app/collections/new/page.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { collectionApi } from '../../../lib/api'; +import AppLayout from '../../../components/layout/AppLayout'; +import CollectionForm from '../../../components/collections/CollectionForm'; +import { Collection } from '../../../types/api'; + +export default function NewCollectionPage() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const router = useRouter(); + + const handleSubmit = async (formData: { + name: string; + description?: string; + tags?: string[]; + storyIds?: string[]; + coverImage?: File; + }) => { + try { + setLoading(true); + setError(null); + + let collection: Collection; + + if (formData.coverImage) { + collection = await collectionApi.createCollectionWithImage({ + name: formData.name, + description: formData.description, + tags: formData.tags, + storyIds: formData.storyIds, + coverImage: formData.coverImage, + }); + } else { + collection = await collectionApi.createCollection({ + name: formData.name, + description: formData.description, + tagNames: formData.tags, + storyIds: formData.storyIds, + }); + } + + // Redirect to the new collection's detail page + router.push(`/collections/${collection.id}`); + } catch (err: any) { + console.error('Failed to create collection:', err); + setError(err.response?.data?.message || 'Failed to create collection'); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + router.push('/collections'); + }; + + return ( + +
+
+

Create New Collection

+

+ Organize your stories into a curated collection for better reading experience. +

+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/collections/page.tsx b/frontend/src/app/collections/page.tsx new file mode 100644 index 0000000..7f972d6 --- /dev/null +++ b/frontend/src/app/collections/page.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { collectionApi, tagApi } from '../../lib/api'; +import { Collection, Tag } from '../../types/api'; +import AppLayout from '../../components/layout/AppLayout'; +import { Input } from '../../components/ui/Input'; +import Button from '../../components/ui/Button'; +import CollectionGrid from '../../components/collections/CollectionGrid'; +import TagFilter from '../../components/stories/TagFilter'; +import LoadingSpinner from '../../components/ui/LoadingSpinner'; + +type ViewMode = 'grid' | 'list'; + +export default function CollectionsPage() { + const [collections, setCollections] = useState([]); + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTags, setSelectedTags] = useState([]); + const [viewMode, setViewMode] = useState('grid'); + const [showArchived, setShowArchived] = useState(false); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(20); + const [totalPages, setTotalPages] = useState(1); + const [totalCollections, setTotalCollections] = useState(0); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + // Load tags for filtering + useEffect(() => { + const loadTags = async () => { + try { + const tagsResult = await tagApi.getTags({ page: 0, size: 1000 }); + setTags(tagsResult?.content || []); + } catch (error) { + console.error('Failed to load tags:', error); + } + }; + + loadTags(); + }, []); + + // Load collections with search and filters + useEffect(() => { + const debounceTimer = setTimeout(() => { + const loadCollections = async () => { + try { + setLoading(true); + + const result = await collectionApi.getCollections({ + page: page, + limit: pageSize, + search: searchQuery.trim() || undefined, + tags: selectedTags.length > 0 ? selectedTags : undefined, + archived: showArchived, + }); + + setCollections(result?.results || []); + setTotalPages(Math.ceil((result?.totalHits || 0) / pageSize)); + setTotalCollections(result?.totalHits || 0); + } catch (error) { + console.error('Failed to load collections:', error); + setCollections([]); + } finally { + setLoading(false); + } + }; + + loadCollections(); + }, searchQuery ? 300 : 0); // Debounce search, but not other changes + + return () => clearTimeout(debounceTimer); + }, [searchQuery, selectedTags, page, pageSize, showArchived, refreshTrigger]); + + // Reset page when search or filters change + const resetPage = () => { + if (page !== 0) { + setPage(0); + } + }; + + const handleTagToggle = (tagName: string) => { + setSelectedTags(prev => { + const newTags = prev.includes(tagName) + ? prev.filter(t => t !== tagName) + : [...prev, tagName]; + resetPage(); + return newTags; + }); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + resetPage(); + }; + + const handlePageSizeChange = (newSize: number) => { + setPageSize(newSize); + resetPage(); + }; + + const clearFilters = () => { + setSearchQuery(''); + setSelectedTags([]); + setShowArchived(false); + resetPage(); + }; + + const handleCollectionUpdate = () => { + // Trigger reload by incrementing refresh trigger + setRefreshTrigger(prev => prev + 1); + }; + + if (loading && collections.length === 0) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+

Collections

+

+ {totalCollections} {totalCollections === 1 ? 'collection' : 'collections'} + {searchQuery || selectedTags.length > 0 || showArchived ? ` found` : ` total`} +

+
+ + +
+ + {/* Search and Filters */} +
+ {/* Search Bar */} +
+
+ +
+ + {/* View Mode Toggle */} +
+ + +
+
+ + {/* Filters and Controls */} +
+ {/* Page Size Selector */} +
+ + +
+ + {/* Archive Toggle */} + + + {/* Clear Filters */} + {(searchQuery || selectedTags.length > 0 || showArchived) && ( + + )} +
+ + {/* Tag Filter */} + +
+ + {/* Collections Display */} + + + {/* Pagination */} + {totalPages > 1 && ( +
+ + +
+ Page + { + const newPage = Math.max(0, Math.min(totalPages - 1, parseInt(e.target.value) - 1)); + if (!isNaN(newPage)) { + setPage(newPage); + } + }} + className="w-16 px-2 py-1 text-center rounded theme-card theme-text theme-border border focus:outline-none focus:ring-2 focus:ring-theme-accent" + /> + of {totalPages} +
+ + +
+ )} + + {/* Loading Overlay */} + {loading && collections.length > 0 && ( +
+
+ +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/library/page.tsx b/frontend/src/app/library/page.tsx index 05b6c5c..2d015d1 100644 --- a/frontend/src/app/library/page.tsx +++ b/frontend/src/app/library/page.tsx @@ -6,7 +6,7 @@ import { Story, Tag } from '../../types/api'; import AppLayout from '../../components/layout/AppLayout'; import { Input } from '../../components/ui/Input'; import Button from '../../components/ui/Button'; -import StoryCard from '../../components/stories/StoryCard'; +import StoryMultiSelect from '../../components/stories/StoryMultiSelect'; import TagFilter from '../../components/stories/TagFilter'; import LoadingSpinner from '../../components/ui/LoadingSpinner'; @@ -242,20 +242,12 @@ export default function LibraryPage() { )} ) : ( -
- {stories.map((story) => ( - - ))} -
+ )} {/* Pagination */} diff --git a/frontend/src/app/stories/[id]/detail/page.tsx b/frontend/src/app/stories/[id]/detail/page.tsx index 0366073..337d637 100644 --- a/frontend/src/app/stories/[id]/detail/page.tsx +++ b/frontend/src/app/stories/[id]/detail/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; import { storyApi, seriesApi, getImageUrl } from '../../../../lib/api'; -import { Story } from '../../../../types/api'; +import { Story, Collection } from '../../../../types/api'; import AppLayout from '../../../../components/layout/AppLayout'; import Button from '../../../../components/ui/Button'; import LoadingSpinner from '../../../../components/ui/LoadingSpinner'; @@ -17,6 +17,7 @@ export default function StoryDetailPage() { const [story, setStory] = useState(null); const [seriesStories, setSeriesStories] = useState([]); + const [collections, setCollections] = useState([]); const [loading, setLoading] = useState(true); const [updating, setUpdating] = useState(false); @@ -32,6 +33,10 @@ export default function StoryDetailPage() { const seriesData = await seriesApi.getSeriesStories(storyData.seriesId); setSeriesStories(seriesData); } + + // Load collections that contain this story + const collectionsData = await storyApi.getStoryCollections(storyId); + setCollections(collectionsData); } catch (error) { console.error('Failed to load story data:', error); router.push('/library'); @@ -250,6 +255,57 @@ export default function StoryDetailPage() { )} + {/* Collections */} + {collections.length > 0 && ( +
+

+ Part of Collections ({collections.length}) +

+
+ {collections.map((collection) => ( + +
+ {collection.coverImagePath ? ( + {`${collection.name} + ) : ( +
+ + {collection.storyCount} + +
+ )} +
+

+ {collection.name} +

+

+ {collection.storyCount} {collection.storyCount === 1 ? 'story' : 'stories'} + {collection.estimatedReadingTime && ( + • ~{Math.ceil(collection.estimatedReadingTime / 60)}h reading + )} +

+
+ {collection.rating && ( +
+ + {collection.rating} +
+ )} +
+ + ))} +
+
+ )} + {/* Summary */} {story.summary && (
diff --git a/frontend/src/components/collections/AddToCollectionModal.tsx b/frontend/src/components/collections/AddToCollectionModal.tsx new file mode 100644 index 0000000..1d06904 --- /dev/null +++ b/frontend/src/components/collections/AddToCollectionModal.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { collectionApi, searchApi } from '../../lib/api'; +import { Collection, Story } from '../../types/api'; +import Button from '../ui/Button'; +import { Input } from '../ui/Input'; +import LoadingSpinner from '../ui/LoadingSpinner'; + +interface AddToCollectionModalProps { + isOpen: boolean; + onClose: () => void; + collection: Collection; + onUpdate: () => void; +} + +export default function AddToCollectionModal({ + isOpen, + onClose, + collection, + onUpdate +}: AddToCollectionModalProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [availableStories, setAvailableStories] = useState([]); + const [selectedStoryIds, setSelectedStoryIds] = useState([]); + const [loading, setLoading] = useState(false); + const [adding, setAdding] = useState(false); + + // Get IDs of stories already in the collection + const existingStoryIds = collection.collectionStories?.map(cs => cs.story.id) || []; + + useEffect(() => { + if (isOpen) { + loadStories(); + } + }, [isOpen, searchQuery]); + + const loadStories = async () => { + try { + setLoading(true); + const result = await searchApi.search({ + query: searchQuery || '*', + page: 0, + size: 50, + }); + + // Filter out stories already in the collection + const filteredStories = result.results.filter( + story => !existingStoryIds.includes(story.id) + ); + + setAvailableStories(filteredStories); + } catch (error) { + console.error('Failed to load stories:', error); + } finally { + setLoading(false); + } + }; + + const toggleStorySelection = (storyId: string) => { + setSelectedStoryIds(prev => + prev.includes(storyId) + ? prev.filter(id => id !== storyId) + : [...prev, storyId] + ); + }; + + const handleAddStories = async () => { + if (selectedStoryIds.length === 0) return; + + try { + setAdding(true); + await collectionApi.addStoriesToCollection(collection.id, selectedStoryIds); + onUpdate(); + onClose(); + setSelectedStoryIds([]); + } catch (error) { + console.error('Failed to add stories to collection:', error); + } finally { + setAdding(false); + } + }; + + const handleClose = () => { + if (!adding) { + setSelectedStoryIds([]); + setSearchQuery(''); + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

+ Add Stories to "{collection.name}" +

+ +
+ + {/* Search */} +
+ setSearchQuery(e.target.value)} + placeholder="Search stories to add..." + className="w-full" + /> +
+ + {/* Stories List */} +
+ {loading ? ( +
+ +
+ ) : availableStories.length === 0 ? ( +
+ {searchQuery ? 'No stories found matching your search.' : 'All stories are already in this collection.'} +
+ ) : ( +
+ {availableStories.map((story) => { + const isSelected = selectedStoryIds.includes(story.id); + return ( +
toggleStorySelection(story.id)} + > +
+ toggleStorySelection(story.id)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+

+ {story.title} +

+

+ by {story.authorName} +

+
+ {story.wordCount?.toLocaleString()} words + {story.rating && ( + + ★ {story.rating} + + )} +
+
+
+
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+
+ {selectedStoryIds.length} stories selected +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/collections/CollectionCard.tsx b/frontend/src/components/collections/CollectionCard.tsx new file mode 100644 index 0000000..28fcdab --- /dev/null +++ b/frontend/src/components/collections/CollectionCard.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { Collection } from '../../types/api'; +import { getImageUrl } from '../../lib/api'; +import Link from 'next/link'; + +interface CollectionCardProps { + collection: Collection; + viewMode: 'grid' | 'list'; + onUpdate?: () => void; +} + +export default function CollectionCard({ collection, viewMode, onUpdate }: CollectionCardProps) { + const formatReadingTime = (minutes: number): string => { + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; + }; + + const renderRatingStars = (rating?: number) => { + if (!rating) return null; + + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ★ + + ))} +
+ ); + }; + + if (viewMode === 'grid') { + return ( + +
+ {/* Cover Image or Placeholder */} +
+ {collection.coverImagePath ? ( + {`${collection.name} + ) : ( +
+
+
+ {collection.storyCount} +
+
+ {collection.storyCount === 1 ? 'story' : 'stories'} +
+
+
+ )} + + {collection.isArchived && ( +
+ Archived +
+ )} +
+ + {/* Collection Info */} +
+

+ {collection.name} +

+ + {collection.description && ( +

+ {collection.description} +

+ )} + +
+ {collection.storyCount} stories + {collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'} +
+ + {collection.rating && ( +
+ {renderRatingStars(collection.rating)} +
+ )} + + {/* Tags */} + {collection.tags && collection.tags.length > 0 && ( +
+ {collection.tags.slice(0, 3).map((tag) => ( + + {tag.name} + + ))} + {collection.tags.length > 3 && ( + + +{collection.tags.length - 3} more + + )} +
+ )} +
+
+ + ); + } + + // List view + return ( + +
+
+ {/* Cover Image */} +
+ {collection.coverImagePath ? ( + {`${collection.name} + ) : ( +
+
+
+ {collection.storyCount} +
+
+
+ )} +
+ + {/* Collection Details */} +
+
+
+

+ {collection.name} + {collection.isArchived && ( + + Archived + + )} +

+ + {collection.description && ( +

+ {collection.description} +

+ )} + +
+ {collection.storyCount} stories + {collection.estimatedReadingTime ? formatReadingTime(collection.estimatedReadingTime) : '—'} reading + {collection.averageStoryRating && collection.averageStoryRating > 0 && ( + ★ {collection.averageStoryRating.toFixed(1)} avg + )} +
+ + {/* Tags */} + {collection.tags && collection.tags.length > 0 && ( +
+ {collection.tags.slice(0, 5).map((tag) => ( + + {tag.name} + + ))} + {collection.tags.length > 5 && ( + + +{collection.tags.length - 5} more + + )} +
+ )} +
+ + {collection.rating && ( +
+ {renderRatingStars(collection.rating)} +
+ )} +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/collections/CollectionDetailView.tsx b/frontend/src/components/collections/CollectionDetailView.tsx new file mode 100644 index 0000000..0455cd6 --- /dev/null +++ b/frontend/src/components/collections/CollectionDetailView.tsx @@ -0,0 +1,360 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Collection } from '../../types/api'; +import { collectionApi, getImageUrl } from '../../lib/api'; +import Button from '../ui/Button'; +import StoryReorderList from './StoryReorderList'; +import AddToCollectionModal from './AddToCollectionModal'; +import LoadingSpinner from '../ui/LoadingSpinner'; +import Link from 'next/link'; + +interface CollectionDetailViewProps { + collection: Collection; + onUpdate: () => void; + onDelete: () => void; +} + +export default function CollectionDetailView({ + collection, + onUpdate, + onDelete +}: CollectionDetailViewProps) { + const router = useRouter(); + const [showAddStories, setShowAddStories] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(collection.name); + const [editDescription, setEditDescription] = useState(collection.description || ''); + const [editRating, setEditRating] = useState(collection.rating || ''); + const [saving, setSaving] = useState(false); + const [actionLoading, setActionLoading] = useState(null); + + const formatReadingTime = (minutes: number): string => { + if (minutes < 60) { + return `${minutes} minutes`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours} hours`; + }; + + const renderRatingStars = (rating?: number) => { + if (!rating) return null; + + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ★ + + ))} +
+ ); + }; + + const handleSaveEdits = async () => { + try { + setSaving(true); + await collectionApi.updateCollection(collection.id, { + name: editName.trim(), + description: editDescription.trim() || undefined, + rating: editRating ? parseInt(editRating.toString()) : undefined, + }); + setIsEditing(false); + onUpdate(); + } catch (error) { + console.error('Failed to update collection:', error); + } finally { + setSaving(false); + } + }; + + const handleCancelEdit = () => { + setEditName(collection.name); + setEditDescription(collection.description || ''); + setEditRating(collection.rating || ''); + setIsEditing(false); + }; + + const handleArchive = async () => { + const action = collection.isArchived ? 'unarchive' : 'archive'; + if (confirm(`Are you sure you want to ${action} this collection?`)) { + try { + setActionLoading('archive'); + await collectionApi.archiveCollection(collection.id, !collection.isArchived); + onUpdate(); + } catch (error) { + console.error(`Failed to ${action} collection:`, error); + } finally { + setActionLoading(null); + } + } + }; + + const handleDelete = async () => { + if (confirm('Are you sure you want to delete this collection? This cannot be undone. Stories will not be deleted.')) { + try { + setActionLoading('delete'); + await collectionApi.deleteCollection(collection.id); + onDelete(); + } catch (error) { + console.error('Failed to delete collection:', error); + } finally { + setActionLoading(null); + } + } + }; + + const startReading = () => { + if (collection.collectionStories && collection.collectionStories.length > 0) { + const firstStory = collection.collectionStories[0].story; + router.push(`/collections/${collection.id}/read/${firstStory.id}`); + } + }; + + return ( +
+ {/* Header Section */} +
+
+ {/* Cover Image */} +
+
+ {collection.coverImagePath ? ( + {`${collection.name} + ) : ( +
+
+
+ {collection.storyCount} +
+
+ {collection.storyCount === 1 ? 'story' : 'stories'} +
+
+
+ )} +
+
+ + {/* Collection Info */} +
+
+
+ {isEditing ? ( +
+ setEditName(e.target.value)} + className="text-3xl font-bold theme-header bg-transparent border-b-2 border-gray-300 focus:border-blue-500 focus:outline-none w-full" + /> +