&*j;=u%25ay-%>3@81tGe^_z*C7pb9y*Ed^H3t$BIKH2o+olp#$q;)_ zfpjCb_^VFg5fU~K)nf*d*r@BCC>UZ!0&b?AGk_jTPXaSnCuW110wjHPPe^9R^;jo3 zwvzTl)C`Zl5}O2}3lec=hZ*$JnkW#7enKKc)(pM${_$9Hc=Sr_A9Biwe*Y=T?~1CK z6eZ9uPICjy-sMGbZl$yQmpB&`ouS8v{58__t0$JP%i3R&%QR3ianbZqDs<2#5FdN@n5bCn^ZtH992~5k(eA|8|@G9u`wdn7bnpg|@{m z^d6Y`*$Zf2Xr&|g%sai#5}Syvv(>Jnx&EM7-|Jr7!M~zdAyjt*xl;OLhvW-a%H1m0 z*x5*nb=R5u><7lyVpN AR?q@1U59 zO+)QW wL8t zyip?u_nI+K$uh{ y)~}qj?(w0&=SE^8`_WMM zTybjG=999h38Yes7}-4*LJ7H)UE8{mE(6;8voE+TYY%33A>S6`G_95^5QHNTo_;Ao ztIQIZ_}49%{8|=O;isBZ?=7kfdF8_@azfoTd+hEJKWE!)$)N%HIe2cplaK`ry#=pV z0q{9w-`i0h@!R8K3GC{ivt{70IWG`EP |(1g7i_Q<>aEAT{5( yD z=!O?kq61VegV+st@XCw475j6vS)_z@efuqQgHQR1T4;|-#OLZNQJPV4k$AX1Uk8Lm z{N*b*ia=I+MB}kWpupJ~>!C@xEN#Wa7V+7{m4j8c?)ChV=D?o~sjT?0C_AQ 7B-vxqX30s0I_`2$in86#`mAsT-w?j{&AL@B3$;P z31G4(lV|b}uSD CIrjk+M1R!X7s 4Aabn<)zpgT}#gE|mIvV38^ODy@<&yflpCwS#fRf9ZX3lPV_?8@C5)A;T zqmouFLFk;qIs4rA=hh=GL~sCFsXHsqO6_y~*AFt93 9UYVBSx1s(=Kb&5;j7cSowdE;7()CC2|-i9Zz+_BIw8#ll~-tyH?F3{%`QCsYa*b#s*9iCc`1P1oC26?`g<9))EJ3%xz+O!B3 zZ7$j~To)C@PquR>a1+Dh>-a%IvH_Y7^ys|4o?E%3`I&ADXfC8++hAdZfzIT#%C+Jz z1lU~K_vAm0m8Qk}K$F>| >RPK%<1SI0(G+8q~H zAsjezyP+u!Se4q3GW)`h`NPSRlMoBjCzNPesWJwVTY!o@G8=(6I%4XHGaSiS3MEBK zhgGFv6Jc>L$4jVE!I?TQuwvz_%CyO!bLh94nqK11C2W$*aa2ueGopG8DnBICVUORP zgytv#)49fVXDaR$SukloYC3u7#5H)}1K21=?DKj^U)8G;MS)&Op)g^zR2($<>C*zW z;X7`hLxiIO#J`ANdyAOJle4V%ppa*(+0i3w;8i*BA_;u8gOO6)MY`ueq7stBMJTB; z-a0R>hT*}>z|Gg}@^zDL1MrH+2hsR8 zHc}*9IvuQC^Ju)^#Y{fOr(96rQNPNhxc;mH@W*m206>Lo<*SaaH?~8zg&f&%YiOEG zGiz?*CP>Bci}!WiS=zj#K5I}>DtpregpP_tfZtPa(N<%vo^#WCQ5BTv0vr%Z{)0q+ z)RbfHktUm|lg&U3YM%lMUM(f u}i#kjX9h>GYctkx9Mt_8{@s%!K_EI zScgwy6%_fR?CG JQtmgNAj^h9B#zma MDWgH55pGuY1Gv7D z;8Psm(vEPiwn#MgJYu4Ty9D|h!?Rj0ddE|&L3S{IP%H4^N!m`60ZwZw^;eg4sk6K{ ziA^`Sbl_4~f&Oo%n;8Ye(tiAdlZKI!Z=|j$5hS|D$bDJ}p{gh$KN&JZYLUjv4h{NY zBJ>X9z !xfDGY z+oh_Z&_e#Q(-}>ssZfm=j$D&4W4FNy&-kAO1~#3Im;F)Nwe{(*75(p=P^VI?X 0GFakfh+X-px4a%Uw@fSbmp9hM1_~R>?Z8+ ziy|e9>8V*`OP}4x5JjdWp}7eX;lVxp5qS} 0YZek;SNmm7tEeSF*-dI)6U-A%m6YvCgM(}_=k#a6o^%-K4{`B1+}O4x zztDT%hVb;v#?j`lTvlFQ3aV#zkX=7 ;YFLS$uIzb0E3lozs5`Xy zi~vF+%{z9uLjKvKPhP%x5f ~7-Gj+%5N`%^=yk*Qn{`> z;xj&ROY6g`iy2a@{O)V(jk&8#hHACVDXey5a+KDod_Z&}kHM}xt7}Md@pil{2x7E~ zL$k^d2@Ec2XskjrN+IILw;#7((abu;OJii&v3?60x>d_Ma(onIPtcVnX@ELF0aL?T zSmWiL3(dOFkt!x=1O!_0n(cAzZW+3nHJ{2S>tgSK?~cF ha^y(l@-Mr2W$%MN{#af8J;V*>hdq!gx=d0h$T7l}>91Wh07)9CTX zh2_ZdQCyFOQ)l(}gft0UZ G`Sh2`x-w`5vC2UD}lZs*5 zG76$akzn}Xi))L3oGJ75#pcN=cX3!=57$Ha=hQ2^lwdyU#a}4JJOz6ddR%zae%#4& za)bFj)z=YQela(F#Y|Q#dp}PJghITwXouVaMq$BM?K%cXn9^Y@g43$=O)F&ZlOUom zJiad#dea;-eywBA@e&D6Pdso1?2^(pXiN91?jvcaUyYoKUmvl5G9e$W!okWe*@a<^ z8cQQ6cNSf+UPDx%?_G4a IiybZHHagF{ ;IcD(dPO!#=u zWfqLcPc^+7Uu#l(B pxft{*4lv#*u7X9AOzDO z1D9?^jIo}?%iz(_dwLa{ex#T}76ZfN_Z-hwpus9y+4xaUu9cX}&P{XrZVWE{1^0yw zO;YhLEW!pJcbCt3L8~a7>jsaN{V3>tz6_7`&pi%GxZ=V3?3K^ U+*ryLSb)8^IblJ0 zSRLNDvIxt)S}g30?s_3NX>F?NKIGrG_zB9@Z>uSW3k2es_H2kU;Rnn%j5qP)!XHKE zPB2mHP~tLCg4K_vH$xv`HbRsJwbZMUV(t=ez;Ec(vyHH)FbfLg`c61I$W_uBB>i^r z&{_P;369-&>23R%qNIULe=1~T$(DA`ev*EWZ6j(B$(te}x1WvmIll21zvygkS%vwG zzkR6Z#RKA2!z!C%M!O>!=Gr0(J0FP=-MN=5t-Ir)of50y10W}j`GtRCsXBakrKtG& zazmITDJMA0C51&BnLY)SY9r)NVTMs);1<=oosS9g31l{4ztjD3#+2H7u_|66b|_*O z;Qk6nalpqdHOjx|K&vUS_6ITgGll;TdaN*ta=M_YtyC)I9Tmr~VaPrH2q b6sd~=AcIxV+%z{E&0@y=DPArw zdV7z(G1hBx7hd{>(cr43^WF%4Y@PXZ?wPpj{OQ#tvc$pABJbvPGvdR`cAtHn)cSEV zrpu}1tJwQ3y!mSmH*uz*x0o|CS<^w%&KJzsj~DU0cLQUxk5B!hWE>aBkjJle8z~;s z-!A=($+}Jq_BTK5^B!`R>!MulZN)F=iXXeUd0w5lUsE5VP*H*oCy( ;?S$p*TVvTxwAeWFB$jHyb0593)$zqalVlDX=GcCN1gU0 zlgU)I$LcXZ8Oyc2TZYTPu@-;7<4YYB-``Qa;IDcvydIA$%kHhJKV^m*- zxcvU4viy &Kr5GVM{IT>WRywKQ9;>SEiQD*NqplK-KK4YR`p0@JW)n_{TU3bt0 zim%;(m1=#v2}zTps=?fU5w^(*y)xT%1vtQH&}50ZF!9YxW=&7*W($2kgKyz1mUgfs zfV<*XVVIFnohW=|j+@Kfo!#liQR^x>2yQdrG;2o8WZR+XzU_nG=Ed2rK?ntA;K5B{ z>M8+*A4 !Jm^Bg}aW?R?6;@QG@uQ8&oJ{hFixcfEnJ4QH?A4>P=q29oDGW;L;= z9-a0;g%c`C+Ai!UmK$NC*4#;Jp<1=TioL=t^YM)<<%u#hnnfSS`nq63QKGO1L8RzX z@MFDq s1z ztYmxDl@LU)5acvHk)~Z`RW7=aJ_nGD!mOSYD>5Odjn@TK#LY{jf?+piB5AM-CAoT_ z?S-*q7}wyLJzK>N%eMPuFgN)Q_otKP;aqy=D5f! 7<=n(lNkYRXVpkB{TAYLYg{|(jtRqYmg$xH zjmq ?B(RE4 zQx^~Pt}gxC2~l=K$$-sYy_r$CO(d=+b3H1MB*y_5g6WLaWTXn+TKQ|hNY^>Mp6k*$ zwkovomhu776vQATqT4blf~g;TY(MWCrf^^yfWJvSAB$p5l;jm@o#=!lqw+Lqfq>X= z$6~kxfm7`3q4zUEB;u4qa#BdJxO!;xGm)wwuisj{0y2x{R(IGMrsIzDY9LW>m!Y`= z04sx3IjnYvL<4JqxQ8f7qYd0s2Ig%`ytYPEMKI)s(LD}D@EY>x`VFtqvnADNBdeao zC96X+MxnwKmjpg{U&gP3HE}1=s!lv&D{6(g_lzyF3A`7Jn*&d_kL<;dAFx!UZ>hB8 z5A*%LsAn;VLp>3${0>M?PSQ)9s3}|h2e?TG4_F{}{Cs>#3Q*t$(CUc}M)I}8cPF6% z=+h(Kh^8)}gj(0}#e7O^FQ6`~fd1#8#!}LMuo3A0bN`o}PYsm!Y}sdOz$+Tegc=qT z8x`PH$7lvnhJp{kHWb22l;@7B7|4yL4UOOVM0MP_>P%S1Lnid)+k9{+3D+JFa#Pyf zhVc#&df87APl4W9X)F3pGS>@etfl=_E5tBcVoOfrD4hmVeTY-cj((pkn%n@EgN{0f zwb_^Rk0I#i ZuHK!l*lN`ceJn(sI{$Fq6nN& zE<-=0_2WN}m+*ivmIOxB@#~Q-cZ>l136w{#TIJe478`KE7@=a{>SzPHsKLzYAyBQO zAtuuF$-JSDy_S@6GW0MOE~R)b;+0f%_NMrW(+V#c_d&U8Z9+ec4=HmOHw?gdjF(Lu zzra 83M_BoO-1b3;9`%&DHfuUY)6YDV21P$C!Rc?mv&{lx#f8oc6?0?x zK08{WP65?#>(vPfA-c=MCY| %*1_<3D4NX zeVTi-JGl2uP_2@0F{G({pxQOXt_d{g_CV6b?jNpfUG9;8yle-^4KHRvZs-_2siata zt+d_T@U$&t*xaD22(fH(W1r$Mo?3dc%Tncm=C6{V9y{v&VT#^1L04vDrLM9qBoZ4@ z6DBN#m57hX7$C(=#$Y5$bJmwA$T8jKD8+6A!-IJwA{WOfs%s}yxUw^?MRZjF$n_KN z6`_bGXcmE#5e4Ym)aQJ)xg3Pg0@k`iGuHe?f(5LtuzSq=nS^5z>vqU0EuZ&75V%Z{ zYyhRLN^)$c6Ds{f7*FBpE;n5iglx5PkHfWrj3`x^j^t z7ntuV`g!9Xg#^3!x)l*}IW=(Tz3>Y5l4uGaB&lz{GDjm2D5S$CExLT`I1#n^lBH7Y zDgpMag@`iETKAI=p<5E#LTkw zVR@=yY|uBVI1HG|8h+d;G-qfuj}-ZR6fN>EfCCW z9~wRQoAPEa#aO?3h?x{YvV*d+NtPkf&4V0k4|L=uj!U{L+oLa(z#&iuhJr3-PjO3R z5s?=nn_5^*^Rawr>>Nr@K(jwkB#JK-=+HqwfdO<+P5byeim)wvqGlP-P|~Nse8=XF zz`?RYB|D6SwS}C+YQv+;}k6$-%D(@+t14BL@vM z2q%q?f6D-A5s$_WY3{^G0F131bbh|g!}#BKw=HQ7mx;Dzg4Z*bTLQSfo{ed{4}NZW zfrRm^Ca$rlE{Ue~uYv>R9{3s mwATcdM_6+yWIO z*ZRH~uXE@#p$XTbCt5j7j2=86e{9>HIB6xDzV+vAo&B?KUiMP|ttOElepnl%|DPqL b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/src/App.js b/src/App.js new file mode 100644 index 0000000..ae92d39 --- /dev/null +++ b/src/App.js @@ -0,0 +1,40 @@ +import { lazy, Suspense } from 'react'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import './css/App.css'; +import './css/Modal.css'; + +const Login = lazy(() => import('./Login')); +const Profile = lazy(() => import('./Profile')); +const Registration = lazy(() => import('./Registration')); +const Mainpage = lazy(() => import('./Mainpage')); +const KBBoardsList = lazy(() => import('./KBBoardsList')); +const KBBoard = lazy(() => import('./KBBoard')); +const OtherProfile = lazy(() => import('./OtherProfile')); + +const Loader = () => ( + + ++); + +function App() { + return ( ++ + ); +} + +export default App; diff --git a/src/App.test.js b/src/App.test.js new file mode 100644 index 0000000..1f03afe --- /dev/null +++ b/src/App.test.js @@ -0,0 +1,8 @@ +import { render, screen } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + render(}> + + + +} /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ); + const linkElement = screen.getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/src/Header.js b/src/Header.js new file mode 100644 index 0000000..f98ba61 --- /dev/null +++ b/src/Header.js @@ -0,0 +1,65 @@ +import { memo, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import './css/Header.css'; + +const Header = () => { + const [user, setUser] = useState({ display_name: '', avatar: '' }); + const [isLoading, setIsLoading] = useState(true); + const [isAnimated, setIsAnimated] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + let animationTimer; + + (async () => { + try { + const { data } = await axios.get('/api/users/me', { signal: controller.signal }); + setUser({ display_name: data.display_name, avatar: data.avatar_url }); + } catch {} + setIsLoading(false); + animationTimer = setTimeout(() => setIsAnimated(true), 150); + })(); + + return () => { + controller.abort(); + clearTimeout(animationTimer); + }; + }, []); + + return ( + + + ); +}; + +export default memo(Header); diff --git a/src/KBBoard/API.js b/src/KBBoard/API.js new file mode 100644 index 0000000..23acb2f --- /dev/null +++ b/src/KBBoard/API.js @@ -0,0 +1,91 @@ +import axios from 'axios'; + +export const loadBoardDataAPI = async (boardId) => { + return axios.post('/api/boards/load', {id: boardId}); +}; + +export const createTaskAPI = async (taskCateg, taskTitle, taskDescription, taskDedline, memberId) => { + return axios.post('/api/boards/categories/tasks/create', { + category_id: taskCateg, title: taskTitle, description: taskDescription, deadline: taskDedline, assigned_user: memberId + }); +}; + +export const createCategoryAPI = async (boardId, categTitle) => { + return axios.post('/api/boards/categories/create', { + board_id: boardId, title: categTitle + }); +}; + +export const updateTaskAPI = async (editedTaskId, editedTaskTitle, editedTaskDescription, editedTaskPosition, editedTaskDedline, editedTaskCategId) => { + return axios.put('/api/boards/categories/tasks/update', { + id: editedTaskId, title: editedTaskTitle, description: editedTaskDescription, position: editedTaskPosition, deadline: editedTaskDedline, category_id: editedTaskCategId + }); +}; + +export const updateCategoryAPI = async (editedCategId, editedCategMethod, editedCategTitle) => { + return axios.put('/api/boards/categories/update', { + id: editedCategId, update_method: editedCategMethod, value: editedCategTitle + }); +}; + +export const deleteCategoryAPI = async (categId) => { + return axios.delete('/api/boards/categories/delete', { + data: {id: categId} + }); +}; + +export const deleteTaskAPI = async (taskId) => { + return axios.delete('/api/boards/categories/tasks/delete', { + data: {id: taskId} + }); +}; + +export const assignMemberAPI = async (editedTaskId, memberId) => { + return axios.put('/api/boards/categories/tasks/assign', { + id: editedTaskId, member_id: memberId + }); +}; + +export const unassignMemberAPI = async (editedTaskId, memberId) => { + return axios.put('/api/boards/categories/tasks/unassign', { + id: editedTaskId, member_id: memberId + }); +}; + +export const addMemberAPI = async (boardId, username) => { + return axios.post('/api/boards/members/add', { + username: username, board_id: boardId + }); +}; + +export const deleteMemberAPI = async (boardId, memberId) => { + return axios.delete('/api/boards/members/delete', { + data: {member_id: memberId, board_id: boardId} + }); +}; + +export const quitMemberAPI = async (boardId) => { + return axios.delete('/api/boards/members/quit', { + data: {board_id: boardId} + }); +}; + +export const updateBoardsAPI = async (editedBoardId, editedBoardMethod, editedBoardValue) => { + return axios.put('/api/boards/update', { + id: editedBoardId, update_method: editedBoardMethod, value: editedBoardValue + }); +}; + +export const deleteBoardsAPI = async (boardId) => { + return axios.delete('/api/boards/delete', { + data: {id: boardId} + }); +}; + +export const meAPI = () => { + return axios.get('/api/users/me'); +}; + +export const getWsTicketAPI = async () => { + return axios.get('/api/boards/ws-ticket'); +}; \ No newline at end of file diff --git a/src/KBBoard/Logic.js b/src/KBBoard/Logic.js new file mode 100644 index 0000000..8fce950 --- /dev/null +++ b/src/KBBoard/Logic.js @@ -0,0 +1,250 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + meAPI, + loadBoardDataAPI, updateBoardsAPI, deleteBoardsAPI, + createTaskAPI, updateTaskAPI, deleteTaskAPI, + createCategoryAPI, updateCategoryAPI, deleteCategoryAPI, + addMemberAPI, assignMemberAPI, unassignMemberAPI, deleteMemberAPI, quitMemberAPI +} from './API'; + +export const useBoardLogic = (id, setError, setInfo, setCategories, setLoading, setItems) => { + const navigate = useNavigate(); + + const loadBoardData = useCallback(async (ws) => { + if (!ws){ + setLoading(true); + } + + try { + setError(''); + const response = await loadBoardDataAPI(id); + setInfo(response.data); + setItems(response.data.members) + setCategories(response.data.categories || []); + } catch (err) { + if (err.response?.data?.message === 'Token Error' || + err.response?.data?.message === 'Invalid Token') { + setError('Вы не авторизованы'); + setTimeout(() => navigate('/login'), 1000); + } else if (err.response?.data?.detail === "Доска не найдена.") { + setError("Доска не существует"); + setTimeout(() => navigate('/kanban-boards-list'), 1000); + } else { + setError('Ошибка загрузки доски'); + } + } finally { + setLoading(false); + } + }, [id, setError, setInfo, setCategories, setLoading, setItems, navigate]); + + const checkOwner = useCallback(async (ownerId, setIsOwner) => { + setLoading(true); + const result = await meAPI(ownerId); + const res = (result?.data?.id === ownerId); + setLoading(false); + setIsOwner(res); + }, [setLoading]); + + const createCategory = useCallback(async (categoryTitle, modalCrCateg) => { + setLoading(true); + try { + await createCategoryAPI(id, categoryTitle); + await loadBoardData(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка создания категории'); + } finally { + modalCrCateg(); + setLoading(false); + } + }, [id, loadBoardData, setLoading, setError]); + + const createTask = useCallback(async (taskCategori, taskTitle, taskDescription, modalCrTask, taskDedline, memberId) => { + setLoading(true); + try { + await createTaskAPI(taskCategori, taskTitle, taskDescription, taskDedline, memberId); + await loadBoardData(); + modalCrTask(null)(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка создания задачи'); + } finally { + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + + + const editBoard = useCallback(async (editedBoardId, boardTitle, boardDescription, modalEditBoard) => { + setLoading(true); + try { + await updateBoardsAPI( editedBoardId, 'title', boardTitle ); + await updateBoardsAPI( editedBoardId, 'description', boardDescription ); + await modalEditBoard(); + await loadBoardData(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка редактирования доски'); + } finally { + modalEditBoard(); + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + const editCategory = useCallback(async (editedCategId, categoryTitle, modalEditCateg) => { + setLoading(true); + try { + await updateCategoryAPI( editedCategId, 'title', categoryTitle ); + await loadBoardData(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка редактирования категории'); + } finally { + modalEditCateg({})(); + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + const editCategoryPosition = useCallback(async (editedCategId, categoryPosition) => { + setLoading(true); + try { + await updateCategoryAPI( editedCategId, 'position', categoryPosition ); + await loadBoardData(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка смены позиции'); + } finally { + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + const editTask = useCallback(async (editedTaskId, taskTitle, taskDescription, taskPosition, taskDeadline, taskCategory, modalEditTask) => { + if(modalEditTask !== null) { + setLoading(true); + } + try { + if (typeof taskDeadline != "string") { + taskDeadline = null + } + await updateTaskAPI(editedTaskId, taskTitle, taskDescription, taskPosition, taskDeadline, taskCategory); + if(modalEditTask !== null) { + await loadBoardData(); + modalEditTask({}, null)(); + } + } catch (err) { + setError(err.response?.data?.message || 'Ошибка редактирования задачи'); + } finally { + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + + + const deleteBoards = useCallback(async (boardId, modalDelBoard) => { + setLoading(true); + try { + await deleteBoardsAPI(boardId); + modalDelBoard(); + navigate('/kanban-boards-list'); + } catch (err) { + setError('Ошибка удаления доски: '+ (err.response?.data?.message || err.response?.data?.detail)); + } finally { + setLoading(false); + } + }, [setLoading, setError, navigate]); + + const deleteCategory = useCallback(async (categoryId, modalDelCateg, modalEditCateg) => { + setLoading(true); + try { + await deleteCategoryAPI(categoryId); + await loadBoardData(); + modalDelCateg(); + modalEditCateg({})(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка удаления категории'); + } finally { + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + const deleteTask = useCallback(async (taskId, modalDelTask, modalEditTask) => { + setLoading(true); + try { + await deleteTaskAPI(taskId); + await loadBoardData(); + modalDelTask(); + modalEditTask({}, null)(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка удаления задачи'); + } finally { + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + const addMember = useCallback(async (username, boardId, modalAddMember) => { + setLoading(true); + try { + await addMemberAPI( boardId, username ); + await loadBoardData(); + modalAddMember(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка добавления участника на доску'); + } finally { + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + const assignMember = useCallback(async (editedTaskId, memberId, act, modalAssignMember) => { + setLoading(true); + try { + if (act) { + await assignMemberAPI( editedTaskId, memberId ); + } else if (!act) { + await unassignMemberAPI( editedTaskId, memberId ); + } + await loadBoardData(); + modalAssignMember(false)(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка назначения или открепления участника от задачи'); + } finally{ + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + + const quitMember = useCallback(async (boardId, modalQuitMember ) => { + setLoading(true); + try { + setLoading(true); + await quitMemberAPI( boardId ); + await loadBoardData(); + modalQuitMember(); + navigate('/kanban-boards-list'); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка: Не удалось покинуть доску'); + } finally{ + setLoading(false); + } + }, [loadBoardData, setLoading, setError, navigate]); + + const deleteMember = useCallback(async (boardId, memberId, modalDelitMember ) => { + setLoading(true); + try { + setLoading(true); + await deleteMemberAPI( boardId, memberId ); + await loadBoardData(); + modalDelitMember(); + } catch (err) { + setError(err.response?.data?.message || 'Ошибка: Не удалось удалить пользователя'); + } finally { + setLoading(false); + } + }, [loadBoardData, setLoading, setError]); + + + + + return { + loadBoardData, checkOwner, editBoard, deleteBoards, + createTask, editTask, deleteTask, + createCategory, editCategory, deleteCategory, + addMember, assignMember, quitMember, deleteMember, + editCategoryPosition + }; +}; \ No newline at end of file diff --git a/src/KBBoard/index.js b/src/KBBoard/index.js new file mode 100644 index 0000000..0bcb7f6 --- /dev/null +++ b/src/KBBoard/index.js @@ -0,0 +1,892 @@ +import {useEffect, useState } from 'react'; +import {useNavigate, useParams} from 'react-router-dom'; +import {useBoardLogic} from './Logic'; +import Header from '../Header'; +import './../css/Board.css'; +import {getWsTicketAPI} from "./API"; + +const KBBoard = () => { + const navigate = useNavigate(); + const {id} = useParams(); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [info, setInfo] = useState({}); + const [categories, setCategories] = useState([]); + const [isOwner, setIsOwner] = useState(null); + const [items, setItems] = useState([]); + const [draggedItem, setDraggedItem] = useState(null); + const [draggedType, setDraggedType] = useState(null); + + const [memList, setMemList] = useState(false); + const [crTask, setCrTask] = useState(false); + const [crCateg, setCrCateg] = useState(false); + const [edTask, setEdTask] = useState(false); + const [edCateg, setEdCateg] = useState(false); + const [edBoard, setEdBoard] = useState(false); + const [delTask, setDelTask] = useState(false); + const [delCateg, setDelCateg] = useState(false); + const [asgnMember, setAsgnMember] = useState(false); + const [assignAction, setAssignAction] = useState(false); + const [addMemb, setAddMemb] = useState(false); + const [delBoards, setDelBoards] = useState(false); + const [qtMember, setQtMember] = useState(false); + const [delMember, setDelMember] = useState(false); + + const [boardTitle, setBoardTitle] = useState(''); + const [boardDescription, setBoardDescription] = useState(''); + const [categoryTitle, setCategoryTitle] = useState(''); + const [taskTitle, setTaskTitle] = useState(''); + const [taskDescription, setTaskDescription] = useState(''); + const [taskCategory, setTaskCategory] = useState(null); + const [taskCategori, setTaskCategori] = useState(null); + const [taskDeadline, setTaskDeadline] = useState(null); + const [assignedMembers, setAssignedMembers] = useState(null); + const [editedTask, setEditedTask] = useState({}); + const [editedCateg, setEditedCateg] = useState({}); + const [taskPosition, setTaskPosition] = useState(null); + const [assignedMember, setAssignedMember] = useState(0); + const [addedUsername, setAddedUsername] = useState(''); + const [deletedMember, setDeletedMember] = useState(''); + const { + loadBoardData, + createTask, + checkOwner, + createCategory, + editBoard, + editTask, + editCategory, + editCategoryPosition, + deleteCategory, + deleteTask, + assignMember, + addMember, + deleteBoards, + quitMember, + deleteMember, + } = useBoardLogic(id, setError, setInfo, setCategories, setLoading, setItems); + + function ListItem({item}) { + if (!item) return null; + const user = () => { + navigate('/profile/' + item.id); + }; + return ( + <> + + > + ); + }; + + + const [socket, setSocket] = useState(null); + useEffect(() => { + let ws; + const connect = async () => { + try { + const res = await getWsTicketAPI(); + const token = res.data.token; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${protocol}//26.22.232.18:24454/api/boards/ws/${id}?token=${token}`); + //ws = new WebSocket(`${protocol}//ws.back.fool-stack.ru/api/boards/ws/${id}?token=${token}`); + ws.onopen = () => { + console.log('WebSocket соединение установлено'); + setSocket(ws); + }; + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + loadBoardData(true); + }; + ws.onclose = () => console.log('WebSocket соединение закрыто'); + ws.onerror = (error) => console.error('Ошибка WebSocket:', error); + } catch (e) { + console.error('Не удалось получить ws-ticket:', e); + } + }; + connect(); + return () => ws && ws.close(); + }, [id, loadBoardData]); + + useEffect(() => { + if (id) loadBoardData(); + }, [id, loadBoardData]); + + useEffect(() => { + if (info?.owner?.id !== undefined) checkOwner(info?.owner?.id, setIsOwner); + }, [info?.owner?.id, checkOwner, setIsOwner]); + + const modalMemList = () => { + setMemList(!memList); + } + const modalCrTask = (categori) => () => { + setCrTask(!crTask); + setTaskCategori(categori); + setTaskTitle(''); + setTaskDescription(''); + setTaskDeadline(null) + } + const modalCrCateg = () => { + setCrCateg(!crCateg); + setCategoryTitle(''); + } + const modalEditBoard = () => { + setEdBoard(!edBoard); + setBoardTitle(info.title); + setBoardDescription(info.description); + } + const modalEditTask = (task, id_categ) => () => { + setEdTask(!edTask); + setTaskCategori(id_categ); + setEditedTask(task); + setTaskTitle(task.title); + setTaskDescription(task.description); + setTaskPosition(task.position); + setTaskCategory(task.category_id); + setTaskDeadline(task.deadline) + } + const modalEditCateg = (categ) => () => { + setEdCateg(!edCateg); + setEditedCateg(categ); + setCategoryTitle(categ.title); + } + const modalDelTask = () => { + setDelTask(!delTask); + } + const modalDelCateg = () => { + setDelCateg(!delCateg); + } + const modalAssignMember = (action) => () => { + setAsgnMember(!asgnMember); + setAssignAction(action) + } + const modalAddMember = () => { + setAddMemb(!addMemb); + } + const modalDeleteBoards = () => { + setDelBoards(!delBoards); + } + const modalDeleteMember = () => { + setDelMember(!delMember); + } + const modalQuitMember = () => { + setQtMember(!qtMember); + } + + + const handleCreateCategory = async (e) => { + e.preventDefault(); + await createCategory(categoryTitle, modalCrCateg); + }; + const handleCreateTask = async (e) => { + e.preventDefault(); + await createTask(taskCategori, taskTitle, taskDescription, modalCrTask, taskDeadline, assignedMembers); + }; + const handleEditBoard = async (e) => { + e.preventDefault(); + await editBoard(id, boardTitle, boardDescription, modalEditBoard); + }; + const handleEditCategory = async (e) => { + e.preventDefault(); + await editCategory(editedCateg.id, categoryTitle, modalEditCateg); + }; + const handleEditTask = async (e) => { + e.preventDefault(); + await editTask(editedTask.id, taskTitle, taskDescription, taskPosition, taskDeadline, taskCategory, modalEditTask); + }; + const handleDeleteTask = async (e) => { + e.preventDefault(); + await deleteTask(editedTask.id, modalDelTask, modalEditTask); + }; + const handleDeleteCategory = async (e) => { + e.preventDefault(); + await deleteCategory(editedCateg.id, modalDelCateg, modalEditCateg); + }; + const handleAssignMember = async (e) => { + e.preventDefault(); + await assignMember(editedTask.id, assignedMember, assignAction, modalAssignMember); + }; + const handleAddMember = async (e) => { + e.preventDefault(); + await addMember(addedUsername, id, modalAddMember); + }; + const handleDeleteBoards = async (e) => { + e.preventDefault(); + await deleteBoards(id, modalDeleteBoards); + }; + const handleQuitMember = async (e) => { + e.preventDefault(); + await quitMember(id, modalQuitMember); + }; + const handleDeleteMember = async (e) => { + e.preventDefault(); + await deleteMember(id, deletedMember, modalDeleteMember); + }; + + const handleCategoryDragStart = (e, category) => { + e.stopPropagation(); + setDraggedType('c'); + setDraggedItem(category); + e.dataTransfer.setData('text/plain', JSON.stringify({ + id: category.id, + type: 'c', + position: category.position + })); + e.currentTarget.style.opacity = '0.5'; + }; + + const handleTaskDragStart = (e, task, categoryId) => { + e.stopPropagation(); + setDraggedType('t'); + setDraggedItem({...task, categoryId}); + e.dataTransfer.setData('text/plain', JSON.stringify({ + id: task.id, + type: 't', + position: task.position, + categoryId: categoryId + })); + e.currentTarget.style.opacity = '0.5'; + e.currentTarget.style.transform = 'scale(0.95)'; + }; + + + const handleDragEnd = (e) => { + setDraggedType(null); + setDraggedItem(null); + e.currentTarget.style.opacity = '1'; + e.currentTarget.style.transform = 'scale(1)'; + }; + const handleDragOver = (e) => { + e.preventDefault(); + const data = e.dataTransfer.getData('text/plain'); + if (!data) return; + try { + const draggedData = JSON.parse(data); + if (draggedData.type === 't') { + e.currentTarget.style.boxShadow = '0 0 10px rgba(0, 0, 255, 0.3)'; + e.currentTarget.style.border = '2px dashed #007bff'; + } else if (draggedData.type === 'c') { + e.currentTarget.style.backgroundColor = '#f0f0f0'; + } + } catch (err) { + console.error('Ошибка парсинга данных drag:', err); + } + }; + const handleDragLeave = (e) => { + e.currentTarget.style.backgroundColor = ''; + e.currentTarget.style.boxShadow = ''; + e.currentTarget.style.border = ''; + }; + + const handleCategoryReorder = async (e, targetPosition) => { + e.preventDefault(); + e.currentTarget.style.boxShadow = ''; + e.currentTarget.style.border = ''; + e.currentTarget.style.backgroundColor = ''; + e.currentTarget.style.transform = ''; + + const data = e.dataTransfer.getData('text/plain'); + if (!data) return; + + try { + const draggedData = JSON.parse(data); + if (draggedData.type !== 'c') return; + + if (draggedType === 'c' && draggedItem.position !== targetPosition) { + setCategories((prevItems) => { + const draggedIndex = prevItems.findIndex(item => item.position === draggedItem.position); + const targetIndex = prevItems.findIndex(item => item.position === targetPosition); + const newItems = [...prevItems]; + newItems.splice(draggedIndex, 1); + newItems.splice(targetIndex, 0, draggedItem); + const updatedItems = newItems.map((item, index) => ({ + ...item, + position: index + })); + return updatedItems; + }); + await editCategoryPosition(draggedItem.id, targetPosition); + } + } catch (err) { + console.error('Ошибка при переупорядочивании категорий:', err); + setError('Не удалось переупорядочить категории'); + } + }; + + + const handleTaskReorder = async (e, targetPosition, targetCategoryPos) => { + e.stopPropagation(); + e.preventDefault(); + e.currentTarget.style.boxShadow = ''; + e.currentTarget.style.border = ''; + e.currentTarget.style.backgroundColor = ''; + e.currentTarget.style.transform = ''; + + const data = e.dataTransfer.getData('text/plain'); + if (!data) return; + + if (!draggedItem || draggedType !== 't') return; + try { + const draggedData = JSON.parse(data); + + // Обрабатываем только задачи + if (draggedData.type !== 't' || !draggedItem) return; + + // Находим целевую категорию по позиции + const targetCat = categories.find(cat => cat.position === targetCategoryPos); + if (!targetCat) return; + + // Проверяем, что задача не перемещается в ту же позицию той же категории + if ((draggedItem.position === targetPosition || + (draggedItem.position === targetCat.length - 1 && targetPosition === targetCat.length)) && + draggedItem.category_id === targetCat.id) return; + + + await editTask(draggedItem.id, draggedItem.title, + draggedItem.description, targetPosition, + draggedItem.deadline, targetCat.id, null); + + } catch (err) { + console.error('Ошибка при переупорядочивании задач:', err); + setError('Не удалось переупорядочить задачи'); + } + }; + + + return ( ++++ ++ +Fool-Stack
+ +++ ); +} + +export default KBBoard; \ No newline at end of file diff --git a/src/KBBoardsList.js b/src/KBBoardsList.js new file mode 100644 index 0000000..7d148bd --- /dev/null +++ b/src/KBBoardsList.js @@ -0,0 +1,197 @@ +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; +import Header from './Header'; +import './css/BoardList.css'; + +const BoardListItem = memo(({ item, onOpen }) => { + if (!item) return null; + return ( ++ + { + error &&+{error}+ } +++++{info.title}
++ +
++ +
+++Описание: {info.description ? info.description : 'Отсутствует'}
++ {loading ? ( + <> + + > + ) : isOwner ? ( + <> + + + + + > + ) : ( + <> + + > + )} +++ {categories.map((category) => ( ++ + {memList && ( +handleCategoryDragStart(e, category)} + onDragLeave={handleDragLeave} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + onDrop={(e) => handleCategoryReorder(e, category.position)} + > + ++ ))} + + {categories.length < 10 ? ( ++ +++ {category.tasks.length > 0 ? ( + category.tasks.map((task) => ( + + )) + ) : ( ++Нет задач
+ )} +handleTaskReorder(e, category.tasks.length, category.position)} + onDragLeave={handleDragLeave} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + >+++ ) : ( + <>> + )} ++ ++++ )} + + {crCateg && ( ++++ +Изменение задачи
+ {items.length > 0 ? ( + items.map((item) => ( ++ ++ )) + ) : ( + Нет участников
+ )} +++ )} + + {crTask && ( ++++ + +Новая категория
++ )} + + {edBoard && ( ++++ + +Новая задача
++ )} + + {edCateg && ( ++++ + +Изменение доски
++ )} + + {edTask && ( ++++ + + +Изменение категории
++ )} + + {delTask && ( ++++ + + +Изменение задачи
++ )} + + {delCateg && ( ++++ +Удаление задачи
++ )} + + {delBoards && ( ++++ +Удаление категории
++ )} + + + {addMemb && ( ++++ +Удаление доски
++ )} + + {asgnMember && ( ++++ +Добавление участника
+ {assignAction ? ( ++ )} + + {qtMember && ( +++ ) : ( ++ + +Назначение пользователя
++ )} ++ + +Снятие пользователя
++ )} + + {delMember && ( ++++ +Покинуть доску
++ )} + ++++ +Выгнать участника
+ + + ); +}); + +const Pagination = memo(({ totalPages, page, setPage, loading }) => { + if (totalPages <= 1) return null; + return ( ++ {Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => ( + + ))} ++ ); +}); + +const KBBoardsList = () => { + const navigate = useNavigate(); + const debounceRef = useRef(null); + const timerRef = useRef(400); + + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + + const [sort_method, setSortMethod] = useState('title'); + const [reverse, setReverse] = useState(false); + const [search_text, setSearchText] = useState(''); + const [page, setPage] = useState(1); + const limit = 5; + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [items, setItems] = useState([]); + const [totalItems, setTotalItems] = useState(0); + + const openBoard = useCallback((id) => navigate(`/kanban-board/${id}`), [navigate]); + + const loadBoardList = useCallback(async () => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + try { + const { data } = await axios.post( + '/api/boards/list', + { sort_method, reverse, search_text, page, limit }, + { withCredentials: true } + ); + if (Array.isArray(data.boards)) { + setItems(data.boards); + setTotalItems(data.count || data.boards.length); + } else { + setItems([]); + setTotalItems(0); + } + setError(''); + } catch (err) { + const msg = err.response?.data?.message; + if (msg === 'Token Error' || msg === 'Invalid Token') { + setError('Вы не авторизованы'); + setTimeout(() => navigate('/login'), 1000); + } else { + setError('Ошибка загрузки досок'); + setItems([]); + } + } + timerRef.current = 400; + }, timerRef.current); + }, [sort_method, reverse, search_text, page, navigate]); + + useEffect(() => { + loadBoardList(); + return () => clearTimeout(debounceRef.current); + }, [loadBoardList]); + + const setFilter = (method) => () => { + setSortMethod(method); + setReverse((prev) => (sort_method === method ? !prev : false)); + timerRef.current = 100; + }; + + const createBoard = async (e) => { + e.preventDefault(); + setLoading(true); + try { + await axios.post('/api/boards/create', { title, description }); + setShowCreateModal(false); + setTitle(''); + setDescription(''); + await loadBoardList(); + } catch { + setError('Ошибка создания доски'); + } finally { + setLoading(false); + } + }; + + const closeCreateModal = () => { + setShowCreateModal(false); + setDescription(''); + setTitle(''); + }; + + const totalPages = Math.ceil(totalItems / limit); + + return ( + <> ++ + {error &&+ + {showCreateModal && ( +{error}} + +++ +Сортировка по:
++ + + ++ setSearchText(e.target.value)} + /> +++ +++ {items.length > 0 ? ( +Доступные канбан-доски:
+ ++ {items.map((item) => ( +
+ ) : ( ++ ))} + Нет данных
+ )} ++ ++ )} + > + ); +}; + +export default KBBoardsList; diff --git a/src/Login/API.js b/src/Login/API.js new file mode 100644 index 0000000..74d70c2 --- /dev/null +++ b/src/Login/API.js @@ -0,0 +1,6 @@ +import axios from 'axios'; + +export const loginAPI = async (username, password) => { + const newUser = { username, password }; + return axios.post('/api/users/login', newUser, { withCredentials: true }); +}; \ No newline at end of file diff --git a/src/Login/Logic.js b/src/Login/Logic.js new file mode 100644 index 0000000..ab6bd5d --- /dev/null +++ b/src/Login/Logic.js @@ -0,0 +1,26 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { loginAPI } from './API'; + +export const useLogic = (setError, setLoading) => { + const navigate = useNavigate(); + + const login = useCallback(async (username, password) => { + setError(''); + setLoading(true); + try { + await loginAPI(username, password); + setTimeout(() => { + navigate('/kanban-boards-list'); + }, 500); + } catch (err) { + setError(err.response.data.detail || 'Ошибка входа'); + } finally { + setLoading(false); + } + }, [setLoading, setError, navigate]); + + return { + login + }; +}; \ No newline at end of file diff --git a/src/Login/index.js b/src/Login/index.js new file mode 100644 index 0000000..7df3c1b --- /dev/null +++ b/src/Login/index.js @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Header from '../Header'; +import { useLogic } from './Logic'; + + +const Login = () => { + const navigate = useNavigate(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const { login } = useLogic(setError, setLoading); + + const handleRegisterClick = () => { + navigate('/registration'); // Переход к регистрации + }; + + const handleLogin = async (e) => { + e.preventDefault(); + await login(username, password); + }; + + return ( + <> +++Придумайте название и описание канбан-доски
+ ++ ++ > + ); +}; + +export default Login; \ No newline at end of file diff --git a/src/Mainpage.js b/src/Mainpage.js new file mode 100644 index 0000000..42335e6 --- /dev/null +++ b/src/Mainpage.js @@ -0,0 +1,116 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; +import Header from './Header'; +import { useNavigate } from 'react-router-dom'; +import './css/Mainpage.css'; + +const DATE_FORMAT = new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}); + +const formatDeadline = (deadline) => { + if (!deadline) return 'Отсутствует'; + return DATE_FORMAT.format(new Date(deadline)).replace(',', ' ·'); +}; + +const TaskItem = memo(({ task, onClick }) => ( +Вход в систему
+ { + error &&{error}+ } + + +onClick(task.board_id)}> + +)); + +const Mainpage = () => { + const [tasks, setTasks] = useState([]); + const [error_message, setErrorMessage] = useState(null); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + const controller = new AbortController(); + (async () => { + try { + const { data } = await axios.get('/api/users/my_tasks', { signal: controller.signal }); + setTasks(data.tasks || []); + setCount(data.count || 0); + setErrorMessage(null); + } catch (err) { + if (err.name === 'CanceledError') return; + setErrorMessage(err.response?.data?.message || 'Ошибка авторизации'); + } finally { + setLoading(false); + } + })(); + return () => controller.abort(); + }, []); + + const handleTaskClick = useCallback((boardId) => { + navigate(`/kanban-board/${boardId}`); + }, [navigate]); + + const isAuthenticated = !error_message; + const showScrollbar = useMemo(() => count > 3, [count]); + + return ( + <> +{task.title}+ + {task.category_title && ( ++ Статус + {task.category_title} ++ )} + + {task.board_title && ( ++ Доска + {task.board_title} ++ )} + ++ Дедлайн + + {formatDeadline(task.deadline)} + +++ ++ > + ); +}; + +export default Mainpage; diff --git a/src/OtherProfile.js b/src/OtherProfile.js new file mode 100644 index 0000000..c498b2d --- /dev/null +++ b/src/OtherProfile.js @@ -0,0 +1,51 @@ +import {useState, useEffect} from 'react'; +import './css/OtherProfile.css'; +import {useParams, useNavigate} from 'react-router-dom'; +import axios from 'axios'; +import Header from './Header'; + +const OtherProfile = () => { + const navigate = useNavigate(); + const {id} = useParams(''); + const [user, setUser] = useState(null); + const [avatar, setAvatar] = useState(''); + const [error, setError] = useState(''); + const [description, setUserDescription] = useState(''); + + useEffect(() => { + const checkSession = async () => { + try { + const response = await axios.get('/api/users/' + id); + setUser(response.data.display_name); + setAvatar(response.data.avatar_url); + if (response.data.description === '') { + setUserDescription('Описание отсутствует') + } else { + setUserDescription(response.data.description); + } + } catch (err) { + setError('Вы не авторизованы'); + setTimeout(() => { + navigate('/login'); + }, 1500); + } + }; + checkSession(); + }, [id, navigate]); + + return ( + <>++++Добро пожаловать в Kanban!
++ Kanban-доска помогает видеть весь процесс целиком: что запланировано, + что уже в работе и что готово. Перетаскивайте карточки между колонками, + фиксируйте договорённости и не теряйте контекст — всё в одном месте. +
+++Мои задачи ({count})
+ {loading ? ( +Загрузка...
+ ) : isAuthenticated ? ( + tasks.length === 0 ? ( +Нет задач
+ ) : ( ++ {tasks.map((task) => ( +
+ ) + ) : ( ++ ))} + Войдите, чтобы увидеть свои задачи.
+ )} ++ + {error &&+ > + ); +} +export default OtherProfile; \ No newline at end of file diff --git a/src/Profile.js b/src/Profile.js new file mode 100644 index 0000000..8169a34 --- /dev/null +++ b/src/Profile.js @@ -0,0 +1,471 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import Header from './Header'; + +const Profile = () => { + const [user, setUser] = useState(null); + const [userName, setUserName] = useState(null); + const [error, setError] = useState(''); + const [showConfirm, setShowConfirm] = useState(false); // Состояние для поп-апа + const [ShowConfirmUserName, setShowConfirmUserName] = useState(false); + const [new_username, setnew_username] = useState(''); + const [ShowConfirmName, setShowConfirmName] = useState(false); + //const [display_name, setdisplay_name] = useState(''); + const [new_display_name, setnew_display_name] = useState(''); + const navigate = useNavigate(); + const [showChangePassword, setShowChangePassword] = useState(false); + const [old_password, setold_password] = useState(''); + const [new_password, setnew_password] = useState(''); + const [new_password_confirm, setnew_password_confirm] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [showDescriptionModal, setShowDescriptionModal] = useState(false); + const [profileDescription, setProfileDescription] = useState(''); + const [descriptionError, setDescriptionError] = useState(''); + const [avatarUrl, setAvatarUrl] = useState(null); + const [isUploading, setIsUploading] = useState(false); + + useEffect(() => { + const checkSession = async () => { + try { + const response = await axios.get('/api/users/me'); + setUser(response.data.display_name); + setUserName(response.data.username); + setAvatarUrl(response.data.avatar_url || null); + } catch (err) { + setError('Вы не авторизованы'); + setTimeout(() => { + navigate('/login'); + }, 1500); + } + }; + checkSession(); + }, [navigate]); + + const handleLogout = async () => { + try { + await axios.post('/api/users/logout'); + setUser(null); + navigate('/login'); // Редирект после выхода + } catch (err) { + setError('Ошибка'); + } + }; + + const uploadAvatar = async (event) => { + const file = event.target.files[0]; + if (!file) return; + + setIsUploading(true); + setError(''); + + try { + const formData = new FormData(); + formData.append('new_avatar', file); + + await axios.put( + '/api/users/change_avatar', + formData, + { + withCredentials: true, + headers: { + 'Content-Type': 'multipart/form-data' + } + } + ); + + window.location.reload(); + + } catch (err) { + setError(err.response?.data?.detail || 'Ошибка загрузки аватара'); + } finally { + setIsUploading(false); + } + }; + + const NewDisplayName = async (e) => { + e.preventDefault(); + setError(''); + try { + await axios.put('api/users/change_display_name', {new_display_name}, + { + withCredentials: true + /*headers: { + 'Content-Type': 'application/json' + }*/ + } + ); + setShowConfirmName(false); + try { + const response = await axios.get('/api/users/me'); + setUser(response.data.display_name); + setUserName(response.data.username); + } catch (err) { + // Если нет сессии - редирект на логин + setError('Вы не авторизованы'); + setTimeout(() => { + window.location.href = '/login'; + }, 1500); + } + } catch (err) { + setError(err.response.data.detail || 'Ошибка входа'); + } + }; + + const NewUserName = async (e) => { + e.preventDefault(); + setError(''); + try { + await axios.put('api/users/change_username', {new_username}, + { + withCredentials: true + /*headers: { + 'Content-Type': 'application/json' + }*/ + } + ); + setShowConfirmUserName(false); + try { + const response = await axios.get('/api/users/me'); + setUserName(response.data.username); + setUser(response.data.display_name); + } catch (err) { + // Если нет сессии - редирект на логин + setError('Вы не авторизованы'); + } + } catch (err) { + setError(err.response.data.detail || 'Ошибка входа'); + } + }; + + const changePassword = async (e) => { + e.preventDefault(); + setPasswordError(''); + + try { + const NewPassword = {old_password, new_password, new_password_confirm} + await axios.put('/api/users/change_password', NewPassword, { withCredentials: true }); + alert('Пароль успешно изменён!'); + setShowChangePassword(false); + setold_password(''); + setnew_password(''); + setnew_password_confirm(''); + } catch (err) { + setPasswordError( err.response?.data?.detail || 'Ошибка при смене пароля' ); + } + }; + + const saveProfileDescription = async (e) => { + e.preventDefault(); + setDescriptionError(''); + + try { + await axios.put('/api/users/change_description', + { new_description: profileDescription }, + { withCredentials: true } + ); + try { + const response = await axios.get('/api/users/me'); + setUser(response.data.display_name); + setUserName(response.data.username); + // Обновляем описание, если оно есть в ответе + if (response.data.description) { + setProfileDescription(response.data.description); + } + } catch (err) { + setError('Ошибка обновления данных профиля'); + } + + setShowDescriptionModal(false); // Закрываем модальное окно + + } catch (err) { + setDescriptionError( + err.response?.data?.detail || 'Ошибка при сохранении описания' + ); + } + }; + + + const OpenWindowDelete = () => { + setShowConfirm(true); // Показываем окно подтверждения + }; + + const closeWindowDelete = () => { + setShowConfirm(false); + }; + + const OpenWindowName = () => { + setShowConfirmName(true); // Показываем окно подтверждения + }; + + const closeWindowName = () => { + setShowConfirmName(false); + }; + + const OpenWindowUserName = () => { + setShowConfirmUserName(true); // Показываем окно подтверждения + }; + + const closeWindowUserName = () => { + setShowConfirmUserName(false); // Показываем окно подтверждения + }; + + const openDescriptionModal = async () => { + setDescriptionError(''); + try { + const response = await axios.get('/api/users/me'); + setProfileDescription(response.data.description || ''); + setShowDescriptionModal(true); + } catch (err) { + setError('Не удалось загрузить данные профиля'); + console.error('Ошибка загрузки данных пользователя:', err); + } + }; + + const closeDescriptionModal = () => { + setShowDescriptionModal(false); + setDescriptionError(''); + }; + const deleteAccount = async () => { + try { + await axios.delete('/api/users/me', { withCredentials: true }); + setUser(null); + navigate('/login'); + setShowConfirm(false); // Закрываем окно после удаления + } catch (err) { + setError('Ошибка удаления'); + setShowConfirm(false); // Закрываем окно при ошибке + } + }; + + return ( + <> +{error}} +++++{user}++
{description}++ + { + error &&+ > + ); +}; + +export default Profile; \ No newline at end of file diff --git a/src/Registration/API.js b/src/Registration/API.js new file mode 100644 index 0000000..cf1b33b --- /dev/null +++ b/src/Registration/API.js @@ -0,0 +1,6 @@ +import axios from 'axios'; + +export const registrationAPI = async (username, display_name, password, password_confirm) => { + const newUser = { username, display_name, password, password_confirm}; + await axios.post('/api/users/register', newUser); +}; \ No newline at end of file diff --git a/src/Registration/Logic.js b/src/Registration/Logic.js new file mode 100644 index 0000000..dceee80 --- /dev/null +++ b/src/Registration/Logic.js @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { registrationAPI } from './API'; + +export const useLogic = (setError, setLoading) => { + const navigate = useNavigate(); + + const registration = useCallback(async (username, display_name, password, password_confirm) => { + setLoading(true); + setError(null); + try { + await registrationAPI(username, display_name, password, password_confirm); + alert("Регистрация прошла успешно") + navigate('/login'); + } catch (err) { + setError(err.response.data.detail || 'Пароль должен иметь длинну от 8 до 16 символов, содержать заглавные и строчные буквы, цифры и спец символ(_-?.!@\'`)'); + } finally { + setLoading(false); + } + }, [ setLoading, setError, navigate]); + + return { + registration + }; +}; \ No newline at end of file diff --git a/src/Registration/index.js b/src/Registration/index.js new file mode 100644 index 0000000..c53b205 --- /dev/null +++ b/src/Registration/index.js @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useLogic } from './Logic'; +import Header from './../Header'; + +const Registration = () => { + const navigate = useNavigate(); + const [username, setuserName] = useState(''); + const [display_name, setdisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [password_confirm, setPassword_confirm] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const { registration } = useLogic(setError, setLoading); + + const handleReg = async (e) => { + e.preventDefault(); + await registration(username, display_name, password, password_confirm); + }; + + const handleLoginClick = () => { navigate('/login') }; + + return ( + <>{error}+ } + {user && ( +++ )} + + {ShowConfirmUserName && ( ++ + + {isUploading &&+Загружается...} +Привет, {user}! Добро пожаловать в личный кабинет.
+Здесь ты сможешь управлять данными своей учётной записи.+Информация об учётной записи
+Отображаемое имя++++ +{user}
Описание профиля
++ Добавьте краткое описание о себе, которое увидят другие пользователи. ++ +Логин++++ +{userName}
Изменить пароль
+В целях безопасности мы рекомендуем выбрать пароль, который ещё не использовался вами в других учётных записях.+ + + +++ )} + + {ShowConfirmName && ( ++++Обновите логин
+Текущий логин: {userName}
Новый логин+ + +++ )} + + {showChangePassword && ( ++++Обновите отображаемое имя
+Текущее отображаемое имя: {user}
Новое отображаемое имя+ + +++ )} + + {showDescriptionModal && ( +++Изменить пароль
+ + {passwordError && ( ++ {passwordError} ++ )} + + +++ )} + + {/* Всплывающее окно подтверждения */} + {showConfirm && ( +++{profileDescription ? 'Редактировать описание профиля' : 'Добавить описание профиля'}
+ + {descriptionError && ( ++ {descriptionError} ++ )} + + +++ )} +++Вы действительно хотите удалить аккаунта?
++ + +++ +> + ); +} + +export default Registration; \ No newline at end of file diff --git a/src/_template/API.js b/src/_template/API.js new file mode 100644 index 0000000..2688708 --- /dev/null +++ b/src/_template/API.js @@ -0,0 +1,6 @@ +import axios from 'axios'; + +export const functionAPI = async (first, second) => { + const data = { first, second }; + return axios.post('/api/target/func', data); +}; \ No newline at end of file diff --git a/src/_template/Logic.js b/src/_template/Logic.js new file mode 100644 index 0000000..749ce3e --- /dev/null +++ b/src/_template/Logic.js @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { functionAPI } from './API'; + +export const useLogic = (setError, setLoading) => { + const navigate = useNavigate(); + + const function1 = useCallback(async (first, second) => { + setLoading(true); + try { + await functionAPI(first, second); + setTimeout(() => { + navigate('/kanban-boards-list'); + }, 500); + } catch (err) { + setError(err.response.data.detail || 'Ошибка'); + } finally { + setLoading(false); + } + }, [setLoading, setError, navigate]); + + return { + function1 + }; +}; \ No newline at end of file diff --git a/src/_template/index.js b/src/_template/index.js new file mode 100644 index 0000000..4420099 --- /dev/null +++ b/src/_template/index.js @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Header from '../Header'; +import { useLogic } from './Logic'; + + +const Name = () => { + const navigate = useNavigate(); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const { function1 } = useLogic(setError, setLoading); + + const handleClick = () => { + navigate('/target'); // Переход + }; + + const handleFunction1 = async (e) => { + e.preventDefault(); + await function1(first, second); + }; + + return ( + <> +Регистрация
+ { + error &&{error}+ } + + ++ ++ > + ); +}; + +export default Name; \ No newline at end of file diff --git a/src/css/App.css b/src/css/App.css new file mode 100644 index 0000000..a1dedfd --- /dev/null +++ b/src/css/App.css @@ -0,0 +1,448 @@ +@font-face { + font-family: "Roboto Regular"; + src: url("./../fonts/roboto-regular/roboto-regular.woff2") format("woff2"), + url("./../fonts/roboto-regular/roboto-regular.woff") format("woff"), + url("./../fonts/roboto-regular/roboto-regular.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +.App { text-align: center; } + +.app-loader { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +.spinner { + width: 42px; + height: 42px; + border: 3px solid rgba(91, 140, 255, 0.2); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +ul { + list-style-type: none; + padding: 0; +} + +a { color: inherit; text-decoration: none; } + +.app { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.app-container { + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; +} + +/* ── Страницы ── */ +.login-page, +.profile-page { + background: var(--gradient-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: var(--space-2xl); + margin: var(--space-xl) auto; + animation: fadeIn var(--transition-slow) ease both; +} + +.login-page { + max-width: 420px; + margin-top: 64px; +} + +.profile-page { + max-width: 1200px; + padding: var(--space-xl); +} + +.page-container { + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow-x: auto; + background: var(--gradient-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: var(--space-xl); + margin: var(--space-md) 0; + max-width: 99%; + width: 100%; +} + +/* ── Типографика ── */ +h1, h2, h3 { + color: var(--color-text); + margin: 0 0 var(--space-lg) 0; + font-weight: 700; + letter-spacing: -0.01em; +} + +h2 { + text-align: center; + font-size: 1.6rem; + background: var(--gradient-accent); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +h3 { + font-size: 1.2rem; + text-align: left; +} + +p { + color: var(--color-text-muted); + line-height: 1.6; + margin: 0; +} + +.modal-content p { + text-align: left; + color: var(--color-text); +} + +.modal-content div { + margin-bottom: var(--space-md); +} + +.text-block { + text-align: left; + color: var(--color-text-dim); + margin-bottom: 2em; +} + +.com { + color: var(--color-text-dim); + text-align: left; + margin: var(--space-sm) 0 auto; +} + +.com p { color: var(--color-text); font-size: 1rem; } + +.user-info p { + text-align: left; + color: var(--color-text); + margin: var(--space-md) 0; + font-size: 1.1rem; +} + +/* ── Формы ── */ +form div { + margin-bottom: var(--space-md); +} + +label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--color-text-muted); + font-size: 0.9rem; + text-align: left; +} + +input[type="text"], +input[type="password"], +input[type="datetime-local"], +input[type="email"], +select, +textarea { + width: 100%; + padding: 11px 14px; + background-color: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text); + font-size: 0.95rem; + font-family: inherit; + transition: border-color var(--transition-base), box-shadow var(--transition-base), background-color var(--transition-base); +} + +input::placeholder { color: var(--color-text-dim); } + +input:hover, select:hover, textarea:hover { + border-color: var(--color-border-strong); +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-glow); + background-color: var(--color-surface); +} + +/* ── Кнопки ── */ +button { + width: 100%; + padding: 11px 18px; + margin: 8px 0; + background: var(--gradient-primary); + color: #fff; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + font-size: 0.95rem; + font-weight: 600; + font-family: inherit; + letter-spacing: 0.01em; + transition: transform var(--transition-fast), box-shadow var(--transition-base), filter var(--transition-base); + position: relative; + overflow: hidden; +} + +button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-glow); + filter: brightness(1.08); +} + +button:active:not(:disabled) { + transform: translateY(0); +} + +button:disabled { + background: var(--color-surface-3); + color: var(--color-text-dim); + cursor: not-allowed; + box-shadow: none; +} + +.Important-button { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); +} +.Important-button:hover:not(:disabled) { + box-shadow: 0 0 24px rgba(239, 68, 68, 0.4); +} + +.button-small { + width: auto; + min-width: auto; + padding: 8px 14px; +} + +/* Switch-link */ +.switch-link { + margin-top: 15px; + text-align: center; +} + +.switch-link button { + background: none; + border: none; + color: var(--color-primary); + cursor: pointer; + font-size: 14px; + text-decoration: underline; + padding: 0; + width: auto; + box-shadow: none; +} + +/* ── Ошибки ── */ +.error { + background-color: rgba(239, 68, 68, 0.12); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + text-align: center; + font-size: 0.9rem; + animation: fadeIn var(--transition-base) ease both; +} + +/* ── Modal buttons ── */ +.modal-buttons { + display: flex; + gap: var(--space-md); + margin-top: var(--space-xl); +} + +.modal-buttons button { margin: 0; } + +/* Small-button row */ +.buttonName { + display: flex; + gap: var(--space-sm); +} + +.buttonName button { + background: var(--color-surface-2); + margin: var(--space-md) 0; + width: 44px; + min-width: 44px; + padding: 8px; + border-radius: var(--radius-md); + font-size: 1.1rem; +} + +/* Рамка (Profile) */ +.frame { + background: var(--color-surface-2); + margin: var(--space-md) 0; + width: 100%; + max-width: 344px; + padding: 12px 14px; + border-radius: var(--radius-md); + font-size: 1rem; + border: 1px solid var(--color-border); + transition: border-color var(--transition-base), transform var(--transition-base); +} + +.frame p { font-size: 0.95rem; } + +.frame:hover { + border-color: var(--color-primary); + transform: translateY(-1px); +} + +/* ── Profile: description ── */ +.description-textarea { + width: 100%; + padding: 12px 14px; + background-color: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text); + font-family: inherit; + font-size: 0.95rem; + resize: vertical; + min-height: 120px; + max-height: 240px; +} + +.description-textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-glow); +} + +.char-counter { + text-align: right; + color: var(--color-text-dim); + font-size: 0.85rem; + margin-top: 4px; +} + +.margin-top-large { margin-top: 10em; } + +/* ── Avatar uploader ── */ +.avatar-wrapper { + position: relative; + display: inline-block; + float: right; + margin: 24px; +} + +.avatar-upload-label { + display: block; + cursor: pointer; +} + +.avatar-file-input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.avatar-container { + position: relative; + width: 200px; + height: 200px; + border-radius: 50%; + overflow: hidden; + background: var(--color-surface-3); + box-shadow: 0 0 0 4px var(--color-surface), var(--shadow-md); + transition: transform var(--transition-base), box-shadow var(--transition-base); +} + +.avatar-image { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--transition-base), filter var(--transition-base); +} + +.avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + font-size: 64px; + color: var(--color-text-muted); + text-transform: uppercase; + background: var(--gradient-primary); + color: #fff; + font-weight: 700; +} + +.avatar-overlay { + position: absolute; + inset: 0; + background: rgba(15, 20, 28, 0.65); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + visibility: hidden; + transition: opacity var(--transition-base), visibility var(--transition-base); + border-radius: 50%; + z-index: 3; +} + +.rounded-plus { + display: flex; + justify-content: center; + align-items: center; + width: 72px; + height: 72px; + background: var(--gradient-primary); + color: #fff; + font-size: 56px; + font-weight: 300; + border-radius: 50%; + box-shadow: var(--shadow-md); + transition: transform var(--transition-base); +} + +.avatar-upload-label:hover .rounded-plus { transform: scale(1.08); } +.avatar-upload-label:hover .avatar-overlay { opacity: 1; visibility: visible; } +.avatar-upload-label:hover .avatar-container { transform: scale(1.03); box-shadow: 0 0 0 4px var(--color-surface), var(--shadow-lg); } +.avatar-upload-label:hover .avatar-image { filter: brightness(0.8); } + +.uploading-spinner { + margin-top: 10px; + font-size: 12px; + color: var(--color-text-muted); + text-align: center; +} + +/* ── Form-group ── */ +.form-group { margin-bottom: var(--space-md); } +.form-group label { text-align: left; } diff --git a/src/css/Board.css b/src/css/Board.css new file mode 100644 index 0000000..36176d8 --- /dev/null +++ b/src/css/Board.css @@ -0,0 +1,332 @@ +.inf-panel { + flex: 0 0 auto; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + padding: var(--space-lg); + border-radius: var(--radius-md); + margin-bottom: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.inf-panel strong { + margin-right: 1ch; + color: var(--color-text-dim); + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.inf-panel .row { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-md); + align-items: center; +} + +.row p, .row h3 { + display: flex; + margin: 0; + word-break: break-word; + align-items: center; + color: var(--color-text); +} + +.row h3 { + font-size: 1.15rem; + font-weight: 700; +} + +.row p { + font-size: 0.92rem; +} + +.modal-content .row { margin: 0; } + +.members-avatar { + height: 30px; + width: 30px; + margin-left: var(--space-sm); + border-radius: var(--radius-full); + object-fit: cover; + border: 2px solid var(--color-surface-2); + margin-right: -14px; + transition: transform var(--transition-base); +} + +.members-avatar:hover { transform: translateY(-2px) scale(1.08); z-index: 2; } + +.row button { + width: auto; + background: transparent; + display: flex; + align-items: center; + padding: 6px 10px; + margin: 0; + color: var(--color-text); + font-weight: 500; + border-radius: var(--radius-sm); + box-shadow: none; +} + +.row button:hover:not(:disabled) { + background: var(--color-surface-3); + color: var(--color-text); + transform: none; + box-shadow: none; +} + +/* Members-list */ +.members-list { + gap: var(--space-sm); + padding: var(--space-sm); + display: flex; + flex-direction: column; +} + +.members-list button { + border-radius: var(--radius-md); + margin: 0; + padding: var(--space-md); + display: flex; + flex-direction: column; + align-items: flex-start; + background: var(--color-surface-3); + width: 100%; +} + +.members-list button:hover { + background: var(--color-surface-3); + box-shadow: 0 0 0 2px var(--color-primary-glow); +} + +.modal-member { + flex: 0 1 auto; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + align-items: stretch; +} + +/* Setting panel */ +.set-panel { + flex: 0 0 auto; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + padding: var(--space-md); + border-radius: var(--radius-md); + margin-bottom: var(--space-md); + display: flex; + flex-direction: row; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.set-panel button { + width: auto; + margin: 0; + flex: 1 1 140px; +} + +/* Board */ +.board-panel { + flex: 1 1 auto; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + overflow-x: auto; + overflow-y: hidden; + gap: var(--space-md); + padding: var(--space-md); + border-radius: var(--radius-md); + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; +} + +.categori { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + background: var(--color-surface-3); + border: 1px solid var(--color-border); + min-width: 300px; + max-width: 340px; + padding: var(--space-md); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + align-items: stretch; + transition: border-color var(--transition-base), box-shadow var(--transition-base); +} + +.categori:hover { + border-color: var(--color-border-strong); +} + +.categori > button { + width: 100%; + display: flex; + background: transparent; + padding: var(--space-sm) var(--space-md); + margin: 0 0 var(--space-sm); + justify-content: flex-start; + color: var(--color-text); + box-shadow: none; +} + +.categori > button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.04); + transform: none; + box-shadow: none; +} + +.categori > button h3 { + margin: 0; + font-size: 1.05rem; + color: var(--color-text); +} + +.bib { width: 100%; height: 100%; } + +.task-list { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: var(--space-sm); + width: 100%; + margin-top: var(--space-sm); + padding-right: 4px; +} + +.task { + text-align: left; + margin: 0; + color: var(--color-text); + padding: var(--space-md); + display: flex; + justify-content: space-around; + flex-direction: column; + border-radius: var(--radius-md); + font-size: 0.92rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + width: 100%; + box-sizing: border-box; + gap: 6px; + transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base); + cursor: grab; +} + +.task:active { cursor: grabbing; } + +.task:hover { + background: var(--color-surface-3); + border-color: var(--color-primary); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.task > div:first-child { + font-weight: 600; + color: var(--color-text); +} + +.task > div:not(:first-child) { + font-size: 0.82rem; + color: var(--color-text-muted); +} + +.create { + background: transparent; + border: 2px dashed var(--color-border-strong); + display: flex; + flex-direction: row; + align-items: center; + transition: border-color var(--transition-base), background var(--transition-base); +} + +.create button { + background: transparent; + color: var(--color-text-muted); + margin: 0; + width: 100%; + box-shadow: none; +} + +.create button:hover:not(:disabled) { + background: transparent; + color: var(--color-text); + transform: none; + box-shadow: none; +} + +.create:hover { + border-color: var(--color-primary); + background: rgba(91, 140, 255, 0.06); +} + +.categori .create { + margin-top: 0; + flex: 0 0 auto; +} + +.categori.create { + margin-top: 0; + align-items: center; + justify-content: center; + flex: 0 0 200px; + min-height: 120px; +} + +.categori.create .bib button { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-xs); +} + +.categori.create .bib button div { font-size: 2rem; font-weight: 300; line-height: 1; } + +.task.create { margin-top: 0; padding: var(--space-sm); } + +.task.create button { font-size: 0.88rem; padding: 8px; } + +.task.move { + min-height: 30px; + opacity: 0; + background: transparent; + border: 2px dashed transparent; + transition: opacity var(--transition-base), border-color var(--transition-base); +} + +label { text-align: left; } + +/* Scrollbars */ +.task-list::-webkit-scrollbar, +.board-panel::-webkit-scrollbar { + width: 8px; + height: 10px; +} +.task-list::-webkit-scrollbar-track, +.board-panel::-webkit-scrollbar-track { + background: transparent; +} +.task-list::-webkit-scrollbar-thumb, +.board-panel::-webkit-scrollbar-thumb { + background: var(--color-surface-3); + border-radius: var(--radius-full); +} +.task-list::-webkit-scrollbar-thumb:hover, +.board-panel::-webkit-scrollbar-thumb:hover { + background: var(--color-primary); +} diff --git a/src/css/BoardList.css b/src/css/BoardList.css new file mode 100644 index 0000000..2e6324e --- /dev/null +++ b/src/css/BoardList.css @@ -0,0 +1,210 @@ +.kan-ban-list-sort { + background: var(--color-surface-2); + border: 1px solid var(--color-border); + padding: var(--space-lg); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.kan-ban-list-sort h3 { + font-size: 1.05rem; + margin: 0; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} + +.nav-sort { + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.nav-sort button { + width: auto; + padding: 8px 16px; + font-size: 0.88rem; + margin: 0; + background: var(--color-surface-3); + border: 1px solid var(--color-border); + color: var(--color-text-muted); + box-shadow: none; +} + +.nav-sort button:hover:not(:disabled) { + background: var(--color-surface-3); + color: var(--color-text); + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-glow); +} + +.nav-sort button.active { + background: var(--gradient-primary); + color: #fff; + border-color: transparent; +} + +.kan-ban-list-sort input { + max-width: 360px; +} + +.kan-ban-list { + background: var(--color-surface-2); + border: 1px solid var(--color-border); + padding: var(--space-lg); + border-radius: var(--radius-md); +} + +.kan-ban-list .inf { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-md); + margin-bottom: var(--space-md); + flex-wrap: wrap; +} + +.kan-ban-list .inf h3 { + font-size: 1.1rem; + margin: 0; + color: var(--color-text); +} + +.kan-ban-list .inf button { + width: auto; + min-width: 200px; + margin: 0; +} + +.kan-ban-list ul { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.kan-ban-list ul li { list-style: none; } + +.kan-ban-list ul button { + width: 100%; + padding: var(--space-lg); + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + align-items: stretch; + text-align: left; + color: var(--color-text); + font-weight: 500; + margin: 0; + transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base); + position: relative; + overflow: hidden; +} + +.kan-ban-list ul button::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--gradient-primary); + opacity: 0; + transition: opacity var(--transition-base); +} + +.kan-ban-list ul button:hover { + transform: translateY(-2px); + border-color: var(--color-primary); + box-shadow: var(--shadow-md); + background: var(--color-surface-3); +} + +.kan-ban-list ul button:hover::before { opacity: 1; } + +.kan-ban-list .sort-row { + display: flex; + justify-content: space-between; + gap: var(--space-md); + flex-wrap: wrap; +} + +.sort-row p, .sort-row h3 { + margin: 4px 0; + word-break: break-word; +} + +.sort-row h3 { + font-size: 1.1rem; + color: var(--color-text); +} + +.sort-row p { + font-size: 0.9rem; + color: var(--color-text-muted); +} + +.sort-row p strong { + color: var(--color-text-dim); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-right: 6px; +} + +.sort-row + .sort-row p { + margin-top: var(--space-sm); +} + +/* Пагинация */ +.pagination { + background: var(--color-surface-2); + border: 1px solid var(--color-border); + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-sm); + margin-top: var(--space-lg); + padding: var(--space-md); + border-radius: var(--radius-md); +} + +.pagination button { + padding: 0; + background: var(--color-surface-3); + cursor: pointer; + width: 38px; + height: 38px; + margin: 0; + font-size: 0.9rem; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + box-shadow: none; +} + +.pagination button:hover:not(.active):not(:disabled) { + background: var(--color-surface-3); + color: var(--color-text); + border-color: var(--color-primary); +} + +.pagination button.active { + background: var(--gradient-primary); + color: #fff; + border-color: transparent; +} + +.pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 560px) { + .kan-ban-list .inf { flex-direction: column; align-items: stretch; } + .kan-ban-list .inf button { min-width: 0; } +} diff --git a/src/css/Header.css b/src/css/Header.css new file mode 100644 index 0000000..722b025 --- /dev/null +++ b/src/css/Header.css @@ -0,0 +1,104 @@ +.header { + position: sticky; + top: 0; + z-index: 100; + flex: 0 0 auto; + height: 64px; + width: 100%; + background: rgba(28, 37, 51, 0.75); + backdrop-filter: blur(16px) saturate(180%); + -webkit-backdrop-filter: blur(16px) saturate(180%); + border-bottom: 1px solid var(--color-border); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25); +} + +.header-container { + max-width: 1280px; + height: 64px; + box-sizing: border-box; + padding: 0 24px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo a { + text-decoration: none; + display: inline-block; +} + +.logo h1 { + margin: 0; + font-size: 1.35rem; + font-weight: 800; + letter-spacing: -0.02em; + background: var(--gradient-accent); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + transition: filter var(--transition-base); +} + +.logo h1:hover { filter: brightness(1.2); } + +.nav-list { + display: flex; + gap: 4px; + list-style: none; + margin: 0; + padding: 0; + opacity: 0; + transform: translateY(-4px); + transition: opacity var(--transition-slow), transform var(--transition-slow); +} + +.nav-list.visible { + opacity: 1; + transform: translateY(0); +} + +.nav-list li { + padding: 0; + display: flex; + align-items: center; +} + +.nav-list a { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 10px 14px; + color: var(--color-text-muted); + font-size: 0.95rem; + font-weight: 500; + border-radius: var(--radius-md); + transition: color var(--transition-base), background-color var(--transition-base); +} + +.nav-list a:hover { + color: var(--color-text); + background-color: rgba(91, 140, 255, 0.10); +} + +.nav-username { line-height: 1; } + +.nav-avatar { + width: 30px; + height: 30px; + border-radius: var(--radius-full); + object-fit: cover; + border: 2px solid var(--color-border); + transition: border-color var(--transition-base); +} + +.nav-list a:hover .nav-avatar { + border-color: var(--color-primary); +} + +/* Mobile */ +@media (max-width: 560px) { + .header-container { padding: 0 14px; } + .nav-list a { padding: 8px 10px; font-size: 0.88rem; } + .nav-avatar { width: 26px; height: 26px; } +} diff --git a/src/css/Mainpage.css b/src/css/Mainpage.css new file mode 100644 index 0000000..60f1428 --- /dev/null +++ b/src/css/Mainpage.css @@ -0,0 +1,247 @@ +.mainpage { + padding: 0 16px; + animation: fadeIn var(--transition-slow) ease both; +} + +.mainpage__intro { + background: var(--gradient-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: var(--space-2xl); + margin: var(--space-xl) auto; + max-width: 1200px; + display: flex; + gap: var(--space-xl); + align-items: stretch; +} + +/* Left */ +.left-content { + flex: 1 1 0; + min-width: 0; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2xl); + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + overflow: hidden; +} + +.left-content::before { + content: ''; + position: absolute; + top: -40%; + right: -20%; + width: 320px; + height: 320px; + background: radial-gradient(circle, var(--color-primary-glow), transparent 70%); + pointer-events: none; +} + +.left-content h1 { + position: relative; + margin: 0 0 var(--space-md) 0; + font-size: 1.75rem; + line-height: 1.2; + background: var(--gradient-accent); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.left-content p { + position: relative; + margin: var(--space-sm) 0 0; + font-size: 1rem; + line-height: 1.7; + color: var(--color-text-muted); + text-align: left; +} + +/* Right — tasks */ +.tasks-container { + flex: 1 1 0; + max-width: 480px; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-xl); + display: flex; + flex-direction: column; +} + +.tasks-title { + margin: 0 0 var(--space-lg); + font-size: 1.1rem; + font-weight: 700; + color: var(--color-text); + text-align: left; + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 10; + background: var(--color-surface-2); + -webkit-background-clip: initial; + background-clip: initial; + -webkit-text-fill-color: var(--color-text); +} + +.tasks-loading, +.tasks-empty { + color: var(--color-text-dim); + text-align: center; + padding: var(--space-xl) 0; + font-size: 0.95rem; +} + +.tasks-list { + list-style-type: none; + padding: 0; + margin: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.tasks-list--scroll { + max-height: 400px; + overflow-y: auto; + padding-right: var(--space-sm); +} + +.tasks-list::-webkit-scrollbar { width: 6px; } + +/* Task card */ +.task-item { + background: var(--color-surface-3); + padding: 14px 16px; + border-radius: var(--radius-md); + color: var(--color-text); + text-align: left; + cursor: pointer; + border: 1px solid var(--color-border); + transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base); + position: relative; + overflow: hidden; +} + +.task-item::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--gradient-primary); + opacity: 0; + transition: opacity var(--transition-base); +} + +.task-item:hover { + transform: translateY(-2px); + border-color: var(--color-primary); + box-shadow: var(--shadow-md); +} + +.task-item:hover::before { opacity: 1; } + +.task-item__title { + font-weight: 600; + font-size: 1rem; + color: var(--color-text); + margin-bottom: var(--space-sm); + line-height: 1.35; + word-break: break-word; +} + +.task-item__meta { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-top: 6px; + flex-wrap: wrap; + min-width: 0; +} + +.task-item__label { + font-size: 0.7rem; + font-weight: 700; + color: var(--color-text-dim); + white-space: nowrap; + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.task-item__value { + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text-muted); + word-break: break-word; + min-width: 0; +} + +.task-item__value--truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; + display: inline-block; + vertical-align: bottom; +} + +.task-item__value--muted { + color: var(--color-text-dim); + font-style: italic; +} + +.task-item__badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + padding: 3px 10px; + border-radius: var(--radius-full); + background: rgba(91, 140, 255, 0.14); + color: #a8c0ff; + border: 1px solid rgba(91, 140, 255, 0.28); + white-space: nowrap; + letter-spacing: 0.02em; +} + +/* Tablet */ +@media (max-width: 960px) { + .mainpage__intro { padding: var(--space-xl); gap: var(--space-lg); } + .tasks-container { max-width: 380px; padding: var(--space-lg); } + .task-item__value--truncate { max-width: 160px; } +} + +/* Mobile */ +@media (max-width: 768px) { + .mainpage { padding: 0 var(--space-sm); } + .mainpage__intro { + flex-direction: column; + gap: var(--space-md); + padding: var(--space-lg); + margin: var(--space-sm) auto; + } + .left-content { padding: var(--space-xl); } + .left-content h1 { font-size: 1.5rem; } + .tasks-container { max-width: 100%; } + .tasks-list--scroll { max-height: 320px; } + .task-item__value--truncate { + max-width: none; + white-space: normal; + overflow: visible; + text-overflow: unset; + } +} + +@media (max-width: 400px) { + .mainpage__intro { padding: var(--space-md); } + .task-item { padding: 10px 12px; } + .task-item__title { font-size: 0.95rem; } +} diff --git a/src/css/Modal.css b/src/css/Modal.css new file mode 100644 index 0000000..5fb0557 --- /dev/null +++ b/src/css/Modal.css @@ -0,0 +1,50 @@ +.confirm-modal { + position: fixed; + inset: 0; + background: rgba(8, 12, 18, 0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: var(--space-lg); + animation: fadeIn var(--transition-base) ease both; +} + +.modal-content { + background: var(--gradient-surface); + border: 1px solid var(--color-border-strong); + padding: var(--space-xl); + border-radius: var(--radius-lg); + text-align: center; + min-width: 320px; + max-width: 560px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg), 0 0 0 1px rgba(91, 140, 255, 0.08); + animation: modalPop var(--transition-slow) cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes modalPop { + from { opacity: 0; transform: translateY(12px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.modal-content h3 { + text-align: center; + font-size: 1.2rem; + margin-bottom: var(--space-lg); +} + +.modal-buttons { + display: flex; + gap: var(--space-md); + margin-top: var(--space-xl); +} + +.modal-buttons button { + margin: 0; + flex: 1; +} diff --git a/src/css/OtherProfile.css b/src/css/OtherProfile.css new file mode 100644 index 0000000..fe7ffb2 --- /dev/null +++ b/src/css/OtherProfile.css @@ -0,0 +1,64 @@ +.profile-info { + margin: 0 0 var(--space-md); + animation: fadeIn var(--transition-slow) ease both; +} + +.name-with-avatar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-lg); + padding: var(--space-lg) var(--space-xl); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.user-name { + margin: 0; + font-size: 2.4rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--color-text); + flex: 1 1 auto; + word-break: break-word; + overflow-wrap: break-word; + background: var(--gradient-accent); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.profile-avatar { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + border: 3px solid transparent; + background: + linear-gradient(var(--color-surface-2), var(--color-surface-2)) padding-box, + var(--gradient-primary) border-box; + box-shadow: var(--shadow-md); +} + +.user-description { + padding: var(--space-lg) var(--space-xl); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-2); + font-size: 1rem; + line-height: 1.65; + color: var(--color-text); + text-align: start; + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; + margin-top: var(--space-lg); +} + +@media (max-width: 560px) { + .user-name { font-size: 1.8rem; } + .profile-avatar { width: 88px; height: 88px; } + .name-with-avatar { padding: var(--space-md); } +} diff --git a/src/fonts/roboto-regular/roboto-regular.svg b/src/fonts/roboto-regular/roboto-regular.svg new file mode 100644 index 0000000..d87778c --- /dev/null +++ b/src/fonts/roboto-regular/roboto-regular.svg @@ -0,0 +1,10004 @@ + + + diff --git a/src/fonts/roboto-regular/roboto-regular.ttf b/src/fonts/roboto-regular/roboto-regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..15a9c517dbab750e19eaac4a98256d4ba70108c8 GIT binary patch literal 129808 zcmd442V7Lg+6O!{rz{}73knM>y@+&CVFg8Oh>8lffCW*}s4<8oh>8udVeg>WOIBly z@tQ6~je5N%G0h~lo11nMV`7T3hwndUi)b`&-tYUpzu!kZd(Q5eGxN+dPk){{A(Rl} ziVu-!Gc$Ahj{W%Ui-g|)1;4iG(XD%?hS}XF^k59Whxf?oH}JUyna+eB#{Ise$G}19 z=NjHi!2RKbu=V{0wvRn|DC}cG=svVB7(JЗаголовок
+ { + error &&{error}+ } ++ ... ++@kkHPnL#gnF&O+7OM{WBYM)4Al?(S=0`uc58B7~N?n6_!nrh6wG@ zehJ!ZCl^i{``;5s@(EpVhY+D^%CpZ+&$>Lwj?l$(2+{4IGHvV>#|LM35aRzm+V>?K zS2)%rT=?i2r_T3?KZe2InL94g{Cxhw#IKqnjUU<$w|Nd Zj<$f5GPl;z^z%5}wS#nPYuFy+8!q6~sey74C@AW+@I``kT(ezetf~ z7*mJW3HvdJXoumOmj5zk=CtRCp4c`UWug#Lq}j}HgYM|ZM@*3nYCFchCs-r&YviA% zKYq=8$wBrxDHoearnsNXl|CiKxYpCxNIBa}D%p!f7N(Otv5I8VwIoK^LiVzDq)r$> zoW*%$Dvoz>EX45~jw~D-aV*7Ah@%L{2quv_=0HY^&ZGyvZxmz565$d_(S(r#$%#Zr zp5%b!LQ18H lS$$^ z5+qF^YtW~sd<)l3BppX6elKL9WTlWzLU3*+ `0>c8HW#! zk?bT%qYubxoIRvxNd?C}zONETp`QaJ6YEqgen$N0&t$du9T_SZG1qP+PgqCFghsTP zO6GFhtG}Hfv)KbuA@(Grg)wBRP(miK6=VU%-X#1$(pVc(hyG` Y!wGjI&&Jun)CVdvx*-ME2rDPICFYN`;w=gLF>4`QrbcM z#p>qU!1247=T4m8z *6nC3xehXYj1bH; n@B8 nFhI~2?Rblp5MlE6Ukh85?Lvq!S#I-CHE(X#e6)UO-9R$ab5=eahb%A z>gKzAe2iI$H5{k%0OTmwZSt?AEA#>%(@o@XaG1EP#96ZfIGRtQCA;Q}Vt(^ynke#y zbf0Vx--rC{NgOl| L5b zWI8u1wzJ4on;QIXmMeTbxs;Sy$4!Pj?hSd%$Q01r2hbl61UHyV{b;038`4ShG3m?K zkHcz)@s#8M9kUE`7(;dmYj7Uj{GHIh`DgJeezyU>_}T;ieEs 1>9P^C722&Mzv>zCrXtPtp}|s(7-VHDnm(I7+UEtoS$1Lr9_&iTU>?Iif9@t$CYt z2d#9q5y*adC)W8l(25^u