From 5777848d74a1f00d33a33d8e9a0822e926dd43b5 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 12 Aug 2018 09:49:12 -0400 Subject: [PATCH] Add part8 --- intro/part8/.gitignore | 6 + intro/part8/.travis.yml | 34 + intro/part8/README.md | 17 + .../assets/icons/android-chrome-192x192.png | Bin 0 -> 2774 bytes .../assets/icons/android-chrome-512x512.png | Bin 0 -> 9631 bytes intro/part8/assets/icons/apple-touch-icon.png | Bin 0 -> 2327 bytes intro/part8/assets/icons/browserconfig.xml | 9 + intro/part8/assets/icons/favicon-16x16.png | Bin 0 -> 736 bytes intro/part8/assets/icons/favicon-32x32.png | Bin 0 -> 985 bytes intro/part8/assets/icons/favicon.ico | Bin 0 -> 15086 bytes intro/part8/assets/icons/mstile-144x144.png | Bin 0 -> 1956 bytes intro/part8/assets/icons/mstile-150x150.png | Bin 0 -> 2285 bytes intro/part8/assets/icons/mstile-310x150.png | Bin 0 -> 2542 bytes intro/part8/assets/icons/mstile-310x310.png | Bin 0 -> 4166 bytes intro/part8/assets/icons/mstile-70x70.png | Bin 0 -> 1563 bytes .../part8/assets/icons/safari-pinned-tab.svg | 29 + intro/part8/assets/images/error.jpg | Bin 0 -> 74241 bytes intro/part8/assets/images/loading.svg | 17 + intro/part8/assets/site.webmanifest | 19 + intro/part8/elm.json | 35 + intro/part8/index.html | 41 ++ intro/part8/src/Api.elm | 53 ++ intro/part8/src/Article.elm | 304 +++++++++ intro/part8/src/Article/Body.elm | 38 ++ intro/part8/src/Article/Comment.elm | 139 ++++ intro/part8/src/Article/Feed.elm | 421 ++++++++++++ intro/part8/src/Article/FeedSources.elm | 109 +++ intro/part8/src/Article/Slug.elm | 35 + intro/part8/src/Article/Tag.elm | 41 ++ intro/part8/src/Asset.elm | 48 ++ intro/part8/src/Author.elm | 251 +++++++ intro/part8/src/Avatar.elm | 56 ++ intro/part8/src/CommentId.elm | 29 + intro/part8/src/Email.elm | 45 ++ intro/part8/src/Loading.elm | 25 + intro/part8/src/Log.elm | 20 + intro/part8/src/Main.elm | 334 ++++++++++ intro/part8/src/Page.elm | 159 +++++ intro/part8/src/Page/Article.elm | 588 +++++++++++++++++ intro/part8/src/Page/Article/Editor.elm | 620 ++++++++++++++++++ intro/part8/src/Page/Blank.elm | 10 + intro/part8/src/Page/Home.elm | 259 ++++++++ intro/part8/src/Page/Login.elm | 338 ++++++++++ intro/part8/src/Page/NotFound.elm | 21 + intro/part8/src/Page/Profile.elm | 346 ++++++++++ intro/part8/src/Page/Register.elm | 319 +++++++++ intro/part8/src/Page/Settings.elm | 434 ++++++++++++ intro/part8/src/PaginatedList.elm | 98 +++ intro/part8/src/Profile.elm | 56 ++ intro/part8/src/Route.elm | 107 +++ intro/part8/src/Session.elm | 116 ++++ intro/part8/src/Timestamp.elm | 100 +++ intro/part8/src/Username.elm | 47 ++ intro/part8/src/Viewer.elm | 85 +++ intro/part8/src/Viewer/Cred.elm | 85 +++ 55 files changed, 5943 insertions(+) create mode 100644 intro/part8/.gitignore create mode 100644 intro/part8/.travis.yml create mode 100644 intro/part8/README.md create mode 100644 intro/part8/assets/icons/android-chrome-192x192.png create mode 100644 intro/part8/assets/icons/android-chrome-512x512.png create mode 100644 intro/part8/assets/icons/apple-touch-icon.png create mode 100644 intro/part8/assets/icons/browserconfig.xml create mode 100644 intro/part8/assets/icons/favicon-16x16.png create mode 100644 intro/part8/assets/icons/favicon-32x32.png create mode 100644 intro/part8/assets/icons/favicon.ico create mode 100644 intro/part8/assets/icons/mstile-144x144.png create mode 100644 intro/part8/assets/icons/mstile-150x150.png create mode 100644 intro/part8/assets/icons/mstile-310x150.png create mode 100644 intro/part8/assets/icons/mstile-310x310.png create mode 100644 intro/part8/assets/icons/mstile-70x70.png create mode 100644 intro/part8/assets/icons/safari-pinned-tab.svg create mode 100644 intro/part8/assets/images/error.jpg create mode 100644 intro/part8/assets/images/loading.svg create mode 100644 intro/part8/assets/site.webmanifest create mode 100644 intro/part8/elm.json create mode 100644 intro/part8/index.html create mode 100644 intro/part8/src/Api.elm create mode 100644 intro/part8/src/Article.elm create mode 100644 intro/part8/src/Article/Body.elm create mode 100644 intro/part8/src/Article/Comment.elm create mode 100644 intro/part8/src/Article/Feed.elm create mode 100644 intro/part8/src/Article/FeedSources.elm create mode 100644 intro/part8/src/Article/Slug.elm create mode 100644 intro/part8/src/Article/Tag.elm create mode 100644 intro/part8/src/Asset.elm create mode 100644 intro/part8/src/Author.elm create mode 100644 intro/part8/src/Avatar.elm create mode 100644 intro/part8/src/CommentId.elm create mode 100644 intro/part8/src/Email.elm create mode 100644 intro/part8/src/Loading.elm create mode 100644 intro/part8/src/Log.elm create mode 100644 intro/part8/src/Main.elm create mode 100644 intro/part8/src/Page.elm create mode 100644 intro/part8/src/Page/Article.elm create mode 100644 intro/part8/src/Page/Article/Editor.elm create mode 100644 intro/part8/src/Page/Blank.elm create mode 100644 intro/part8/src/Page/Home.elm create mode 100644 intro/part8/src/Page/Login.elm create mode 100644 intro/part8/src/Page/NotFound.elm create mode 100644 intro/part8/src/Page/Profile.elm create mode 100644 intro/part8/src/Page/Register.elm create mode 100644 intro/part8/src/Page/Settings.elm create mode 100644 intro/part8/src/PaginatedList.elm create mode 100644 intro/part8/src/Profile.elm create mode 100644 intro/part8/src/Route.elm create mode 100644 intro/part8/src/Session.elm create mode 100644 intro/part8/src/Timestamp.elm create mode 100644 intro/part8/src/Username.elm create mode 100644 intro/part8/src/Viewer.elm create mode 100644 intro/part8/src/Viewer/Cred.elm diff --git a/intro/part8/.gitignore b/intro/part8/.gitignore new file mode 100644 index 0000000..3ebe788 --- /dev/null +++ b/intro/part8/.gitignore @@ -0,0 +1,6 @@ +# elm-package generated files +elm-stuff/ +# elm-repl generated files +repl-temp-* + +elm.js diff --git a/intro/part8/.travis.yml b/intro/part8/.travis.yml new file mode 100644 index 0000000..ecd6830 --- /dev/null +++ b/intro/part8/.travis.yml @@ -0,0 +1,34 @@ +sudo: false + +cache: + directories: + - tests/elm-stuff/build-artifacts + - sysconfcpus + +before_install: + - echo -e "Host github.com\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config + - | # epic build time improvement - see https://github.com/elm-lang/elm-compiler/issues/1473#issuecomment-245704142 + if [ ! -d sysconfcpus/bin ]; + then + git clone https://github.com/obmarg/libsysconfcpus.git; + cd libsysconfcpus; + ./configure --prefix=$TRAVIS_BUILD_DIR/sysconfcpus; + make && make install; + cd ..; + fi + + +install: + - npm install -g elm@0.18.0 elm-test elm-format@exp + - mv $(npm config get prefix)/bin/elm-make $(npm config get prefix)/bin/elm-make-old + - printf '%s\n\n' '#!/bin/bash' 'echo "Running elm-make with sysconfcpus -n 2"' '$TRAVIS_BUILD_DIR/sysconfcpus/bin/sysconfcpus -n 2 elm-make-old "$@"' > $(npm config get prefix)/bin/elm-make + - chmod +x $(npm config get prefix)/bin/elm-make + - travis_retry elm-package install --yes + - cd tests + - npm install + - travis_retry elm-package install --yes + - cd .. + +script: + - elm-format --validate src tests + - elm test diff --git a/intro/part8/README.md b/intro/part8/README.md new file mode 100644 index 0000000..66c0184 --- /dev/null +++ b/intro/part8/README.md @@ -0,0 +1,17 @@ +# Part 8 + +Once again, we'll be building `src/Main.elm`, but editing a different file. + +To build everything, `cd` into the `part8/` directory and run8 + +```shell +elm make src/Main.elm --output ../server/public/elm.js +``` + +Then open `http://localhost:3000` in your browser. + +## Exercise + +We need to make login work. Currently it doesn't actually send a HTTP request to the server. + +We'll fix this by editing `src/Page/Login.elm` and resolving the TODOs there. diff --git a/intro/part8/assets/icons/android-chrome-192x192.png b/intro/part8/assets/icons/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..ff83974ad3f0957dce04cf055cf43f880a0044e4 GIT binary patch literal 2774 zcmZ`)3s4i+8a}((goJ=Rkfg*^2g*V*a0^1pZqZW=1!1<# z{iGfZM{6kF!cfy8gl*wH$*=m{7T%|G#R;*AAWVOZW^-i-4LhozA9}SW+_pMm7{4a} zx+?NW$bSd#cdzwmY(D41e?O8s0U0yn)!``etEIm$sEq!=OIvs-ra#?3wql;4EiOY) z{?1Pn|F>PjL1SiOFjsg$yU&yn{lS#CEY`sN;+yH(*I98drme_+DG2m_zB0#G7q6MM zYRXh`!ZTxjuFs2^lM@FDmV5U-4-ghk%JZ7?jHUkO)r`q2`X=#KKJEANU-e{SzTbq$ ze-{*T)Oj0~k?{P;vgpWgu#G;R4*{!?pYkmL%()*QWb=LpE+nmrqhcbgp3qU6qvOV3 zH1+^6qNBn?g{6OX_UM})ggYzWOdVra@?B~5@$M(58~AUmoKxgD+qq;bdhOxEhk-rA zy4~J~9`q~}4;;Q&vi7-Y@2xK{8p+B%o<)ww#kn1f&G%L|?_P57U$dvu#hL4Vl|6nN zaY*bdxX2bdw~C9<^XqI{U5e1a4(5gScsau{!iMezC7Hi_U`L65jH?ta zJJ}{XTn;<5@K`p)C2R`52=(H4m$?Ia%L%Rq^m%E}aL0a^=^CtWqK1(@u@Str?*=K% zI@o7keA#&WsK!V_iSRSj=p_F_l;O1DYnT3OHf{^2aB(oJ^^uW(>(?961cHa|t!4^{ zz4RdbH*^vw4RP632Wo!q@Cyp&DF^A0X{(*wfp^)Gw0I3YdiC}qne4Z zINpHVI$nZB;50t#oT*06+y-So$EfZaqflQwOr<@^Y$ICx;@f49I_f1cg*G#R^^P2S zH{%=B4m8M*DtvZaVQmDJvfGk6C0ps;IOs)}z!oNKzsV_;3@Y;`B2{y*|GCrgJsO1F zmi(j~2k71#SoS2dEQBK@wEjOPddL8N5cKGrHvzd?SObQt#M0bcR}2}UU%uNxjtO;B zCga*BHi}44m6TD_>&v9$t>t{=qQd94))}KQ_Txl7 z+f}PbR3dWbC1VRV9bDqb74wxeLiX;&Cd2k88^0%5gcCM2j=q#=wQd4~8S2gg$42CV z?9t{{l7V;vYrl#!!x{a5^}6uTe)NQCKj9>DYShnfoqV$#5fr}ME0D%7Wza8y7#{at z1N$H%4%HRim&7P-3E8w0n+?^(c$Euta6U3u)c9mU3@{|0(bJF+V12s61-^Ohfgg}r zWkM-;7r@IFn*|({$9!*e#R7=^c#SljgSk1h4c=iL0~MlYfdOR1>Us~kG>z75VpN-w zX8(v7X1Atl#O|S;{&babGj^5?BRWhA8kd~NV5kOM?c0a91KeDZdJL=0AG^T?C7bFm zr}26Hu3%6H$Q%|{5@5ZB93gf~CWJn`GiJGdJF`NPPgG~%szHA-!_C-3mz(|4fkrWI za3*^IA!pVRrpx2WI<&rOEHLWEku_tmVJp_4B$ha@a12&XTyF5Je3gp@8Okxax!QdZ zakeT#rOl;r4DOg+li?yDBek77*A7{+ZODZnTblBf%S4|39Z-=K>eV&M?mH-F(2Chk zdVp^8rrO?ARmUB`8A_IU&%@$6yMpWWTWPp?j!HVpIymPA&y#z1t3aLFup5a>8qz@^0Wrtw;qx2mn_(?Xe*KhalFz!Z^6(O8v zh2y1u=UHUKLE~k_242gSURUKG4XpGuU8jyE()!=*S}E&tASK{}q!x7Q=o&@q{Q?KI z;X5EV-%^`m5sh@k7Wh4S$LMtSBI8RiBsmHm#xaWJ9lY8RqPtAfq=W*(m8AC_uzMYaf zJky}?j0{PT|MANIrl&pntlzwPCFxVP;G(COAZj>SR^@?usb6Fp9-{k76^Zf$T?uP% zz4^fECNJ#YE#|Ip!z*-Os3M7nzUN+-9Kv%t3LH)a8>Y4D#AP>*K||3?oukiuv!`@N zOJ}gO91B$I`i!^GeesHTd69NDOSp(+l-;ORa0TW1CTy&_%5YWT4nJq0KrS*%pXR@_ zKQ&@lQ+A^VTq=kh4G?R-X78hq{7U#^# zD$0Qi2;j{K;_?EyyZ|9@=A0QpFsAVW=J0sJ5RjspA9p9_Lv9_?jI*4 z7Kn>WGK+FRNlA%cUjB-2voZ^F{0fS44-Eu=21Uv1g^P<5GD|pxMFrx#l{uW^b%i;c zyu#uv07@&LzO`YlUgYC*BkpE<)UyOY^J4pDv7Nk}qW3t{oH*gctSi|xud_nV&F*_` kSKF_8T(!DM@Z}5qfY#Na_-$C=J~#qIMZ|`m4im}#2h)4vC;$Ke literal 0 HcmV?d00001 diff --git a/intro/part8/assets/icons/android-chrome-512x512.png b/intro/part8/assets/icons/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..ec8f58163e057f4a7d03cc14ca0b49c766d053bd GIT binary patch literal 9631 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 K{<-f3KmH$Sk#vXv literal 0 HcmV?d00001 diff --git a/intro/part8/assets/icons/apple-touch-icon.png b/intro/part8/assets/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0a048cc9004391214941081a76c9ad02a78ce8fc GIT binary patch literal 2327 zcmZ`(2~<;88h#KE0V$4x$fnF#smlO?utq>pl7O-(V1hst2s}a}Noo?=L=iP21){}; zgvEroKtfQKK#5Z7NW~?AP!cN4pvPK6=GCe-pa(|CJkRT)9nYC_?)~rgfB*L{_q==F zncZ;&I~x}p008WwqaqXFv+Lttih`}p%6G5@EOH`ZBLJYCZ~Gy`681ePQ3*( z;Kk@CN<_R!F6JV7n1|C}%Yip?0JbnTnSeK~A6-*FfM{Tg$Y2slN5Jo7>mMZ21upW3 zvhDXMOu`E(h+sqvTUfl9g5X6GX^oJJ7?}al!1$9Cn1>fq5Ya5w|1*!w0EZ9_?0y1} z5YiN>27iu@Fr^3l*^YA(;t~P=6;#1lbH1edTwzny*W#MWBGLJ;E?=l9HoMq(CLq}E zNO}5w_GzON?AJd(C_emZ?VDEm ziyg$V$^==%$XKe`bDSXMf0!2j6m2Lx1`R@l~7rNksx* zQ$3gY0PtC9aKUexgJ~o>PFhdBCF+I$?g%4_6}-@eSh2Kj@tssXwCVc^f^@wSt$Ecny{x< zwZt{8myS{iC2EDEGOk@`(Y5zShST!N13oda+jk|7YW|^(?D~6vqu0(@{1(WSVR1N- zC#-(OTtj5`a?R-P^X@{8jvd0|ZQ^cy-(+})@WeeCnN1*dYN#`hUrOJjMv`Z=F+|MB z{IB>Dt0ysg*(1S<;h3b(kqwHc|KjGa&>r!}yZhB>ovA0W2d_1=H7}rAfoN#^`+Vn_ z$pUst!)fot>#At3r!ul3Fn=-(Ujmd5krGwb+(0ZT7Bi@uH7x9xd~46FmfC7UXv&al z-VsKf8#|JGQZFskx82$*^4?+8rm=UE%k|B(`m;)h$*t*H`=8WiTwkGd2y+WMvCTiW zf&NZvMy8e?as1Z^NE1xIvE%`_ixGbf1+gJc|6H=u(e2CiBC0RtbT*m4UZKR2ZQow(49&g zUsW)Z&TEp-_9|{{N>=!;8^K_Rd8%R5$e9)zeY2fo#p#2j_?I>piPp zHa!)fsunV?Nj3{|Xk~)RHn6cd{$*TUi=je)PMGD{BomTnoE$KAdFlgW@GU5hO%UBd zsT9aOMg1*IP&#V3T3ZNxUK9;p#GcwO9N&8^mDjC*O{;UJtsjgbZH@ptu)Kw%)Axb^f*(B+c78_C3>|Px)Te z&p00IDbazawL7~)M8pnt z+uU$SO7pmk_=7rp20Q0NSx6O*ONejPYz6JRm(qAU>TjSts7fof?vyM{X*VgYjmh%X z*#i&s=Ta1zhjmitP6^|FXkt)CW3#KTJ!AqL#~;vD#V$Jdx4WK$OXg%{aI&$KL)q{F z_@RA+z0d((Xul*h2J0J)^+j(+`(e>&wg)u(KM8rvtX#10|0kH2B;eo#=Z_r{nII=W z<4`t`pP%oY%b>F<8LVt?=AoRrSD`Mjh*Xr6%t^?|_h21jg1HB?JvfD|Y>!+PhXMcv z_0QifTlyt&!-j|Pk5thw5&+aXTW^Z(%5^Ju)i|J5dPJ7`-_1g;>rC=^^jLd;KsDet gVD+dpO0?4(fSmrqdAmK}0^9GXpZBH-zFFaFJ#{d8T literal 0 HcmV?d00001 diff --git a/intro/part8/assets/icons/browserconfig.xml b/intro/part8/assets/icons/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/intro/part8/assets/icons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/intro/part8/assets/icons/favicon-16x16.png b/intro/part8/assets/icons/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..246ebd738342ea5e54cda880944ec5315bad3f87 GIT binary patch literal 736 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>rFC0o{dp-A>GuO+j(et_I zyk{OBCGJmc-QAwJz5Sdc4b;MzpcO;4W!g*rW5977~7`<{O()}$c9lAvsHgKi%N5z)O$emAGKZCnwXX QKr0wLUHx3vIVCg!0Dzqsn*aa+ literal 0 HcmV?d00001 diff --git a/intro/part8/assets/icons/favicon-32x32.png b/intro/part8/assets/icons/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..72a40d1b1c02c162e7df5aa0d2335a0562fa2377 GIT binary patch literal 985 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10;$eVKi0l9V|3gT1H)(~-#)@mtSCrIbe{S0rpb({Rxmi0Up{MJAU6HRW!*ov`K1hqVkq^l>c% z!+(alOIE8wLEQK27*+v|x@eQQ^=wp9#e1-V=;X?e>llE>0u@K4Q~*)wq?x79TpBJ| z11*inEe2Wvw7Tx1^|Q@Dy;eZG9<|hGf&$#bJ4;X3%1XnzJ!^1Nd?Qa#g$x(syO#9tlqPrX7~JBj$L&- z{dO?sGcRH3>xthDG?6jM+uensgH_f8$l)yTh%5$*#e*`sfBUUD6 zA>NOho<0T2v3a^UhFF|#J$2pbkb{83MZq_$g4_q0l$@^INV>ivV)EVp{}t0Gr5bPM z{J8HPv+>r5koj}HD-8bkGlVLMu>^EaWpdoAuwYtOQY3?j_Uc8Z+C~ecUO%{UM}WaK zd-B$;dy|i_tZ=)1dE2>naWVPtxEJshT|6o;J+V3F%cXo4=2r&7wVyp@I45*A{#_~H zAiLeRzg9?Sfq;A~bDQ#imxf@@wsgk_^EcNQa-Q>_bYTCc|5ke#zfay_`d@F)^?)BL za+m%~*k!W4QUBQUU$yEg&j$ZRV*6d+&tm-O*|V7cY{Tcknz~FjF8QP171&khyVNsz z*>}dVEvpfVWeT?M>}%`tf5g7ZuIs+NW!!tGxV@7~fPti1;u=wsl30>zm0Xkxq!^40 z3@vmGOmz*6LJW+p3@xk-jkOJotPBjCPJBo~(U6;;l9^VCTZ8dbj~hS@k{}y`^V3So z6N^$A%FE03GV`*FlM@S4_413-XTP(N0xAlx3W+EQN-S3>D9TUE%t=)!sVqoU$Sf#H zW?-n8^Y{}FM`4(T#wq{PXFQ(m_pwD+_y17GV}vaA`0(oWiWUIYi;~jVmXP loH-(Mg#C1b#{w@shF9W(C7+y3rvj~D@O1TaS?83{1OR(=hlv0H literal 0 HcmV?d00001 diff --git a/intro/part8/assets/icons/favicon.ico b/intro/part8/assets/icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ac278087f4c38139b33db4edbfbf1f46cf9f8a58 GIT binary patch literal 15086 zcmdU$TWr%-7{|Xf740U*DxP>iBa@i4N5r-CMnH&5HEmBwv>hU}orX3ckbt!X+IV|_ z?PX&;lsn}%h6+XFQg)XDj17%j+6nDp($ooP_CyofN<2);{@<~WP<4ghf0v^U9wB-B!?XN^UWbEyVD%<;LkM&oxQrheOYCnG9p>+Y^HX|li!@f;w*j1 zHM`8oE(Z_zo#vpkSM)12PU>BsdvdPt+5?E z<~QfC1m}a8Cs5hL!;&FiD{~kJev!kj#`@*r8j2OPs=(_RyUd+)7$^RC+otuR-Z_Wr zK4m`o%`>U%ne7kn8wdV`&fYV$oVbif4q)fF9`l>nTh4*NjqrlIJaK) zkUzzKVJN6_>-X=Y#QU(QIfRFD?n5c|8Q{O;5r5*N=7w_S6P%*Xb$0m-l79{+cJT99 zk#9+{Um99ws&~mQ?I`gvSStTe7NQNDw}S<{2mEICk!;UP_x@;aD6Ki=00J>nkW3**PDF{Lfq7TVKKP3$Zw5cgU}^i$02bKEChsx>P*a zJ%{VY4Z8z=oqfFSfyKC)>hIiVZZntDlA-@hdiD(UKPA|0_@B2mHB@q6oUDDu*tbmm zXLthGe=+j7{cPYbH2ZY0=EcsrYW!1@UE?2aX{@U%V1M_d-eo_V0QOr_+Y4LZdiRu_ z^N(->exd=kB}CiU&L9#Gv$KitCpNH4e2GKE!F~nr6-Cs-pJ_n2i(|yG2q1j15)Ufa z*@DAZuMI;EMnQ%!>dKyx62#HdHd8 zZsR!d{p@m@#`_UxI$Ok@#TGjJVifC!V8IcIz#{fP3M{m?!?3pCUY1P$$J^m#@&(%g z^}Q-zM18l)uMpuQyZi8w-Boqc)Jan(OB2Io+Wc%`d{3w_3^MQ2^7+niolTB& zTWH#eQ&KxEe>vYYKJ}p0dE%r|+ftu7yk(GicbTyTn zPCw>_-=?l)DYU+`Vo2+MvJV(-Zfzm7ai4xdJcmV<4#99K{=JzYtG@esQ1gz4yVB;) zq8;s8J^Wx~aHQ{?LDu=nG;R6rLBFzxx*xv;{U0TRWBu$5ns)eSVUcF0CGZcC9~_LCw2Ad?vG|`u<1in2t8OI{0!<`WnOG13UfV z9E&PvQ*q5Y_S-yHlZXC6TG#ET?ruNkCp&Y|uf+UFPWsLDs&-HO1p3g4oEV_}*YzzW zZuRjTeMsc3YHM?yi7ru=*16Hz)xWN7eqeC9ok=@G{zs{M9p5_~LwS))!F4A5 zsC(TsXk0VqYyNDJztGn29kgqwtMLu(?L^z0e@k=yG0xi??TuH>xms<@{3k*?m4Dsw zLgy{(qP598ZH(tvWL&4#tGsj3W<~|$OOU82KvazBfznd^%NPFDD^P@~15ATN2-6r8 zEDBMecpjCN&Z5$?=~Nt;MnyqCW+r@McA^M}7Q>|!u4M=jK&T)>M*I<9GN}}ipNa`z zV_k-I0P7&u3fBDpWIQbaebVPC0AOCue7<27c%L|}SHp2nHeddFRclj2EQc7v7vpYB zJx>~9XTR968nK@m;@kbE^Ynwu=T@^Xis3rzD8|!0R}S)T$ig?>pWn-9h<&wx!x}IQ zT5t^RyR~&q3rR)#V#rQ+${+m{?Z|?2$!J26Mj(fcu zkFsyNe?#ougCEx7o?EdiHPJn<<9dHY`lkF{eKYgj>zfW6eN}&FzV~}Whs?gb z&CJf=%qbkcf~F(k_Ea!GJuJr>W)v5;2nS zm`!+yfCw?7K&)6$Tie>wDOgZ!A&3rZ?W#PM=q^Hl;$y7rhm4l}V`t9&o!|MLbAI19 zckbLGab(yE$5oC1fE7XkB7xbs@DOnj`>S1xVZtZ#BlrNc8Ju1uIKY}N6G$QeP^|+% zqXpnOlr%N~K4Sy$C>{V#H305eb>BpB0l;L7w@FbrkZ%pnAK-v_5P#`LtV{t>V-1D~ z^;ev`!23B1eBe+0pp~N;;HdjIL4Cdf%)eT2|3z^rhvJ(Onk<%ZY<_-zy6|ZzeChk;lE`RaIDt{e}3z zyy}C;@}uKdYlxUbhI*``3S&G_&``Gju&Mkr?a2!H5p(6f!c#|eIaQ6g?5v}W*q5oN zamKQ&v~qmK*~~qamNR=>GSYF))lD@SwN2@nXX=_yoZgFV#2jm>#_y@AwWJ-ddwDkX zMCb#DdUylOuJABK6?E%J8Y~EMVFXGTcEI9XNo%fXufZ=XT!`?ciaS3KJ=^ELaV6ot zyHD627K<)rv2-~KU$lf(fc=;YNXQiRsKuN1G~!9eR}+ud{UiPFPkJ)uX14DxKTuzD zV^o3ppk21MPZpfuE8AMteqDpG1If-2_-{Eb6IGhq(ZzE;=og#W)@3O%<_Ph-=I>m^ z66@t{(t>4cExbzF1_eXXg{{BDey>fO=2;t>+a z#EenX3Cy=9yh=PwR~YWiZq|$V%IMK$Nfaedh;E>ikp_!WI`Ea@ql6?^2VSiFzKJWs zN+{UWsOui(njHO)CTBCTIX}ud5z(PFmr#+A7dtEW?AfqSL%GWLK`xE=yx6i$3sS97 zE#pHel(!OilCm*>^N6c)oBYQ)1PwAGgVSAzz(@`0{hgbfamPe-8ZP#rd2xI&b|g|H z))KOjrn~ha_A=ru-ZATVd&DIhUNdcICibojIUmkTAYZ!>(S zI5?NZi!zXZHikHhC!R@t+H4nc?u`HR5#c0P5H<1j$5VWPft<&m5%W_Fl4$?3n;+qS z`$=7D3oq6!V<;Ks;0)4oWOk)>XUF*20un=wWt`W6U5YD=4g$#%@@X_TG%!Q`y+=K4MA5IM>MpNeQ^BAIVirvJ!*UJr^?M%7oVUOqSGB7P6A5wG0 zSOb+m=tf}L15r;AMXG*$jr*myt>{p>veeep;I!HMWLQg4`)$7`&rFT9M&k3C`$+#O z0(1KC?Ey8LVY?+mIwbsel*0^C%EVVWzfHD;M3HAFii%7J3=*}k6}P@wCW^to&E=Cv zr?iOistn2R;^ohc*h}aY-M$4oXbr?2^}0Mosm*UqbFxA5?K_MfTXr$Mshe5wIZa2L zR+ZW&*9p`wAFdvx@-sx+FFHK`Yf}>w+dJcJ2y1EEI{WGJ>yK0Q@kH8o{Nbq>#>&EO zeLS7UT2Z2uq@dn1b-AZ2gh z_y=+PH~O*zIBd4(%Bykzvyh&dn7Ui}{};q)-Y{JFn@SoZmn0mZXXRw>PTiYCmn*Z9 z=&4z9832m9k!fdQ_9x!n*G0WIgkutb^KyD$=H%w(7G6QYxzQ0#U}qxE>w=WtJ2cqS jb)(Cpi_ohPn6|zT9=Vg{(_1zkg+~A(8i}-Si7)ss0TkbW86FiO27)+ZO?Qmr*ZDD>?>qP0eb!lfopsOM z=j@M+;5a#;4hVucg@p!1BZv*&^4Q}5S(`fj2)y3gwlaJrg4D|$UnNe1u{$?3IvhcS zix5Qo1%kW;NIZlf8Ds=`l7JwLZxF;S?bzigCJ611ToV&)A>5*ibWE|1iI|auf3%5p z{u{ViA{_&IXt}=xSU|16Naruo`ir$p%do>Anq?M19TS2#i45R~J3!4s%k5+UXg-4& zYmjGe+JF+VYK~REMF1D;{6IWMO9yeQh`8MnfQXe0v9OH=t_;;+HSis7^_kycA-`+6 zoir;h)-tS%zV+75%GbX2X|bpC&(m7;Au(TPQGgggafijT_|3QHcf952bVyo3Zg21u1cOY=AOe+pHAh3dFvpL#zD*=8+z92ec9pa%cwaNOI$Jct|!t9d% z;zh#41CKvC^kL4yL6SU+nESr`bg4t%0@C2950?MI{k(f<{@q@8(O>RR=5>!!(tP;y z=6Clj9$J=;t2EsHco)^}SHTirLdK(KPj=t(T%PIY>XmAI=>FudUcS(h7%GyVP4gU4YO257@DZrdcW>HVR`pOx`VvxWzHtnG}%l-w>?WcoPo9I z?+pQE1OAwuu(1p`P!ONmYu{JT?uFnniMd-n5m@J!p1{-&-^6TKwF+B~y?mGjsVR_J z$b-~a*$e1*%kI`*(nwj%onywpIz#U_BbVuCz=Rto$=UQWLMA3$TvI^&LQ_)v=70TKi`FL(Rs8W-k_fKlOFHpY!Y(t6XWUMU%i6C!kfW1GE!#C!C)d&Mgjo> zMuCYunuGj`;VzM9Xv6~;k13}{e_1hpeXrZq`;(EchnnP{E3?bHr})h*S5zJl`#|)x z%vRD>(G@vKZe7=VFgwP#8uA`tv*niyFFS95$!nZ-?1v)HwFOPx6*Y;^1M7~Yy~uV! z<=y$$e6z~Ci)gt_6Z+&)VzvV|N^q2Z^mnv{Yj&Y+Vzr?{aJN{Zkc6{`3*x;8(u`+! zoX(N_wyt;RIf+_Eef`^c_(eT?D_ScyR*XO-;Xd!`bai%Z zteXUT%1-NId4MfJ80`$f$0@^1xe$b%l*DkW2|oCKk}@<~$9V&Q>xY*qbOVaGusc4I!{tN8hQDo!mJkK*u79L=dC!+nkh=SYkNdS=yl&gzMQgA&0Sj zYR*!$MX&FCh%QdA&U<|F_&7P)qq(m}_xwdyTG@7l&86E@DZdId8eSse3Lek5G$>u-JasbunwS$`KB%s)W~$c>2~wEx&gwcqT&D! zBK4A|jvvX*T$CZ3FyMHC95@TmxRN(VJ#P|b>DXTQo?v|d(@?uII} zlU+w+;Z*IEO?+p&;|`nzfBN#Dc9aklKk#RquvD#~cU~x@Ts=BllYlOQlqDLq_uk}r zKuy6MrOLg$q`P$};EtN>=mvN%;~Y2Mk3=^Tt_}7)mftk)3esw!-W9YHyb{8o`ewax zn~SAT8_V>$p3+kgloC&bG~pWKHM7>kN*OzkVTXLT!)Z#9jf*(9Pg|}VTV4z-xN!mX z6Iday-nT?UDypMwl`GAv?&fnM&hS6~&pr32w_u#;acKD`OOYpTvjk(Se7}Yzkg(v0pxRXl GdwvHZ(=_7% literal 0 HcmV?d00001 diff --git a/intro/part8/assets/icons/mstile-310x150.png b/intro/part8/assets/icons/mstile-310x150.png new file mode 100644 index 0000000000000000000000000000000000000000..e3f88b827f18860878c91454a2f554742edd4b74 GIT binary patch literal 2542 zcma)6c~nzZ9)8HU;xeKoE{ISTQON>=EWs+9f{`tz1=AQdp|VsVgb@-*a$Lt!6+~au z+FC6U*`@F-5mKfjSZGj_kPt=O6B|LGMivn?bMK4easHTd%(*A;cfa3vzx(~}b~q|x z)0|mLW+4bNCoD9Wjv!M=2x2jJh9y8wD>62M&)>cZ2oFGzx?@(82~$DeJ}HzQjvzc2 z1QG2=kk=uSWX=p~F#I7$K0L@f9llMM{6fgv4W=A|;iX zlq*#Ffq&5QZ+!+q1y!h|3Kdk5!XI{3RM21+fJ%Q5-~;^tN2CI3ke1)&2S8H=@}e5~ z3vW;B5woVT>Ky`bk-}1YB z%(zJ5XP)$(x0~w(ig!MtJteh30rkN^ff6c!7=R)b^en<_7pUIx3sk;>|JG>M1e_1W zL2S1AAqY6;_8tTrGt-O%8z3-)CXD;jJkm--7PO-F}udkO@Ajq_wu;2hjZqGn( zOi6F>!h;FlWpF&)vN{&q%w9xE{mo@lN%+lp)!v!coiLMcfA_Z=e~LDA(fc!7Cs@zb zH`921mAs+neaomo{NsKe)X3K>dOFHpIY!)gyfbps{_o3DM=r>0-PJs$-Q4qCZ*F}i zJFEPOFfj7>9-P6Hi|b#Han?$=e803*oNc_;iW63k za0U%{v~>W|&Zd!j`W0yBRBDfIbnibv3O#YjMevGStU-dz-Ods4vR*PaT2vK2~Rc zXUKgThCY5Pp2MlDp%PNbm6+!kTp$Z+JMXlXGp1^Z^d5$QgEW*^UMp$5m9&qw#p(~| zX7M)S7$bcp?@ka2J)pPlp=A?CVAWCaqy$-mhJF_oap0-7ipB>v?D*|%RS#KL`9O5{ z6Vrq3_=f&F`&pl)3B@Z7Eh=mp5}1l#vjfw%;aMkX!-(}4v@DaH&2bMqLCYrBGwlo& z5*1YL;H36BH=}yENTH`_1?1yQln1uWA(fOP>rBt1T!LlEbcsiO#2R)fb^=f4ku+=_ z<_6>Q|74;3!{rjg)a$J}`k-?S;1h0JpOjw^-)0)xE;h2hVcQwW6FN*$7*;Ips0qs{zyK~Fu7p!-|PIa}Q z2KHL)m!PURVx<4GxC^{H>L2Dx1=#cTMPo*Fm)h8(s72ih2a>n)eG-E;#&1oA!^}i} zUboQT3(jTVmoyeA{0A+Ny^pmHd(`S1LH#4OVC$l-q_K|G`UBgDWRy%lHDaX4lF1W? zBGrMU?%C0WRbo0(mw4nd8Z&u_U9JA~nS=!ftl2^=WY684=0HfFR^i+r)=mb!WVW}R zIiEJOUqXk3SfYcBkq+_)D8XQ~D>j_El!r_knJu$IzulCAf&RuQIo|Joc=Fi-I2*#*+DN!^3Z z(X=2a)WDP%J7GKS-134qNU;-B&* zZTz0;_*L{I{#0l=8C6XDSyHrP4E&7OfeSkR#8rBG4Ub5}-z4g2nQ&PxSQjvDX%wv- zzGbRQ$x7g|bx{*~fw0I^Kpalg7Pnllb(X)qNcgz7{o`1sIi@L+qfQnnGji*|2xo3c zEO$o&H`y`qC|NVezZWhUFFW^$7dB=_tSuIGkUqkEt})Q-BrH2D MBqF#bFn;fU0P%sYHvj+t literal 0 HcmV?d00001 diff --git a/intro/part8/assets/icons/mstile-310x310.png b/intro/part8/assets/icons/mstile-310x310.png new file mode 100644 index 0000000000000000000000000000000000000000..f0702f48c81fff349e9034ba23dc00ad9d402417 GIT binary patch literal 4166 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 literal 0 HcmV?d00001 diff --git a/intro/part8/assets/icons/mstile-70x70.png b/intro/part8/assets/icons/mstile-70x70.png new file mode 100644 index 0000000000000000000000000000000000000000..8b7596f428df3507300793440458ec4f59ac0055 GIT binary patch literal 1563 zcmZ`&X;4#F6uvJT0fHs4KzVm(O-1lbQH!)G$ ztSp=?0036(@X$CI(S?VIgR0T-lXCMn}t`fYh_z3P7C8TD(`zVJvlt3?02S|DYAU~O(38%pc z5HwwN(VNzpd?Gi;bg@Mc(qL}#@GbVHW-Yc%$-ga85--P;FY-+ZTnceG7M4b%QNJwB z4}LzrFfM8buQ*-{6FoJr*#{i@dvcRTxR zI+i}U*KTe9xb1eStZB%ZIxtMSYO8u=y`=S_%SdO7)dQR64$^a1m5e&**#2~MxwX@? zwq?$(m!#oFxDVMMu`Tpq$6?(HSeOafkzr;p@ffV#l7qk0?gId8!474`i=Jt_Qh5Dz zO8yInt8U9wM^s!@z6zJ+vXSb0lWjCIwd|r*ZRWXc@9MVvdRs~JHl4_!vOdQ~ zzeDHF9iMP)NR(O$(Qh;GnTj7D#o{wTffoQ7cp|L~V5TpMFeHulU@-p7DkhT4Kje&% z&a6}*xyh6~EbY7k4|`GgGj8eF>DZ%r?r6bzTg%Up-!TV3ox+8N3ut+(9q#j<2Gb)v zy_ZTqS%SG>aUWd9Fi@Mxp#m+lKtWbmyLtw>TyMP?>AL#4>(O!X!Q45@|bv!4ROrA zaQ+&NqUe5aUo6Wv9AVL!L?4BCb$vK~%tkm{gu$S(>Hz`K0}xn}efAf?-1Z30gcrsM z$bOE$e?J%hW&pE-8G`CgV?5CniupJuL9^==&{`rcOk$-{91pObN-*^`#+imsUryc( z=Kh#@Nf_>~F1m>(*SvZ#95^hK-N3EsJWEbQbs5a3P2$%+KIlAMK0eBu#2l^zh$^3pJe2Q;$jSiOx-ajW=#Q+FC(bm4B-=u8BtiZVIym#6s!sCF}%l=L<7+m zlk4^6JLGy5vgiGmtUk&0$DqNmM_O$R)`uWsagDss8mF6$lx8wWoQVl(nbEft88n>I zT=7&J+`!FI3UwX<<+m-$K1>u7P`zW0J1#|1)i+Dfh*wiL>BWd(^o7`sN;O;u_ddkj znF}gK>h&d>kSJ{Wl%?{axg8qal&o1YJrSXdqbu&M#mT@?30wT8nF%L=(ksa|Pm-uWm?d|2dKdMilqip*1u8 z01y@bHbWw2?DX(xkM8JX zzlsA`cS|3hCE1-EafyN@(?Zb=Dn8b|CZ5(Y_@uSDv)Q@XtOE^~f93 + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + + + + + diff --git a/intro/part8/assets/images/error.jpg b/intro/part8/assets/images/error.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dfc53d1432625a658baa9d39e2f2794846345564 GIT binary patch literal 74241 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 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/intro/part8/assets/site.webmanifest b/intro/part8/assets/site.webmanifest new file mode 100644 index 0000000..b20abb7 --- /dev/null +++ b/intro/part8/assets/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/intro/part8/elm.json b/intro/part8/elm.json new file mode 100644 index 0000000..2dd3243 --- /dev/null +++ b/intro/part8/elm.json @@ -0,0 +1,35 @@ +{ + "type": "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.0", + "dependencies": { + "direct": { + "NoRedInk/json-decode-pipeline": "1.0.0", + "elm/browser": "1.0.0", + "elm/core": "1.0.0", + "elm/html": "1.0.0", + "elm/http": "1.0.0", + "elm/json": "1.0.0", + "elm/time": "1.0.0", + "elm/url": "1.0.0", + "elm-explorations/markdown": "1.0.0", + "lukewestby/elm-http-builder": "6.0.0", + "rtfeldman/elm-iso8601": "1.0.1" + }, + "indirect": { + "elm/parser": "1.0.0", + "elm/regex": "1.0.0", + "elm/virtual-dom": "1.0.0" + } + }, + "test-dependencies": { + "direct": { + "elm-explorations/test": "1.0.0" + }, + "indirect": { + "elm/random": "1.0.0" + } + } +} diff --git a/intro/part8/index.html b/intro/part8/index.html new file mode 100644 index 0000000..7b6bea9 --- /dev/null +++ b/intro/part8/index.html @@ -0,0 +1,41 @@ + + + + + Conduit + + + + + + + + + + + + + + + + + + + + + diff --git a/intro/part8/src/Api.elm b/intro/part8/src/Api.elm new file mode 100644 index 0000000..bed2e13 --- /dev/null +++ b/intro/part8/src/Api.elm @@ -0,0 +1,53 @@ +module Api exposing (addServerError, decodeErrors, url) + +import Http +import Json.Decode as Decode exposing (Decoder, decodeString, field, string) +import Json.Decode.Pipeline as Pipeline exposing (optional) +import Url.Builder + + + +-- URL + + +{-| Get a URL to the Conduit API. +-} +url : List String -> String +url paths = + -- NOTE: Url.Builder takes care of percent-encoding special URL characters. + -- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode + Url.Builder.relative ("api" :: paths) [] + + + +-- ERRORS + + +addServerError : List String -> List String +addServerError list = + "Server error" :: list + + +{-| Many API endpoints include an "errors" field in their BadStatus responses. +-} +decodeErrors : Http.Error -> List String +decodeErrors error = + case error of + Http.BadStatus response -> + response.body + |> decodeString (field "errors" errorsDecoder) + |> Result.withDefault [ "Server error" ] + + err -> + [ "Server error" ] + + +errorsDecoder : Decoder (List String) +errorsDecoder = + Decode.keyValuePairs (Decode.list Decode.string) + |> Decode.map (List.concatMap fromPair) + + +fromPair : ( String, List String ) -> List String +fromPair ( field, errors ) = + List.map (\error -> field ++ " " ++ error) errors diff --git a/intro/part8/src/Article.elm b/intro/part8/src/Article.elm new file mode 100644 index 0000000..d0bbfa8 --- /dev/null +++ b/intro/part8/src/Article.elm @@ -0,0 +1,304 @@ +module Article + exposing + ( Article + , Full + , Preview + , author + , body + , favorite + , favoriteButton + , fetch + , fromPreview + , fullDecoder + , mapAuthor + , metadata + , previewDecoder + , slug + , unfavorite + , unfavoriteButton + , url + ) + +{-| The interface to the Article data structure. + +This includes: + + - The Article type itself + - Ways to make HTTP requests to retrieve and modify articles + - Ways to access information about an article + - Converting between various types + +-} + +import Api +import Article.Body as Body exposing (Body) +import Article.Slug as Slug exposing (Slug) +import Article.Tag as Tag exposing (Tag) +import Author exposing (Author) +import Html exposing (Attribute, Html, i) +import Html.Attributes exposing (class) +import Html.Events exposing (stopPropagationOn) +import Http +import HttpBuilder exposing (RequestBuilder, withBody, withExpect, withQueryParams) +import Json.Decode as Decode exposing (Decoder, bool, int, list, string) +import Json.Decode.Pipeline exposing (custom, hardcoded, required) +import Json.Encode as Encode +import Markdown +import Profile exposing (Profile) +import Time +import Timestamp +import Username as Username exposing (Username) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- TYPES + + +{-| An article, optionally with an article body. + +To see the difference between { extraInfo : a } and { extraInfo : Maybe Body }, +consider the difference between the "view individual article" page (which +renders one article, including its body) and the "article feed" - +which displays multiple articles, but without bodies. + +This definition for `Article` means we can write: + +viewArticle : Article Full -> Html msg +viewFeed : List (Article Preview) -> Html msg + +This indicates that `viewArticle` requires an article _with a `body` present_, +wereas `viewFeed` accepts articles with no bodies. (We could also have written +it as `List (Article a)` to specify that feeds can accept either articles that +have `body` present or not. Either work, given that feeds do not attempt to +read the `body` field from articles.) + +This is an important distinction, because in Request.Article, the `feed` +function produces `List (Article Preview)` because the API does not return bodies. +Those articles are useful to the feed, but not to the individual article view. + +-} +type Article a + = Article Internals a + + +type alias Internals = + { slug : Slug + , author : Author + , metadata : Metadata + } + + +type Preview + = Preview + + +type Full + = Full Body + + + +-- INFO + + +author : Article a -> Author +author (Article internals _) = + internals.author + + +metadata : Article a -> Metadata +metadata (Article internals _) = + internals.metadata + + +slug : Article a -> Slug +slug (Article internals _) = + internals.slug + + +body : Article Full -> Body +body (Article _ (Full extraInfo)) = + extraInfo + + + +-- TRANSFORM + + +{-| This is the only way you can transform an existing article: +you can change its author (e.g. to follow or unfollow them). +All other article data necessarily comes from the server! + +We can tell this for sure by looking at the types of the exposed functions +in this module. + +-} +mapAuthor : (Author -> Author) -> Article a -> Article a +mapAuthor transform (Article info extras) = + Article { info | author = transform info.author } extras + + +fromPreview : Body -> Article Preview -> Article Full +fromPreview newBody (Article info Preview) = + Article info (Full newBody) + + + +-- SERIALIZATION + + +previewDecoder : Maybe Cred -> Decoder (Article Preview) +previewDecoder maybeCred = + Decode.succeed Article + |> custom (internalsDecoder maybeCred) + |> hardcoded Preview + + +fullDecoder : Maybe Cred -> Decoder (Article Full) +fullDecoder maybeCred = + Decode.succeed Article + |> custom (internalsDecoder maybeCred) + |> required "body" (Decode.map Full Body.decoder) + + +internalsDecoder : Maybe Cred -> Decoder Internals +internalsDecoder maybeCred = + Decode.succeed Internals + |> required "slug" Slug.decoder + |> required "author" (Author.decoder maybeCred) + |> custom metadataDecoder + + +type alias Metadata = + { description : String + , title : String + , tags : List String + , favorited : Bool + , favoritesCount : Int + , createdAt : Time.Posix + } + + +metadataDecoder : Decoder Metadata +metadataDecoder = + {- 👉 TODO: replace the calls to `hardcoded` with calls to `required` + in order to decode these fields: + + --- "description" -------> description : String + --- "title" -------------> title : String + --- "tagList" -----------> tags : List String + --- "favorited" ---------> favorited : Bool + --- "favoritesCount" ----> favoritesCount : Int + + Once this is done, the articles in the feed should look normal again. + + 💡 HINT: Order matters! These must be decoded in the same order + as the order of the fields in `type alias Metadata` above. ☝️ + -} + Decode.succeed Metadata + |> hardcoded "(needs decoding!)" + |> hardcoded "(needs decoding!)" + |> hardcoded [] + |> hardcoded False + |> hardcoded 0 + |> required "createdAt" Timestamp.iso8601Decoder + + + +-- SINGLE + + +fetch : Maybe Cred -> Slug -> Http.Request (Article Full) +fetch maybeCred articleSlug = + let + expect = + fullDecoder maybeCred + |> Decode.field "article" + |> Http.expectJson + in + url articleSlug [] + |> HttpBuilder.get + |> HttpBuilder.withExpect expect + |> Cred.addHeaderIfAvailable maybeCred + |> HttpBuilder.toRequest + + + +-- FAVORITE + + +favorite : Slug -> Cred -> Http.Request (Article Preview) +favorite articleSlug cred = + buildFavorite HttpBuilder.post articleSlug cred + + +unfavorite : Slug -> Cred -> Http.Request (Article Preview) +unfavorite articleSlug cred = + buildFavorite HttpBuilder.delete articleSlug cred + + +buildFavorite : + (String -> RequestBuilder a) + -> Slug + -> Cred + -> Http.Request (Article Preview) +buildFavorite builderFromUrl articleSlug cred = + let + expect = + previewDecoder (Just cred) + |> Decode.field "article" + |> Http.expectJson + in + builderFromUrl (url articleSlug [ "favorite" ]) + |> Cred.addHeader cred + |> withExpect expect + |> HttpBuilder.toRequest + + +{-| This is a "build your own element" API. + +You pass it some configuration, followed by a `List (Attribute msg)` and a +`List (Html msg)`, just like any standard Html element. + +-} +favoriteButton : Cred -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg +favoriteButton _ msg attrs kids = + toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids + + +unfavoriteButton : Cred -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg +unfavoriteButton _ msg attrs kids = + toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids + + +toggleFavoriteButton : + String + -> msg + -> List (Attribute msg) + -> List (Html msg) + -> Html msg +toggleFavoriteButton classStr msg attrs kids = + Html.button + (class classStr :: onClickStopPropagation msg :: attrs) + (i [ class "ion-heart" ] [] :: kids) + + +onClickStopPropagation : msg -> Attribute msg +onClickStopPropagation msg = + stopPropagationOn "click" + (Decode.succeed ( msg, True )) + + + +-- URLS + + +url : Slug -> List String -> String +url articleSlug paths = + allArticlesUrl (Slug.toString articleSlug :: paths) + + +allArticlesUrl : List String -> String +allArticlesUrl paths = + Api.url ("articles" :: paths) diff --git a/intro/part8/src/Article/Body.elm b/intro/part8/src/Article/Body.elm new file mode 100644 index 0000000..b1c55f1 --- /dev/null +++ b/intro/part8/src/Article/Body.elm @@ -0,0 +1,38 @@ +module Article.Body exposing (Body, MarkdownString, decoder, toHtml, toMarkdownString) + +import Html exposing (Attribute, Html) +import Json.Decode as Decode exposing (Decoder) +import Markdown + + + +-- TYPES + + +type Body + = Body MarkdownString + + +{-| Internal use only. I want to remind myself that the string inside Body contains markdown. +-} +type alias MarkdownString = + String + + + +-- CONVERSIONS + + +toHtml : Body -> List (Attribute msg) -> Html msg +toHtml (Body markdown) attributes = + Markdown.toHtml attributes markdown + + +toMarkdownString : Body -> MarkdownString +toMarkdownString (Body markdown) = + markdown + + +decoder : Decoder Body +decoder = + Decode.map Body Decode.string diff --git a/intro/part8/src/Article/Comment.elm b/intro/part8/src/Article/Comment.elm new file mode 100644 index 0000000..5517a30 --- /dev/null +++ b/intro/part8/src/Article/Comment.elm @@ -0,0 +1,139 @@ +module Article.Comment + exposing + ( Comment + , author + , body + , createdAt + , delete + , id + , list + , post + ) + +import Api +import Article exposing (Article) +import Article.Slug as Slug exposing (Slug) +import Author exposing (Author) +import CommentId exposing (CommentId) +import Http +import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams) +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline exposing (custom, required) +import Json.Encode as Encode exposing (Value) +import Profile exposing (Profile) +import Time +import Timestamp +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- TYPES + + +type Comment + = Comment Internals + + +type alias Internals = + { id : CommentId + , body : String + , createdAt : Time.Posix + , author : Author + } + + + +-- INFO + + +id : Comment -> CommentId +id (Comment comment) = + comment.id + + +body : Comment -> String +body (Comment comment) = + comment.body + + +createdAt : Comment -> Time.Posix +createdAt (Comment comment) = + comment.createdAt + + +author : Comment -> Author +author (Comment comment) = + comment.author + + + +-- LIST + + +list : Maybe Cred -> Slug -> Http.Request (List Comment) +list maybeCred articleSlug = + allCommentsUrl articleSlug [] + |> HttpBuilder.get + |> HttpBuilder.withExpect (Http.expectJson (Decode.field "comments" (Decode.list (decoder maybeCred)))) + |> Cred.addHeaderIfAvailable maybeCred + |> HttpBuilder.toRequest + + + +-- POST + + +post : Slug -> String -> Cred -> Http.Request Comment +post articleSlug commentBody cred = + allCommentsUrl articleSlug [] + |> HttpBuilder.post + |> HttpBuilder.withBody (Http.jsonBody (encodeCommentBody commentBody)) + |> HttpBuilder.withExpect (Http.expectJson (Decode.field "comment" (decoder (Just cred)))) + |> Cred.addHeader cred + |> HttpBuilder.toRequest + + +encodeCommentBody : String -> Value +encodeCommentBody str = + Encode.object [ ( "comment", Encode.object [ ( "body", Encode.string str ) ] ) ] + + + +-- DELETE + + +delete : Slug -> CommentId -> Cred -> Http.Request () +delete articleSlug commentId cred = + commentUrl articleSlug commentId + |> HttpBuilder.delete + |> Cred.addHeader cred + |> HttpBuilder.toRequest + + + +-- SERIALIZATION + + +decoder : Maybe Cred -> Decoder Comment +decoder maybeCred = + Decode.succeed Internals + |> required "id" CommentId.decoder + |> required "body" Decode.string + |> required "createdAt" Timestamp.iso8601Decoder + |> required "author" (Author.decoder maybeCred) + |> Decode.map Comment + + + +-- URLS + + +commentUrl : Slug -> CommentId -> String +commentUrl articleSlug commentId = + allCommentsUrl articleSlug [ CommentId.toString commentId ] + + +allCommentsUrl : Slug -> List String -> String +allCommentsUrl articleSlug paths = + Api.url ([ "articles", Slug.toString articleSlug, "comments" ] ++ paths) diff --git a/intro/part8/src/Article/Feed.elm b/intro/part8/src/Article/Feed.elm new file mode 100644 index 0000000..a5a482a --- /dev/null +++ b/intro/part8/src/Article/Feed.elm @@ -0,0 +1,421 @@ +module Article.Feed + exposing + ( Model + , Msg + , init + , selectTag + , update + , viewArticles + , viewFeedSources + ) + +import Api +import Article exposing (Article, Preview) +import Article.FeedSources as FeedSources exposing (FeedSources, Source(..)) +import Article.Slug as ArticleSlug exposing (Slug) +import Article.Tag as Tag exposing (Tag) +import Author +import Avatar exposing (Avatar) +import Browser.Dom as Dom +import Html exposing (..) +import Html.Attributes exposing (attribute, class, classList, href, id, placeholder, src) +import Html.Events exposing (onClick) +import Http +import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams) +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline exposing (required) +import Page +import PaginatedList exposing (PaginatedList) +import Profile +import Route exposing (Route) +import Session exposing (Session) +import Task exposing (Task) +import Time +import Timestamp +import Username exposing (Username) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + +{-| NOTE: This module has its own Model, view, and update. This is not normal! +If you find yourself doing this often, please watch + +This is the reusable Article Feed that appears on both the Home page as well as +on the Profile page. There's a lot of logic here, so it's more convenient to use +the heavyweight approach of giving this its own Model, view, and update. + +This means callers must use Html.map and Cmd.map to use this thing, but in +this case that's totally worth it because of the amount of logic wrapped up +in this thing. + +For every other reusable view in this application, this API would be totally +overkill, so we use simpler APIs instead. + +-} + + + +-- MODEL + + +type Model + = Model InternalModel + + +{-| This should not be exposed! We want to benefit from the guarantee that only +this module can create or alter this model. This way if it ever ends up in +a surprising state, we know exactly where to look: this module. +-} +type alias InternalModel = + { session : Session + , errors : List String + , articles : PaginatedList (Article Preview) + , sources : FeedSources + , isLoading : Bool + } + + +init : Session -> FeedSources -> Task Http.Error Model +init session sources = + let + fromArticles articles = + Model + { session = session + , errors = [] + , articles = articles + , sources = sources + , isLoading = False + } + in + FeedSources.selected sources + |> fetch (Session.cred session) 1 + |> Task.map fromArticles + + + +-- VIEW + + +viewArticles : Time.Zone -> Model -> List (Html Msg) +viewArticles timeZone (Model { articles, sources, session }) = + let + maybeCred = + Session.cred session + + articlesHtml = + PaginatedList.values articles + |> List.map (viewPreview maybeCred timeZone) + + feedSource = + FeedSources.selected sources + + pagination = + PaginatedList.view ClickedFeedPage articles (limit feedSource) + in + List.append articlesHtml [ pagination ] + + +viewPreview : Maybe Cred -> Time.Zone -> Article Preview -> Html Msg +viewPreview maybeCred timeZone article = + let + slug = + Article.slug article + + { title, description, createdAt } = + Article.metadata article + + author = + Article.author article + + profile = + Author.profile author + + username = + Author.username author + + faveButton = + case maybeCred of + Just cred -> + let + { favoritesCount, favorited } = + Article.metadata article + + viewButton = + if favorited then + Article.unfavoriteButton cred (ClickedUnfavorite cred slug) + + else + Article.favoriteButton cred (ClickedFavorite cred slug) + in + viewButton [ class "pull-xs-right" ] + [ text (" " ++ String.fromInt favoritesCount) ] + + Nothing -> + text "" + in + div [ class "article-preview" ] + [ div [ class "article-meta" ] + [ a [ Route.href (Route.Profile username) ] + [ img [ Avatar.src (Profile.avatar profile) ] [] ] + , div [ class "info" ] + [ Author.view username + , Timestamp.view timeZone createdAt + ] + , faveButton + ] + , a [ class "preview-link", Route.href (Route.Article (Article.slug article)) ] + [ h1 [] [ text title ] + , p [] [ text description ] + , span [] [ text "Read more..." ] + ] + ] + + +viewFeedSources : Model -> Html Msg +viewFeedSources (Model { sources, isLoading, errors }) = + let + errorsHtml = + Page.viewErrors ClickedDismissErrors errors + in + ul [ class "nav nav-pills outline-active" ] <| + List.concat + [ List.map (viewFeedSource False) (FeedSources.before sources) + , [ viewFeedSource True (FeedSources.selected sources) ] + , List.map (viewFeedSource False) (FeedSources.after sources) + , [ errorsHtml ] + ] + + +viewFeedSource : Bool -> Source -> Html Msg +viewFeedSource isSelected source = + li [ class "nav-item" ] + [ a + [ classList [ ( "nav-link", True ), ( "active", isSelected ) ] + , onClick (ClickedFeedSource source) + + -- The RealWorld CSS requires an href to work properly. + , href "" + ] + [ text (sourceName source) ] + ] + + +selectTag : Maybe Cred -> Tag -> Cmd Msg +selectTag maybeCred tag = + let + source = + TagFeed tag + in + fetch maybeCred 1 source + |> Task.attempt (CompletedFeedLoad source) + + +sourceName : Source -> String +sourceName source = + case source of + YourFeed _ -> + "Your Feed" + + GlobalFeed -> + "Global Feed" + + TagFeed tagName -> + "#" ++ Tag.toString tagName + + FavoritedFeed username -> + "Favorited Articles" + + AuthorFeed username -> + "My Articles" + + +limit : Source -> Int +limit feedSource = + case feedSource of + YourFeed _ -> + 10 + + GlobalFeed -> + 10 + + TagFeed tagName -> + 10 + + FavoritedFeed username -> + 5 + + AuthorFeed username -> + 5 + + + +-- UPDATE + + +type Msg + = ClickedDismissErrors + | ClickedFavorite Cred Slug + | ClickedUnfavorite Cred Slug + | ClickedFeedPage Int + | ClickedFeedSource Source + | CompletedFavorite (Result Http.Error (Article Preview)) + | CompletedFeedLoad Source (Result Http.Error (PaginatedList (Article Preview))) + + +update : Maybe Cred -> Msg -> Model -> ( Model, Cmd Msg ) +update maybeCred msg (Model model) = + case msg of + ClickedDismissErrors -> + ( Model { model | errors = [] }, Cmd.none ) + + ClickedFeedSource source -> + ( Model { model | isLoading = True } + , source + |> fetch maybeCred 1 + |> Task.attempt (CompletedFeedLoad source) + ) + + CompletedFeedLoad source (Ok articles) -> + ( Model + { model + | articles = articles + , sources = FeedSources.select source model.sources + , isLoading = False + } + , Cmd.none + ) + + CompletedFeedLoad _ (Err error) -> + ( Model + { model + | errors = Api.addServerError model.errors + , isLoading = False + } + , Cmd.none + ) + + ClickedFavorite cred slug -> + fave Article.favorite cred slug model + + ClickedUnfavorite cred slug -> + fave Article.unfavorite cred slug model + + CompletedFavorite (Ok article) -> + ( Model { model | articles = PaginatedList.map (replaceArticle article) model.articles } + , Cmd.none + ) + + CompletedFavorite (Err error) -> + ( Model { model | errors = Api.addServerError model.errors } + , Cmd.none + ) + + ClickedFeedPage page -> + let + source = + FeedSources.selected model.sources + in + ( Model model + , fetch maybeCred page source + |> Task.andThen (\articles -> Task.map (\_ -> articles) scrollToTop) + |> Task.attempt (CompletedFeedLoad source) + ) + + +scrollToTop : Task x () +scrollToTop = + Dom.setViewport 0 0 + -- It's not worth showing the user anything special if scrolling fails. + -- If anything, we'd log this to an error recording service. + |> Task.onError (\_ -> Task.succeed ()) + + +fetch : Maybe Cred -> Int -> Source -> Task Http.Error (PaginatedList (Article Preview)) +fetch maybeCred page feedSource = + let + articlesPerPage = + limit feedSource + + offset = + (page - 1) * articlesPerPage + + params = + [ ( "limit", String.fromInt articlesPerPage ) + , ( "offset", String.fromInt offset ) + ] + in + Task.map (PaginatedList.mapPage (\_ -> page)) <| + case feedSource of + YourFeed cred -> + params + |> buildFromQueryParams (Just cred) (Api.url [ "articles", "feed" ]) + |> Cred.addHeader cred + |> HttpBuilder.toRequest + |> Http.toTask + + GlobalFeed -> + list maybeCred params + + TagFeed tagName -> + list maybeCred (( "tag", Tag.toString tagName ) :: params) + + FavoritedFeed username -> + list maybeCred (( "favorited", Username.toString username ) :: params) + + AuthorFeed username -> + list maybeCred (( "author", Username.toString username ) :: params) + + +list : + Maybe Cred + -> List ( String, String ) + -> Task Http.Error (PaginatedList (Article Preview)) +list maybeCred params = + buildFromQueryParams maybeCred (Api.url [ "articles" ]) params + |> Cred.addHeaderIfAvailable maybeCred + |> HttpBuilder.toRequest + |> Http.toTask + + +replaceArticle : Article a -> Article a -> Article a +replaceArticle newArticle oldArticle = + if Article.slug newArticle == Article.slug oldArticle then + newArticle + + else + oldArticle + + + +-- SERIALIZATION + + +decoder : Maybe Cred -> Decoder (PaginatedList (Article Preview)) +decoder maybeCred = + Decode.succeed PaginatedList.fromList + |> required "articlesCount" Decode.int + |> required "articles" (Decode.list (Article.previewDecoder maybeCred)) + + + +-- REQUEST + + +buildFromQueryParams : Maybe Cred -> String -> List ( String, String ) -> RequestBuilder (PaginatedList (Article Preview)) +buildFromQueryParams maybeCred url queryParams = + HttpBuilder.get url + |> withExpect (Http.expectJson (decoder maybeCred)) + |> withQueryParams queryParams + + + +-- INTERNAL + + +fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> InternalModel -> ( Model, Cmd Msg ) +fave toRequest cred slug model = + ( Model model + , toRequest slug cred + |> Http.toTask + |> Task.attempt CompletedFavorite + ) diff --git a/intro/part8/src/Article/FeedSources.elm b/intro/part8/src/Article/FeedSources.elm new file mode 100644 index 0000000..cef87eb --- /dev/null +++ b/intro/part8/src/Article/FeedSources.elm @@ -0,0 +1,109 @@ +module Article.FeedSources exposing (FeedSources, Source(..), after, before, fromLists, select, selected) + +import Article +import Article.Tag as Tag exposing (Tag) +import Username exposing (Username) +import Viewer.Cred as Cred exposing (Cred) + + + +-- TYPES + + +type FeedSources + = FeedSources + { before : List Source + , selected : Source + , after : List Source + } + + +type Source + = YourFeed Cred + | GlobalFeed + | TagFeed Tag + | FavoritedFeed Username + | AuthorFeed Username + + + +-- BUILDING + + +fromLists : Source -> List Source -> FeedSources +fromLists selectedSource afterSources = + FeedSources + { before = [] + , selected = selectedSource + , after = afterSources + } + + + +-- SELECTING + + +select : Source -> FeedSources -> FeedSources +select selectedSource (FeedSources sources) = + let + ( newBefore, newAfter ) = + (sources.before ++ (sources.selected :: sources.after)) + -- By design, tags can only be included if they're selected. + |> List.filter isNotTag + |> splitOn (\source -> source == selectedSource) + in + FeedSources + { before = List.reverse newBefore + , selected = selectedSource + , after = List.reverse newAfter + } + + +splitOn : (Source -> Bool) -> List Source -> ( List Source, List Source ) +splitOn isSelected sources = + let + ( _, newBefore, newAfter ) = + List.foldl (splitOnHelp isSelected) ( False, [], [] ) sources + in + ( newBefore, newAfter ) + + +splitOnHelp : (Source -> Bool) -> Source -> ( Bool, List Source, List Source ) -> ( Bool, List Source, List Source ) +splitOnHelp isSelected source ( foundSelected, beforeSelected, afterSelected ) = + if isSelected source then + ( True, beforeSelected, afterSelected ) + + else if foundSelected then + ( foundSelected, beforeSelected, source :: afterSelected ) + + else + ( foundSelected, source :: beforeSelected, afterSelected ) + + +isNotTag : Source -> Bool +isNotTag currentSource = + case currentSource of + TagFeed _ -> + False + + _ -> + True + + + +-- INFO + + +selected : FeedSources -> Source +selected (FeedSources record) = + record.selected + + +before : FeedSources -> List Source +before (FeedSources record) = + record.before + + +after : FeedSources -> List Source +after (FeedSources record) = + record.after diff --git a/intro/part8/src/Article/Slug.elm b/intro/part8/src/Article/Slug.elm new file mode 100644 index 0000000..723f5f9 --- /dev/null +++ b/intro/part8/src/Article/Slug.elm @@ -0,0 +1,35 @@ +module Article.Slug exposing (Slug, decoder, toString, urlParser) + +import Json.Decode as Decode exposing (Decoder) +import Url.Parser exposing (Parser) + + + +-- TYPES + + +type Slug + = Slug String + + + +-- CREATE + + +urlParser : Parser (Slug -> a) a +urlParser = + Url.Parser.custom "SLUG" (\str -> Just (Slug str)) + + +decoder : Decoder Slug +decoder = + Decode.map Slug Decode.string + + + +-- TRANSFORM + + +toString : Slug -> String +toString (Slug str) = + str diff --git a/intro/part8/src/Article/Tag.elm b/intro/part8/src/Article/Tag.elm new file mode 100644 index 0000000..878733d --- /dev/null +++ b/intro/part8/src/Article/Tag.elm @@ -0,0 +1,41 @@ +module Article.Tag exposing (Tag, list, toString) + +import Api +import Http +import Json.Decode as Decode exposing (Decoder) + + + +-- TYPES + + +type Tag + = Tag String + + + +-- TRANSFORM + + +toString : Tag -> String +toString (Tag slug) = + slug + + + +-- LIST + + +list : Http.Request (List Tag) +list = + Decode.field "tags" (Decode.list decoder) + |> Http.get (Api.url [ "tags" ]) + + + +-- SERIALIZATION + + +decoder : Decoder Tag +decoder = + Decode.map Tag Decode.string diff --git a/intro/part8/src/Asset.elm b/intro/part8/src/Asset.elm new file mode 100644 index 0000000..f3e7432 --- /dev/null +++ b/intro/part8/src/Asset.elm @@ -0,0 +1,48 @@ +module Asset exposing (Image, defaultAvatar, error, loading, src) + +{-| Assets, such as images, videos, and audio. (We only have images for now.) + +We should never expose asset URLs directly; this module should be in charge of +all of them. One source of truth! + +-} + +import Html exposing (Attribute, Html) +import Html.Attributes as Attr + + +type Image + = Image String + + + +-- IMAGES + + +error : Image +error = + image "error.jpg" + + +loading : Image +loading = + image "loading.svg" + + +defaultAvatar : Image +defaultAvatar = + Image "smiley-cyrus.jpg" + + +image : String -> Image +image filename = + Image ("/assets/images/" ++ filename) + + + +-- USING IMAGES + + +src : Image -> Attribute msg +src (Image url) = + Attr.src url diff --git a/intro/part8/src/Author.elm b/intro/part8/src/Author.elm new file mode 100644 index 0000000..e7de3c5 --- /dev/null +++ b/intro/part8/src/Author.elm @@ -0,0 +1,251 @@ +module Author + exposing + ( Author(..) + , FollowedAuthor + , UnfollowedAuthor + , decoder + , fetch + , follow + , followButton + , profile + , requestFollow + , requestUnfollow + , unfollow + , unfollowButton + , username + , view + ) + +{-| The author of an Article. It includes a Profile. + +I designed this to make sure the compiler would help me keep these three +possibilities straight when displaying follow buttons and such: + + - I'm following this author. + - I'm not following this author. + - I _can't_ follow this author, because it's me! + +To do this, I defined `Author` a custom type with three variants, one for each +of those possibilities. + +I also made separate types for FollowedAuthor and UnfollowedAuthor. +They are custom type wrappers around Profile, and thier sole purpose is to +help me keep track of which operations are supported. + +For example, consider these functions: + +requestFollow : UnfollowedAuthor -> Cred -> Http.Request Author +requestUnfollow : FollowedAuthor -> Cred -> Http.Request Author + +These types help the compiler prevent several mistakes: + + - Displaying a Follow button for an author the user already follows. + - Displaying an Unfollow button for an author the user already doesn't follow. + - Displaying either button when the author is ourself. + +There are still ways we could mess things up (e.g. make a button that calls Author.unfollow when you click it, but which displays "Follow" to the user) - but this rules out a bunch of potential problems. + +-} + +import Api +import Html exposing (Html, a, i, text) +import Html.Attributes exposing (attribute, class, href, id, placeholder) +import Html.Events exposing (onClick) +import Http +import HttpBuilder exposing (RequestBuilder, withExpect) +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline exposing (custom, required) +import Json.Encode as Encode exposing (Value) +import Profile exposing (Profile) +import Route exposing (Route) +import Username exposing (Username) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + +{-| An author - either the current user, another user we're following, or +another user we aren't following. + +These distinctions matter because we can only perform "follow" requests for +users we aren't following, we can only perform "unfollow" requests for +users we _are_ following, and we can't perform either for ourselves. + +-} +type Author + = IsFollowing FollowedAuthor + | IsNotFollowing UnfollowedAuthor + | IsViewer Cred Profile + + +{-| An author we're following. +-} +type FollowedAuthor + = FollowedAuthor Username Profile + + +{-| An author we're not following. +-} +type UnfollowedAuthor + = UnfollowedAuthor Username Profile + + +{-| Return an Author's username. +-} +username : Author -> Username +username author = + case author of + IsViewer cred _ -> + Cred.username cred + + IsFollowing (FollowedAuthor val _) -> + val + + IsNotFollowing (UnfollowedAuthor val _) -> + val + + +{-| Return an Author's profile. +-} +profile : Author -> Profile +profile author = + case author of + IsViewer _ val -> + val + + IsFollowing (FollowedAuthor _ val) -> + val + + IsNotFollowing (UnfollowedAuthor _ val) -> + val + + + +-- FETCH + + +fetch : Username -> Maybe Cred -> Http.Request Author +fetch uname maybeCred = + Api.url [ "profiles", Username.toString uname ] + |> HttpBuilder.get + |> HttpBuilder.withExpect (Http.expectJson (Decode.field "profile" (decoder maybeCred))) + |> Cred.addHeaderIfAvailable maybeCred + |> HttpBuilder.toRequest + + + +-- FOLLOWING + + +follow : UnfollowedAuthor -> FollowedAuthor +follow (UnfollowedAuthor uname prof) = + FollowedAuthor uname prof + + +unfollow : FollowedAuthor -> UnfollowedAuthor +unfollow (FollowedAuthor uname prof) = + UnfollowedAuthor uname prof + + +requestFollow : UnfollowedAuthor -> Cred -> Http.Request Author +requestFollow (UnfollowedAuthor uname _) cred = + requestHelp HttpBuilder.post uname cred + + +requestUnfollow : FollowedAuthor -> Cred -> Http.Request Author +requestUnfollow (FollowedAuthor uname _) cred = + requestHelp HttpBuilder.delete uname cred + + +requestHelp : + (String -> RequestBuilder a) + -> Username + -> Cred + -> Http.Request Author +requestHelp builderFromUrl uname cred = + Api.url [ "profiles", Username.toString uname, "follow" ] + |> builderFromUrl + |> Cred.addHeader cred + |> withExpect (Http.expectJson (Decode.field "profile" (decoder Nothing))) + |> HttpBuilder.toRequest + + +followButton : (Cred -> UnfollowedAuthor -> msg) -> Cred -> UnfollowedAuthor -> Html msg +followButton toMsg cred ((UnfollowedAuthor uname _) as author) = + toggleFollowButton "Follow" + [ "btn-outline-secondary" ] + (toMsg cred author) + uname + + +unfollowButton : (Cred -> FollowedAuthor -> msg) -> Cred -> FollowedAuthor -> Html msg +unfollowButton toMsg cred ((FollowedAuthor uname _) as author) = + toggleFollowButton "Unfollow" + [ "btn-secondary" ] + (toMsg cred author) + uname + + +toggleFollowButton : String -> List String -> msg -> Username -> Html msg +toggleFollowButton txt extraClasses msgWhenClicked uname = + let + classStr = + "btn btn-sm " ++ String.join " " extraClasses ++ " action-btn" + + caption = + " " ++ txt ++ " " ++ Username.toString uname + in + Html.button [ class classStr, onClick msgWhenClicked ] + [ i [ class "ion-plus-round" ] [] + , text caption + ] + + + +-- SERIALIZATION + + +decoder : Maybe Cred -> Decoder Author +decoder maybeCred = + Decode.succeed Tuple.pair + |> custom Profile.decoder + |> required "username" Username.decoder + |> Decode.andThen (decodeFromPair maybeCred) + + +decodeFromPair : Maybe Cred -> ( Profile, Username ) -> Decoder Author +decodeFromPair maybeCred ( prof, uname ) = + case maybeCred of + Nothing -> + -- If you're logged out, you can't be following anyone! + Decode.succeed (IsNotFollowing (UnfollowedAuthor uname prof)) + + Just cred -> + if uname == Cred.username cred then + Decode.succeed (IsViewer cred prof) + + else + nonViewerDecoder prof uname + + +nonViewerDecoder : Profile -> Username -> Decoder Author +nonViewerDecoder prof uname = + Decode.field "following" Decode.bool + |> Decode.map (authorFromFollowing prof uname) + + +authorFromFollowing : Profile -> Username -> Bool -> Author +authorFromFollowing prof uname isFollowing = + if isFollowing then + IsFollowing (FollowedAuthor uname prof) + + else + IsNotFollowing (UnfollowedAuthor uname prof) + + +{-| View an author. We basically render their username and a link to their +profile, and that's it. +-} +view : Username -> Html msg +view uname = + a [ class "author", Route.href (Route.Profile uname) ] + [ Username.toHtml uname ] diff --git a/intro/part8/src/Avatar.elm b/intro/part8/src/Avatar.elm new file mode 100644 index 0000000..7ecafb3 --- /dev/null +++ b/intro/part8/src/Avatar.elm @@ -0,0 +1,56 @@ +module Avatar exposing (Avatar, decoder, encode, src, toMaybeString) + +import Asset +import Html exposing (Attribute) +import Html.Attributes +import Json.Decode as Decode exposing (Decoder) +import Json.Encode as Encode exposing (Value) + + + +-- TYPES + + +type Avatar + = Avatar (Maybe String) + + + +-- CREATE + + +decoder : Decoder Avatar +decoder = + Decode.map Avatar (Decode.nullable Decode.string) + + + +-- TRANSFORM + + +encode : Avatar -> Value +encode (Avatar maybeUrl) = + case maybeUrl of + Just url -> + Encode.string url + + Nothing -> + Encode.null + + +src : Avatar -> Attribute msg +src (Avatar maybeUrl) = + case maybeUrl of + Nothing -> + Asset.src Asset.defaultAvatar + + Just "" -> + Asset.src Asset.defaultAvatar + + Just url -> + Html.Attributes.src url + + +toMaybeString : Avatar -> Maybe String +toMaybeString (Avatar maybeUrl) = + maybeUrl diff --git a/intro/part8/src/CommentId.elm b/intro/part8/src/CommentId.elm new file mode 100644 index 0000000..f136e1b --- /dev/null +++ b/intro/part8/src/CommentId.elm @@ -0,0 +1,29 @@ +module CommentId exposing (CommentId, decoder, toString) + +import Json.Decode as Decode exposing (Decoder) + + + +-- TYPES + + +type CommentId + = CommentId Int + + + +-- CREATE + + +decoder : Decoder CommentId +decoder = + Decode.map CommentId Decode.int + + + +-- TRANSFORM + + +toString : CommentId -> String +toString (CommentId id) = + String.fromInt id diff --git a/intro/part8/src/Email.elm b/intro/part8/src/Email.elm new file mode 100644 index 0000000..f696c01 --- /dev/null +++ b/intro/part8/src/Email.elm @@ -0,0 +1,45 @@ +module Email exposing (Email, decoder, encode, toString) + +import Json.Decode as Decode exposing (Decoder) +import Json.Encode as Encode exposing (Value) + + +{-| An email address. + +Having this as a custom type that's separate from String makes certain +mistakes impossible. Consider this function: + +updateEmailAddress : Email -> String -> Http.Request +updateEmailAddress email password = ... + +(The server needs your password to confirm that you should be allowed +to update the email address.) + +Because Email is not a type alias for String, but is instead a separate +custom type, it is now impossible to mix up the argument order of the +email and the password. If we do, it won't compile! + +If Email were instead defined as `type alias Email = String`, we could +call updateEmailAddress password email and it would compile (and never +work properly). + +This way, we make it impossible for a bug like that to compile! + +-} +type Email + = Email String + + +toString : Email -> String +toString (Email str) = + str + + +encode : Email -> Value +encode (Email str) = + Encode.string str + + +decoder : Decoder Email +decoder = + Decode.map Email Decode.string diff --git a/intro/part8/src/Loading.elm b/intro/part8/src/Loading.elm new file mode 100644 index 0000000..a1ded78 --- /dev/null +++ b/intro/part8/src/Loading.elm @@ -0,0 +1,25 @@ +module Loading exposing (error, icon, slowThreshold) + +{-| A loading spinner icon. +-} + +import Asset +import Html exposing (Attribute, Html) +import Html.Attributes exposing (alt, height, src, width) +import Process +import Task exposing (Task) + + +icon : Html msg +icon = + Html.img [ Asset.src Asset.loading, width 64, height 64, alt "Loading..." ] [] + + +error : String -> Html msg +error str = + Html.text ("Error loading " ++ str ++ ".") + + +slowThreshold : Task x () +slowThreshold = + Process.sleep 500 diff --git a/intro/part8/src/Log.elm b/intro/part8/src/Log.elm new file mode 100644 index 0000000..fe6111e --- /dev/null +++ b/intro/part8/src/Log.elm @@ -0,0 +1,20 @@ +module Log exposing (error) + +{-| This is a placeholder API for how we might do logging through +some service like (which is what we use at work). + +Whenever you see Log.error used in this code base, it means +"Something unexpected happened. This is where we would log an +error to our server with some diagnostic info so we could investigate +what happened later." + +(Since this is outside the scope of the RealWorld spec, and is only +a placeholder anyway, I didn't bother making this function accept actual +diagnostic info, authentication tokens, etc.) + +-} + + +error : Cmd msg +error = + Cmd.none diff --git a/intro/part8/src/Main.elm b/intro/part8/src/Main.elm new file mode 100644 index 0000000..cf1c4cc --- /dev/null +++ b/intro/part8/src/Main.elm @@ -0,0 +1,334 @@ +module Main exposing (main) + +import Article.FeedSources as FeedSources +import Article.Slug exposing (Slug) +import Browser exposing (Document) +import Browser.Navigation as Nav +import Html exposing (..) +import Json.Decode as Decode exposing (Value) +import Page exposing (Page) +import Page.Article as Article +import Page.Article.Editor as Editor +import Page.Blank as Blank +import Page.Home as Home +import Page.Login as Login +import Page.NotFound as NotFound +import Page.Profile as Profile +import Page.Register as Register +import Page.Settings as Settings +import Route exposing (Route) +import Session exposing (Session) +import Task +import Time +import Url exposing (Url) +import Username exposing (Username) +import Viewer.Cred as Cred exposing (Cred) + + + +-- WARNING: Based on discussions around how asset management features +-- like code splitting and lazy loading have been shaping up, I expect +-- most of this file to become unnecessary in a future release of Elm. +-- Avoid putting things in here unless there is no alternative! + + +type Model + = Redirect Session + | NotFound Session + | Home Home.Model + | Settings Settings.Model + | Login Login.Model + | Register Register.Model + | Profile Username Profile.Model + | Article Article.Model + | Editor (Maybe Slug) Editor.Model + + + +-- MODEL + + +init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg ) +init flags url navKey = + changeRouteTo (Route.fromUrl url) + (Redirect (Session.decode navKey flags)) + + + +-- VIEW + + +view : Model -> Document Msg +view model = + let + viewPage page toMsg config = + let + { title, body } = + Page.view (Session.viewer (toSession model)) page config + in + { title = title + , body = List.map (Html.map toMsg) body + } + in + case model of + Redirect _ -> + viewPage Page.Other (\_ -> Ignored) Blank.view + + NotFound _ -> + viewPage Page.Other (\_ -> Ignored) NotFound.view + + Settings settings -> + viewPage Page.Other GotSettingsMsg (Settings.view settings) + + Home home -> + viewPage Page.Home GotHomeMsg (Home.view home) + + Login login -> + viewPage Page.Other GotLoginMsg (Login.view login) + + Register register -> + viewPage Page.Other GotRegisterMsg (Register.view register) + + Profile username profile -> + viewPage (Page.Profile username) GotProfileMsg (Profile.view profile) + + Article article -> + viewPage Page.Other GotArticleMsg (Article.view article) + + Editor Nothing editor -> + viewPage Page.NewArticle GotEditorMsg (Editor.view editor) + + Editor (Just _) editor -> + viewPage Page.Other GotEditorMsg (Editor.view editor) + + + +-- UPDATE + + +type Msg + = Ignored + | ChangedRoute (Maybe Route) + | ChangedUrl Url + | ClickedLink Browser.UrlRequest + | GotHomeMsg Home.Msg + | GotSettingsMsg Settings.Msg + | GotLoginMsg Login.Msg + | GotRegisterMsg Register.Msg + | GotProfileMsg Profile.Msg + | GotArticleMsg Article.Msg + | GotEditorMsg Editor.Msg + | GotSession Session + + +toSession : Model -> Session +toSession page = + case page of + Redirect session -> + session + + NotFound session -> + session + + Home home -> + Home.toSession home + + Settings settings -> + Settings.toSession settings + + Login login -> + Login.toSession login + + Register register -> + Register.toSession register + + Profile _ profile -> + Profile.toSession profile + + Article article -> + Article.toSession article + + Editor _ editor -> + Editor.toSession editor + + +changeRouteTo : Maybe Route -> Model -> ( Model, Cmd Msg ) +changeRouteTo maybeRoute model = + let + session = + toSession model + in + case maybeRoute of + Nothing -> + ( NotFound session, Cmd.none ) + + Just Route.Root -> + ( model, Route.replaceUrl (Session.navKey session) Route.Home ) + + Just Route.Logout -> + ( model, Session.logout ) + + Just Route.NewArticle -> + Editor.initNew session + |> updateWith (Editor Nothing) GotEditorMsg model + + Just (Route.EditArticle slug) -> + Editor.initEdit session slug + |> updateWith (Editor (Just slug)) GotEditorMsg model + + Just Route.Settings -> + Settings.init session + |> updateWith Settings GotSettingsMsg model + + Just Route.Home -> + Home.init session + |> updateWith Home GotHomeMsg model + + Just Route.Login -> + Login.init session + |> updateWith Login GotLoginMsg model + + Just Route.Register -> + Register.init session + |> updateWith Register GotRegisterMsg model + + Just (Route.Profile username) -> + Profile.init session username + |> updateWith (Profile username) GotProfileMsg model + + Just (Route.Article slug) -> + Article.init session slug + |> updateWith Article GotArticleMsg model + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case ( msg, model ) of + ( Ignored, _ ) -> + ( model, Cmd.none ) + + ( ClickedLink urlRequest, _ ) -> + case urlRequest of + Browser.Internal url -> + case url.fragment of + Nothing -> + -- If we got a link that didn't include a fragment, + -- it's from one of those (href "") attributes that + -- we have to include to make the RealWorld CSS work. + -- + -- In an application doing path routing instead of + -- fragment-based routing, this entire + -- `case url.fragment of` expression this comment + -- is inside would be unnecessary. + ( model, Cmd.none ) + + Just _ -> + ( model + , Nav.pushUrl (Session.navKey (toSession model)) (Url.toString url) + ) + + Browser.External href -> + ( model + , Nav.load href + ) + + ( ChangedUrl url, _ ) -> + changeRouteTo (Route.fromUrl url) model + + ( ChangedRoute route, _ ) -> + changeRouteTo route model + + ( GotSettingsMsg subMsg, Settings settings ) -> + Settings.update subMsg settings + |> updateWith Settings GotSettingsMsg model + + ( GotLoginMsg subMsg, Login login ) -> + Login.update subMsg login + |> updateWith Login GotLoginMsg model + + ( GotRegisterMsg subMsg, Register register ) -> + Register.update subMsg register + |> updateWith Register GotRegisterMsg model + + ( GotHomeMsg subMsg, Home home ) -> + Home.update subMsg home + |> updateWith Home GotHomeMsg model + + ( GotProfileMsg subMsg, Profile username profile ) -> + Profile.update subMsg profile + |> updateWith (Profile username) GotProfileMsg model + + ( GotArticleMsg subMsg, Article article ) -> + Article.update subMsg article + |> updateWith Article GotArticleMsg model + + ( GotEditorMsg subMsg, Editor slug editor ) -> + Editor.update subMsg editor + |> updateWith (Editor slug) GotEditorMsg model + + ( GotSession session, Redirect _ ) -> + ( Redirect session + , Route.replaceUrl (Session.navKey session) Route.Home + ) + + ( _, _ ) -> + -- Disregard messages that arrived for the wrong page. + ( model, Cmd.none ) + + +updateWith : (subModel -> Model) -> (subMsg -> Msg) -> Model -> ( subModel, Cmd subMsg ) -> ( Model, Cmd Msg ) +updateWith toModel toMsg model ( subModel, subCmd ) = + ( toModel subModel + , Cmd.map toMsg subCmd + ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + case model of + NotFound _ -> + Sub.none + + Redirect _ -> + Session.changes GotSession (Session.navKey (toSession model)) + + Settings settings -> + Sub.map GotSettingsMsg (Settings.subscriptions settings) + + Home home -> + Sub.map GotHomeMsg (Home.subscriptions home) + + Login login -> + Sub.map GotLoginMsg (Login.subscriptions login) + + Register register -> + Sub.map GotRegisterMsg (Register.subscriptions register) + + Profile _ profile -> + Sub.map GotProfileMsg (Profile.subscriptions profile) + + Article article -> + Sub.map GotArticleMsg (Article.subscriptions article) + + Editor _ editor -> + Sub.map GotEditorMsg (Editor.subscriptions editor) + + + +-- MAIN + + +main : Program Value Model Msg +main = + Browser.application + { init = init + , onUrlChange = ChangedUrl + , onUrlRequest = ClickedLink + , subscriptions = subscriptions + , update = update + , view = view + } diff --git a/intro/part8/src/Page.elm b/intro/part8/src/Page.elm new file mode 100644 index 0000000..2986b9c --- /dev/null +++ b/intro/part8/src/Page.elm @@ -0,0 +1,159 @@ +module Page exposing (Page(..), view, viewErrors) + +import Avatar +import Browser exposing (Document) +import Html exposing (Html, a, button, div, footer, i, img, li, nav, p, span, text, ul) +import Html.Attributes exposing (class, classList, href, style) +import Html.Events exposing (onClick) +import Profile +import Route exposing (Route) +import Session exposing (Session) +import Username exposing (Username) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + +{-| Determines which navbar link (if any) will be rendered as active. + +Note that we don't enumerate every page here, because the navbar doesn't +have links for every page. Anything that's not part of the navbar falls +under Other. + +-} +type Page + = Other + | Home + | Login + | Register + | Settings + | Profile Username + | NewArticle + + +{-| Take a page's Html and frames it with a header and footer. + +The caller provides the current user, so we can display in either +"signed in" (rendering username) or "signed out" mode. + +isLoading is for determining whether we should show a loading spinner +in the header. (This comes up during slow page transitions.) + +-} +view : Maybe Viewer -> Page -> { title : String, content : Html msg } -> Document msg +view maybeViewer page { title, content } = + { title = title ++ " - Conduit" + , body = viewHeader page maybeViewer :: content :: [ viewFooter ] + } + + +viewHeader : Page -> Maybe Viewer -> Html msg +viewHeader page maybeViewer = + nav [ class "navbar navbar-light" ] + [ div [ class "container" ] + [ a [ class "navbar-brand", Route.href Route.Home ] + [ text "conduit" ] + , ul [ class "nav navbar-nav pull-xs-right" ] <| + navbarLink page Route.Home [ text "Home" ] + :: viewMenu page maybeViewer + ] + ] + + +viewMenu : Page -> Maybe Viewer -> List (Html msg) +viewMenu page maybeViewer = + let + linkTo = + navbarLink page + in + case maybeViewer of + Just viewer -> + let + cred = + Viewer.cred viewer + + username = + Cred.username cred + + avatar = + Profile.avatar (Viewer.profile viewer) + in + [ linkTo Route.NewArticle [ i [ class "ion-compose" ] [], text " New Post" ] + , linkTo Route.Settings [ i [ class "ion-gear-a" ] [], text " Settings" ] + , linkTo + (Route.Profile username) + [ img [ class "user-pic", Avatar.src avatar ] [] + , Username.toHtml username + ] + , linkTo Route.Logout [ text "Sign out" ] + ] + + Nothing -> + [ linkTo Route.Login [ text "Sign in" ] + , linkTo Route.Register [ text "Sign up" ] + ] + + +viewFooter : Html msg +viewFooter = + footer [] + [ div [ class "container" ] + [ a [ class "logo-font", href "/" ] [ text "conduit" ] + , span [ class "attribution" ] + [ text "An interactive learning project from " + , a [ href "https://thinkster.io" ] [ text "Thinkster" ] + , text ". Code & design licensed under MIT." + ] + ] + ] + + +navbarLink : Page -> Route -> List (Html msg) -> Html msg +navbarLink page route linkContent = + li [ classList [ ( "nav-item", True ), ( "active", isActive page route ) ] ] + [ a [ class "nav-link", Route.href route ] linkContent ] + + +isActive : Page -> Route -> Bool +isActive page route = + case ( page, route ) of + ( Home, Route.Home ) -> + True + + ( Login, Route.Login ) -> + True + + ( Register, Route.Register ) -> + True + + ( Settings, Route.Settings ) -> + True + + ( Profile pageUsername, Route.Profile routeUsername ) -> + pageUsername == routeUsername + + ( NewArticle, Route.NewArticle ) -> + True + + _ -> + False + + +{-| Render dismissable errors. We use this all over the place! +-} +viewErrors : msg -> List String -> Html msg +viewErrors dismissErrors errors = + if List.isEmpty errors then + Html.text "" + + else + div + [ class "error-messages" + , style "position" "fixed" + , style "top" "0" + , style "background" "rgb(250, 250, 250)" + , style "padding" "20px" + , style "border" "1px solid" + ] + <| + List.map (\error -> p [] [ text error ]) errors + ++ [ button [ onClick dismissErrors ] [ text "Ok" ] ] diff --git a/intro/part8/src/Page/Article.elm b/intro/part8/src/Page/Article.elm new file mode 100644 index 0000000..d454f5f --- /dev/null +++ b/intro/part8/src/Page/Article.elm @@ -0,0 +1,588 @@ +module Page.Article exposing (Model, Msg, init, subscriptions, toSession, update, view) + +{-| Viewing an individual article. +-} + +import Api +import Article exposing (Article, Full, Preview) +import Article.Body exposing (Body) +import Article.Comment as Comment exposing (Comment) +import Article.Slug as Slug exposing (Slug) +import Author exposing (Author(..), FollowedAuthor, UnfollowedAuthor) +import Avatar +import Browser.Navigation as Nav +import CommentId exposing (CommentId) +import Html exposing (..) +import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder) +import Html.Events exposing (onClick, onInput, onSubmit) +import Http +import HttpBuilder exposing (RequestBuilder, withBody, withExpect, withQueryParams) +import Loading +import Log +import Page +import Profile exposing (Profile) +import Route +import Session exposing (Session) +import Task exposing (Task) +import Time +import Timestamp +import Username exposing (Username) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , timeZone : Time.Zone + , errors : List String + + -- Loaded independently from server + , comments : Status ( CommentText, List Comment ) + , article : Status (Article Full) + } + + +type Status a + = Loading + | LoadingSlowly + | Loaded a + | Failed + + +type CommentText + = Editing String + | Sending String + + +init : Session -> Slug -> ( Model, Cmd Msg ) +init session slug = + let + maybeCred = + Session.cred session + in + ( { session = session + , timeZone = Time.utc + , errors = [] + , comments = Loading + , article = Loading + } + , Cmd.batch + [ Article.fetch maybeCred slug + |> Http.send CompletedLoadArticle + , Comment.list maybeCred slug + |> Http.send CompletedLoadComments + , Task.perform GotTimeZone Time.here + , Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold + ] + ) + + + +-- VIEW + + +view : Model -> { title : String, content : Html Msg } +view model = + case model.article of + Loaded article -> + let + { title } = + Article.metadata article + + author = + Article.author article + + avatar = + Profile.avatar (Author.profile author) + + slug = + Article.slug article + + profile = + Author.profile author + + buttons = + case Session.cred model.session of + Just cred -> + viewButtons cred article author + + Nothing -> + [] + in + { title = title + , content = + div [ class "article-page" ] + [ div [ class "banner" ] + [ div [ class "container" ] + [ h1 [] [ text title ] + , div [ class "article-meta" ] <| + List.append + [ a [ Route.href (Route.Profile (Author.username author)) ] + [ img [ Avatar.src (Profile.avatar profile) ] [] ] + , div [ class "info" ] + [ Author.view (Author.username author) + , Timestamp.view model.timeZone (Article.metadata article).createdAt + ] + ] + buttons + , Page.viewErrors ClickedDismissErrors model.errors + ] + ] + , div [ class "container page" ] + [ div [ class "row article-content" ] + [ div [ class "col-md-12" ] + [ Article.Body.toHtml (Article.body article) [] ] + ] + , hr [] [] + , div [ class "article-actions" ] + [ div [ class "article-meta" ] <| + List.append + [ a [ Route.href (Route.Profile (Author.username author)) ] + [ img [ Avatar.src avatar ] [] ] + , div [ class "info" ] + [ Author.view (Author.username author) + , Timestamp.view model.timeZone (Article.metadata article).createdAt + ] + ] + buttons + ] + , div [ class "row" ] + [ div [ class "col-xs-12 col-md-8 offset-md-2" ] <| + -- Don't render the comments until the article has loaded! + case model.comments of + Loading -> + [] + + LoadingSlowly -> + [ Loading.icon ] + + Loaded ( commentText, comments ) -> + -- Don't let users add comments until they can + -- see the existing comments! Otherwise you + -- may be about to repeat something that's + -- already been said. + viewAddComment slug commentText (Session.viewer model.session) + :: List.map (viewComment model.timeZone slug) comments + + Failed -> + [ Loading.error "comments" ] + ] + ] + ] + } + + Loading -> + { title = "Article", content = text "" } + + LoadingSlowly -> + { title = "Article", content = Loading.icon } + + Failed -> + { title = "Article", content = Loading.error "article" } + + +viewAddComment : Slug -> CommentText -> Maybe Viewer -> Html Msg +viewAddComment slug commentText maybeViewer = + case maybeViewer of + Just viewer -> + let + avatar = + Profile.avatar (Viewer.profile viewer) + + cred = + Viewer.cred viewer + + ( commentStr, buttonAttrs ) = + case commentText of + Editing str -> + ( str, [] ) + + Sending str -> + ( str, [ disabled True ] ) + in + Html.form [ class "card comment-form", onSubmit (ClickedPostComment cred slug) ] + [ div [ class "card-block" ] + [ textarea + [ class "form-control" + , placeholder "Write a comment..." + , attribute "rows" "3" + , onInput EnteredCommentText + ] + [] + ] + , div [ class "card-footer" ] + [ img [ class "comment-author-img", Avatar.src avatar ] [] + , button + (class "btn btn-sm btn-primary" :: buttonAttrs) + [ text "Post Comment" ] + ] + ] + + Nothing -> + p [] + [ a [ Route.href Route.Login ] [ text "Sign in" ] + , text " or " + , a [ Route.href Route.Register ] [ text "sign up" ] + , text " to comment." + ] + + +viewButtons : Cred -> Article Full -> Author -> List (Html Msg) +viewButtons cred article author = + case author of + IsFollowing followedAuthor -> + [ Author.unfollowButton ClickedUnfollow cred followedAuthor + , text " " + , favoriteButton cred article + ] + + IsNotFollowing unfollowedAuthor -> + [ Author.followButton ClickedFollow cred unfollowedAuthor + , text " " + , favoriteButton cred article + ] + + IsViewer _ _ -> + [ editButton article + , text " " + , deleteButton cred article + ] + + +viewComment : Time.Zone -> Slug -> Comment -> Html Msg +viewComment timeZone slug comment = + let + author = + Comment.author comment + + profile = + Author.profile author + + authorUsername = + Author.username author + + deleteCommentButton = + case author of + IsViewer cred _ -> + let + msg = + ClickedDeleteComment cred slug (Comment.id comment) + in + span + [ class "mod-options" + , onClick msg + ] + [ i [ class "ion-trash-a" ] [] ] + + _ -> + -- You can't delete other peoples' comments! + text "" + + timestamp = + Timestamp.format timeZone (Comment.createdAt comment) + in + div [ class "card" ] + [ div [ class "card-block" ] + [ p [ class "card-text" ] [ text (Comment.body comment) ] ] + , div [ class "card-footer" ] + [ a [ class "comment-author", href "" ] + [ img [ class "comment-author-img", Avatar.src (Profile.avatar profile) ] [] + , text " " + ] + , text " " + , a [ class "comment-author", Route.href (Route.Profile authorUsername) ] + [ text (Username.toString authorUsername) ] + , span [ class "date-posted" ] [ text timestamp ] + , deleteCommentButton + ] + ] + + + +-- UPDATE + + +type Msg + = ClickedDeleteArticle Cred Slug + | ClickedDeleteComment Cred Slug CommentId + | ClickedDismissErrors + | ClickedFavorite Cred Slug Body + | ClickedUnfavorite Cred Slug Body + | ClickedFollow Cred UnfollowedAuthor + | ClickedUnfollow Cred FollowedAuthor + | ClickedPostComment Cred Slug + | EnteredCommentText String + | CompletedLoadArticle (Result Http.Error (Article Full)) + | CompletedLoadComments (Result Http.Error (List Comment)) + | CompletedDeleteArticle (Result Http.Error ()) + | CompletedDeleteComment CommentId (Result Http.Error ()) + | CompletedFavoriteChange (Result Http.Error (Article Full)) + | CompletedFollowChange (Result Http.Error Author) + | CompletedPostComment (Result Http.Error Comment) + | GotTimeZone Time.Zone + | GotSession Session + | PassedSlowLoadThreshold + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + ClickedDismissErrors -> + ( { model | errors = [] }, Cmd.none ) + + ClickedFavorite cred slug body -> + ( model, fave Article.favorite cred slug body ) + + ClickedUnfavorite cred slug body -> + ( model, fave Article.unfavorite cred slug body ) + + CompletedLoadArticle (Ok article) -> + ( { model | article = Loaded article }, Cmd.none ) + + CompletedLoadArticle (Err error) -> + ( { model | article = Failed } + , Log.error + ) + + CompletedLoadComments (Ok comments) -> + ( { model | comments = Loaded ( Editing "", comments ) }, Cmd.none ) + + CompletedLoadComments (Err error) -> + ( { model | article = Failed }, Log.error ) + + CompletedFavoriteChange (Ok newArticle) -> + ( { model | article = Loaded newArticle }, Cmd.none ) + + CompletedFavoriteChange (Err error) -> + ( { model | errors = Api.addServerError model.errors } + , Log.error + ) + + ClickedUnfollow cred followedAuthor -> + ( model + , Author.requestUnfollow followedAuthor cred + |> Http.send CompletedFollowChange + ) + + ClickedFollow cred unfollowedAuthor -> + ( model + , Author.requestFollow unfollowedAuthor cred + |> Http.send CompletedFollowChange + ) + + CompletedFollowChange (Ok newAuthor) -> + case model.article of + Loaded article -> + ( { model | article = Loaded (Article.mapAuthor (\_ -> newAuthor) article) }, Cmd.none ) + + _ -> + ( model, Log.error ) + + CompletedFollowChange (Err error) -> + ( { model | errors = Api.addServerError model.errors } + , Log.error + ) + + EnteredCommentText str -> + case model.comments of + Loaded ( Editing _, comments ) -> + -- You can only edit comment text once comments have loaded + -- successfully, and when the comment is not currently + -- being submitted. + ( { model | comments = Loaded ( Editing str, comments ) } + , Cmd.none + ) + + _ -> + ( model, Log.error ) + + ClickedPostComment cred slug -> + case model.comments of + Loaded ( Editing "", comments ) -> + -- No posting empty comments! + -- We don't use Log.error here because this isn't an error, + -- it just doesn't do anything. + ( model, Cmd.none ) + + Loaded ( Editing str, comments ) -> + ( { model | comments = Loaded ( Sending str, comments ) } + , cred + |> Comment.post slug str + |> Http.send CompletedPostComment + ) + + _ -> + -- Either we have no comment to post, or there's already + -- one in the process of being posted, or we don't have + -- a valid article, in which case how did we post this? + ( model, Log.error ) + + CompletedPostComment (Ok comment) -> + case model.comments of + Loaded ( _, comments ) -> + ( { model | comments = Loaded ( Editing "", comment :: comments ) } + , Cmd.none + ) + + _ -> + ( model, Log.error ) + + CompletedPostComment (Err error) -> + ( { model | errors = Api.addServerError model.errors } + , Log.error + ) + + ClickedDeleteComment cred slug id -> + ( model + , cred + |> Comment.delete slug id + |> Http.send (CompletedDeleteComment id) + ) + + CompletedDeleteComment id (Ok ()) -> + case model.comments of + Loaded ( commentText, comments ) -> + ( { model | comments = Loaded ( commentText, withoutComment id comments ) } + , Cmd.none + ) + + _ -> + ( model, Log.error ) + + CompletedDeleteComment id (Err error) -> + ( { model | errors = Api.addServerError model.errors } + , Log.error + ) + + ClickedDeleteArticle cred slug -> + ( model + , delete slug cred + |> Http.send CompletedDeleteArticle + ) + + CompletedDeleteArticle (Ok ()) -> + ( model, Route.replaceUrl (Session.navKey model.session) Route.Home ) + + CompletedDeleteArticle (Err error) -> + ( { model | errors = Api.addServerError model.errors } + , Log.error + ) + + GotTimeZone tz -> + ( { model | timeZone = tz }, Cmd.none ) + + GotSession session -> + ( { model | session = session } + , Route.replaceUrl (Session.navKey session) Route.Home + ) + + PassedSlowLoadThreshold -> + let + -- If any data is still Loading, change it to LoadingSlowly + -- so `view` knows to render a spinner. + article = + case model.article of + Loading -> + LoadingSlowly + + other -> + other + + comments = + case model.comments of + Loading -> + LoadingSlowly + + other -> + other + in + ( { model | article = article, comments = comments }, Cmd.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- HTTP + + +delete : Slug -> Cred -> Http.Request () +delete slug cred = + Article.url slug [] + |> HttpBuilder.delete + |> Cred.addHeader cred + |> HttpBuilder.toRequest + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session + + + +-- INTERNAL + + +fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> Body -> Cmd Msg +fave toRequest cred slug body = + toRequest slug cred + |> Http.toTask + |> Task.map (Article.fromPreview body) + |> Task.attempt CompletedFavoriteChange + + +withoutComment : CommentId -> List Comment -> List Comment +withoutComment id list = + List.filter (\comment -> Comment.id comment /= id) list + + +favoriteButton : Cred -> Article Full -> Html Msg +favoriteButton cred article = + let + { favoritesCount, favorited } = + Article.metadata article + + slug = + Article.slug article + + body = + Article.body article + + kids = + [ text (" Favorite Article (" ++ String.fromInt favoritesCount ++ ")") ] + in + if favorited then + Article.unfavoriteButton cred (ClickedUnfavorite cred slug body) [] kids + + else + Article.favoriteButton cred (ClickedFavorite cred slug body) [] kids + + +deleteButton : Cred -> Article a -> Html Msg +deleteButton cred article = + let + msg = + ClickedDeleteArticle cred (Article.slug article) + in + button [ class "btn btn-outline-danger btn-sm", onClick msg ] + [ i [ class "ion-trash-a" ] [], text " Delete Article" ] + + +editButton : Article a -> Html Msg +editButton article = + a [ class "btn btn-outline-secondary btn-sm", Route.href (Route.EditArticle (Article.slug article)) ] + [ i [ class "ion-edit" ] [], text " Edit Article" ] diff --git a/intro/part8/src/Page/Article/Editor.elm b/intro/part8/src/Page/Article/Editor.elm new file mode 100644 index 0000000..837d902 --- /dev/null +++ b/intro/part8/src/Page/Article/Editor.elm @@ -0,0 +1,620 @@ +module Page.Article.Editor exposing (Model, Msg, initEdit, initNew, subscriptions, toSession, update, view) + +import Api +import Article exposing (Article, Full) +import Article.Body exposing (Body) +import Article.Slug as Slug exposing (Slug) +import Browser.Navigation as Nav +import Html exposing (..) +import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder, type_, value) +import Html.Events exposing (onInput, onSubmit) +import Http +import HttpBuilder exposing (withBody, withExpect) +import Json.Decode as Decode +import Json.Encode as Encode +import Loading +import Page +import Profile exposing (Profile) +import Route +import Session exposing (Session) +import Task exposing (Task) +import Time +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , status : Status + } + + +type + Status + -- Edit Article + = Loading Slug + | LoadingSlowly Slug + | LoadingFailed Slug + | Saving Slug Form + | Editing Slug (List Problem) Form + -- New Article + | EditingNew (List Problem) Form + | Creating Form + + +type Problem + = InvalidEntry ValidatedField String + | ServerError String + + +type alias Form = + { title : String + , body : String + , description : String + , tags : String + } + + +initNew : Session -> ( Model, Cmd msg ) +initNew session = + ( { session = session + , status = + EditingNew [] + { title = "" + , body = "" + , description = "" + , tags = "" + } + } + , Cmd.none + ) + + +initEdit : Session -> Slug -> ( Model, Cmd Msg ) +initEdit session slug = + ( { session = session + , status = Loading slug + } + , Cmd.batch + [ Article.fetch (Session.cred session) slug + |> Http.toTask + -- If init fails, store the slug that failed in the msg, so we can + -- at least have it later to display the page's title properly! + |> Task.mapError (\httpError -> ( slug, httpError )) + |> Task.attempt CompletedArticleLoad + , Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold + ] + ) + + + +-- VIEW + + +view : Model -> { title : String, content : Html Msg } +view model = + { title = + case getSlug model.status of + Just slug -> + "Edit Article - " ++ Slug.toString slug + + Nothing -> + "New Article" + , content = + case Session.cred model.session of + Just cred -> + viewAuthenticated cred model + + Nothing -> + text "Sign in to edit this article." + } + + +viewProblems : List Problem -> Html msg +viewProblems problems = + ul [ class "error-messages" ] + (List.map viewProblem problems) + + +viewProblem : Problem -> Html msg +viewProblem problem = + let + errorMessage = + case problem of + InvalidEntry _ message -> + message + + ServerError message -> + message + in + li [] [ text errorMessage ] + + +viewAuthenticated : Cred -> Model -> Html Msg +viewAuthenticated cred model = + let + formHtml = + case model.status of + Loading _ -> + [] + + LoadingSlowly _ -> + [ Loading.icon ] + + Saving slug form -> + [ viewForm cred form (editArticleSaveButton [ disabled True ]) ] + + Creating form -> + [ viewForm cred form (newArticleSaveButton [ disabled True ]) ] + + Editing slug problems form -> + [ viewProblems problems + , viewForm cred form (editArticleSaveButton []) + ] + + EditingNew problems form -> + [ viewProblems problems + , viewForm cred form (newArticleSaveButton []) + ] + + LoadingFailed _ -> + [ text "Article failed to load." ] + in + div [ class "editor-page" ] + [ div [ class "container page" ] + [ div [ class "row" ] + [ div [ class "col-md-10 offset-md-1 col-xs-12" ] + formHtml + ] + ] + ] + + +viewForm : Cred -> Form -> Html Msg -> Html Msg +viewForm cred fields saveButton = + Html.form [ onSubmit (ClickedSave cred) ] + [ fieldset [] + [ fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , placeholder "Article Title" + , onInput EnteredTitle + , value fields.title + ] + [] + ] + , fieldset [ class "form-group" ] + [ input + [ class "form-control" + , placeholder "What's this article about?" + , onInput EnteredDescription + , value fields.description + ] + [] + ] + , fieldset [ class "form-group" ] + [ textarea + [ class "form-control" + , placeholder "Write your article (in markdown)" + , attribute "rows" "8" + , onInput EnteredBody + , value fields.body + ] + [] + ] + , fieldset [ class "form-group" ] + [ input + [ class "form-control" + , placeholder "Enter tags" + , onInput EnteredTags + , value fields.tags + ] + [] + ] + , saveButton + ] + ] + + +editArticleSaveButton : List (Attribute msg) -> Html msg +editArticleSaveButton extraAttrs = + saveArticleButton "Update Article" extraAttrs + + +newArticleSaveButton : List (Attribute msg) -> Html msg +newArticleSaveButton extraAttrs = + saveArticleButton "Publish Article" extraAttrs + + +saveArticleButton : String -> List (Attribute msg) -> Html msg +saveArticleButton caption extraAttrs = + button (class "btn btn-lg pull-xs-right btn-primary" :: extraAttrs) + [ text caption ] + + + +-- UPDATE + + +type Msg + = ClickedSave Cred + | EnteredBody String + | EnteredDescription String + | EnteredTags String + | EnteredTitle String + | CompletedCreate (Result Http.Error (Article Full)) + | CompletedEdit (Result Http.Error (Article Full)) + | CompletedArticleLoad (Result ( Slug, Http.Error ) (Article Full)) + | GotSession Session + | PassedSlowLoadThreshold + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + ClickedSave cred -> + model.status + |> save cred + |> Tuple.mapFirst (\status -> { model | status = status }) + + EnteredTitle title -> + updateForm (\form -> { form | title = title }) model + + EnteredDescription description -> + updateForm (\form -> { form | description = description }) model + + EnteredTags tags -> + updateForm (\form -> { form | tags = tags }) model + + EnteredBody body -> + updateForm (\form -> { form | body = body }) model + + CompletedCreate (Ok article) -> + ( model + , Route.Article (Article.slug article) + |> Route.replaceUrl (Session.navKey model.session) + ) + + CompletedCreate (Err error) -> + ( { model | status = savingError error model.status } + , Cmd.none + ) + + CompletedEdit (Ok article) -> + ( model + , Route.Article (Article.slug article) + |> Route.replaceUrl (Session.navKey model.session) + ) + + CompletedEdit (Err error) -> + ( { model | status = savingError error model.status } + , Cmd.none + ) + + CompletedArticleLoad (Err ( slug, error )) -> + ( { model | status = LoadingFailed slug } + , Cmd.none + ) + + CompletedArticleLoad (Ok article) -> + let + { title, description, tags } = + Article.metadata article + + status = + Editing (Article.slug article) + [] + { title = title + , body = Article.Body.toMarkdownString (Article.body article) + , description = description + , tags = String.join " " tags + } + in + ( { model | status = status } + , Cmd.none + ) + + GotSession session -> + ( { model | session = session } + , Route.replaceUrl (Session.navKey session) Route.Home + ) + + PassedSlowLoadThreshold -> + let + -- If any data is still Loading, change it to LoadingSlowly + -- so `view` knows to render a spinner. + status = + case model.status of + Loading slug -> + LoadingSlowly slug + + other -> + other + in + ( { model | status = status }, Cmd.none ) + + +save : Cred -> Status -> ( Status, Cmd Msg ) +save cred status = + case status of + Editing slug _ form -> + case validate form of + Ok validForm -> + ( Saving slug form + , edit slug validForm cred + |> Http.send CompletedEdit + ) + + Err problems -> + ( Editing slug problems form + , Cmd.none + ) + + EditingNew _ form -> + case validate form of + Ok validForm -> + ( Creating form + , create validForm cred + |> Http.send CompletedCreate + ) + + Err problems -> + ( EditingNew problems form + , Cmd.none + ) + + _ -> + -- We're in a state where saving is not allowed. + -- We tried to prevent getting here by disabling the Save + -- button, but somehow the user got here anyway! + -- + -- If we had an error logging service, we would send + -- something to it here! + ( status, Cmd.none ) + + +savingError : Http.Error -> Status -> Status +savingError error status = + let + problems = + [ ServerError "Error saving article" ] + in + case status of + Saving slug form -> + Editing slug problems form + + Creating form -> + EditingNew problems form + + _ -> + status + + +{-| Helper function for `update`. Updates the form, if there is one, +and returns Cmd.none. + +Useful for recording form fields! + +This could also log errors to the server if we are trying to record things in +the form and we don't actually have a form. + +-} +updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg ) +updateForm transform model = + let + newModel = + case model.status of + Loading _ -> + model + + LoadingSlowly _ -> + model + + LoadingFailed _ -> + model + + Saving slug form -> + { model | status = Saving slug (transform form) } + + Editing slug errors form -> + { model | status = Editing slug errors (transform form) } + + EditingNew errors form -> + { model | status = EditingNew errors (transform form) } + + Creating form -> + { model | status = Creating (transform form) } + in + ( newModel, Cmd.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- FORM + + +{-| Marks that we've trimmed the form's fields, so we don't accidentally send +it to the server without having trimmed it! +-} +type TrimmedForm + = Trimmed Form + + +{-| When adding a variant here, add it to `fieldsToValidate` too! +-} +type ValidatedField + = Title + | Body + + +fieldsToValidate : List ValidatedField +fieldsToValidate = + [ Title + , Body + ] + + +{-| Trim the form and validate its fields. If there are problems, report them! +-} +validate : Form -> Result (List Problem) TrimmedForm +validate form = + let + trimmedForm = + trimFields form + in + case List.concatMap (validateField trimmedForm) fieldsToValidate of + [] -> + Ok trimmedForm + + problems -> + Err problems + + +validateField : TrimmedForm -> ValidatedField -> List Problem +validateField (Trimmed form) field = + List.map (InvalidEntry field) <| + case field of + Title -> + if String.isEmpty form.title then + [ "title can't be blank." ] + + else + [] + + Body -> + if String.isEmpty form.body then + [ "body can't be blank." ] + + else + [] + + +{-| Don't trim while the user is typing! That would be super annoying. +Instead, trim only on submit. +-} +trimFields : Form -> TrimmedForm +trimFields form = + Trimmed + { title = String.trim form.title + , body = String.trim form.body + , description = String.trim form.description + , tags = String.trim form.tags + } + + + +-- HTTP + + +create : TrimmedForm -> Cred -> Http.Request (Article Full) +create (Trimmed form) cred = + let + expect = + Article.fullDecoder (Just cred) + |> Decode.field "article" + |> Http.expectJson + + article = + Encode.object + [ ( "title", Encode.string form.title ) + , ( "description", Encode.string form.description ) + , ( "body", Encode.string form.body ) + , ( "tagList", Encode.list Encode.string (tagsFromString form.tags) ) + ] + + jsonBody = + Encode.object [ ( "article", article ) ] + |> Http.jsonBody + in + Api.url [ "articles" ] + |> HttpBuilder.post + |> Cred.addHeader cred + |> withBody jsonBody + |> withExpect expect + |> HttpBuilder.toRequest + + +tagsFromString : String -> List String +tagsFromString str = + String.split " " str + |> List.map String.trim + |> List.filter (not << String.isEmpty) + + +edit : Slug -> TrimmedForm -> Cred -> Http.Request (Article Full) +edit articleSlug (Trimmed form) cred = + let + expect = + Article.fullDecoder (Just cred) + |> Decode.field "article" + |> Http.expectJson + + article = + Encode.object + [ ( "title", Encode.string form.title ) + , ( "description", Encode.string form.description ) + , ( "body", Encode.string form.body ) + ] + + jsonBody = + Encode.object [ ( "article", article ) ] + |> Http.jsonBody + in + Article.url articleSlug [] + |> HttpBuilder.put + |> Cred.addHeader cred + |> withBody jsonBody + |> withExpect expect + |> HttpBuilder.toRequest + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session + + + +-- INTERNAL + + +{-| Used for setting the page's title. +-} +getSlug : Status -> Maybe Slug +getSlug status = + case status of + Loading slug -> + Just slug + + LoadingSlowly slug -> + Just slug + + LoadingFailed slug -> + Just slug + + Saving slug _ -> + Just slug + + Editing slug _ _ -> + Just slug + + EditingNew _ _ -> + Nothing + + Creating _ -> + Nothing diff --git a/intro/part8/src/Page/Blank.elm b/intro/part8/src/Page/Blank.elm new file mode 100644 index 0000000..3ae45a3 --- /dev/null +++ b/intro/part8/src/Page/Blank.elm @@ -0,0 +1,10 @@ +module Page.Blank exposing (view) + +import Html exposing (Html) + + +view : { title : String, content : Html msg } +view = + { title = "" + , content = Html.text "" + } diff --git a/intro/part8/src/Page/Home.elm b/intro/part8/src/Page/Home.elm new file mode 100644 index 0000000..a4dea6d --- /dev/null +++ b/intro/part8/src/Page/Home.elm @@ -0,0 +1,259 @@ +module Page.Home exposing (Model, Msg, init, subscriptions, toSession, update, view) + +{-| The homepage. You can get here via either the / or /#/ routes. +-} + +import Article +import Article.Feed as Feed +import Article.FeedSources as FeedSources exposing (FeedSources, Source(..)) +import Article.Tag as Tag exposing (Tag) +import Html exposing (..) +import Html.Attributes exposing (attribute, class, classList, href, id, placeholder) +import Html.Events exposing (onClick) +import Http +import Loading +import Log +import Page +import Session exposing (Session) +import Task exposing (Task) +import Time +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , timeZone : Time.Zone + + -- Loaded independently from server + , tags : Status (List Tag) + , feed : Status Feed.Model + } + + +type Status a + = Loading + | LoadingSlowly + | Loaded a + | Failed + + +init : Session -> ( Model, Cmd Msg ) +init session = + let + feedSources = + case Session.cred session of + Just cred -> + FeedSources.fromLists (YourFeed cred) [ GlobalFeed ] + + Nothing -> + FeedSources.fromLists GlobalFeed [] + + loadTags = + Tag.list + |> Http.toTask + in + ( { session = session + , timeZone = Time.utc + , tags = Loading + , feed = Loading + } + , Cmd.batch + [ Feed.init session feedSources + |> Task.attempt CompletedFeedLoad + , Tag.list + |> Http.send CompletedTagsLoad + , Task.perform GotTimeZone Time.here + , Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold + ] + ) + + + +-- VIEW + + +view : Model -> { title : String, content : Html Msg } +view model = + { title = "Conduit" + , content = + div [ class "home-page" ] + [ viewBanner + , div [ class "container page" ] + [ div [ class "row" ] + [ div [ class "col-md-9" ] <| + case model.feed of + Loaded feed -> + viewFeed model.timeZone feed + + Loading -> + [] + + LoadingSlowly -> + [ Loading.icon ] + + Failed -> + [ Loading.error "feed" ] + , div [ class "col-md-3" ] <| + case model.tags of + Loaded tags -> + [ div [ class "sidebar" ] <| + [ p [] [ text "Popular Tags" ] + , viewTags tags + ] + ] + + Loading -> + [] + + LoadingSlowly -> + [ Loading.icon ] + + Failed -> + [ Loading.error "tags" ] + ] + ] + ] + } + + +viewBanner : Html msg +viewBanner = + div [ class "banner" ] + [ div [ class "container" ] + [ h1 [ class "logo-font" ] [ text "conduit" ] + , p [] [ text "A place to share your knowledge." ] + ] + ] + + +viewFeed : Time.Zone -> Feed.Model -> List (Html Msg) +viewFeed timeZone feed = + div [ class "feed-toggle" ] + [ Feed.viewFeedSources feed |> Html.map GotFeedMsg ] + :: (Feed.viewArticles timeZone feed |> List.map (Html.map GotFeedMsg)) + + +viewTags : List Tag -> Html Msg +viewTags tags = + div [ class "tag-list" ] (List.map viewTag tags) + + +viewTag : Tag -> Html Msg +viewTag tagName = + a + [ class "tag-pill tag-default" + , onClick (ClickedTag tagName) + + -- The RealWorld CSS requires an href to work properly. + , href "" + ] + [ text (Tag.toString tagName) ] + + + +-- UPDATE + + +type Msg + = ClickedTag Tag + | CompletedFeedLoad (Result Http.Error Feed.Model) + | CompletedTagsLoad (Result Http.Error (List Tag)) + | GotTimeZone Time.Zone + | GotFeedMsg Feed.Msg + | GotSession Session + | PassedSlowLoadThreshold + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + ClickedTag tagName -> + let + subCmd = + Feed.selectTag (Session.cred model.session) tagName + in + ( model, Cmd.map GotFeedMsg subCmd ) + + CompletedFeedLoad (Ok feed) -> + ( { model | feed = Loaded feed }, Cmd.none ) + + CompletedFeedLoad (Err error) -> + ( { model | feed = Failed }, Cmd.none ) + + CompletedTagsLoad (Ok tags) -> + ( { model | tags = Loaded tags }, Cmd.none ) + + CompletedTagsLoad (Err error) -> + ( { model | tags = Failed } + , Log.error + ) + + GotFeedMsg subMsg -> + case model.feed of + Loaded feed -> + let + ( newFeed, subCmd ) = + Feed.update (Session.cred model.session) subMsg feed + in + ( { model | feed = Loaded newFeed } + , Cmd.map GotFeedMsg subCmd + ) + + Loading -> + ( model, Log.error ) + + LoadingSlowly -> + ( model, Log.error ) + + Failed -> + ( model, Log.error ) + + GotTimeZone tz -> + ( { model | timeZone = tz }, Cmd.none ) + + GotSession session -> + ( { model | session = session }, Cmd.none ) + + PassedSlowLoadThreshold -> + let + -- If any data is still Loading, change it to LoadingSlowly + -- so `view` knows to render a spinner. + feed = + case model.feed of + Loading -> + LoadingSlowly + + other -> + other + + tags = + case model.tags of + Loading -> + LoadingSlowly + + other -> + other + in + ( { model | feed = feed, tags = tags }, Cmd.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session diff --git a/intro/part8/src/Page/Login.elm b/intro/part8/src/Page/Login.elm new file mode 100644 index 0000000..fc5ddb6 --- /dev/null +++ b/intro/part8/src/Page/Login.elm @@ -0,0 +1,338 @@ +module Page.Login exposing (Model, Msg, init, subscriptions, toSession, update, view) + +{-| The login page. +-} + +import Api +import Browser.Navigation as Nav +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Http +import Json.Decode as Decode exposing (Decoder, decodeString, field, string) +import Json.Decode.Pipeline exposing (optional) +import Json.Encode as Encode +import Route exposing (Route) +import Session exposing (Session) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , problems : List Problem + , form : Form + } + + +{-| Recording validation problems on a per-field basis facilitates displaying +them inline next to the field where the error occurred. + +I implemented it this way out of habit, then realized the spec called for +displaying all the errors at the top. I thought about simplifying it, but then +figured it'd be useful to show how I would normally model this data - assuming +the intended UX was to render errors per field. + +(The other part of this is having a view function like this: + +viewFieldErrors : ValidatedField -> List Problem -> Html msg + +...and it filters the list of problems to render only InvalidEntry ones for the +given ValidatedField. That way you can call this: + +viewFieldErrors Email problems + +...next to the `email` field, and call `viewFieldErrors Password problems` +next to the `password` field, and so on. + +The `LoginError` should be displayed elsewhere, since it doesn't correspond to +a particular field. + +-} +type Problem + = InvalidEntry ValidatedField String + | ServerError String + + +type alias Form = + { email : String + , password : String + } + + +init : Session -> ( Model, Cmd msg ) +init session = + ( { session = session + , problems = [] + , form = + { email = "" + , password = "" + } + } + , Cmd.none + ) + + + +-- VIEW + + +view : Model -> { title : String, content : Html Msg } +view model = + { title = "Login" + , content = + div [ class "cred-page" ] + [ div [ class "container page" ] + [ div [ class "row" ] + [ div [ class "col-md-6 offset-md-3 col-xs-12" ] + [ h1 [ class "text-xs-center" ] [ text "Sign in" ] + , p [ class "text-xs-center" ] + [ a [ Route.href Route.Register ] + [ text "Need an account?" ] + ] + , ul [ class "error-messages" ] + (List.map viewProblem model.problems) + , viewForm model.form + ] + ] + ] + ] + } + + +viewProblem : Problem -> Html msg +viewProblem problem = + let + errorMessage = + case problem of + InvalidEntry _ str -> + str + + ServerError str -> + str + in + li [] [ text errorMessage ] + + +viewForm : Form -> Html Msg +viewForm form = + Html.form [ onSubmit SubmittedForm ] + [ fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , placeholder "Email" + , onInput EnteredEmail + , value form.email + ] + [] + ] + , fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , type_ "password" + , placeholder "Password" + , onInput EnteredPassword + , value form.password + ] + [] + ] + , button [ class "btn btn-lg btn-primary pull-xs-right" ] + [ text "Sign in" ] + ] + + + +-- UPDATE + + +type Msg + = SubmittedForm + | EnteredEmail String + | EnteredPassword String + | CompletedLogin (Result Http.Error Viewer) + | GotSession Session + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + SubmittedForm -> + let + requestBody : Http.Body + requestBody = + encodeJsonBody model.form + + responseDecoder : Decoder Viewer + responseDecoder = + Decode.field "user" Viewer.decoder + + {- 👉 TODO: Create a Http.Request value that represents + a POST request to "/api/users/login" + + 💡 HINT 1: Documentation for `Http.post` is here: + + http://package.elm-lang.org/packages/elm-lang/http/1.0.0/Http#post + + 💡 HINT 2: Look at the values defined above in this + let-expression. What are their types? What are the types the + `Http.post` function is looking for? + -} + request : Http.Request Viewer + request = + Debug.todo "Call Http.post to represent a POST to /api/users/login" + + {- 👉 TODO: Use Http.send to turn the request we just defined + into a Cmd for `update` to execute. + + 💡 HINT 1: Documentation for `Http.send` is here: + + http://package.elm-lang.org/packages/elm-lang/http/1.0.0/Http#send + + 💡 HINT 2: The `CompletedLogin` variant defined in `type Msg` + will be useful here! + -} + cmd : Cmd Msg + cmd = + Cmd.none + in + ( { model | problems = [] }, cmd ) + + EnteredEmail email -> + updateForm (\form -> { form | email = email }) model + + EnteredPassword password -> + updateForm (\form -> { form | password = password }) model + + CompletedLogin (Err error) -> + let + serverErrors = + Api.decodeErrors error + |> List.map ServerError + in + ( { model | problems = List.append model.problems serverErrors } + , Cmd.none + ) + + CompletedLogin (Ok viewer) -> + ( model + , Session.login viewer + ) + + GotSession session -> + ( { model | session = session } + , Route.replaceUrl (Session.navKey session) Route.Home + ) + + +{-| Helper function for `update`. Updates the form and returns Cmd.none. +Useful for recording form fields! +-} +updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg ) +updateForm transform model = + ( { model | form = transform model.form }, Cmd.none ) + + +encodeJsonBody : Form -> Http.Body +encodeJsonBody form = + let + user = + Encode.object + [ ( "email", Encode.string form.email ) + , ( "password", Encode.string form.password ) + ] + in + Encode.object [ ( "user", user ) ] + |> Http.jsonBody + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- FORM + + +{-| Marks that we've trimmed the form's fields, so we don't accidentally send +it to the server without having trimmed it! +-} +type TrimmedForm + = Trimmed Form + + +{-| When adding a variant here, add it to `fieldsToValidate` too! +-} +type ValidatedField + = Email + | Password + + +fieldsToValidate : List ValidatedField +fieldsToValidate = + [ Email + , Password + ] + + +{-| Trim the form and validate its fields. If there are problems, report them! +-} +validate : Form -> Result (List Problem) TrimmedForm +validate form = + let + trimmedForm = + trimFields form + in + case List.concatMap (validateField trimmedForm) fieldsToValidate of + [] -> + Ok trimmedForm + + problems -> + Err problems + + +validateField : TrimmedForm -> ValidatedField -> List Problem +validateField (Trimmed form) field = + List.map (InvalidEntry field) <| + case field of + Email -> + if String.isEmpty form.email then + [ "email can't be blank." ] + + else + [] + + Password -> + if String.isEmpty form.password then + [ "password can't be blank." ] + + else + [] + + +{-| Don't trim while the user is typing! That would be super annoying. +Instead, trim only on submit. +-} +trimFields : Form -> TrimmedForm +trimFields form = + Trimmed + { email = String.trim form.email + , password = String.trim form.password + } + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session diff --git a/intro/part8/src/Page/NotFound.elm b/intro/part8/src/Page/NotFound.elm new file mode 100644 index 0000000..e0c534b --- /dev/null +++ b/intro/part8/src/Page/NotFound.elm @@ -0,0 +1,21 @@ +module Page.NotFound exposing (view) + +import Asset +import Html exposing (Html, div, h1, img, main_, text) +import Html.Attributes exposing (alt, class, id, src, tabindex) + + + +-- VIEW + + +view : { title : String, content : Html msg } +view = + { title = "Page Not Found" + , content = + main_ [ id "content", class "container", tabindex -1 ] + [ h1 [] [ text "Not Found" ] + , div [ class "row" ] + [ img [ Asset.src Asset.error ] [] ] + ] + } diff --git a/intro/part8/src/Page/Profile.elm b/intro/part8/src/Page/Profile.elm new file mode 100644 index 0000000..af86830 --- /dev/null +++ b/intro/part8/src/Page/Profile.elm @@ -0,0 +1,346 @@ +module Page.Profile exposing (Model, Msg, init, subscriptions, toSession, update, view) + +{-| An Author's profile. +-} + +import Article.Feed as Feed +import Article.FeedSources as FeedSources exposing (FeedSources, Source(..)) +import Author exposing (Author(..), FollowedAuthor, UnfollowedAuthor) +import Avatar exposing (Avatar) +import Html exposing (..) +import Html.Attributes exposing (..) +import Http +import Loading +import Log +import Page +import Profile exposing (Profile) +import Route +import Session exposing (Session) +import Task exposing (Task) +import Time +import Username exposing (Username) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , timeZone : Time.Zone + , errors : List String + + -- Loaded independently from server + , author : Status Author + , feed : Status Feed.Model + } + + +type Status a + = Loading Username + | LoadingSlowly Username + | Loaded a + | Failed Username + + +init : Session -> Username -> ( Model, Cmd Msg ) +init session username = + let + maybeCred = + Session.cred session + in + ( { session = session + , timeZone = Time.utc + , errors = [] + , author = Loading username + , feed = Loading username + } + , Cmd.batch + [ Author.fetch username maybeCred + |> Http.toTask + |> Task.mapError (Tuple.pair username) + |> Task.attempt CompletedAuthorLoad + , defaultFeedSources username + |> Feed.init session + |> Task.mapError (Tuple.pair username) + |> Task.attempt CompletedFeedLoad + , Task.perform GotTimeZone Time.here + , Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold + ] + ) + + + +-- VIEW + + +view : Model -> { title : String, content : Html Msg } +view model = + let + title = + case model.author of + Loaded (IsViewer _ _) -> + myProfileTitle + + Loaded ((IsFollowing followedAuthor) as author) -> + titleForOther (Author.username author) + + Loaded ((IsNotFollowing unfollowedAuthor) as author) -> + titleForOther (Author.username author) + + Loading username -> + titleForMe (Session.cred model.session) username + + LoadingSlowly username -> + titleForMe (Session.cred model.session) username + + Failed username -> + titleForMe (Session.cred model.session) username + in + { title = title + , content = + case model.author of + Loaded author -> + let + profile = + Author.profile author + + username = + Author.username author + + followButton = + case Session.cred model.session of + Just cred -> + case author of + IsViewer _ _ -> + -- We can't follow ourselves! + text "" + + IsFollowing followedAuthor -> + Author.unfollowButton ClickedUnfollow cred followedAuthor + + IsNotFollowing unfollowedAuthor -> + Author.followButton ClickedFollow cred unfollowedAuthor + + Nothing -> + -- We can't follow if we're logged out + text "" + in + div [ class "profile-page" ] + [ Page.viewErrors ClickedDismissErrors model.errors + , div [ class "user-info" ] + [ div [ class "container" ] + [ div [ class "row" ] + [ div [ class "col-xs-12 col-md-10 offset-md-1" ] + [ img [ class "user-img", Avatar.src (Profile.avatar profile) ] [] + , h4 [] [ Username.toHtml username ] + , p [] [ text (Maybe.withDefault "" (Profile.bio profile)) ] + , followButton + ] + ] + ] + ] + , case model.feed of + Loaded feed -> + div [ class "container" ] + [ div [ class "row" ] [ viewFeed model.timeZone feed ] ] + + Loading _ -> + text "" + + LoadingSlowly _ -> + Loading.icon + + Failed _ -> + Loading.error "feed" + ] + + Loading _ -> + text "" + + LoadingSlowly _ -> + Loading.icon + + Failed _ -> + Loading.error "profile" + } + + + +-- PAGE TITLE + + +titleForOther : Username -> String +titleForOther otherUsername = + "Profile — " ++ Username.toString otherUsername + + +titleForMe : Maybe Cred -> Username -> String +titleForMe maybeCred username = + case maybeCred of + Just cred -> + if username == Cred.username cred then + myProfileTitle + + else + defaultTitle + + Nothing -> + defaultTitle + + +myProfileTitle : String +myProfileTitle = + "My Profile" + + +defaultTitle : String +defaultTitle = + "Profile" + + + +-- FEED + + +viewFeed : Time.Zone -> Feed.Model -> Html Msg +viewFeed timeZone feed = + div [ class "col-xs-12 col-md-10 offset-md-1" ] <| + div [ class "articles-toggle" ] + [ Feed.viewFeedSources feed |> Html.map GotFeedMsg ] + :: (Feed.viewArticles timeZone feed |> List.map (Html.map GotFeedMsg)) + + + +-- UPDATE + + +type Msg + = ClickedDismissErrors + | ClickedFollow Cred UnfollowedAuthor + | ClickedUnfollow Cred FollowedAuthor + | CompletedFollowChange (Result Http.Error Author) + | CompletedAuthorLoad (Result ( Username, Http.Error ) Author) + | CompletedFeedLoad (Result ( Username, Http.Error ) Feed.Model) + | GotTimeZone Time.Zone + | GotFeedMsg Feed.Msg + | GotSession Session + | PassedSlowLoadThreshold + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + ClickedDismissErrors -> + ( { model | errors = [] }, Cmd.none ) + + ClickedUnfollow cred followedAuthor -> + ( model + , Author.requestUnfollow followedAuthor cred + |> Http.send CompletedFollowChange + ) + + ClickedFollow cred unfollowedAuthor -> + ( model + , Author.requestFollow unfollowedAuthor cred + |> Http.send CompletedFollowChange + ) + + CompletedFollowChange (Ok newAuthor) -> + ( { model | author = Loaded newAuthor } + , Cmd.none + ) + + CompletedFollowChange (Err error) -> + ( model + , Log.error + ) + + CompletedAuthorLoad (Ok author) -> + ( { model | author = Loaded author }, Cmd.none ) + + CompletedAuthorLoad (Err ( username, err )) -> + ( { model | author = Failed username } + , Log.error + ) + + CompletedFeedLoad (Ok feed) -> + ( { model | feed = Loaded feed } + , Cmd.none + ) + + CompletedFeedLoad (Err ( username, err )) -> + ( { model | feed = Failed username } + , Log.error + ) + + GotFeedMsg subMsg -> + case model.feed of + Loaded feed -> + let + ( newFeed, subCmd ) = + Feed.update (Session.cred model.session) subMsg feed + in + ( { model | feed = Loaded newFeed } + , Cmd.map GotFeedMsg subCmd + ) + + Loading _ -> + ( model, Log.error ) + + LoadingSlowly _ -> + ( model, Log.error ) + + Failed _ -> + ( model, Log.error ) + + GotTimeZone tz -> + ( { model | timeZone = tz }, Cmd.none ) + + GotSession session -> + ( { model | session = session } + , Route.replaceUrl (Session.navKey session) Route.Home + ) + + PassedSlowLoadThreshold -> + let + -- If any data is still Loading, change it to LoadingSlowly + -- so `view` knows to render a spinner. + feed = + case model.feed of + Loading username -> + LoadingSlowly username + + other -> + other + in + ( { model | feed = feed }, Cmd.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session + + + +-- INTERNAL + + +defaultFeedSources : Username -> FeedSources +defaultFeedSources username = + FeedSources.fromLists (AuthorFeed username) [ FavoritedFeed username ] diff --git a/intro/part8/src/Page/Register.elm b/intro/part8/src/Page/Register.elm new file mode 100644 index 0000000..f2f31e2 --- /dev/null +++ b/intro/part8/src/Page/Register.elm @@ -0,0 +1,319 @@ +module Page.Register exposing (Model, Msg, init, subscriptions, toSession, update, view) + +import Api +import Browser.Navigation as Nav +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Http +import Json.Decode as Decode exposing (Decoder, decodeString, field, string) +import Json.Decode.Pipeline exposing (optional) +import Json.Encode as Encode +import Route exposing (Route) +import Session exposing (Session) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , problems : List Problem + , form : Form + } + + +type alias Form = + { email : String + , username : String + , password : String + } + + +type Problem + = InvalidEntry ValidatedField String + | ServerError String + + +init : Session -> ( Model, Cmd msg ) +init session = + ( { session = session + , problems = [] + , form = + { email = "" + , username = "" + , password = "" + } + } + , Cmd.none + ) + + + +-- VIEW + + +view : Model -> { title : String, content : Html Msg } +view model = + { title = "Register" + , content = + div [ class "cred-page" ] + [ div [ class "container page" ] + [ div [ class "row" ] + [ div [ class "col-md-6 offset-md-3 col-xs-12" ] + [ h1 [ class "text-xs-center" ] [ text "Sign up" ] + , p [ class "text-xs-center" ] + [ a [ Route.href Route.Login ] + [ text "Have an account?" ] + ] + , ul [ class "error-messages" ] + (List.map viewProblem model.problems) + , viewForm model.form + ] + ] + ] + ] + } + + +viewForm : Form -> Html Msg +viewForm form = + Html.form [ onSubmit SubmittedForm ] + [ fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , placeholder "Username" + , onInput EnteredUsername + , value form.username + ] + [] + ] + , fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , placeholder "Email" + , onInput EnteredEmail + , value form.email + ] + [] + ] + , fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , type_ "password" + , placeholder "Password" + , onInput EnteredPassword + , value form.password + ] + [] + ] + , button [ class "btn btn-lg btn-primary pull-xs-right" ] + [ text "Sign up" ] + ] + + +viewProblem : Problem -> Html msg +viewProblem problem = + let + errorMessage = + case problem of + InvalidEntry _ str -> + str + + ServerError str -> + str + in + li [] [ text errorMessage ] + + + +-- UPDATE + + +type Msg + = SubmittedForm + | EnteredEmail String + | EnteredUsername String + | EnteredPassword String + | CompletedRegister (Result Http.Error Viewer) + | GotSession Session + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + SubmittedForm -> + case validate model.form of + Ok validForm -> + ( { model | problems = [] } + , Http.send CompletedRegister (register validForm) + ) + + Err problems -> + ( { model | problems = problems } + , Cmd.none + ) + + EnteredUsername username -> + updateForm (\form -> { form | username = username }) model + + EnteredEmail email -> + updateForm (\form -> { form | email = email }) model + + EnteredPassword password -> + updateForm (\form -> { form | password = password }) model + + CompletedRegister (Err error) -> + let + serverErrors = + Api.decodeErrors error + |> List.map ServerError + in + ( { model | problems = List.append model.problems serverErrors } + , Cmd.none + ) + + CompletedRegister (Ok viewer) -> + ( model + , Session.login viewer + ) + + GotSession session -> + ( { model | session = session } + , Route.replaceUrl (Session.navKey session) Route.Home + ) + + +{-| Helper function for `update`. Updates the form and returns Cmd.none and +Ignored. Useful for recording form fields! +-} +updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg ) +updateForm transform model = + ( { model | form = transform model.form }, Cmd.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session + + + +-- FORM + + +{-| Marks that we've trimmed the form's fields, so we don't accidentally send +it to the server without having trimmed it! +-} +type TrimmedForm + = Trimmed Form + + +{-| When adding a variant here, add it to `fieldsToValidate` too! +-} +type ValidatedField + = Username + | Email + | Password + + +fieldsToValidate : List ValidatedField +fieldsToValidate = + [ Username + , Email + , Password + ] + + +{-| Trim the form and validate its fields. If there are problems, report them! +-} +validate : Form -> Result (List Problem) TrimmedForm +validate form = + let + trimmedForm = + trimFields form + in + case List.concatMap (validateField trimmedForm) fieldsToValidate of + [] -> + Ok trimmedForm + + problems -> + Err problems + + +validateField : TrimmedForm -> ValidatedField -> List Problem +validateField (Trimmed form) field = + List.map (InvalidEntry field) <| + case field of + Username -> + if String.isEmpty form.username then + [ "username can't be blank." ] + + else + [] + + Email -> + if String.isEmpty form.email then + [ "email can't be blank." ] + + else + [] + + Password -> + if String.isEmpty form.password then + [ "password can't be blank." ] + + else if String.length form.password < Viewer.minPasswordChars then + [ "password must be at least " ++ String.fromInt Viewer.minPasswordChars ++ " characters long." ] + + else + [] + + +{-| Don't trim while the user is typing! That would be super annoying. +Instead, trim only on submit. +-} +trimFields : Form -> TrimmedForm +trimFields form = + Trimmed + { username = String.trim form.username + , email = String.trim form.email + , password = String.trim form.password + } + + + +-- HTTP + + +register : TrimmedForm -> Http.Request Viewer +register (Trimmed form) = + let + user = + Encode.object + [ ( "username", Encode.string form.username ) + , ( "email", Encode.string form.email ) + , ( "password", Encode.string form.password ) + ] + + body = + Encode.object [ ( "user", user ) ] + |> Http.jsonBody + in + Decode.field "user" Viewer.decoder + |> Http.post (Api.url [ "users" ]) body diff --git a/intro/part8/src/Page/Settings.elm b/intro/part8/src/Page/Settings.elm new file mode 100644 index 0000000..fdea129 --- /dev/null +++ b/intro/part8/src/Page/Settings.elm @@ -0,0 +1,434 @@ +module Page.Settings exposing (Model, Msg, init, subscriptions, toSession, update, view) + +import Api +import Avatar +import Browser.Navigation as Nav +import Email exposing (Email) +import Html exposing (Html, button, div, fieldset, h1, input, li, text, textarea, ul) +import Html.Attributes exposing (attribute, class, placeholder, type_, value) +import Html.Events exposing (onInput, onSubmit) +import Http +import HttpBuilder +import Json.Decode as Decode exposing (Decoder, decodeString, field, list, string) +import Json.Decode.Pipeline exposing (optional) +import Json.Encode as Encode +import Profile exposing (Profile) +import Route +import Session exposing (Session) +import Username as Username exposing (Username) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , problems : List Problem + , form : Form + } + + +type alias Form = + { avatar : String + , bio : String + , email : String + , username : String + , password : String + } + + +type Problem + = InvalidEntry ValidatedField String + | ServerError String + + +init : Session -> ( Model, Cmd msg ) +init session = + ( { session = session + , problems = [] + , form = + case Session.viewer session of + Just viewer -> + let + profile = + Viewer.profile viewer + + cred = + Viewer.cred viewer + in + { avatar = Maybe.withDefault "" (Avatar.toMaybeString (Profile.avatar profile)) + , email = Email.toString (Viewer.email viewer) + , bio = Maybe.withDefault "" (Profile.bio profile) + , username = Username.toString (Cred.username cred) + , password = "" + } + + Nothing -> + -- It's fine to store a blank form here. You won't be + -- able to submit it if you're not logged in anyway. + { avatar = "" + , email = "" + , bio = "" + , username = "" + , password = "" + } + } + , Cmd.none + ) + + +{-| A form that has been validated. Only the `edit` function uses this. Its +purpose is to prevent us from forgetting to validate the form before passing +it to `edit`. + +This doesn't create any guarantees that the form was actually validated. If +we wanted to do that, we'd need to move the form data into a separate module! + +-} +type ValidForm + = Valid Form + + + +-- VIEW + + +view : Model -> { title : String, content : Html Msg } +view model = + { title = "Settings" + , content = + case Session.cred model.session of + Just cred -> + div [ class "settings-page" ] + [ div [ class "container page" ] + [ div [ class "row" ] + [ div [ class "col-md-6 offset-md-3 col-xs-12" ] + [ h1 [ class "text-xs-center" ] [ text "Your Settings" ] + , ul [ class "error-messages" ] + (List.map viewProblem model.problems) + , viewForm cred model.form + ] + ] + ] + ] + + Nothing -> + text "Sign in to view your settings." + } + + +viewForm : Cred -> Form -> Html Msg +viewForm cred form = + Html.form [ onSubmit (SubmittedForm cred) ] + [ fieldset [] + [ fieldset [ class "form-group" ] + [ input + [ class "form-control" + , placeholder "URL of profile picture" + , value form.avatar + , onInput EnteredAvatar + ] + [] + ] + , fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , placeholder "Username" + , value form.username + , onInput EnteredUsername + ] + [] + ] + , fieldset [ class "form-group" ] + [ textarea + [ class "form-control form-control-lg" + , placeholder "Short bio about you" + , attribute "rows" "8" + , value form.bio + , onInput EnteredBio + ] + [] + ] + , fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , placeholder "Email" + , value form.email + , onInput EnteredEmail + ] + [] + ] + , fieldset [ class "form-group" ] + [ input + [ class "form-control form-control-lg" + , type_ "password" + , placeholder "Password" + , value form.password + , onInput EnteredPassword + ] + [] + ] + , button + [ class "btn btn-lg btn-primary pull-xs-right" ] + [ text "Update Settings" ] + ] + ] + + +viewProblem : Problem -> Html msg +viewProblem problem = + let + errorMessage = + case problem of + InvalidEntry _ message -> + message + + ServerError message -> + message + in + li [] [ text errorMessage ] + + + +-- UPDATE + + +type Msg + = SubmittedForm Cred + | EnteredEmail String + | EnteredUsername String + | EnteredPassword String + | EnteredBio String + | EnteredAvatar String + | CompletedSave (Result Http.Error Viewer) + | GotSession Session + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + SubmittedForm cred -> + case validate model.form of + Ok validForm -> + ( { model | problems = [] } + , edit cred validForm + |> Http.send CompletedSave + ) + + Err problems -> + ( { model | problems = problems } + , Cmd.none + ) + + EnteredEmail email -> + updateForm (\form -> { form | email = email }) model + + EnteredUsername username -> + updateForm (\form -> { form | username = username }) model + + EnteredPassword password -> + updateForm (\form -> { form | password = password }) model + + EnteredBio bio -> + updateForm (\form -> { form | bio = bio }) model + + EnteredAvatar avatar -> + updateForm (\form -> { form | avatar = avatar }) model + + CompletedSave (Err error) -> + let + serverErrors = + Api.decodeErrors error + |> List.map ServerError + in + ( { model | problems = List.append model.problems serverErrors } + , Cmd.none + ) + + CompletedSave (Ok cred) -> + ( model + , Session.login cred + ) + + GotSession session -> + ( { model | session = session } + , Route.replaceUrl (Session.navKey session) Route.Home + ) + + +{-| Helper function for `update`. Updates the form and returns Cmd.none and +Ignored. Useful for recording form fields! +-} +updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg ) +updateForm transform model = + ( { model | form = transform model.form }, Cmd.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session + + + +-- FORM + + +{-| Marks that we've trimmed the form's fields, so we don't accidentally send +it to the server without having trimmed it! +-} +type TrimmedForm + = Trimmed Form + + +{-| When adding a variant here, add it to `fieldsToValidate` too! + +NOTE: there are no ImageUrl or Bio variants here, because they aren't validated! + +-} +type ValidatedField + = Username + | Email + | Password + + +fieldsToValidate : List ValidatedField +fieldsToValidate = + [ Username + , Email + , Password + ] + + +{-| Trim the form and validate its fields. If there are problems, report them! +-} +validate : Form -> Result (List Problem) TrimmedForm +validate form = + let + trimmedForm = + trimFields form + in + case List.concatMap (validateField trimmedForm) fieldsToValidate of + [] -> + Ok trimmedForm + + problems -> + Err problems + + +validateField : TrimmedForm -> ValidatedField -> List Problem +validateField (Trimmed form) field = + List.map (InvalidEntry field) <| + case field of + Username -> + if String.isEmpty form.username then + [ "username can't be blank." ] + + else + [] + + Email -> + if String.isEmpty form.email then + [ "email can't be blank." ] + + else + [] + + Password -> + let + passwordLength = + String.length form.password + in + if passwordLength > 0 && passwordLength < Viewer.minPasswordChars then + [ "password must be at least " ++ String.fromInt Viewer.minPasswordChars ++ " characters long." ] + + else + [] + + +{-| Don't trim while the user is typing! That would be super annoying. +Instead, trim only on submit. +-} +trimFields : Form -> TrimmedForm +trimFields form = + Trimmed + { avatar = String.trim form.avatar + , bio = String.trim form.bio + , email = String.trim form.email + , username = String.trim form.username + , password = String.trim form.password + } + + + +-- HTTP + + +{-| This takes a Valid Form as a reminder that it needs to have been validated +first. +-} +edit : Cred -> TrimmedForm -> Http.Request Viewer +edit cred (Trimmed form) = + let + encodedAvatar = + case form.avatar of + "" -> + Encode.null + + avatar -> + Encode.string avatar + + updates = + [ ( "username", Encode.string form.username ) + , ( "email", Encode.string form.email ) + , ( "bio", Encode.string form.bio ) + , ( "image", encodedAvatar ) + ] + + encodedUser = + Encode.object <| + case form.password of + "" -> + updates + + password -> + ( "password", Encode.string password ) :: updates + + body = + Encode.object [ ( "user", encodedUser ) ] + |> Http.jsonBody + + expect = + Decode.field "user" Viewer.decoder + |> Http.expectJson + in + Api.url [ "user" ] + |> HttpBuilder.put + |> HttpBuilder.withExpect expect + |> HttpBuilder.withBody body + |> Cred.addHeader cred + |> HttpBuilder.toRequest + + +nothingIfEmpty : String -> Maybe String +nothingIfEmpty str = + if String.isEmpty str then + Nothing + + else + Just str diff --git a/intro/part8/src/PaginatedList.elm b/intro/part8/src/PaginatedList.elm new file mode 100644 index 0000000..9e73e71 --- /dev/null +++ b/intro/part8/src/PaginatedList.elm @@ -0,0 +1,98 @@ +module PaginatedList exposing (PaginatedList, fromList, map, mapPage, page, total, values, view) + +import Html exposing (Html, a, li, text, ul) +import Html.Attributes exposing (class, classList, href) +import Html.Events exposing (onClick) + + + +-- TYPES + + +type PaginatedList a + = PaginatedList + { values : List a + , total : Int + , page : Int + } + + + +-- INFO + + +values : PaginatedList a -> List a +values (PaginatedList info) = + info.values + + +total : PaginatedList a -> Int +total (PaginatedList info) = + info.total + + +page : PaginatedList a -> Int +page (PaginatedList info) = + info.page + + + +-- CREATE + + +fromList : Int -> List a -> PaginatedList a +fromList totalCount list = + PaginatedList { values = list, total = totalCount, page = 1 } + + + +-- TRANSFORM + + +map : (a -> a) -> PaginatedList a -> PaginatedList a +map transform (PaginatedList info) = + PaginatedList { info | values = List.map transform info.values } + + +mapPage : (Int -> Int) -> PaginatedList a -> PaginatedList a +mapPage transform (PaginatedList info) = + PaginatedList { info | page = transform info.page } + + + +-- VIEW + + +view : (Int -> msg) -> PaginatedList a -> Int -> Html msg +view toMsg list resultsPerPage = + let + totalPages = + ceiling (toFloat (total list) / toFloat resultsPerPage) + + activePage = + page list + + viewPageLink currentPage = + pageLink toMsg currentPage (currentPage == activePage) + in + if totalPages > 1 then + List.range 1 totalPages + |> List.map viewPageLink + |> ul [ class "pagination" ] + + else + Html.text "" + + +pageLink : (Int -> msg) -> Int -> Bool -> Html msg +pageLink toMsg targetPage isActive = + li [ classList [ ( "page-item", True ), ( "active", isActive ) ] ] + [ a + [ class "page-link" + , onClick (toMsg targetPage) + + -- The RealWorld CSS requires an href to work properly. + , href "" + ] + [ text (String.fromInt targetPage) ] + ] diff --git a/intro/part8/src/Profile.elm b/intro/part8/src/Profile.elm new file mode 100644 index 0000000..e8e32e7 --- /dev/null +++ b/intro/part8/src/Profile.elm @@ -0,0 +1,56 @@ +module Profile exposing (Profile, avatar, bio, decoder) + +{-| A user's profile - potentially your own! + +Contrast with Cred, which is the currently signed-in user. + +-} + +import Api +import Avatar exposing (Avatar) +import Http +import HttpBuilder exposing (RequestBuilder, withExpect) +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline exposing (required) +import Username exposing (Username) +import Viewer.Cred as Cred exposing (Cred) + + + +-- TYPES + + +type Profile + = Profile Internals + + +type alias Internals = + { bio : Maybe String + , avatar : Avatar + } + + + +-- INFO + + +bio : Profile -> Maybe String +bio (Profile info) = + info.bio + + +avatar : Profile -> Avatar +avatar (Profile info) = + info.avatar + + + +-- SERIALIZATION + + +decoder : Decoder Profile +decoder = + Decode.succeed Internals + |> required "bio" (Decode.nullable Decode.string) + |> required "image" Avatar.decoder + |> Decode.map Profile diff --git a/intro/part8/src/Route.elm b/intro/part8/src/Route.elm new file mode 100644 index 0000000..1e524fe --- /dev/null +++ b/intro/part8/src/Route.elm @@ -0,0 +1,107 @@ +module Route exposing (Route(..), fromUrl, href, replaceUrl) + +import Article.Slug as Slug exposing (Slug) +import Browser.Navigation as Nav +import Html exposing (Attribute) +import Html.Attributes as Attr +import Profile exposing (Profile) +import Url exposing (Url) +import Url.Parser as Parser exposing ((), Parser, oneOf, s, string) +import Username exposing (Username) + + + +-- ROUTING + + +type Route + = Home + | Root + | Login + | Logout + | Register + | Settings + | Article Slug + | Profile Username + | NewArticle + | EditArticle Slug + + +parser : Parser (Route -> a) a +parser = + oneOf + [ Parser.map Home Parser.top + , Parser.map Login (s "login") + , Parser.map Logout (s "logout") + , Parser.map Settings (s "settings") + , Parser.map Profile (s "profile" Username.urlParser) + , Parser.map Register (s "register") + , Parser.map Article (s "article" Slug.urlParser) + , Parser.map NewArticle (s "editor") + , Parser.map EditArticle (s "editor" Slug.urlParser) + ] + + + +-- PUBLIC HELPERS + + +href : Route -> Attribute msg +href targetRoute = + Attr.href (routeToString targetRoute) + + +replaceUrl : Nav.Key -> Route -> Cmd msg +replaceUrl key route = + Nav.replaceUrl key (routeToString route) + + +fromUrl : Url -> Maybe Route +fromUrl url = + -- The RealWorld spec treats the fragment like a path. + -- This makes it *literally* the path, so we can proceed + -- with parsing as if it had been a normal path all along. + { url | path = Maybe.withDefault "" url.fragment, fragment = Nothing } + |> Parser.parse parser + + + +-- INTERNAL + + +routeToString : Route -> String +routeToString page = + let + pieces = + case page of + Home -> + [] + + Root -> + [] + + Login -> + [ "login" ] + + Logout -> + [ "logout" ] + + Register -> + [ "register" ] + + Settings -> + [ "settings" ] + + Article slug -> + [ "article", Slug.toString slug ] + + Profile username -> + [ "profile", Username.toString username ] + + NewArticle -> + [ "editor" ] + + EditArticle slug -> + [ "editor", Slug.toString slug ] + in + "#/" ++ String.join "/" pieces diff --git a/intro/part8/src/Session.elm b/intro/part8/src/Session.elm new file mode 100644 index 0000000..ee70a0c --- /dev/null +++ b/intro/part8/src/Session.elm @@ -0,0 +1,116 @@ +port module Session + exposing + ( Session + , changes + , cred + , decode + , login + , logout + , navKey + , viewer + ) + +import Browser.Navigation as Nav +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline exposing (custom, required) +import Json.Encode as Encode exposing (Value) +import Profile exposing (Profile) +import Time +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- TYPES + + +type Session + = LoggedIn Nav.Key Viewer + | Guest Nav.Key + + + +-- INFO + + +viewer : Session -> Maybe Viewer +viewer session = + case session of + LoggedIn _ val -> + Just val + + Guest _ -> + Nothing + + +cred : Session -> Maybe Cred +cred session = + case session of + LoggedIn _ val -> + Just (Viewer.cred val) + + Guest _ -> + Nothing + + +navKey : Session -> Nav.Key +navKey session = + case session of + LoggedIn key _ -> + key + + Guest key -> + key + + + +-- LOGIN + + +login : Viewer -> Cmd msg +login newViewer = + Viewer.encode newViewer + |> Encode.encode 0 + |> Just + |> storeSession + + + +-- LOGOUT + + +logout : Cmd msg +logout = + storeSession Nothing + + +port storeSession : Maybe String -> Cmd msg + + + +-- CHANGES + + +changes : (Session -> msg) -> Nav.Key -> Sub msg +changes toMsg key = + onSessionChange (\val -> toMsg (decode key val)) + + +port onSessionChange : (Value -> msg) -> Sub msg + + +decode : Nav.Key -> Value -> Session +decode key value = + -- It's stored in localStorage as a JSON String; + -- first decode the Value as a String, then + -- decode that String as JSON. + case + Decode.decodeValue Decode.string value + |> Result.andThen (Decode.decodeString Viewer.decoder) + |> Result.toMaybe + of + Just decodedViewer -> + LoggedIn key decodedViewer + + Nothing -> + Guest key diff --git a/intro/part8/src/Timestamp.elm b/intro/part8/src/Timestamp.elm new file mode 100644 index 0000000..fde03e0 --- /dev/null +++ b/intro/part8/src/Timestamp.elm @@ -0,0 +1,100 @@ +module Timestamp exposing (format, iso8601Decoder, view) + +import Html exposing (Html, span, text) +import Html.Attributes exposing (class) +import Iso8601 +import Json.Decode as Decode exposing (Decoder, fail, succeed) +import Time exposing (Month(..)) + + + +-- VIEW + + +view : Time.Zone -> Time.Posix -> Html msg +view timeZone timestamp = + span [ class "date" ] [ text (format timeZone timestamp) ] + + + +-- DECODE + + +{-| Decode an ISO-8601 date string. +-} +iso8601Decoder : Decoder Time.Posix +iso8601Decoder = + Decode.string + |> Decode.andThen fromString + + +fromString : String -> Decoder Time.Posix +fromString str = + case Iso8601.toTime str of + Ok successValue -> + succeed successValue + + Err _ -> + fail ("Invalid date: " ++ str) + + + +-- FORMAT + + +{-| Format a timestamp as a String, like so: + + "February 14, 2018" + +For more complex date formatting scenarios, here's a nice package: + + +-} +format : Time.Zone -> Time.Posix -> String +format zone time = + let + month = + case Time.toMonth zone time of + Jan -> + "January" + + Feb -> + "February" + + Mar -> + "March" + + Apr -> + "April" + + May -> + "May" + + Jun -> + "June" + + Jul -> + "July" + + Aug -> + "August" + + Sep -> + "September" + + Oct -> + "October" + + Nov -> + "November" + + Dec -> + "December" + + day = + String.fromInt (Time.toDay zone time) + + year = + String.fromInt (Time.toYear zone time) + in + month ++ " " ++ day ++ ", " ++ year diff --git a/intro/part8/src/Username.elm b/intro/part8/src/Username.elm new file mode 100644 index 0000000..a7f17ec --- /dev/null +++ b/intro/part8/src/Username.elm @@ -0,0 +1,47 @@ +module Username exposing (Username, decoder, encode, toHtml, toString, urlParser) + +import Html exposing (Html) +import Json.Decode as Decode exposing (Decoder) +import Json.Encode as Encode exposing (Value) +import Url.Parser + + + +-- TYPES + + +type Username + = Username String + + + +-- CREATE + + +decoder : Decoder Username +decoder = + Decode.map Username Decode.string + + + +-- TRANSFORM + + +encode : Username -> Value +encode (Username username) = + Encode.string username + + +toString : Username -> String +toString (Username username) = + username + + +urlParser : Url.Parser.Parser (Username -> a) a +urlParser = + Url.Parser.custom "USERNAME" (\str -> Just (Username str)) + + +toHtml : Username -> Html msg +toHtml (Username username) = + Html.text username diff --git a/intro/part8/src/Viewer.elm b/intro/part8/src/Viewer.elm new file mode 100644 index 0000000..7ecfedb --- /dev/null +++ b/intro/part8/src/Viewer.elm @@ -0,0 +1,85 @@ +module Viewer exposing (Viewer, cred, decoder, email, encode, minPasswordChars, profile) + +{-| The logged-in user currently viewing this page. +-} + +import Avatar exposing (Avatar) +import Email exposing (Email) +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline exposing (custom, required) +import Json.Encode as Encode exposing (Value) +import Profile exposing (Profile) +import Username exposing (Username) +import Viewer.Cred as Cred exposing (Cred) + + + +-- TYPES + + +type Viewer + = Viewer Internals + + +type alias Internals = + { cred : Cred + , profile : Profile + , email : Email + } + + + +-- INFO + + +cred : Viewer -> Cred +cred (Viewer info) = + info.cred + + +profile : Viewer -> Profile +profile (Viewer info) = + info.profile + + +email : Viewer -> Email +email (Viewer info) = + info.email + + +{-| Passwords must be at least this many characters long! +-} +minPasswordChars : Int +minPasswordChars = + 6 + + + +-- SERIALIZATION + + +encode : Viewer -> Value +encode (Viewer info) = + Encode.object + [ ( "email", Email.encode info.email ) + , ( "username", Username.encode (Cred.username info.cred) ) + , ( "image", Avatar.encode (Profile.avatar info.profile) ) + , ( "token", Cred.encodeToken info.cred ) + , ( "bio" + , case Profile.bio info.profile of + Just bio -> + Encode.string bio + + Nothing -> + Encode.null + ) + ] + + +decoder : Decoder Viewer +decoder = + Decode.succeed Internals + |> custom Cred.decoder + |> custom Profile.decoder + |> required "email" Email.decoder + |> Decode.map Viewer diff --git a/intro/part8/src/Viewer/Cred.elm b/intro/part8/src/Viewer/Cred.elm new file mode 100644 index 0000000..d010acc --- /dev/null +++ b/intro/part8/src/Viewer/Cred.elm @@ -0,0 +1,85 @@ +module Viewer.Cred exposing (Cred, addHeader, addHeaderIfAvailable, decoder, encodeToken, username) + +{-| The authentication credentials for the Viewer (that is, the currently logged-in user.) + +This includes: + + - The cred's Username + - The cred's authentication token + +By design, there is no way to access the token directly as a String. +It can be encoded for persistence, and it can be added to a header +to a HttpBuilder for a request, but that's it. + +This token should never be rendered to the end user, and with this API, it +can't be! + +-} + +import HttpBuilder exposing (RequestBuilder, withHeader) +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline exposing (required) +import Json.Encode as Encode exposing (Value) +import Username exposing (Username) + + +{-| The authentication token for the currently logged-in user. + +The token records the username associated with this token, which you can ask it for. + +By design, there is no way to access the token directly as a String. You can encode it for persistence, and you can add it to a header to a HttpBuilder for a request, but that's it. + +-} + + + +-- TYPES + + +type Cred + = Cred Username String + + + +-- INFO + + +username : Cred -> Username +username (Cred val _) = + val + + + +-- SERIALIZATION + + +decoder : Decoder Cred +decoder = + Decode.succeed Cred + |> required "username" Username.decoder + |> required "token" Decode.string + + + +-- TRANSFORM + + +encodeToken : Cred -> Value +encodeToken (Cred _ str) = + Encode.string str + + +addHeader : Cred -> RequestBuilder a -> RequestBuilder a +addHeader (Cred _ str) builder = + builder + |> withHeader "authorization" ("Token " ++ str) + + +addHeaderIfAvailable : Maybe Cred -> RequestBuilder a -> RequestBuilder a +addHeaderIfAvailable maybeCred builder = + case maybeCred of + Just cred -> + addHeader cred builder + + Nothing -> + builder