From 4ed98c30b2182fc6003c5ff6f16c049db118f0af Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 21 Aug 2026 15:32:09 +0100 Subject: [PATCH 01/15] Fix undefined property error on passkey login Webauthn\PublicKeyCredential has no $id property (only $rawId, inherited from Credential). Every passkey login attempt was throwing Undefined property: Webauthn\PublicKeyCredential::$id and failing with a 500. Encode $rawId the same way it's stored during registration to look up the matching Passkey record. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LHN7V9kyZxrsxaspMSqoGJ --- app/Http/Controllers/Admin/PasskeysController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Admin/PasskeysController.php b/app/Http/Controllers/Admin/PasskeysController.php index 8012f9b8..d9c18f5b 100644 --- a/app/Http/Controllers/Admin/PasskeysController.php +++ b/app/Http/Controllers/Admin/PasskeysController.php @@ -254,7 +254,7 @@ class PasskeysController extends Controller ], 400); } - $passkey = Passkey::firstWhere('passkey_id', $publicKeyCredential->id); + $passkey = Passkey::firstWhere('passkey_id', Base64UrlSafe::encodeUnpadded($publicKeyCredential->rawId)); if (! $passkey) { return response()->json([ 'success' => false, From 30ec2ec0e83977232e2ee4b1ab07123f82780452 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 21 Aug 2026 15:26:01 +0100 Subject: [PATCH 02/15] Initial work on better admin page styling --- public/assets/css/app.css | 2 +- public/assets/css/app.css.br | Bin 1233 -> 1477 bytes public/assets/css/app.css.map | 2 +- public/assets/css/app.css.zst | Bin 1479 -> 1762 bytes resources/css/admin-tokens.css | 65 +++++++++++++++++++ resources/css/app.css | 1 + resources/views/admin/tokens/index.blade.php | 52 ++++++++++----- 7 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 resources/css/admin-tokens.css diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 4e2ebd32..1d99e7bc 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,2 +1,2 @@ -@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:2/3}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform, opacity, display allow-discrete, overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}} +@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:2/3}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform, opacity, display allow-discrete, overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list{border-collapse:collapse;width:100%;margin-block-start:1em;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.6em .8em}& th{text-transform:uppercase;letter-spacing:.03em;opacity:.7;font-size:.85em}& tr.is-revoked{opacity:.6}& a{word-break:break-all}}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;line-height:1.5;display:inline-block}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145))}.badge-revoked{color:var(--clr-text)}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index eeec79a35027043c8a76e16f3f60ad4ad6679731..e2d1ff369d415fd1742a121226c0afdf863f2167 100644 GIT binary patch literal 1477 zcmca8Fohw(@WGU`E4|sGmy5&&Z4o%kecyiiQnS~WL+b3j=Ot|CGd)`@`a!VGeYU<< zkawQh+Sq_La9j^j$mS#WZ#Lrqs;*2WH;q3p^V|CPZ;HX)jcH zn3V0CD_;2O-*VZBo?;WYE5BS*Teu}e(sJgPF6~5-H+OuSRz=P|ks!3xtM%}t{alNk zb}KEg?OW%Xu`eZRr+euhx0ip+?l$e0o&LDcwD2v9=UV~iXH)NNh?G6S(feI;!!M05 zmec>|1g_qz+OuPs%SZL3Wz1_^chnzQApgRp`Yp!_*|W~EdWpVs_ejqwSb23`57P?YZL%S!rGB@kggiMJ&=+-J!f}_id9kN6F1(v_$Vr&T zWYGo@!82+nmb4YrsdcScUB?_QcfQzq_qpP7zJQK5rTMcrFJEI$zTMY$nH^X)r97Lx z+{A#VsokUgcx1`?`ha+4hLDZ1Yv)XQ!n+i@K1W*J1u=`x$FOgruDPhBSR)ssiyn&+dYZQKXvPe-kXbS zx6BcJdS$uZ^4C|(w%OGfeJCkc*%`N0?AWcZ_im*>a;Z2F-Xgg9{O?6>>`-g(cbhW>>?euiro@igC%uIt#` zeoH}pgBe98UaiuY*;)8-#)u9TNc^^Mz}t^dR=KzhOQIlFQU z?;c*}sw93o?U>`LZI1uO-`(Qc(Cu4_lq2X&XC#j?!AqgEiqMS z(z@tB9d041yvMv-Zx!y8TB6L7D98B8Mmai2Oi)Mb^QjPJ%QKCo+I#Y5#0wo;=q#Ee z{r=j4*AG<+g9BafX#TpQQGN6B#w$O%dn1H{x-)e5HFQfFXGOp?f z-Yb?LcsH5oPMpS`$$$9hrTv;OID_ZqgiD=#R=JF=)Y5~O;b$`2#mQ}37b*rVdStL5 zeAQFlyJ}a?+_Po#{5Hw{(d3q7&&PKazpdHOEck=@u2`A>#Bg@w5XWDSFLgS7vhx16 zZjZXJtgrZ&`Sbs9DC%xd`Tdq9)iplj>yuye=EW8+`)bd&WWu+tsrzRCf3)ny#Rr*v zXRlR0tdp=d=>GP$cvm;a(;HiK6z06(@#t;5qhZ7J)I&~B%E2$cW>fygHNVy{96h%B zltD__ejatH!rvC>w(Kv->|!;Wp855$ZvHy^M++W*SYGcQJ#E$Fb+&2;1t<7!mE>Q# zGp0d!CV!y+fsNC;^>6NW_^?Fgd{c1F^WgXAt}4m+ujJmv>R6VSwc$b|XPnOKodU0o z=k!@zbXj7ly{$~eadXQXrB18P1^$;B`%-pCbWFNbcy0Y2m1iw?uFJoDHsjDnF8P1G z`A*GxJZ^73wTN2Zd6F4>VrAsE?#_9q<_E~We5$AZqwl~`eKq}% GiyHvv%jWq2 literal 1233 zcmX^3qnzP^&4ygj*WJubJl%;JoPT|5ZiPg5y;vBobhN?ZzN1IrMX&2gQ>X4<`TK6_ zs~Z{p9j$Zpe($$mxYj=Mvq@wHoYqop^ggR=ziV(Ye~X`0bBp`=%cD+9$Pl zQCyVH$!c+}v)(sO*LNLOl{$W1GW>s+v{~cLn-X)Vco1{UuUWN^~)VvDYM^NVRnJb z_BD|wz8UR47PoD#`8Lk^JW6+G+}twv>Z0}cRF=>1WPZqJF37^HqW*oJ?&}~WD}_z1 zQg8eBDpmeDzv7K&+d=*BS9UIL|35$J|AJ5)Uv4bzg z<*QL}TYf{1LCB)#!WXt4b}{^<>~~muM=dM=D`7d;&~>Grr}HMfdhko4c)IN}?%62^ zpOn}!HH3WpCt_=LG3I38a<7INv&2^R^`$UrB<|-su(x)8wtGlTry6_dY%GFu&24AmPzPhIVW$Rnz zgKw)$-+39Oy%XKEI`~|Qzw7OT@%%4rymi;@5)uBd^6c^9-D`#Hrc6HnnT_M4yv(yZ zM=E}te_~`mEB1gkp^;YHse{_F-Xe>Dmpi zG>czIr(`-UnY!>w`<{Ud&aawV=^-@SH0lKn#8 zJljj@mWkh=rB2$byT6^iGv?B)rvm+Vx9sJp;kwTM+izm@>WiUTmX{;drA}rmvi<)v z|Ngm%BmaN>5St*T)LExB@#6Zp7ZJa=Jq^&+ahB=0`sCZYn)RnI&sIJsv!K{@k&w=z MfY header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 2/3;\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n #theme-selector {\n button {\n appearance: none;\n border: none;\n background: none;\n\n &:hover {\n cursor: pointer;\n }\n }\n\n /* This is the element with the [popover] attribute */\n #theme-selector-dropdown {\n position: absolute;\n position-area: block-end span-inline-start;\n margin: 0;\n border: 2px solid var(--clr-border);\n background-color: var(--clr-background);\n color: var(--clr-text);\n\n fieldset {\n border: 0;\n display: flex;\n flex-direction: row;\n gap: 1ch;\n\n input {\n display: none;\n }\n }\n }\n\n /*\n * This is the element with the [popover] attribute\n * Here we are styling the open and closing animations\n */\n @media (prefers-reduced-motion: no-preference) {\n #theme-selector-dropdown {\n &:popover-open {\n transform: translateY(0) scale(1);\n opacity: 1;\n\n /*\n * Start styles for the opening transition.\n * Added to :popover-open, but after the opened styles.\n */\n @starting-style {\n transform: translateY(30px) scale(0);\n opacity: 0;\n }\n }\n\n /*\n * End styles for the closing transition.\n */\n transform: translateY(0) scale(0);\n opacity: 0;\n\n /*\n * Enumerate transitioning properties, including display and overlay.\n */\n transition: transform, opacity, display allow-discrete, overlay allow-discrete;\n transition-duration: 0.5s;\n transform-origin: top right;\n }\n }\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n"],"names":[]} \ No newline at end of file +{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,uBAIA,iDAIE,yEAME,uBAON,oDHvCF,kBG+CE,SACE,oBKhDF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CHrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA,mICrFA,uEAKE,4GAQA,8EAOA,2BAIA,0BAKF,+IAUA,uCAKA,sEAIA,qCAIA,kNAQA","sources":["resources/css/app.css","resources/css/colours.css","resources/css/layout.css","resources/css/reset.css","resources/css/pagination.css","resources/css/theme-selector.css","resources/css/admin-tokens.css","resources/css/notes.css","resources/css/header.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 2/3;\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n #theme-selector {\n button {\n appearance: none;\n border: none;\n background: none;\n\n &:hover {\n cursor: pointer;\n }\n }\n\n /* This is the element with the [popover] attribute */\n #theme-selector-dropdown {\n position: absolute;\n position-area: block-end span-inline-start;\n margin: 0;\n border: 2px solid var(--clr-border);\n background-color: var(--clr-background);\n color: var(--clr-text);\n\n fieldset {\n border: 0;\n display: flex;\n flex-direction: row;\n gap: 1ch;\n\n input {\n display: none;\n }\n }\n }\n\n /*\n * This is the element with the [popover] attribute\n * Here we are styling the open and closing animations\n */\n @media (prefers-reduced-motion: no-preference) {\n #theme-selector-dropdown {\n &:popover-open {\n transform: translateY(0) scale(1);\n opacity: 1;\n\n /*\n * Start styles for the opening transition.\n * Added to :popover-open, but after the opened styles.\n */\n @starting-style {\n transform: translateY(30px) scale(0);\n opacity: 0;\n }\n }\n\n /*\n * End styles for the closing transition.\n */\n transform: translateY(0) scale(0);\n opacity: 0;\n\n /*\n * Enumerate transitioning properties, including display and overlay.\n */\n transition: transform, opacity, display allow-discrete, overlay allow-discrete;\n transition-duration: 0.5s;\n transform-origin: top right;\n }\n }\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer components {\n .token-list {\n width: 100%;\n border-collapse: collapse;\n margin-block-start: 1em;\n\n th,\n td {\n text-align: left;\n padding: 0.6em 0.8em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.85em;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n font-size: 0.85em;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n }\n\n .badge-revoked {\n color: var(--clr-text);\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index d258af4911e60e0e789b20b7c9b38a6b5d8fb617..72d74415073995b6a8e3de562975b98edab3f986 100644 GIT binary patch literal 1762 zcmdPcs{c2oS#YTtgOuM9UJU_X7yb=Q7v^{9YchQg+#GF|Vr1C(;?u#R6dktTkG9{L zv2)F;EDj6N^9&amPBZ8iZHYT~qAvSJ>BF*pJ=dNuVDx=#@bYfAj_2<$Z!aDdyzl#A z)7|CAY7X68@KbnmdrkK~_t>I+>GRjlWm*{4_LlL{&$7upI}d79AF-nWJEdDai$vZsf&DJNy7VfX_Fx|amug=+TmYAG5 zUq(Ob#U=$^uFT3MM<-6}oZV}kc#L)HoJqBFl`P`^PWt!t{lyn2HW}=%m zYI}C6Y);&3pR{eu`4!SRfgA2`jji;TP~wQa$~fuE;d?)|MKp!_Yh?Ik)@>Hz`&@SC zQX{iSqmIGPY0h@fXE=GWTrv<_Y0AU0+Q@IhrxSttyEk4vKf&$$oreKzt1lP7$&O_W zx^_6i;l#x*pUC;k6CXZVT<9Dr!upUWkZ&=YjzCaze`f@rwDaNwThHuIbJnri&Y9b^ zCfR$f-tS_o&9OTR-`AdeKlzxrg5628OP3Dr)ZpT<@-5$^b+g{}m!rhyQ$43kXUF_r zbuZA`Rikd5a+CH;)@T90{Ht}=bLQW@(OY9^^w;VC9@U>WSlBL~T2_3qIkYNvWowkDxaCs z&-d3~DY*Fa^L3U>Wt>(MTJ$2=mt1qN_VatS_49I<>qjEk=U=%NUN5+qop0MrcTcGx zg`>6>t0x5<|K`6kU{a>9%A6yMJys+yU~sZL=~Y&}nw#y=ro>>YNmd(z%(q#e*%n;7 zL3sPu)YG1ibNXNRO*?YO?GoSOHw}B@%aTpM?N^n|dV5~i^Y96``4cD2DrOMk>MBZ^ z;j&gYaDVpG7DiScDT^nIr-+w)_H1xsIB3}g}Et-N0m zsbO|lc_A+^@7@1PI#Q>VS>!o8oa;Fu&{uz_?vOdpMt!2%XMboN9d6_&41$cLc zE_Jj>m~dF_ztrW%$`yqhgFM_)TvR-{CLMF+IFumKdh0Et&_xHnNv9N=JB)&qoE9l) ztqf^16`rtp!2~Jo)m&?w^iJmA7UC}tTM;K%(c&xV#Q7rh%BGLL;4T635X77D* z@fFJ*-H7F}{4bTGw4a7nODcSxbFa+p2-C{l)oJ-3oSN1D+V-$LsO1h_;7>s6 zzLTOg(VQr0?l?zB56m;dd3(_!|ojcdc-aGrHPy4%#0IbJeMIK$bvwYqax zpk?NaX}s%xWKZ5yYjDQeKcIJeCtLOA?3hlazpXcgFIx0WDQ!8eeo*UCK!H(G5v{)R&w2m$9wfyTh2VLV$$Qv;80<8 zS>n26Qo&10zP8x@s-6>N%5{8ZXDZjdmEc~jxwx?Mw5AW+-;-Ze_&+|F=yh$!!IzV& zZ!%^ST?*Qwd8O&v{tY#%6|eFVOj5ojcilPqbk&OLjnSn{YKf;kH-Edz?&)5Z%$fDi zD&*C(lxFECTYh-#t1z%Jd{D2L9U-sHy2QwCZm4>j3&X63wpTw`pV*$~?8wgfiqR#} zfv4Svx!|VFz2?iT7hS?#joepGObz{ic9HXq#tnK7!MA%HWh(AHy6n92OwjCnu{(z= z*ROs)B_WsX_NJtJ1xs6_8ysIQS=;=5THy-WIlWIxBr@1S#142|VY=niQ6YQpfw}x@ zg*(}k4L3F^Oju^nm6T{xamP*EAyjU0b+LWhhI#65GM>&j-4%ZMp4pM&-+Ok7XS;5b z%TLJv#_7ba@UMN&w|%XS0*Xr`mq&>&mgdW9{_AtHKw=xuyw_X5E$}ROZ{wp?sXXnq p$dNX;6PxSKbDhuEE?+nE=)7~^vXw-h9WMW8CnCI>DZs$g3IOtVP!0e9 literal 1479 zcmdPcs{c2If$yv?!zot}9(4uZIqVyle#{oz_K<6P@2tD$G`AdM5Z-wzrLKh6_(2(0 zwJ-~Zpp)wkhHVUc81Bcoc-~l??x)B1;#E`s8@Y{7|IV`fdvTq6iPx^>WLpN+p_$* zrMzc_(Q4N)Evt6@C2Mup>;91qtk_W3+iF|1BYh$_V@6`bG}bL97mPCRt`L>dYEM4? z)p*v0u#et9uI_ekR_?O<5fFOr?Cy;+7efnzJ$E_I{eN@ohVO@ax9VJYesbO%_1_{a zf^L4FmV3(2)IGZ2|Io8L7f-aADqp`gbye5m6|T&W_tu6ic^sp|V-QudAy=b&kwT5) zO3x*XJS<1Ll5;Vy_cOn+0MGAKJeI*3sa%SN;9_>J!hh7p8qKLJRm# z%;A3)HRp`iZ|O-|UKhT}9J%6Oetr@s-xT3)l|=W#DbM20t4!p5`u@a(N2gW>m~^!& zP2_E2?zr-3*@-tfMnV&M&txqqxiLj6C0{Ic`b!6Ozvu3Kx`~@zlj}sLtmtW(t$O{T zMSaRPwI}6T>ki+D4Hl~7Z@uT=G(-P zi$_jt{M?kXPF1en@?rNfrN8%1Br$6m*tHPA<<$y?kQRRGWa- z$dpjUZ#K7+zkhJ=jXjt2)F*G-62nWr+svOv>Aim`xcJuIlmze4Qm5 zoAD=nO=rQHY~9|J?Mtd&YdJY6%v@7+ZTgZ%y}i>ezsSs-lk)9+e){H*jRnq@4pO#aS0mn?xWctm_EOKYrSH=p=y!Aa z{Zlzwe%4)0@c!Yqm3(y(rHhzEnH=n9C&o9;d|0$0FZ!BPv&^0N_Xbb)6{<^Fsrk-3 zDz(8NZQjAxB7uv&Cxi&b3M#lZ`_8doD7(0QqM!dXTom@%bm0mukE97A^l)5W&Gd`;eDPQ0wyI^#5M(F6R8$ z=(cV$*Sj9=H4`lC6}Xt^WTtnBho5__U?D4TRwcA$`NEW})zu#rEfmbUpI`jlu8&?Ziq|K7fnxVShAZ|5}-oc2} z1Zn+8-zpyKgmwFH@^k6`ZgFAf_~WcqF@H}&(Zgkxi+731_84#Rt~-&sMk0f)yL6Xz zhUyOse!ZyyljoMqN|w#|bo2e~hu<@n&hzfPap|U-Ur6-6AAOhF5AlSaGRmF-0EE@J ASpWb4 diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css new file mode 100644 index 00000000..42d83b13 --- /dev/null +++ b/resources/css/admin-tokens.css @@ -0,0 +1,65 @@ +@layer components { + .token-list { + width: 100%; + border-collapse: collapse; + margin-block-start: 1em; + + th, + td { + text-align: left; + padding: 0.6em 0.8em; + border-bottom: 1px solid var(--clr-border); + vertical-align: middle; + } + + th { + font-size: 0.85em; + text-transform: uppercase; + letter-spacing: 0.03em; + opacity: 0.7; + } + + tr.is-revoked { + opacity: 0.6; + } + + a { + word-break: break-all; + } + } + + .badge, + .token-list button.revoke { + display: inline-block; + padding: 0.2em 0.7em; + border-radius: 999px; + font-size: 0.85em; + line-height: 1.5; + white-space: nowrap; + } + + .badge { + background: transparent; + border: 1px solid currentcolor; + } + + .badge-active { + color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg)); + } + + .badge-revoked { + color: var(--clr-text); + } + + .token-list button.revoke { + background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg)); + color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg)); + border: 1px solid transparent; + cursor: pointer; + transition: background-color 150ms ease; + } + + .token-list button.revoke:hover { + background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg)); + } +} diff --git a/resources/css/app.css b/resources/css/app.css index f25e47e3..1f787028 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -8,3 +8,4 @@ @import url('notes.css'); @import url('pagination.css'); @import url('theme-selector.css'); +@import url('admin-tokens.css'); diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php index 8836ab07..fe787ad9 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -7,21 +7,41 @@ @if($tokens->isEmpty())

No tokens have been issued.

@else -
    - @foreach($tokens as $token) -
  • - {{ $token->client_id }} — scope: {{ $token->scope }} — issued {{ $token->created_at->diffForHumans() }} - @if($token->isRevoked) - — revoked {{ $token->revoked_at->diffForHumans() }} - @else -
    - {{ csrf_field() }} - {{ method_field('PUT') }} - -
    - @endif -
  • - @endforeach -
+ + + + + + + + + + + + @foreach($tokens as $token) + + + + + + + + @endforeach + +
ClientScopeIssuedStatusAction
{{ $token->client_id }}{{ $token->scope }} + @if($token->isRevoked) + Revoked {{ $token->revoked_at->diffForHumans() }} + @else + Active + @endif + + @unless($token->isRevoked) +
+ {{ csrf_field() }} + {{ method_field('PUT') }} + +
+ @endunless +
@endif @stop From 6a0bb623fc79e389a0ab5cb5660e241a7322ed69 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 21 Aug 2026 16:05:48 +0100 Subject: [PATCH 03/15] Restyle the Micropub tokens admin table Breaks the table out of the centred content column to full page width via a subgrid on
(adds a reusable .full-bleed utility), and reworks the table itself to match a Claude Design mockup: a rounded card wrapper, scope values as pill chips, a dotted status pill, and proportional column widths. Colours use the site's existing --primary-hue/light-dark() tokens so it stays correct in both themes. The card scrolls horizontally on narrow viewports instead of clipping content. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LHN7V9kyZxrsxaspMSqoGJ --- public/assets/css/app.css | 2 +- public/assets/css/app.css.br | Bin 1477 -> 1690 bytes public/assets/css/app.css.map | 2 +- public/assets/css/app.css.zst | Bin 1762 -> 2010 bytes resources/css/admin-tokens.css | 84 +++++++++++++++++-- resources/css/layout.css | 12 ++- resources/views/admin/tokens/index.blade.php | 80 ++++++++++-------- 7 files changed, 135 insertions(+), 45 deletions(-) diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 1d99e7bc..2d06d701 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,2 +1,2 @@ -@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:2/3}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform, opacity, display allow-discrete, overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list{border-collapse:collapse;width:100%;margin-block-start:1em;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.6em .8em}& th{text-transform:uppercase;letter-spacing:.03em;opacity:.7;font-size:.85em}& tr.is-revoked{opacity:.6}& a{word-break:break-all}}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;line-height:1.5;display:inline-block}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145))}.badge-revoked{color:var(--clr-text)}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}} +@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{& button{appearance:none;background:0 0;border:none;&:hover{cursor:pointer}}& #theme-selector-dropdown{position-area:block-end span-inline-start;border:2px solid var(--clr-border);background-color:var(--clr-background);color:var(--clr-text);margin:0;position:absolute;& fieldset{border:0;flex-direction:row;gap:1ch;display:flex;& input{display:none}}}@media (prefers-reduced-motion:no-preference){& #theme-selector-dropdown{&:popover-open{opacity:1;transform:translateY(0)scale(1);@starting-style{opacity:0;transform:translateY(30px)scale(0)}}opacity:0;transition:transform, opacity, display allow-discrete, overlay allow-discrete;transform-origin:100% 0;transition-duration:.5s;transform:translateY(0)scale(0)}}}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index e2d1ff369d415fd1742a121226c0afdf863f2167..9ca789b9af38df7f5e3f37fefbeae7419e9b5312 100644 GIT binary patch literal 1690 zcmcckVH3jx9iNA9k~9ypCQ0-yUH_?f&6UL3SzeV}>b7u&Ef2VC6V4{-!M1Ow>+M-# zaZxtcmNfZX&UdKX7`!cwe{#syhqABE$a5|$k&!RBQ?qNcl%w2@!jqm8!{?f7Vsea)l`)2#A&e5=mN96Rwth>v$wc&+2ysT;#Piz242 zcUm8y|G8uHJyXrIzu)XG5N-FKawh+FjZMv~+BdbU>}Q`7Sjn5{RH0g&ecf2B<=E@> z4}SNoSo`+B;+B73GdRmTtkUbgJT%I~)(E-@fONvb)w|0Cn!W0eWndC_jm zd_MP!UXVEYCL!`P^W}~5CJXn5MG0{xm|ZMevAXKj`2f`={%v1ZFf;A!Su?HwLecY8 z))uX;suB?{%Vv5Q-d-?S>eSD^jK#hmi(AaB-@pDF_wcag0YAmK@9D*S9UVo@Q`dYt z_{p^R_JQdcT=fZ?+3(05`zAeQ^>T-oJKS&iosZZq|6l&Ed2W>3vnfxHD$iTAZRNFP zd>vZnS6`iLEq0feT$S!__|2yKVuzMt-O8)Imme;W-6yEU@Ich< zT!xNNkJeY87-!oac5SUqd69P}pG%ysJj49o@?P`5iweKHx<9*jd~3<6oYHHTIrepD zZA*Mvw13vjAMXCn5{I6tw;cTbXMdlKOU)j~|}|b9~v$>`SF#WLyvyUztZ|m$4UO}{1YpG-<{4d(^2JOd9BFX zDt}$?4C@oYo2LD*m~d>n;%coXX!gOT8efG zd}|8QWLufyAai=7_Qaybo{qwmLfuPB+#F_#KjFx07r81jWm8M!qz*O@wdkF@E{V-j z&D$#_-PZi3>zM8i;rOcp+utscun@eHV9nXPq%rpImwe6hnJTZ{w@sfXEsd9h5k@W-Rp{tL+mCu%Y6-nra#+RE5%gq_La9j^j$mS#WZ#Lrqs;*2WH;q3p^V|CPZ;HX)jcH zn3V0CD_;2O-*VZBo?;WYE5BS*Teu}e(sJgPF6~5-H+OuSRz=P|ks!3xtM%}t{alNk zb}KEg?OW%Xu`eZRr+euhx0ip+?l$e0o&LDcwD2v9=UV~iXH)NNh?G6S(feI;!!M05 zmec>|1g_qz+OuPs%SZL3Wz1_^chnzQApgRp`Yp!_*|W~EdWpVs_ejqwSb23`57P?YZL%S!rGB@kggiMJ&=+-J!f}_id9kN6F1(v_$Vr&T zWYGo@!82+nmb4YrsdcScUB?_QcfQzq_qpP7zJQK5rTMcrFJEI$zTMY$nH^X)r97Lx z+{A#VsokUgcx1`?`ha+4hLDZ1Yv)XQ!n+i@K1W*J1u=`x$FOgruDPhBSR)ssiyn&+dYZQKXvPe-kXbS zx6BcJdS$uZ^4C|(w%OGfeJCkc*%`N0?AWcZ_im*>a;Z2F-Xgg9{O?6>>`-g(cbhW>>?euiro@igC%uIt#` zeoH}pgBe98UaiuY*;)8-#)u9TNc^^Mz}t^dR=KzhOQIlFQU z?;c*}sw93o?U>`LZI1uO-`(Qc(Cu4_lq2X&XC#j?!AqgEiqMS z(z@tB9d041yvMv-Zx!y8TB6L7D98B8Mmai2Oi)Mb^QjPJ%QKCo+I#Y5#0wo;=q#Ee z{r=j4*AG<+g9BafX#TpQQGN6B#w$O%dn1H{x-)e5HFQfFXGOp?f z-Yb?LcsH5oPMpS`$$$9hrTv;OID_ZqgiD=#R=JF=)Y5~O;b$`2#mQ}37b*rVdStL5 zeAQFlyJ}a?+_Po#{5Hw{(d3q7&&PKazpdHOEck=@u2`A>#Bg@w5XWDSFLgS7vhx16 zZjZXJtgrZ&`Sbs9DC%xd`Tdq9)iplj>yuye=EW8+`)bd&WWu+tsrzRCf3)ny#Rr*v zXRlR0tdp=d=>GP$cvm;a(;HiK6z06(@#t;5qhZ7J)I&~B%E2$cW>fygHNVy{96h%B zltD__ejatH!rvC>w(Kv->|!;Wp855$ZvHy^M++W*SYGcQJ#E$Fb+&2;1t<7!mE>Q# zGp0d!CV!y+fsNC;^>6NW_^?Fgd{c1F^WgXAt}4m+ujJmv>R6VSwc$b|XPnOKodU0o z=k!@zbXj7ly{$~eadXQXrB18P1^$;B`%-pCbWFNbcy0Y2m1iw?uFJoDHsjDnF8P1G z`A*GxJZ^73wTN2Zd6F4>VrAsE?#_9q<_E~We5$AZqwl~`eKq}% GiyHvv%jWq2 diff --git a/public/assets/css/app.css.map b/public/assets/css/app.css.map index e224fe5b..88c58430 100644 --- a/public/assets/css/app.css.map +++ b/public/assets/css/app.css.map @@ -1 +1 @@ -{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,uBAIA,iDAIE,yEAME,uBAON,oDHvCF,kBG+CE,SACE,oBKhDF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CHrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA,mICrFA,uEAKE,4GAQA,8EAOA,2BAIA,0BAKF,+IAUA,uCAKA,sEAIA,qCAIA,kNAQA","sources":["resources/css/app.css","resources/css/colours.css","resources/css/layout.css","resources/css/reset.css","resources/css/pagination.css","resources/css/theme-selector.css","resources/css/admin-tokens.css","resources/css/notes.css","resources/css/header.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 2/3;\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n #theme-selector {\n button {\n appearance: none;\n border: none;\n background: none;\n\n &:hover {\n cursor: pointer;\n }\n }\n\n /* This is the element with the [popover] attribute */\n #theme-selector-dropdown {\n position: absolute;\n position-area: block-end span-inline-start;\n margin: 0;\n border: 2px solid var(--clr-border);\n background-color: var(--clr-background);\n color: var(--clr-text);\n\n fieldset {\n border: 0;\n display: flex;\n flex-direction: row;\n gap: 1ch;\n\n input {\n display: none;\n }\n }\n }\n\n /*\n * This is the element with the [popover] attribute\n * Here we are styling the open and closing animations\n */\n @media (prefers-reduced-motion: no-preference) {\n #theme-selector-dropdown {\n &:popover-open {\n transform: translateY(0) scale(1);\n opacity: 1;\n\n /*\n * Start styles for the opening transition.\n * Added to :popover-open, but after the opened styles.\n */\n @starting-style {\n transform: translateY(30px) scale(0);\n opacity: 0;\n }\n }\n\n /*\n * End styles for the closing transition.\n */\n transform: translateY(0) scale(0);\n opacity: 0;\n\n /*\n * Enumerate transitioning properties, including display and overlay.\n */\n transition: transform, opacity, display allow-discrete, overlay allow-discrete;\n transition-duration: 0.5s;\n transform-origin: top right;\n }\n }\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer components {\n .token-list {\n width: 100%;\n border-collapse: collapse;\n margin-block-start: 1em;\n\n th,\n td {\n text-align: left;\n padding: 0.6em 0.8em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.85em;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n font-size: 0.85em;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n }\n\n .badge-revoked {\n color: var(--clr-text);\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n"],"names":[]} \ No newline at end of file +{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,mEAKE,oBAIA,gCAKF,iDAIE,yEAME,uBAON,oDHjDF,kBGyDE,SACE,oBC1DF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CGrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MD/BA,sFAME,+CENF,gBACE,oDAKE,wBAMF,gMAQE,4DAME,uBAUJ,8CACE,2BACE,yDAQE,gBAAiB,8CAST,0KAahB,iEAIA,kGAKA,4DAIA,qDAIA,mIHrFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA","sources":["resources/css/app.css","resources/css/colours.css","resources/css/reset.css","resources/css/layout.css","resources/css/admin-tokens.css","resources/css/header.css","resources/css/pagination.css","resources/css/notes.css","resources/css/theme-selector.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n","@layer components {\n #theme-selector {\n button {\n appearance: none;\n border: none;\n background: none;\n\n &:hover {\n cursor: pointer;\n }\n }\n\n /* This is the element with the [popover] attribute */\n #theme-selector-dropdown {\n position: absolute;\n position-area: block-end span-inline-start;\n margin: 0;\n border: 2px solid var(--clr-border);\n background-color: var(--clr-background);\n color: var(--clr-text);\n\n fieldset {\n border: 0;\n display: flex;\n flex-direction: row;\n gap: 1ch;\n\n input {\n display: none;\n }\n }\n }\n\n /*\n * This is the element with the [popover] attribute\n * Here we are styling the open and closing animations\n */\n @media (prefers-reduced-motion: no-preference) {\n #theme-selector-dropdown {\n &:popover-open {\n transform: translateY(0) scale(1);\n opacity: 1;\n\n /*\n * Start styles for the opening transition.\n * Added to :popover-open, but after the opened styles.\n */\n @starting-style {\n transform: translateY(30px) scale(0);\n opacity: 0;\n }\n }\n\n /*\n * End styles for the closing transition.\n */\n transform: translateY(0) scale(0);\n opacity: 0;\n\n /*\n * Enumerate transitioning properties, including display and overlay.\n */\n transition: transform, opacity, display allow-discrete, overlay allow-discrete;\n transition-duration: 0.5s;\n transform-origin: top right;\n }\n }\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index 72d74415073995b6a8e3de562975b98edab3f986..d7479672cf281dfbba69bb5d09034c9262bf0692 100644 GIT binary patch delta 2008 zcmaFFdyAh>W2^q(lz7opJBF-~4}25(r0S2HW%}#>|4ziUO{-QZF9^BE@QC37!=E?D z%ARyjd>Q`hx$nv2Y9CzZT{~@gGoN?2(VcVCFaDY{<$(qN;(L2fOxhBDUoKVV<6UO` z+k3OlsL%186>nI5V*88gE2__{zumm0%a@gULrP&@zWK=s(mk8p-!Sh!IN@j4spBVp z#+&9{v3XOkc|1F7YmUXwU2jrkYHBAq9Dd20P%8T@WxeYB!lUH(NgG z{_twjnYOS0IwddLEzdJ>J6LzU>}d6&3l1Cq>%Od*u=Lu$JCo{7=Hz^_)C?(pw@OCE z)A`NZ{Z;;~eAi14A8Xn$OUjigC?h1)u8)=Pdt_&OrFGt_1CCt|Vm|wxPB?G5?2=HU z1y5TRAM?RgJyVzb_@be|d*jvf6WqSv`53^q`fBl;?7OZ}Vn;d@dVC@kS11;Jy0~Mb z!Re-gHVyy9DlQzW4jy;Wsc-K+7`S2G3e&>A^Dm<=9lohKp)_aJ)zo^w^ljSa@Am5d ztiAMu>4^GGwlJ>7$O&F9C^$fH^6yW!0+#nG;`b(3JPG z+_31*jeiEW{iZh0-LY0b+D|IId6}!}^@(2&YgntipME!jmqCb&>*)=X&Zy``b=OaE zGI5CLESMr(@;NKawZVz$z}}t=M#EK)Etpg?8x>TG-uV_RnxB{0A=ba?!;Q8*`Fqwp zvEb%utN z?ZLNWT@i5`_&!OM%d~kMzF;ut*Zm36rODbGrDwGX7FC~@akecL;}&}DCAn+bp3YiR zb)N9UZ2PCay|Y+N^PBQQZhsa_Ufz3Kz9rWc&;MCoC~>sj>BP$!2RtRe`iR$VxXE%{ zYpe2alb7v{ZCf_?=_tCF2Yq_9R`7}q-$};C(<>S`26?%-xTtt?O*)pyVc6xFp?L4n z0h4nwSKb^|;o_<@GY@%nNtcwB{|FlNtA@;fvMBhi|h5-nRuF`ugF(f_APwM`wh! zZalKwQH(q8634}vTn1rh)^qW>iv-Ag+4SJ*SJgFK%NTYP@!UBOe2&w6RddsW`QB_R z6}azAlfG8TpvHK9qDPef=LOrMx2~7|x>951g6*QFoWDG>!@qL9{=2~=vEHAr_!^t9 z0PmguTW0LFS;4Pi#k}{Ta`vLl^J~sCW|!uKXBNa?sWfEXf7bHvtlks$OQkysorJu$ zrs`Q7IPfXvrgczHpt_p?>)SRJXXDDPrvxgJ7T*4_W&adAo79UopRc~JQhU?!%D|LG zuASBU!|R}V&jjq6yK2|>`=$mcANW_#XnrSW!Ik4$Ik}gF+l*_o1oT)+-~PEiyT6y? zPTsU>OG^{v(M?62Vk)^t!MR&!+0PF;$HTLmQQ@n{m9=? zZ+x@Gc*^~6D$=)~$x1QzW^))jn6bxR{%*g@ykJ#)l#9 z9gEcl3Esf(vm0lY&1Rg*)?|>jZ%)Y>&cC8{8=~u5kBO>qedyeL_T39R<>v_p^P>3- z18x7!IC$fC)~-ipZtFkQyqzx0+;&-ul( zzCFokH2mxo9P>EBdr1W&m(uk`F~2Up5;?Z<_3Adv^;uO%rX4*#^z}8 zyC0Z^Q!OjIP2G5SSd1DqwF0W|)}BySm~wjSm9{wwe)Cp;S{Yq06vgzlx7zRB9+{Qj z9Sf|ib5721e~_Ulq7YcMPjus6#=dm+n#WURBWC5#zQ4tl`+$j)xOtM2M{S9-STFl|y5qCM4h{xQcHHb6Sk%uo`>Q_cP_=5k nXXAA5N3DrT=GKkNij_9K)Oq+kCf@G%x#R7#(n5`n-0TDZuSd;a delta 1758 zcmcb`|A?1QW2^q(lxD%DW(-n(M|dalN!6$5u>F3t{mzV?YgT1(ScslyxX5stLBD8A z+_@8V**8ibmhJ1g_Iv@O?_-0Pce`~we}8#<@u=W^-w&JaEY)dp#v>GN0AbkF%8{8&$-eeaVB-L=zr)#Inn zFy|X1{W2{@}Osbu$WD)mw(!a0oFTOaj$zWf}^zEN-Ch+}K+p|k$bK+k6q-|TyuaM3O z+;D$uY^A@15=ZP+#z|ie-}|X8qAAp0Bf~GVZnF^I=dwGO8ta)w8g&eQPII<d(|)kc03KAi~E-@Wnb`3Y{{?>r1(TYb6sO?E76(6z%64ks>l`9#iNp7`*| z;zH+05!Q!1fqaYEbOeH$`#U4}q@5Qh*m`DvnzN48cFx?UHOby<^?nyyZI0bp_`de! z`^m?|73@x$UAlB|rv?{Cy_IkI9<7`8uD={5HlOM_T{=7F_o{n=)~*_L>y(?cU$RCE z_~l=%vz{~m?v36WL!-Y=|M#f=yureD`P8!Fi_M``u`6rC*?+tK4Q`hFVz4$VNL%^x z#g}<|J!hVO6}a>km%Cg)62U(I%C+!%!Nu%++h)3ZN(Ct#wY6A1Dd6}w|CIrg zGJRF%99itKB6$IWljTXTvg*~`Y=<@_23t+C+7M*E&HBu?;L;7k+rOrs_I#Yv|GID5 zkvnde_!hru*b`rtZ2E1#s$|yN^SYjgPq@|3pEzk&F@q3SS5e9gm$kZq`?H_6FtYMU zSv*-hMZDy*XM+>dfxSJfZn?AE7+5sVU;bdibvWr@Ad3iZ<^76C4YR|_3we2Y@BUxX zkvgr+BG2L5gKx*WBFZKl|Iu{Nz@Tf{Kl7GdMW2_I?Aj>V)8*Tc`QlFZgxl56#9k&_ z3%vG{tiM)wCVidpf335NA~-B%g)KBE&wIIu-^_Z!##Lo0{c3+5E=(x9Hc|D-3qP|Q zos-?$_2iBfdQ7{umOHKJ_0O2Bm-TtBYAtK7ESgp=%FEo|F#f+dDx0L!HO1NNhi)1p;u=8t}vazJBw5P@3~DObzyUgUtgH@lKbU^ za&gl$6FhwKd^watIX z^_7La_m=LX*!i&+b{~7O=I`O-s!6K$!K>~*Vfj%J9lmD8G7bOm@bDG0mDaL`MjQD> zty<#vV|Sp}$pyVaw^^TQPiwJyYP8Bav7F0vj;ON!oGaIoCfRg^6kNYv&mwguu zX0_j+8YOqj2;KM)#Wvya6Mhl5i|J2Xw36#iJl?Cv+H&S`6_XxU28Rl(%M#ZmlL}s1 z^0meGSM{7IQ?BDPJ5#yttpxXK&BcY4r!{@p{+|4*!vFEXM6YW*4!)dJeUmYx=u*&@ zdd(|M*Ys%LjE^S zCw7H@?Q_2Eb8mGNP+S_hJW71AG+$QpU!Ri&65Dv@z25q5foH*c8y~Go>wLa;`MQ}$=bihOtt9g7aQQzw5#iNL0S2a40FG8ps{jB1 diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css index 42d83b13..a0db29e5 100644 --- a/resources/css/admin-tokens.css +++ b/resources/css/admin-tokens.css @@ -1,21 +1,34 @@ @layer components { + .token-list-wrapper { + margin-block-start: 1em; + border: 1px solid var(--clr-border); + border-radius: 16px; + overflow: auto hidden; + background: light-dark( + oklch(99% 0.02 var(--primary-hue)), + oklch(22% 0.05 var(--primary-hue)) + ); + } + .token-list { width: 100%; + min-width: 640px; + table-layout: fixed; border-collapse: collapse; - margin-block-start: 1em; th, td { text-align: left; - padding: 0.6em 0.8em; + padding: 0.9em 1.2em; border-bottom: 1px solid var(--clr-border); vertical-align: middle; } th { - font-size: 0.85em; + font-size: 0.8em; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.03em; + letter-spacing: 0.08em; opacity: 0.7; } @@ -26,14 +39,65 @@ a { word-break: break-all; } + + th:nth-child(1), + td:nth-child(1) { + width: 31%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 24%; + } + + th:nth-child(3), + td:nth-child(3) { + width: 15%; + } + + th:nth-child(4), + td:nth-child(4) { + width: 16%; + } + + th:nth-child(5), + td:nth-child(5) { + width: 14%; + text-align: right; + } + + tr:last-child td { + border-bottom: none; + } + } + + .scope-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4em; + } + + .scope-chip { + display: inline-block; + padding: 0.2em 0.7em; + border-radius: 999px; + background: light-dark( + oklch(92% 0.05 var(--primary-hue)), + oklch(35% 0.08 var(--primary-hue)) + ); + font-size: 0.85em; + white-space: nowrap; } .badge, .token-list button.revoke { - display: inline-block; - padding: 0.2em 0.7em; + display: inline-flex; + align-items: center; + gap: 0.4em; + padding: 0.3em 0.8em; border-radius: 999px; font-size: 0.85em; + font-weight: 600; line-height: 1.5; white-space: nowrap; } @@ -45,10 +109,18 @@ .badge-active { color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg)); + + .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentcolor; + } } .badge-revoked { color: var(--clr-text); + opacity: 0.7; } .token-list button.revoke { diff --git a/resources/css/layout.css b/resources/css/layout.css index dc5f753f..44fda96f 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -18,7 +18,17 @@ } > main { - grid-column: 2/3; + grid-column: 1/-1; + display: grid; + grid-template-columns: subgrid; + + > * { + grid-column: 2/3; + } + + > .full-bleed { + grid-column: 1/-1; + } } > footer { diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php index fe787ad9..c9443e29 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -7,41 +7,49 @@ @if($tokens->isEmpty())

No tokens have been issued.

@else - - - - - - - - - - - - @foreach($tokens as $token) - - - - - - - - @endforeach - -
ClientScopeIssuedStatusAction
{{ $token->client_id }}{{ $token->scope }} - @if($token->isRevoked) - Revoked {{ $token->revoked_at->diffForHumans() }} - @else - Active - @endif - - @unless($token->isRevoked) -
- {{ csrf_field() }} - {{ method_field('PUT') }} - -
- @endunless -
+
+ + + + + + + + + + + + @foreach($tokens as $token) + + + + + + + + @endforeach + +
ClientScopeIssuedStatusAction
{{ $token->client_id }} + + @foreach(explode(' ', $token->scope) as $scope) + {{ $scope }} + @endforeach + + + @if($token->isRevoked) + Revoked {{ $token->revoked_at->diffForHumans() }} + @else + Active + @endif + + @unless($token->isRevoked) +
+ {{ csrf_field() }} + {{ method_field('PUT') }} + +
+ @endunless +
+
@endif @stop From 714cd95d79b56c8d7548a357c3a9ef19736a4ac4 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 22 Aug 2026 10:42:16 +0100 Subject: [PATCH 04/15] Drop RSS/Atom feeds, support JSON feeds only Removes the RSS and Atom feed routes, controller methods, and views for both the blog and notes feeds, keeping JSON (and JF2) as the only supported feed formats. Also serves the JSON feeds with the spec-required application/feed+json MIME type instead of the generic application/json. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8 --- app/Http/Controllers/FeedsController.php | 65 +++--------------------- resources/views/articles/atom.blade.php | 20 -------- resources/views/articles/rss.blade.php | 26 ---------- resources/views/master.blade.php | 8 +-- resources/views/notes/atom.blade.php | 20 -------- resources/views/notes/rss.blade.php | 26 ---------- routes/web.php | 4 -- tests/Feature/FeedsTest.php | 52 +------------------ 8 files changed, 12 insertions(+), 209 deletions(-) delete mode 100644 resources/views/articles/atom.blade.php delete mode 100644 resources/views/articles/rss.blade.php delete mode 100644 resources/views/notes/atom.blade.php delete mode 100644 resources/views/notes/rss.blade.php diff --git a/app/Http/Controllers/FeedsController.php b/app/Http/Controllers/FeedsController.php index a30c89dc..9aeb4abb 100644 --- a/app/Http/Controllers/FeedsController.php +++ b/app/Http/Controllers/FeedsController.php @@ -7,66 +7,13 @@ namespace App\Http\Controllers; use App\Models\Article; use App\Models\Note; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Response; class FeedsController extends Controller { - /** - * Returns the blog RSS feed. - */ - public function blogRss(): Response - { - $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); - $buildDate = $articles->first()->updated_at->toRssString(); - - return response() - ->view('articles.rss', compact('articles', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); - } - - /** - * Returns the blog Atom feed. - */ - public function blogAtom(): Response - { - $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); - - return response() - ->view('articles.atom', compact('articles')) - ->header('Content-Type', 'application/atom+xml; charset=utf-8'); - } - - /** - * Returns the notes RSS feed. - */ - public function notesRss(): Response - { - $notes = Note::latest()->take(20)->get(); - $buildDate = $notes->first()->updated_at->toRssString(); - - return response() - ->view('notes.rss', compact('notes', 'buildDate')) - ->header('Content-Type', 'application/rss+xml; charset=utf-8'); - } - - /** - * Returns the notes Atom feed. - */ - public function notesAtom(): Response - { - $notes = Note::latest()->take(20)->get(); - - return response() - ->view('notes.atom', compact('notes')) - ->header('Content-Type', 'application/atom+xml; charset=utf-8'); - } - - /** @todo sort out return type for json responses */ - /** * Returns the blog JSON feed. */ - public function blogJson(): array + public function blogJson(): JsonResponse { $articles = Article::where('published', '1')->latest('updated_at')->take(20)->get(); $data = [ @@ -94,13 +41,15 @@ class FeedsController extends Controller ]; } - return $data; + return response()->json($data, 200, [ + 'Content-Type' => 'application/feed+json', + ]); } /** * Returns the notes JSON feed. */ - public function notesJson(): array + public function notesJson(): JsonResponse { $notes = Note::latest()->with('media', 'place', 'tags')->take(20)->get(); $data = [ @@ -130,7 +79,9 @@ class FeedsController extends Controller } } - return $data; + return response()->json($data, 200, [ + 'Content-Type' => 'application/feed+json', + ]); } /** diff --git a/resources/views/articles/atom.blade.php b/resources/views/articles/atom.blade.php deleted file mode 100644 index 9892bcfb..00000000 --- a/resources/views/articles/atom.blade.php +++ /dev/null @@ -1,20 +0,0 @@ - - - Atom feed for {{ config('user.display_name') }}’s blog - - {{ config('app.url')}}/blog - {{ $articles[0]->updated_at->toAtomString() }} - -@foreach($articles as $article) - - {{ $article->title }} - - {{ config('app.url') }}{{ $article->link }} - {{ $article->updated_at->toAtomString() }} - {{ $article->main }} - - {{ config('user.display_name') }} - - -@endforeach - diff --git a/resources/views/articles/rss.blade.php b/resources/views/articles/rss.blade.php deleted file mode 100644 index 00268681..00000000 --- a/resources/views/articles/rss.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - {{ config('user.display_name') }} - - An RSS feed of the blog posts found on {{ config('app.url') }} - {{ config('app.url') }}/blog - {{ $buildDate }} - 1800 - -@foreach($articles as $article) - - {{ strip_tags($article->title) }} - - main }} - @if($article->url)

Permalink

@endif - ]]> -
- @if($article->url != ''){{ $article->url }}@else{{ config('app.url') }}{{ $article->link }}@endif - {{ config('app.url') }}{{ $article->link }} - {{ $article->pubdate }} -
-@endforeach -
-
diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 2e5c6b5a..f269d830 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -7,13 +7,9 @@ @yield('title'){{ config('app.name') }} - - - + - - - + diff --git a/resources/views/notes/atom.blade.php b/resources/views/notes/atom.blade.php deleted file mode 100644 index c84a4a93..00000000 --- a/resources/views/notes/atom.blade.php +++ /dev/null @@ -1,20 +0,0 @@ - - - Atom feed for {{ config('user.display_name') }}’s notes - - {{ config('app.url')}}/notes - {{ $notes[0]->updated_at->toAtomString() }} - -@foreach($notes as $note) - - {{ strip_tags($note->note) }} - - {{ $note->uri }} - {{ $note->updated_at->toAtomString() }} - {{ $note->note }} - - {{ config('user.display_name') }} - - -@endforeach - diff --git a/resources/views/notes/rss.blade.php b/resources/views/notes/rss.blade.php deleted file mode 100644 index 9146ebe0..00000000 --- a/resources/views/notes/rss.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - {{ config('user.display_name') }} - - An RSS feed of the notes found on {{ config('app.url') }} - {{ config('app.url') }}/notes - {{ $buildDate }} - 1800 - -@foreach($notes as $note) - - {{ strip_tags($note->note) }} - - note !!} - ]]> - - {{ $note->uri }} - {{ $note->uri}} - {{ $note->pubdate }} - -@endforeach - - - diff --git a/routes/web.php b/routes/web.php index dd594480..b929a2de 100644 --- a/routes/web.php +++ b/routes/web.php @@ -170,8 +170,6 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { // Blog pages using ArticlesController Route::prefix('blog')->group(function () { - Route::get('/feed.rss', [FeedsController::class, 'blogRss'])->name('feed.blog.rss'); - Route::get('/feed.atom', [FeedsController::class, 'blogAtom'])->name('feed.blog.atom'); Route::get('/feed.json', [FeedsController::class, 'blogJson'])->name('feed.blog.json'); Route::get('/feed.jf2', [FeedsController::class, 'blogJf2'])->name('feed.blog.jf2'); Route::get('/s/{id}', [ArticlesController::class, 'onlyIdInURL']); @@ -182,8 +180,6 @@ Route::prefix('blog')->group(function () { // Notes pages using NotesController Route::prefix('notes')->group(function () { Route::get('/', [NotesController::class, 'index']); - Route::get('/feed.rss', [FeedsController::class, 'notesRss'])->name('feed.notes.rss'); - Route::get('/feed.atom', [FeedsController::class, 'notesAtom'])->name('feed.notes.atom'); Route::get('/feed.json', [FeedsController::class, 'notesJson'])->name('feed.notes.json'); Route::get('/feed.jf2', [FeedsController::class, 'notesJf2'])->name('feed.notes.jf2'); Route::get('/new', [NotesController::class, 'create']); diff --git a/tests/Feature/FeedsTest.php b/tests/Feature/FeedsTest.php index 0323aa73..3f168b58 100644 --- a/tests/Feature/FeedsTest.php +++ b/tests/Feature/FeedsTest.php @@ -15,42 +15,6 @@ class FeedsTest extends TestCase { use RefreshDatabase; - /** - * Test the blog RSS feed. - */ - #[Test] - public function blog_rss_feed_is_present(): void - { - Article::factory()->count(3)->create(); - $response = $this->get('/blog/feed.rss'); - $response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8'); - $response->assertOk(); - } - - /** - * Test the notes RSS feed. - */ - #[Test] - public function notes_rss_feed_is_present(): void - { - Note::factory()->count(3)->create(); - $response = $this->get('/notes/feed.rss'); - $response->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8'); - $response->assertOk(); - } - - /** - * Test the blog RSS feed. - */ - #[Test] - public function blog_atom_feed_is_present(): void - { - Article::factory()->count(3)->create(); - $response = $this->get('/blog/feed.atom'); - $response->assertHeader('Content-Type', 'application/atom+xml; charset=utf-8'); - $response->assertOk(); - } - #[Test] public function blog_jf2_feed_is_present(): void { @@ -73,18 +37,6 @@ class FeedsTest extends TestCase ]); } - /** - * Test the notes RSS feed. - */ - #[Test] - public function notes_atom_feed_is_present(): void - { - Note::factory()->count(3)->create(); - $response = $this->get('/notes/feed.atom'); - $response->assertHeader('Content-Type', 'application/atom+xml; charset=utf-8'); - $response->assertOk(); - } - /** * Test the blog JSON feed. */ @@ -93,7 +45,7 @@ class FeedsTest extends TestCase { Article::factory()->count(3)->create(); $response = $this->get('/blog/feed.json'); - $response->assertHeader('Content-Type', 'application/json'); + $response->assertHeader('Content-Type', 'application/feed+json'); $response->assertOk(); } @@ -105,7 +57,7 @@ class FeedsTest extends TestCase { Note::factory()->count(3)->create(); $response = $this->get('/notes/feed.json'); - $response->assertHeader('Content-Type', 'application/json'); + $response->assertHeader('Content-Type', 'application/feed+json'); $response->assertOk(); } From 99f306ede1a1f1b59b0b4bfb242571500dc298e0 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 22 Aug 2026 10:42:23 +0100 Subject: [PATCH 05/15] Replace RSS icon with a JSON Feed icon in the header The nav's feed link already points at the JSON feed, but was using the feather RSS icon. Adds an SVG recreation of the official JSON Feed mark (traced from jsonfeed.org's icon) and swaps it in, using a single dark-green fill throughout so it stays legible against both the light and dark theme backgrounds. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AXyzNvQZPQgBoSZwW7cLG8 --- resources/views/icons/json-feed.blade.php | 11 +++++++++++ resources/views/master.blade.php | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 resources/views/icons/json-feed.blade.php diff --git a/resources/views/icons/json-feed.blade.php b/resources/views/icons/json-feed.blade.php new file mode 100644 index 00000000..7b8df856 --- /dev/null +++ b/resources/views/icons/json-feed.blade.php @@ -0,0 +1,11 @@ +@php +if (isset($title)) { + $uniqueId = bin2hex(random_bytes(6)); +} +@endphp + + @if($title){{ $title }}@endif + + diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index f269d830..3c82845b 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -36,7 +36,7 @@ Likes Contacts Projects - @include('icons.rss', ['title' => 'RSS Feed']) + @include('icons.json-feed', ['title' => 'JSON Feed'])
-
-
- Select theme: -
- - -
-
- - -
-
- - -
-
-
+ +
From 9532aae37eeac0abc29a6de35097821259c22832 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 23 Aug 2026 10:17:26 +0100 Subject: [PATCH 09/15] Bring back winter snow effect, gated by an admin setting The site has flip-flopped on the snow effect every winter (add it, remove it, repeat), meaning a PR round-trip each time. Instead, restore the winter.js/is-land assets and gate them behind a new admin-toggleable setting so the effect can be switched on/off from /admin/settings without touching code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0113BVPXDpEk9HPEqXvSG2WQ --- .../Controllers/Admin/SettingsController.php | 32 +++++ app/Models/Setting.php | 18 +++ app/Providers/AppServiceProvider.php | 7 + database/factories/SettingFactory.php | 24 ++++ ...026_08_23_101358_create_settings_table.php | 28 ++++ package-lock.json | 20 +++ package.json | 4 + public/assets/js/is-land.min.js | 2 + public/assets/js/is-land.min.js.br | Bin 0 -> 1834 bytes public/assets/js/is-land.min.js.zst | Bin 0 -> 2082 bytes public/assets/js/winter.js | 125 ++++++++++++++++++ public/assets/js/winter.js.br | Bin 0 -> 1117 bytes public/assets/js/winter.js.zst | Bin 0 -> 1329 bytes resources/views/admin/settings/show.blade.php | 26 ++++ resources/views/admin/welcome.blade.php | 5 + resources/views/master.blade.php | 11 ++ routes/web.php | 7 + tests/Feature/Admin/SettingsTest.php | 68 ++++++++++ 18 files changed, 377 insertions(+) create mode 100644 app/Http/Controllers/Admin/SettingsController.php create mode 100644 app/Models/Setting.php create mode 100644 database/factories/SettingFactory.php create mode 100644 database/migrations/2026_08_23_101358_create_settings_table.php create mode 100644 public/assets/js/is-land.min.js create mode 100644 public/assets/js/is-land.min.js.br create mode 100644 public/assets/js/is-land.min.js.zst create mode 100644 public/assets/js/winter.js create mode 100644 public/assets/js/winter.js.br create mode 100644 public/assets/js/winter.js.zst create mode 100644 resources/views/admin/settings/show.blade.php create mode 100644 tests/Feature/Admin/SettingsTest.php diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php new file mode 100644 index 00000000..99d283bb --- /dev/null +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -0,0 +1,32 @@ + $settings, + ]); + } + + public function update(Request $request): RedirectResponse + { + $settings = Setting::firstOrNew(); + $settings->winter_effect_enabled = $request->boolean('winter_effect_enabled'); + $settings->save(); + + return redirect()->route('admin.settings.show'); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 00000000..0c84ea36 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,18 @@ + + */ + protected $casts = [ + 'winter_effect_enabled' => 'boolean', + ]; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 68367a97..a40ae43f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,12 @@ namespace App\Providers; +use App\Models\Setting; use Illuminate\Database\Eloquent\Model; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; @@ -63,5 +65,10 @@ class AppServiceProvider extends ServiceProvider // Force HTTPS URL generation in production URL::forceHttps($this->app->isProduction()); + + // Share whether the winter snow effect is enabled with the base layout + View::composer('master', function ($view) { + $view->with('winterEffectEnabled', Setting::first()?->winter_effect_enabled ?? false); + }); } } diff --git a/database/factories/SettingFactory.php b/database/factories/SettingFactory.php new file mode 100644 index 00000000..8df15756 --- /dev/null +++ b/database/factories/SettingFactory.php @@ -0,0 +1,24 @@ + + */ +class SettingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'winter_effect_enabled' => false, + ]; + } +} diff --git a/database/migrations/2026_08_23_101358_create_settings_table.php b/database/migrations/2026_08_23_101358_create_settings_table.php new file mode 100644 index 00000000..a4ea86d2 --- /dev/null +++ b/database/migrations/2026_08_23_101358_create_settings_table.php @@ -0,0 +1,28 @@ +id(); + $table->boolean('winter_effect_enabled')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/package-lock.json b/package-lock.json index 50949213..05c9ad3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "jbuk-frontend", "version": "0.0.1", "license": "CC0-1.0", + "dependencies": { + "@11ty/is-land": "^5.0.0", + "@zachleat/snow-fall": "^1.0.3" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.1.0", @@ -20,6 +24,16 @@ "stylelint-config-standard": "^40.0.0" } }, + "node_modules/@11ty/is-land": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@11ty/is-land/-/is-land-5.0.1.tgz", + "integrity": "sha512-Rh/sLhE4vrc2JaSjeY385v2UxnDY9BhnQtitETb3SKyr0A48Q5Vn06q2AvDBHObtk9+dcFWsoZX4jhT+O9g+xQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/11ty" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1018,6 +1032,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@zachleat/snow-fall": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@zachleat/snow-fall/-/snow-fall-1.0.3.tgz", + "integrity": "sha512-Y9srRbmO+k31vSm+eINYRV9DRoeWGV5/hlAn9o34bLpoWo+T5945v6XGBrFzQYjhyEGB4j/4zXuTW1zTxp2Reg==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", diff --git a/package.json b/package.json index 9c08f32a..b0ac9882 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "compress": "./scripts/compress.sh", "build": "npm run lint && npm run build-css && npm run build-js && npm run compress" }, + "dependencies": { + "@11ty/is-land": "^5.0.0", + "@zachleat/snow-fall": "^1.0.3" + }, "allowScripts": { "esbuild@0.27.7": true, "lightningcss-cli@1.32.0": true diff --git a/public/assets/js/is-land.min.js b/public/assets/js/is-land.min.js new file mode 100644 index 00000000..17203312 --- /dev/null +++ b/public/assets/js/is-land.min.js @@ -0,0 +1,2 @@ +//! +const e=window,t=e.document,a=e.navigator;function i(){let e,t;return{promise:new Promise((a,i)=>{e=a,t=i}),resolve:e,reject:t}}class r extends HTMLElement{static attributePrefix="on:";static attr={template:"data-island",ready:"ready",defer:"defer-hydration",type:"type",import:"import"};static _tagNames=new Set;static _once=new Map;static ctm(){return"undefined"!=typeof globalThis}static define(t=e.customElements){let a="is-land";this.ctm()&&!t.get(a)&&t.define(a,this)}static _initTypes={default:async e=>{await import(e.getAttribute(r.attr.import))}};static addInitType(e,t){this._initTypes[e]=t}static _fallback={};static addFallback(e,a){this._fallback[e]=a;let i=Array.from(this._tagNames);i.length&&t.querySelectorAll(i.map(e=>`${e}:defined`).join(",")).forEach(e=>{e.replaceFallbackContent()})}getFallback(){return Object.assign({[`:not(:defined):not(${this.localName}):not([${r.attr.defer}])`]:(e,t)=>{let a=r.renameNode(e,t+e.localName);return()=>{a.shadowRoot&&e.shadowRoot.append(...a.shadowRoot.childNodes),e.append(...a.childNodes),a.replaceWith(e)}}},r._fallback)}static renameNode(e,a){let i=t.createElement(a);for(let t of e.getAttributeNames())i.setAttribute(t,e.getAttribute(t));let r=e.shadowRoot;if(!r){let t=e.querySelector(":scope > template[shadowrootmode], :scope > template[shadowroot]");if(t){let a=t.getAttribute("shadowrootmode")||t.getAttribute("shadowroot")||"closed";r=e.attachShadow({mode:a}),r.appendChild(t.content.cloneNode(!0))}}return r&&i.attachShadow({mode:r.mode}).append(...r.childNodes),i.append(...e.childNodes),e.replaceWith(i),i}constructor(){super(),this._ready=i(),this._fallbackExec={},r._tagNames.add(this.localName)}getParents(e,a=!1){let i=[];for(;(e=e.parentNode)&&e&&e!==t.body;)if(e.matches&&e.matches(this.localName)){if(a&&e===a)break;s.hasConditions(e,r.attributePrefix)&&i.push(e)}return i}replaceTemplates(){let e=this.querySelectorAll(`template[${r.attr.template}]`);for(let t of e){if(this.getParents(t,this).length>0)continue;let e=t.getAttribute(r.attr.template);if("replace"===e){let e=Array.from(this.childNodes);for(let t of e)this.removeChild(t);this.appendChild(t.content);break}{let a=t.innerHTML;if("once"===e&&a){if(r._once.has(a))return void t.remove();r._once.set(a,!0)}t.replaceWith(t.content)}}}async beforeReady(){let e,t=this.getAttribute(r.attr.type);t?e=r._initTypes[t]:this.getAttribute(r.attr.import)&&(e=r._initTypes.default),e&&await e(this)}async ready(e,t){return Array.isArray(t)||(t=this.getParents(e)),Promise.all(t.map(e=>e.wait()))}replaceFallbackContent(){let e=`${this.localName}--`;for(let[t,a]of Object.entries(this.getFallback())){if(this._fallbackExec[t])continue;let i=Array.from(this.querySelectorAll(t)).reverse(),r=[];for(let t of i){if(!t.isConnected)continue;let i=this.getParents(t);if(i[0]===this){let s=a(t,e);r.push({node:t,parents:i,returned:s})}}for(let{node:e,parents:t,returned:a}of r.reverse())this.ready(e,t).then(a);this._fallbackExec[t]=!0}}wait(){return this._ready.promise}async connectedCallback(){s.hasConditions(this,r.attributePrefix)&&this.replaceFallbackContent(),await this.hydrate()}async hydrate(){let e=[],t=this.getParents(this);t.length&&e.push(t[0].wait()),e.push(...s.getConditions(this,r.attributePrefix)),await Promise.all(e),this.replaceTemplates(),await this.beforeReady(),this._ready.resolve();let{ready:a,defer:i}=r.attr;this.setAttribute(a,""),this.querySelectorAll(`[${i}]`).forEach(e=>e.removeAttribute(i))}}class s{static _media={};static map={visible:s.visible,idle:s.idle,load:s.pageLoad,interaction:s.interaction,media:s.media,"save-data":s.saveData};static getMap(e=""){return Object.keys(s.map).map(t=>e+t)}static hasConditions(e,t){for(let a of s.getMap(t))if(e.hasAttribute(a))return!0;return!1}static getConditions(e,t){let a=[];for(let i of s.getMap()){let r=t+i;if(e.hasAttribute(r)){let t=e.getAttribute(r);a.push(s.map[i](t,e))}}return a}static visible(t,a){let{promise:r,resolve:s}=i();if("IntersectionObserver"in e){let e=new IntersectionObserver(t=>{let[a]=t;a.isIntersecting&&(e.unobserve(a.target),s())});e.observe(a)}else s();return r}static pageLoad(){if(s._cacheLoad)return s._cacheLoad;let{promise:a,resolve:r}=i();return"complete"===t.readyState?r():e.addEventListener("load",()=>r(),{once:!0}),s._cacheLoad=a,a}static idle(){if(s._cacheIdle)return s._cacheIdle;let{promise:t,resolve:a}=i();return"requestIdleCallback"in e?requestIdleCallback(()=>a()):a(),s._cacheIdle=Promise.all([s.pageLoad(),t]),s._cacheIdle}static interaction(e,t){let a=(e||"click,touchstart").split(",").map(e=>e.trim()),{promise:r,resolve:s}=i();function o(e){s();for(let e of a)t.removeEventListener(e,o)}for(let e of a)t.addEventListener(e,o,{once:!0,passive:!0});return r}static media(t){if(s._media[t])return s._media[t];let{promise:a,resolve:r}=i(),o={matches:!0};return t&&"matchMedia"in e&&(o=e.matchMedia(t)),o.matches?r():o.addListener(e=>{e.matches&&r()}),s._media[t]=a,a}static saveData(e){let{promise:t,resolve:r}=i();return"connection"in a&&a.connection.saveData!==("false"!==e)||r(),t}}new URL(import.meta.url).searchParams.has("nodefine")||r.define(),e.Island=r;export{r as Island}; \ No newline at end of file diff --git a/public/assets/js/is-land.min.js.br b/public/assets/js/is-land.min.js.br new file mode 100644 index 0000000000000000000000000000000000000000..34fe76f2943ce1586ecfa2013bf766c68599d5b7 GIT binary patch literal 1834 zcmcckVF5#eNMH5((-)$2g1MD9PwQF{ztZb@JOejZ$z9JfnVYdui#LaytumkLV$&g8 zkyn`K`Q~^)ttQ6~7i({}rTu&U-e2i?d{MiVm?G?{`@U%Xa75|BXx7huTPIEPWifvm$n&cefND7o3L!6 zw4MHu+Y5DO1XfN5G8X8_TxqeHxZI@J;N)GqQ!&#yCwp*7 zY}YuMFje9AdFe0TY*bIPtlV1oDkbYw!2;&gC6oW|sD2Q?Q_u9~$+K_w2OnL))zaiu zcAd*5Li=X2)SlJN*>Cf|m5bgo`2H_oszT738((@)PrU!tvv2OXODdOVOQ#t%{;l+k z@0utpCb`$@tn(h9az5jgFDGo3ss0(>^LN$lOQ4;(mh1Dzt1P*2W0RG&b%Du8hEq%rqLbGO{@l2% z&X&WGSD&l$jaAO;Ka59|cZwf6G4b%!!|JQ-HgikQem(oRJE!ft`K@Me<4mfAnr|iE zuDX+9;;{btuBUGjjCihk7CrM2d@oS`!#8quOU}*tFZk@=9<^S2*8HW(`b*I*lYZ2d zteAhG@=fT^Kl^+Qm_8}VZ|jClu$-mEF2ERZp+jv0m2t#e`B>6p4vWWKF~9c5Z|lGR`9#S6Vh+Q5uKb#YpVew+9r;(VGrwm| z^8U!I904^S2K~M3`>jx{&T9-*=rnUa|M}Jrk>hylTeDK&rpK9H; z%gOY)J-5DEO-(x%ExA>K=QkZ&}DPfnQ}z?G8D= z`oP3R=dX$c3RSOgI5YFojF}!KI`-im1$Nx~og#}GK3)^;I_^I4da$eYFO9+jf2BI> z{`K~2na!Sip`TyrnER*FDNhA-WS5^1S?%)kJD-$c)U%7Hzp99TD0c|5xXi}E<96}> z&Ah7-d~pxno>*_u@wYqahg7agLcaJEN0)XlZKp$%@0uj_1uc1?DsLUjR~{$v(%^dl zYt6oQMLbLNosKMACh~yq^z+wKN~6nP7V6w!3(K0}yF+m2)ivcCJFic3iOeY1xEUX=R_dyo^t=4%`do-nYNAeZz~q2F>oTn`UV;dDLIZ zI%t%Z@$K@<$Nz3d$xUB=>Asrea-|=JPmX#7FI_4Br~A?kNzDn7s)E)lrawO4HEn&{ zY~|K<@y?7g4lZBn3Zk#ce@ltwR@6`V=5^|h%LdE6QhQ^Roz|P{wk$hbYq0cf_Q71{ zoVPchhS|U0{&RY0__^uZOLJ}-3KuV)ePiW5Mc?V=JDq3dzdltopGQ*S=*BR9!>$uE zv>D@7#FV+}T=njpsQDhUv(@7Mhi(szr^_El+eII~d!;hzgo-z3;-ZId?b&*k@$^28 z^fmW;KH=W7%0{0BFaP%+5NcNrUM*Vl^jXHqsved0!-*oD6X)v0uK(8%#98#b)=%z( z0o$ZW@pU>+5~|jI-v8z5`c)N=_N{Ps-okYz$1kMN%>2G;X*B!p``HV49QbW~-v279 z((&6_ZM!Dw0+&cc+w8C@7oFB?IVWySzt76~=uKYV&6RzXFW=m2mpb_~A}6)G^L(*y`@z z_Ly@%bWYLyQ}cTT&z|G@`u6yycVENgH*RLK)+*y!XgvFwu@1NH2UC}slV|&?Za=g9 OQkCavKJV~_Hp~D5aDl`C literal 0 HcmV?d00001 diff --git a/public/assets/js/is-land.min.js.zst b/public/assets/js/is-land.min.js.zst new file mode 100644 index 0000000000000000000000000000000000000000..f878449071c3b6d393e2a050c9071f3b6bd645eb GIT binary patch literal 2082 zcmdPcs{c1dUU;bkLsm!&pDM%TJDICJPEL7yD3(bv?cx{HkNbU}=cmuC<$v!V|K#MK z|Mx==vOHjT#Bhs2e2Ij7>YCR-Y`5p1`Q732NiK=UXz8+c z)}BjA*Uern?ozdy^!Yc_rDYXNhxZ3cR^Ll^bv?f7to1RyTqEW;ANOr}B2jH7wB6e6 zF@wGLo0rd5F8ZaV%I++jvV6t0 zdb_N?vg`iY%RF!2y~c6h@C`R(Udf%P*3TC|WlF7lc~$l9ud)}h*7q-O)wL=**1x2* zvBoZWQQOn(6Yo-k!%UW5|8V#Cl$1XWZ*8U;GdWC9@n<%Xvk+uz66zFpd6LcjC+6*= z`LoYyCMhbOzVY9v>#@cZe;JnLt0gbCZ*6-&v8+?z%iVJS_&d4h%rka=E#AF5>UG)a znkx^=yq9XGDb>9<^(+hhnBDp0`Ia9ugwkI;-lnT?mhqXw17t;)nZ*Z$ykh|`F z^}FAS?lZTPabH}$Re>or{a@wN_g?ZVV{5d&e=52!5yYX%I$2|D&wJ&IcegFOH|yK> z-xu{KA9|-gwXxCb?%i*{e(t>6c7NZ+9dEyxc0O6S=dj;~7u#w|MQ(lT6Iq@%{dVy* zk0)QI8HHtRH=Uf7e{8|Flsil<>jSb_gLzB^CoWJtA ztdl!;NoFN~V#@048>C%kO1La2cAczJC*C8qMA7N`RM+)e_Z)loB6|MufT=3}B8L{f z`N4W=f^)~W8&i6M-o#AooHKjh+&fi&eD{haP7KW6yi7PgqS@e6gi@mB^K1*%87`m1 z=e(P}tf+MP>!*2NZslDLy!iid$i?k%=FU%3%HH?7Bgy;}%dYh8h29mn&hbB#*j20d zTq674ruoM@rmxMMpi=iY=W|QlzwZC@1dH1iv03^qU-w(;)~jnj<+p3DZJ%gjx8%c- zi4!Zc_pEGPr0LSVA?W|HDHe$t53NO5<$fK@Vi0xtCjT`1KkGi-+>*<(J)Q;zb<^0Q z4o|T>eoudKcLq)B_br(Ps?iEuIc+kB6R)uxX{!)6HtJ2|Jk6ug#`m{y zl0d4GXxE~fch)bk4OC&wams2ERgnGUA>=%x(Z|E^*10Uv7n_r2g>RgwJ)Jv)Q|9h* zr#E?<*>hNrg|6!@Jj$|0cb1Y-y}MXclEmGQvWG9OSm*Lx_JZ(~cubjHqX%QO}d%<+u52=gGR;A9X8M+Ug`?#a-L2lB$Crno-9n|@G$tqaj-KQz{xP!jAOw&9(U(;yb7a{o> zD;7up%zI;z|HtIuU#~=$>R^vwAGZ9TuJS!}%18Alh1Fe#U7NY~UY}+3aLLE5`&HFX zIO-*NGoN*zX?^#JVath-E2~6O&-+i*w6)2&#`e5t;e*K2E;+&`3@6MpX6TwGG{yvM zyLWLv&yx*J7T>+i_>Su;Di$OMbaIC#C)pG=M_u#X_HK6Du2~I$B>{}SULdh7n3vX^@+KX2M$t^IsU{`BZSx>HMc-H_NI<|4rr zwl!i}^L4}N{4B+1HrYJ+^8RRNdGWro-Ru6iY~}q@D0!g2?80`2lG`(P=TvULqPxh8 zS1y}N#z%jxduA}}(xQa4%MKPcqnkhG~LB4L|Xkn_FN%v2lRpNzh7b6d=+{F#dsr0HeqYlmGw# literal 0 HcmV?d00001 diff --git a/public/assets/js/winter.js b/public/assets/js/winter.js new file mode 100644 index 00000000..c28d74d5 --- /dev/null +++ b/public/assets/js/winter.js @@ -0,0 +1,125 @@ +class Snow extends HTMLElement { + static random(min, max) { + return min + Math.floor(Math.random() * (max - min) + 1); + } + + static attrs = { + count: "count", // default: 100 + mode: "mode", + text: "text", // text in snow flake (emoji, too) + } + + generateCss(mode, count) { + let css = []; + css.push(` +:host([mode="element"]) { + display: block; + position: relative; + overflow: hidden; +} +:host([mode="page"]) { + position: fixed; + top: 0; + left: 0; + right: 0; +} +:host([mode="page"]), +:host([mode="element"]) > * { + pointer-events: none; +} +:host([mode="element"]) ::slotted(*) { + pointer-events: all; +} +* { + position: absolute; +} +:host([text]) * { + font-size: var(--snow-fall-size, 1em); +} +:host(:not([text])) * { + width: var(--snow-fall-size, 10px); + height: var(--snow-fall-size, 10px); + background: var(--snow-fall-color, rgba(255,255,255,.5)); + border-radius: 50%; +} +`); + + // using vw units (max 100) + let dimensions = { width: 100, height: 100 }; + let units = { x: "vw", y: "vh"}; + + if(mode === "element") { + dimensions = { + width: this.firstElementChild.clientWidth, + height: this.firstElementChild.clientHeight, + }; + units = { x: "px", y: "px"}; + } + + // Thank you @alphardex: https://codepen.io/alphardex/pen/dyPorwJ + for(let j = 1; j<= count; j++ ) { + let x = Snow.random(1, 100) * dimensions.width/100; // vw + let offset = Snow.random(-10, 10) * dimensions.width/100; // vw + + let yoyo = Math.round(Snow.random(30, 100)); // % time + let yStart = yoyo * dimensions.height/100; // vh + let yEnd = dimensions.height; // vh + + let scale = Snow.random(1, 10000) * .0001; + let duration = Snow.random(10, 30); + let delay = Snow.random(0, 30) * -1; + + css.push(` +:nth-child(${j}) { + opacity: ${Snow.random(0, 1000) * 0.001}; + transform: translate(${x}${units.x}, -10px) scale(${scale}); + animation: fall-${j} ${duration}s ${delay}s linear infinite; +} + +@keyframes fall-${j} { + ${yoyo}% { + transform: translate(${x + offset}${units.x}, ${yStart}${units.y}) scale(${scale}); + } + + to { + transform: translate(${x + offset / 2}${units.x}, ${yEnd}${units.y}) scale(${scale}); + } +}`) + } + return css.join("\n"); + } + + connectedCallback() { + // https://caniuse.com/mdn-api_cssstylesheet_replacesync + if(this.shadowRoot || !("replaceSync" in CSSStyleSheet.prototype)) { + return; + } + + let count = parseInt(this.getAttribute(Snow.attrs.count)) || 100; + + let mode; + if(this.hasAttribute(Snow.attrs.mode)) { + mode = this.getAttribute(Snow.attrs.mode); + } else { + mode = this.firstElementChild ? "element" : "page"; + this.setAttribute(Snow.attrs.mode, mode); + } + + let sheet = new CSSStyleSheet(); + sheet.replaceSync(this.generateCss(mode, count)); + + let shadowroot = this.attachShadow({ mode: "open" }); + shadowroot.adoptedStyleSheets = [sheet]; + + let d = document.createElement("div"); + let text = this.getAttribute(Snow.attrs.text); + d.innerText = text || ""; + for(let j = 0, k = count; j`{AG~sY`N4r7GGR}La zn6yNy1quxO_gro(-I>YL%6`7Mq0cO^lq>#H(UjQNj@~CHUC*ujvpMKX;~btzTTcYI zue;K;{lJzbA=#NWxAwf^`toe|{J^b&D&=bu`wwo^IbLHXa{0c-d64_h^V>}eKF1773D=^5Ye5CYi z(H5U2?56U?vuD3LYP7fN-<{j}Z09Adb8{BhFLkxn?t6Z5=cA9uH;d<-<~q#Hc{FwJ z2ZzTqx)yhAwK*+*Ky6L=6s1?6Tg-e8IeV(eZz!CQsw&s#K8x3IrP%FNt?M7Wy}f;f zd*+MZYrj;i(sM79E#W^U{$XX#+x)A&I#Ig+mwsXF3Hi&AZB}yQ2}kCoWm^j7pGdhc zuWrBN`>&8GHs_*`wX%21PMfG89dzWd@P2WzTga9J=w{()UADchj2;$y<3$9 zTi)){;1F;%t8#q1wQFOzZu-LzPa}~j3MAIIGvd*3kG-nI@d*5tIddthgpqH_tZD zI^n~5Tx3E1mPJlFC8gZo+V|Nswb^>H83kJ0UcDyaT>tNtk!`p4#ndvFh3&R{ylL^p zd2L^6bSEBj=O~=YH_NkrrO8=k<49)KfKmtT(^KwnE$Td2`^%cw#rD#vUfX;RSL@Y6 z?tfEsTIS2$YLC&JF}rTZ38R&LO656UteCnAR-c=;TzyV}lhoho3K|Q3{=9D=b3Fg~ z#Rp47E<3GU_m^+B##|QzlMg+emI2;pCT~y`J;3HvI%Ad?%lh5s7r%Z~3srJh{Z-Jv zwnW8Kj>FvIyut4s4~;9{|BQanz`Dg<)8+n=F1bl1-W{TgKCFx0=jr7=X|JQJhhW9Q zwL01+E6==GQY`#UC*oxN^Q|xW?y!HjrDVVj;)L p=QnYEeDvv*^1LUON7oq5IQ5j>bz;!}Z0H zh*f6K=bFVZpJ6G(IjNroMt4`m?pw1iRcF3jbYVeO=&{~*dGBMtUqz_CtcbX~W#y)c zfop5$N$-u4{-O9ZjpxZf>685Df9R$^*z;;z3}5}1f-gBn3(xSz$xL5lRyA+!KJ)E5 z(J$?%ln8pVE^@fEPd4I3;P*=38;3hpZ{+T>z07f>@ur*Z=INC=9@i)F?{`y~`}bMT z^5!E)jvl^t=?xiT}1u`fO6vwYAO(MH^0XWG>;8^|^S^@nEx@#4f?FFT>uP)l^G%xv)|E=l%4xT6yo+u4+>~b-yS< zScymZWxS1gbmqT_mYNrxRl9?&wfsH>Xr4sE-w+@Ao~4M)2wp&xee)_ zH_TlhJxIw&Vt6U0J*889OI8kh&!PQ)xEzF=pU){4?)nn5{v_b^*ze1Gt3?y$~%|4OtI`@;g%E5EsD&d8INmk zzIZrI{P@bWf2Et{I?R9dFAgsF-5tf2Xgu}#&INaLm#>)kvGUJ^m8QAV@?Gk3UwzBG zR%89XDRP2e{^7kB!&YyIQGXxS5vum@tn;P%w)8XREa6z5r$SA3sc1DcxMNMy$y3WqM1uU=Gik&%W_b)m7yageJ)C`oE@<+py_IP|Mw{xyxou zP&>bAse6;0@AvYkBKKun&C_LMD`ekm+;QMFKT??LWU-L_+&+=UTGt7k(&rBs-Db1c z)D)%j@L*2l{$?R`>8$hFCKMhKK~-`ybIH7smfCqK<20+7rHut|bQ( z(mI^?Gb)}n^E>{!E!K^hyZ2O|SQ9(v?yicSr}Z=b$*$WyF-a=>&OvbJzFAMom|UW4*@PZ(>(FIh3(FxAHy!%+Y(d literal 0 HcmV?d00001 diff --git a/resources/views/admin/settings/show.blade.php b/resources/views/admin/settings/show.blade.php new file mode 100644 index 00000000..801171e1 --- /dev/null +++ b/resources/views/admin/settings/show.blade.php @@ -0,0 +1,26 @@ +@extends('master') + +@section('title')Site Settings « Admin CP « @stop + +@section('content') +

Site settings

+
+ {{ csrf_field() }} + {{ method_field('PUT') }} +
+ +
+
+ +
+
+@stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 663cfdc4..03112665 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -61,4 +61,9 @@

Manager your passkeys.

+ +

Settings

+

+ Edit site settings. +

@stop diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index cb15e685..543f4255 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -80,6 +80,17 @@ @section('scripts') + + @if($winterEffectEnabled ?? false) + + + + + + @endif @show diff --git a/routes/web.php b/routes/web.php index b929a2de..3b6fa7d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Admin\LikesController as AdminLikesController; use App\Http\Controllers\Admin\NotesController as AdminNotesController; use App\Http\Controllers\Admin\PasskeysController; use App\Http\Controllers\Admin\PlacesController as AdminPlacesController; +use App\Http\Controllers\Admin\SettingsController; use App\Http\Controllers\Admin\SyndicationTargetsController; use App\Http\Controllers\Admin\TokensController; use App\Http\Controllers\ArticlesController; @@ -160,6 +161,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { Route::put('/', [BioController::class, 'update']); }); + // Settings + Route::prefix('settings')->group(function () { + Route::get('/', [SettingsController::class, 'show'])->name('admin.settings.show'); + Route::put('/', [SettingsController::class, 'update']); + }); + // Passkeys Route::prefix('passkeys')->group(function () { Route::get('/', [PasskeysController::class, 'index']); diff --git a/tests/Feature/Admin/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php new file mode 100644 index 00000000..bf7d068b --- /dev/null +++ b/tests/Feature/Admin/SettingsTest.php @@ -0,0 +1,68 @@ +make(); + + $response = $this->actingAs($user) + ->get('/admin/settings'); + $response->assertSeeText('Site settings'); + } + + #[Test] + public function admin_can_enable_winter_effect(): void + { + $user = User::factory()->make(); + + $this->actingAs($user) + ->post('/admin/settings', [ + '_method' => 'PUT', + 'winter_effect_enabled' => '1', + ]); + $this->assertDatabaseHas('settings', ['winter_effect_enabled' => true]); + } + + #[Test] + public function admin_can_disable_winter_effect(): void + { + $user = User::factory()->make(); + Setting::factory()->create(['winter_effect_enabled' => true]); + + $this->actingAs($user) + ->post('/admin/settings', [ + '_method' => 'PUT', + ]); + $this->assertDatabaseHas('settings', ['winter_effect_enabled' => false]); + } + + #[Test] + public function winter_effect_markup_is_hidden_when_disabled(): void + { + $response = $this->get('/'); + $response->assertDontSee('snow-fall'); + } + + #[Test] + public function winter_effect_markup_is_shown_when_enabled(): void + { + Setting::factory()->create(['winter_effect_enabled' => true]); + + $response = $this->get('/'); + $response->assertSee('snow-fall', false); + } +} From 9e9fbdc127e983a4a33a9aae313c3930956db6c8 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 28 Aug 2026 14:49:53 +0100 Subject: [PATCH 10/15] Add admin-editable About page Mirrors the existing Bio singleton pattern: a new `about` table/model, admin CRUD at /admin/about, and a public page at /about linked from both the header nav and the admin homepage. Content is wrapped in .e-content so it lays out correctly within the site's CSS grid. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013bzYwaDD8p3XNDMYKvXKrs --- app/Http/Controllers/AboutPageController.php | 16 +++++ .../Controllers/Admin/AboutController.php | 32 +++++++++ app/Models/About.php | 13 ++++ database/factories/AboutFactory.php | 24 +++++++ .../2026_08_28_142830_create_about_table.php | 28 ++++++++ resources/views/about.blade.php | 9 +++ resources/views/admin/about/show.blade.php | 19 ++++++ resources/views/admin/welcome.blade.php | 5 ++ resources/views/master.blade.php | 1 + routes/web.php | 11 +++ tests/Feature/AboutPageTest.php | 26 +++++++ tests/Feature/Admin/AboutTest.php | 68 +++++++++++++++++++ 12 files changed, 252 insertions(+) create mode 100644 app/Http/Controllers/AboutPageController.php create mode 100644 app/Http/Controllers/Admin/AboutController.php create mode 100644 app/Models/About.php create mode 100644 database/factories/AboutFactory.php create mode 100644 database/migrations/2026_08_28_142830_create_about_table.php create mode 100644 resources/views/about.blade.php create mode 100644 resources/views/admin/about/show.blade.php create mode 100644 tests/Feature/AboutPageTest.php create mode 100644 tests/Feature/Admin/AboutTest.php diff --git a/app/Http/Controllers/AboutPageController.php b/app/Http/Controllers/AboutPageController.php new file mode 100644 index 00000000..d174e550 --- /dev/null +++ b/app/Http/Controllers/AboutPageController.php @@ -0,0 +1,16 @@ + About::first()?->content, + ]); + } +} diff --git a/app/Http/Controllers/Admin/AboutController.php b/app/Http/Controllers/Admin/AboutController.php new file mode 100644 index 00000000..cc5be0ad --- /dev/null +++ b/app/Http/Controllers/Admin/AboutController.php @@ -0,0 +1,32 @@ + $about, + ]); + } + + public function update(Request $request): RedirectResponse + { + $about = About::firstOrNew(); + $about->content = $request->input('content'); + $about->save(); + + return redirect()->route('admin.about.show'); + } +} diff --git a/app/Models/About.php b/app/Models/About.php new file mode 100644 index 00000000..26b6a719 --- /dev/null +++ b/app/Models/About.php @@ -0,0 +1,13 @@ + + */ +class AboutFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'content' => $this->faker->paragraph, + ]; + } +} diff --git a/database/migrations/2026_08_28_142830_create_about_table.php b/database/migrations/2026_08_28_142830_create_about_table.php new file mode 100644 index 00000000..15ab6f84 --- /dev/null +++ b/database/migrations/2026_08_28_142830_create_about_table.php @@ -0,0 +1,28 @@ +id(); + $table->text('content'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('about'); + } +}; diff --git a/resources/views/about.blade.php b/resources/views/about.blade.php new file mode 100644 index 00000000..e73c933a --- /dev/null +++ b/resources/views/about.blade.php @@ -0,0 +1,9 @@ +@extends('master') +@section('title')About « @stop + +@section('content') +

About

+
+ {!! $about !!} +
+@stop diff --git a/resources/views/admin/about/show.blade.php b/resources/views/admin/about/show.blade.php new file mode 100644 index 00000000..59c6a433 --- /dev/null +++ b/resources/views/admin/about/show.blade.php @@ -0,0 +1,19 @@ +@extends('master') + +@section('title')Edit About « Admin CP « @stop + +@section('content') +

Edit About

+
+ {{ csrf_field() }} + {{ method_field('PUT') }} +
+ +
+ +
+
+ +
+
+@stop diff --git a/resources/views/admin/welcome.blade.php b/resources/views/admin/welcome.blade.php index 03112665..132da7cc 100644 --- a/resources/views/admin/welcome.blade.php +++ b/resources/views/admin/welcome.blade.php @@ -57,6 +57,11 @@ Edit your bio.

+

About

+

+ Edit your about page. +

+

Passkeys

Manager your passkeys. diff --git a/resources/views/master.blade.php b/resources/views/master.blade.php index 543f4255..b33d1fa9 100644 --- a/resources/views/master.blade.php +++ b/resources/views/master.blade.php @@ -36,6 +36,7 @@ Likes Contacts Projects + About @include('icons.json-feed', ['title' => 'JSON Feed'])

diff --git a/routes/web.php b/routes/web.php index 3b6fa7d2..03d865b1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ name('login'); Route::post('login', [AuthController::class, 'login']); @@ -167,6 +172,12 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { Route::put('/', [SettingsController::class, 'update']); }); + // About + Route::prefix('about')->group(function () { + Route::get('/', [AboutController::class, 'show'])->name('admin.about.show'); + Route::put('/', [AboutController::class, 'update']); + }); + // Passkeys Route::prefix('passkeys')->group(function () { Route::get('/', [PasskeysController::class, 'index']); diff --git a/tests/Feature/AboutPageTest.php b/tests/Feature/AboutPageTest.php new file mode 100644 index 00000000..61455069 --- /dev/null +++ b/tests/Feature/AboutPageTest.php @@ -0,0 +1,26 @@ +create([ + 'content' => 'This is the about page content.', + ]); + + $this->get('/about') + ->assertSee('This is the about page content.'); + } +} diff --git a/tests/Feature/Admin/AboutTest.php b/tests/Feature/Admin/AboutTest.php new file mode 100644 index 00000000..141a548b --- /dev/null +++ b/tests/Feature/Admin/AboutTest.php @@ -0,0 +1,68 @@ +make(); + + $response = $this->actingAs($user) + ->get('/admin/about'); + $response->assertSeeText('Edit About'); + } + + #[Test] + public function admin_can_create_about(): void + { + $user = User::factory()->make(); + + $this->actingAs($user) + ->post('/admin/about', [ + '_method' => 'PUT', + 'content' => 'About content', + ]); + $this->assertDatabaseHas('about', ['content' => 'About content']); + } + + #[Test] + public function admin_can_load_existing_about(): void + { + $user = User::factory()->make(); + $about = About::factory()->create([ + 'content' => 'This is my about page. It uses HTML.', + ]); + + $response = $this->actingAs($user) + ->get('/admin/about'); + $response->assertSeeText('This is my about page. It uses HTML.'); + } + + #[Test] + public function admin_can_edit_about(): void + { + $user = User::factory()->make(); + $about = About::factory()->create(); + + $this->actingAs($user) + ->post('/admin/about', [ + '_method' => 'PUT', + 'content' => 'This about page has been edited', + ]); + $this->assertDatabaseHas('about', [ + 'content' => 'This about page has been edited', + ]); + } +} From 568ae78864aa1f955c80000afe44c790b994cea5 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 13 Sep 2026 10:40:27 +0100 Subject: [PATCH 11/15] Add manual Micropub token generation for non-PKCE clients iA Writer's IndieAuth client predates PKCE support in the spec, so it can't complete the normal authorization flow. Add an admin form to mint a token directly (reusing the existing TokenService), so it can be pasted into clients that support manual token setup instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy --- .../Controllers/Admin/TokensController.php | 32 +++++++++ public/assets/css/app.css | 2 +- public/assets/css/app.css.br | Bin 1690 -> 1792 bytes public/assets/css/app.css.map | 2 +- public/assets/css/app.css.zst | Bin 2010 -> 2117 bytes public/assets/js/app.js.br | Bin 1269 -> 1304 bytes public/assets/js/app.js.zst | Bin 1475 -> 1502 bytes resources/css/admin-tokens.css | 26 +++++++ resources/views/admin/tokens/create.blade.php | 52 ++++++++++++++ resources/views/admin/tokens/index.blade.php | 9 +++ routes/web.php | 2 + tests/Feature/Admin/TokensTest.php | 67 ++++++++++++++++++ 12 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 resources/views/admin/tokens/create.blade.php diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php index 1b5348f9..02b9660c 100644 --- a/app/Http/Controllers/Admin/TokensController.php +++ b/app/Http/Controllers/Admin/TokensController.php @@ -6,7 +6,9 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\MicropubToken; +use App\Services\TokenService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\View\View; class TokensController extends Controller @@ -21,6 +23,36 @@ class TokensController extends Controller return view('admin.tokens.index', compact('tokens')); } + /** + * Show the form to manually generate a new Micropub token. + * + * This is for clients (e.g. iA Writer) that don't support the IndieAuth + * PKCE flow and instead expect to be given a token directly. + */ + public function create(): View + { + return view('admin.tokens.create'); + } + + /** + * Manually generate a new Micropub token. + */ + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'client_id' => 'required|string', + 'scope' => 'required|array|min:1', + ]); + + $token = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => $validated['client_id'], + 'scope' => implode(' ', $validated['scope']), + ]); + + return redirect('/admin/tokens')->with('new_token', $token); + } + /** * Revoke a Micropub token. */ diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 5c50c0b4..e255209f 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,2 +1,2 @@ -@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{background-color:color-mix(in oklch, var(--clr-border) 40%, transparent);border-radius:999px;justify-self:start;align-items:center;gap:.25rem;padding:.25rem;display:flex;& button{appearance:none;color:inherit;background:0 0;border:none;border-radius:999px;place-items:center;padding:.375rem;transition:background-color .2s,color .2s;display:grid;&:hover{cursor:pointer}&:focus-visible{outline:2px solid var(--clr-text);outline-offset:2px}& svg{fill:currentColor;width:1.5rem;height:1.5rem}@media (hover:hover){&:hover{background-color:color-mix(in oklch, var(--clr-border) 70%, transparent)}&[aria-pressed=true]:hover{background-color:var(--clr-text)}}&[aria-pressed=true]{background-color:var(--clr-text);color:var(--clr-background)}}}.sr-only{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}} +@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{background-color:color-mix(in oklch, var(--clr-border) 40%, transparent);border-radius:999px;justify-self:start;align-items:center;gap:.25rem;padding:.25rem;display:flex;& button{appearance:none;color:inherit;background:0 0;border:none;border-radius:999px;place-items:center;padding:.375rem;transition:background-color .2s,color .2s;display:grid;&:hover{cursor:pointer}&:focus-visible{outline:2px solid var(--clr-text);outline-offset:2px}& svg{fill:currentColor;width:1.5rem;height:1.5rem}@media (hover:hover){&:hover{background-color:color-mix(in oklch, var(--clr-border) 70%, transparent)}&[aria-pressed=true]:hover{background-color:var(--clr-text)}}&[aria-pressed=true]{background-color:var(--clr-text);color:var(--clr-background)}}}.sr-only{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}.token-reveal{border:1px solid var(--clr-border);background:light-dark(oklch(96% .08 145),oklch(28% .08 145));border-radius:16px;margin-block-end:1em;padding:1em 1.2em;& input{border:1px solid var(--clr-border);border-radius:8px;width:100%;padding:.5em .7em;font-family:monospace}}.scope-checkboxes{flex-wrap:wrap;align-items:center;gap:1em;display:flex}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index 9ca789b9af38df7f5e3f37fefbeae7419e9b5312..200e0d258d986670b66f63ebf5d4d71b53405b73 100644 GIT binary patch literal 1792 zcmcck;Sj?E8K3ROt4(x$ID$W=y-!zGnfdkf-qXn;>(tGEugG&2{?+AFtMH}8e`S8z z%vIN|3)K}kkDS=CE^dd~(MXHzQ2yYp5C7he%Hj;sN@ZAjs`iE_o70B!rF%spzwTXV zduM^xq;%Qn4g2z(YfN5TTE;tb+w@~C2K=}7t(W?(a%Ea=7= z={xJXuX(2uHR3zt7RFAS@NBPk=q&z;JMF^TuX`-ckLH@j_sU>*SlfJ0t(g*B`4PLG zR=GQB?3v#X^Ng$ibe@u!<&Ee=f;aUi{^@MV?7enm=l<3%`NhKTry2xsp0AzV<8ajS zu0`(ZsMM`Vq0`R)D^KS?&ej&o$-Jv=Urs1I`yyVO6 zFsBJ-3ae(63tMg1xge=@!}W*kN{utuQ`R>!rtFc{FAe@8>!Q!uFJ7^Xuj^BGxWw!e zCd{RrS??0>TokyzdCRqC&s!dR8Zj!bemz~1SP<10zD}UqP%%vRkgiw8-Ye{W{r8UC zyr`QkX#TJxZ_86py94*Vm}K%3lZ78nc3ZN0$+wElQ7={t%}zX{;Zy53>8Po`Z_r^p-T{&m-nDtrBE`gCK?HoG~N z)+OwdR_49dO=4^9NHh6Z@x}gXm*vsy&8g`x4qmuF;c#?g`_i*YhZk=zU&L1}-P`gq zx^SacXv%}ymR{*?ocyteb5AYf)ahQ}#IUVh{?MQ0W(oya&wV6o_)o}r9Q{~hRJG2# z=V1J;<=PTq9CaH6!bIZcZTAZ=dmIqm^f2}BR9b7N=AymC?A<3z*At-}FKl-z``!$>DYJf~`FKwf5$xt)--^mL$#2$O*kmKevpnDOVk^U4ok}$RIZX&Z`%cFmLSO0tW`w>&1bmI5v=b9!IAF0?{VR!3V z!F;g?rAxEKW(i3yUR3=|S^tyYvB-yYESmf0S#i{q3ELiZvK22hd1>Hbz$3i$@zg1^mA7`THQ6MQvF7X)g|o#Q z4dS&+a{rw3X1U~YsAJDp2dmeMIT!yr{-!uc_PXdPpP~)1oBgB{XCc~D^$ETbNtydDdNXe=k6)%7wx;4Hb=Zl zyQ7uoxO(Karp#Lce@%8dFZi7ma>SbX>ps1tBBuWineX0jaGPeb*vNm@E4O|jH6f$S z#n#Pt*rjhzu8-OL(afHU<+b|ttCLw2SJm^Devf$9J>6D-udYt!g#pX5NmdKD{5p{E zd{-`iU+1ZRx=O7}rfc)*ZQ~8*^RQ?DtQ?_>9f|?IEl)izBy08xV_6r zdcrXw_N;bR#TD0IM4WJ(eKa>*@k5hWmj9|xhT*HW`dR9KWnf-%`7HkLFLvf>UALRiVqtM5MSh85e$NA~ ziuP>Nbr&m7x(ilc=%4pVg;|(1r0L-IQSPd628mp>h-eoU5)Aenpv;EncZ!$-|?YQ z{dvO-{f<4eMfbZ;HkDW~@m-rvdHa&`t^;3r>tfewY8(}7Jz9AB*|J@)#`x@hP6y?-qg@2x!g_5b`Y Vnfeurf?qQI-ZAUE^Rs;=%m5*JZlVAH literal 1690 zcmcckVH3jx9iNA9k~9ypCQ0-yUH_?f&6UL3SzeV}>b7u&Ef2VC6V4{-!M1Ow>+M-# zaZxtcmNfZX&UdKX7`!cwe{#syhqABE$a5|$k&!RBQ?qNcl%w2@!jqm8!{?f7Vsea)l`)2#A&e5=mN96Rwth>v$wc&+2ysT;#Piz242 zcUm8y|G8uHJyXrIzu)XG5N-FKawh+FjZMv~+BdbU>}Q`7Sjn5{RH0g&ecf2B<=E@> z4}SNoSo`+B;+B73GdRmTtkUbgJT%I~)(E-@fONvb)w|0Cn!W0eWndC_jm zd_MP!UXVEYCL!`P^W}~5CJXn5MG0{xm|ZMevAXKj`2f`={%v1ZFf;A!Su?HwLecY8 z))uX;suB?{%Vv5Q-d-?S>eSD^jK#hmi(AaB-@pDF_wcag0YAmK@9D*S9UVo@Q`dYt z_{p^R_JQdcT=fZ?+3(05`zAeQ^>T-oJKS&iosZZq|6l&Ed2W>3vnfxHD$iTAZRNFP zd>vZnS6`iLEq0feT$S!__|2yKVuzMt-O8)Imme;W-6yEU@Ich< zT!xNNkJeY87-!oac5SUqd69P}pG%ysJj49o@?P`5iweKHx<9*jd~3<6oYHHTIrepD zZA*Mvw13vjAMXCn5{I6tw;cTbXMdlKOU)j~|}|b9~v$>`SF#WLyvyUztZ|m$4UO}{1YpG-<{4d(^2JOd9BFX zDt}$?4C@oYo2LD*m~d>n;%coXX!gOT8efG zd}|8QWLufyAai=7_Qaybo{qwmLfuPB+#F_#KjFx07r81jWm8M!qz*O@wdkF@E{V-j z&D$#_-PZi3>zM8i;rOcp+utscun@eHV9nXPq%rpImwe6hnJTZ{w@sfXEsd9h5k@W-Rp{tL+mCu%Y6-nra#+RE5%g header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]} \ No newline at end of file +{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,mEAKE,oBAIA,gCAKF,iDAIE,yEAME,uBAON,oDHjDF,kBGyDE,SACE,oBC1DF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CIrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MH/BA,sFAME,+CENF,0LASE,gLAWE,uBAIA,qEAKA,mDAMA,qBACE,iFAIA,6DAKF,oFAOJ,uIAYA,iEAIA,kGAKA,4DAIA,qDAIA,mIDjFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA,0FAIA,wKAUE,iHASF","sources":["resources/css/app.css","resources/css/colours.css","resources/css/layout.css","resources/css/header.css","resources/css/reset.css","resources/css/pagination.css","resources/css/admin-tokens.css","resources/css/theme-selector.css","resources/css/notes.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n\n .token-reveal {\n margin-block-end: 1em;\n padding: 1em 1.2em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n background: light-dark(\n oklch(96% 0.08 145deg),\n oklch(28% 0.08 145deg)\n );\n\n input {\n width: 100%;\n font-family: monospace;\n padding: 0.5em 0.7em;\n border-radius: 8px;\n border: 1px solid var(--clr-border);\n }\n }\n\n .scope-checkboxes {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 1em;\n }\n}\n","@layer components {\n #theme-selector {\n display: flex;\n justify-self: start;\n align-items: center;\n gap: .25rem;\n padding: .25rem;\n border-radius: 999px;\n background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);\n\n button {\n display: grid;\n place-items: center;\n appearance: none;\n border: none;\n border-radius: 999px;\n padding: .375rem;\n background: transparent;\n color: inherit;\n transition: background-color .2s, color .2s;\n\n &:hover {\n cursor: pointer;\n }\n\n &:focus-visible {\n outline: 2px solid var(--clr-text);\n outline-offset: 2px;\n }\n\n svg {\n width: 1.5rem;\n height: 1.5rem;\n fill: currentcolor;\n }\n\n @media (hover: hover) {\n &:hover {\n background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);\n }\n\n &[aria-pressed=\"true\"]:hover {\n background-color: var(--clr-text);\n }\n }\n\n &[aria-pressed=\"true\"] {\n background-color: var(--clr-text);\n color: var(--clr-background);\n }\n }\n }\n\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index d7479672cf281dfbba69bb5d09034c9262bf0692..2fda63037f8da7e97d557de0283fb3148b4aa42e 100644 GIT binary patch literal 2117 zcmdPcs{c17UVN`3gH@OfzlMOX3;%{i7v^{9YchQg-2B^U(=8bVpW5v*h20x(EuJrM zx|@%|p-V&HA;U9<=L~Cay>I)xS$g+GeWzvdFXn$&k?%er9vP*-JY~P>n_UIg@ouk+ z1Dq~frGC%3rg#3%oS9qWM{$}&mV3{kYHkdlt z+|Qe&=-#u*c@495(heKfr_oRTEMK%cLFQZV+&a`Zev|R^TpDM$5^+{nN+(}&LZw_ z=>K2OJES)0^w{5h`nJYxjbeODc(|$Z>%T&qm)3g8ZcJ_XyY6jgdCS8_zQuPM_QaPZ zf7Lx1kX@e_%Bw0}>vi(VoZV{dD_*tl{T%*&ey~x&j&;&*+)iFeB0}dII#zuuG%46| z&dcJ=?LvPxK7l6-6F#kNt2t-bDsp0RqE2wO1V>ovk_AUp)hloIuB~e`+4Y>a-6&$| zyyW=TS`#+O9bGVw#PEdQNV;yI2Mws&V-yZT>^V~L6h_mT3c{l4c*F0#+=b+DWs ze!?;MA8X9@TYlp9&)M2fCy25iQos9N%d^4L^2)rtIpdtVgqZK#$%@4Dppl%0kDetYwZ z&$X|2Rmh#cfH^cIQMx}WvD(k=)z;6?UBr(@u;0IOExcZ6F&p2und&R0f)tM0T4VEfX>VPaF0$Pxn? zXN5`b%$h8defqEDn9dS;WO1F>YKOP$-c`%{3&MKt&5}vioIOeWi1U1&-v9k7r=#+3 z-n5zFbkSz!{V4}xcQd?cs8gM$Fu|2S{L5M^%TLQ@1Sw8>Zj^H2ds4B$&Yrr5FT#U7 z6wd1{?Kl%NY1Vex19O=L?y)gFI_PoXue98aiRaflG-$tISjPD2;F+7Tzl8o7+dkT8 zxzhCI&bgjqRcZ(S{5;TdaV49~n~Ao8{0k>1w>sZoZ{(bwXuUCQhpSOfh3w*ntqmSY z6aGDNc=|sqtG)X{xJ=$9vkCix0&V9>{4P5wadY0I)eSsJf81B^JbSo6A>46Chu&x9 zW9vEA=pKuoxySd}2BoZJFPip0yHnSyeUiu3tR+xgTVy(8(}C8#oR=@Z4h~ZC{I=53 z>_M|?dU12{@#c4}C)sY^4D{W4VNa0cVc{=c8Zn%u(Pzb~ZiI;6*d^52w&>WVV`hTw zDp6^Q4)04D!=mKtKFiPc>Aq%=ruA`_%j$wa;};$3GXqxDo9eipYP-4a?w?5}=^Jlu zRM_w&qFTyh;j(vJk!6mz4Pzbpo7Db<@fNC1F86zT_vhC59e!L&yRzw6U)f0w zBcFg~1%{Q!<&*+0Z0&iGHtV1TyR^;m*_C&<7oO-{@bpPo&vVtNyaHXHJo~?k1$ho6 zYYW;g=6|$cH}fAYp}+&d)BaxOTwoblHE)LS@|T)#40129eVn;*LyOnymdxCj%jU70 z>m;yzm~hzr{}!3M(Z6bVk`0`>wsWo)cyCkL^EcsF`q5P38)>b#0l%$s{T+rQpV3f4|L-f~dQ{D$+E`+r+`Z$2&A(01>_xOdrv)kG( zSF}8sVgIA-58K&RT>sU2ASsYt=)>z-N$a->t^KOLaiQY-Ij^EhYrU^L;H*5XFCn0; z{GK^U{MNkUB!;D%7WtIM-VoO>Jiqrsp2;27{xh>RPfXk(e4$?Pk~qiezt0^{D980) zik%g_Gg?yUdtu!5tBbt^%x>qZn!n$BX;F#8{E|y+YyM{zUgO)?+h}EAqHX(IQE492 zcDD+hI-#uZbw-VH>*ogD<4#lMZt?55`{;jB?!J;$t7RFERx>Bf4U4FaDU7Q9zAfZP z?~Q1ygiiaq&j*f8uT6TzzF>h<(42h}7E~-ez*DnR+?OXkKl|T`6Dy2l41YMxZ$JFA y#r{v@&yy!pSeIYPT%>&4=P%>AJ#QPEt_!QYd?WEJCUKwo^X#TWDWU152igI{*Z%42HEpO)Y?l!t}Zu-SvbEZ77 z;9q=i?}^gP)#Lsxsyel?uG>>OzZOyUxx$8}eOik?shr=&<6G~;DrL0$- zf7mkWxx|YZ{~{eLws1=OR6nf{cAm+4R6~Pp(Tw&awLG)D7Rl*XioCvUt$I-L)%(}= z{qBpEPapbmCHCIA^k&N^-5*{}I@9*`U#H|{yXAQXZU^hGmmRG>birZcf8CcA6P8~4 zcW08xoSZL~njyvSR>`P%I=`8_zsjGL?|SLsV@(@oNx3owWrT#<^|A7OkL+x(w9Z>~ zz_H6g%xB-z3Fj@BT@q@v;AzX^V?Ma5XX=t4Uo`Z0Z@hYbg4_2y9|PD{UoC!_~?~k58oH3dN#N7k6wlINemxrs2O>#f4+l!Q(DE?Y##BH>_J>TG)5~Wz?m^H#H}e z=B&D!TJM*>P22q4Uj3i7mwqrEQNQUHWo0qbmCIg-m}7VgZOnjL?& zI%_#c>-^Ul54^0BxDUNtdn>=4J!H3Ome%%p=a(A%J-_xu%LLKZz!}w|eTW*q^*4U;>*0fo-@zC3f+8f zg6GEO#TIu}tj(9^)UFdR(0{V?oH!pKFy*}~FVGV1Q_tWo2@G=N- zaXr0Z(is)KsP6hHP9_c!odr{bOFn0Xxi&a49oXBG!DzVZu?3S#W}|{?(L3LQMf39# zJH+}oeYnxKCx6eHCl=g1t+_MGEb>guyUt#45W02Y!iB&6T?R_6u_3ogww;<#xn)C# z+)<_|1(vTy4+9;(J@|I4D#zTaxO^3$xG0^N2q6thrpz!NltC1&I*&}Uu%@QJTLqdXJwLM+V$t_ z+7_k%)R_4zDO&IS$(nz?&rh-**E+deCY^cV&YIS|Wpgs)9yNTi`uOl|w!r(gz(Zd@ z99YoKwdd%Ju-1)7mOF}Z$6eyMIFrjD?96&DK6jA-nJ=3jT>YxLhHDwajv}5r2ZGOW zny+eZdNALcZKVSDooUk7DjC!m&rkG-^8dVGTlCiT(qC6J)<0|!3E+_Vnr2~>9zV13)B;%r>G^^`zG(!$#xw(Or`XOnu-=JVC} zRcdcKUKyCO$hEV2e|Q}<@0oyIb64&9e&5sptcs7)-NAEX+7w>4&l^6o zt+Lf~Sy*-{8`YEHsjl@nrzn}pbXCRJaKyWhRnnq|eztCPyy zdN=4WcipgKvDzTP8~A;8WQuvKK{jYDhiuRY@ zQtCOsc-FTk8I6XYoq}T?M|dx(VB}J|z9{C`#aAN7HXh&a6>fB}Xk(^>#{rQIt79V~ey7b%nw}MgsDziS6 zeSb5*xM5u@bBk#AF73u0S42v-PGE8Vzu3pO!_TF>+q-ZPfB&mqZ-XgkGUQJ8DLRO* z*Vr5_e)j{jaH?fxx2YQs4~tQwrdB}p-P#k%3R6yRz0x*E!EfH`Pb;H^qL{w+R{Oo% zBeU|mV}X@*&dC|>4>A-*6auUEiEiA>*q6>;^LVOk#H{?;_qVumA24weH*Zvu6FMpz z?-KCg>*wkD59>X=5-cY2*?6ow>%A^(;zrT^r?=}IY)Lgaz2xewn_{2uT-g&UzIv6+ z#Nr2hXE^0$6+ZCad|un^$l!G9^SSP*EpZm>Wj{}Me0JEu!Jx^Gn|%X|`nhI*)khtw pR;~AJobLUoH8IKDx^Y>t(x#U>51+@x+x<-?cd-{V z9xmay%cxTOM54v=;h&j3X2Po*470v}xc{p3n8JJBMnAbt?++<1zdK>m!NeB7mkSnF z|6Q1|R7OFha!skC)gwj4;&KUrTpd&%gWQ)5p?(KJ|W|AJ{A2T(S92`I0@~ z4zK$D>eH>YRa~;!7Yq%9RwS+zcv_NltW(`kLHXu_$y+^E{*Jj|V*D!O*qv4GCA_nC z{1mbLe1Deh+P`-vUOT=yyy%~Xx4qSBvAw${Jlkw?@$|cVp;A%4?*Cug4{g-z7mmF! z<@X1xw(#^90a`Wsva9#2n!BBn+3vse!Jaw+^~tw z?`zMsZg<>zLeB4nOzGXmIm~7+XK2XG74h`@eeA1{Z&SjH%WD^`4^m%x=b2D)i{HmX1Ow;BtC8Lxl6 z(bex;*drH)N9(b71QQ#5PKh&{F?QkNWk_t@t(EXsb=PDuGkkx zn}2w^JS5_d)ffMye4fRb%QT+yPV6>6%&J-NW!p*lJ9T+7hm>WKBKO*-eOmt05i`{mZ&$*0-B>9kXvQpHetynM`y!KjY(|KSdU+ z)e;v^n7_?+v*?5)Mk_bE|NQx;Z{D2^Gd8sUUapy$(J=Gvn>y|DF$(kl_3b@5O?9Ql z-^c2w9yQep9gC9J^0jfh+VUiAwUkzEeYalEts6_D7~d>@#`Ju;0Z;Ty#Rrb3t==lC ziAnMopX*!XZ8=$Pu6WOpWg>P@57thf{;FmDzLM3&ZJQDnu4;2sGP{58+B<^;wbM_g zp4y(Cy?9;u3Q@-GYlEWPoZd2P{q(I9cq`aLN;uvq= zR@?N*?XNhK|2i|H#J*{KT%Q-N=9TH~KVi43$Z*bwGFfj`Ki=9m=~HT=*S@gXCE0l( zqs>f;yHfPcnq6!xiXSA>CC}!S#XL9>;M$Q^=x(xif^f<*&ute3|36cT%3S4>V&8Jb zf_K(|puIm=DyCJ~9NP5N@l@6d>qFnlm=?F~{h1{wNiA#7*m8bAe~hZ0VQ>)=c4bHL3SytQ~~B f-(HBiF1=V?K>6gmU!Ojj*6%w1R=ix~S~vp$sqctP literal 1269 zcmX?@QO}@oJ?L%y{ONl$-$(?kGbyQFTBftMYjOkIwUZSlKmNa)9?BOTzs)&qu zlkF*KUJc!zi~*&sktY?-a6J#-6*}>(_MIidxe@x#>6)!2<$NV~HGk^aKi^_$s5ytb z#EaYZ)tlgHu1fq1Pb9y!^*-v{^n9Ae6`#$UCtmj0C%eD!?djv!e{OzQEW`F+(@!h% zY`xoDHS_+p-e$gKIi)XVIJDndmOWycTTfMWW=~x+lhb-z z$F|0f)fXpj{=4?p=O_7Mwz=2WSA5qfjGi01Rqy5=xpS9pO)IZ|q_Eij?7QpoZ?_xC zER)hV{CnuNoL=P7faQm_KMK3UyDw}@z#Y5kO}C}QD_)+vP#kow@~HLk*16&Otf?L! zlLReiyek&mb4J(grt+6~_p??HWfSK1ZEak%S>c=Oqqrv)vm_2KzHD0Bf3<4z{)$9~ z^**f`IjeyF42NW)=qDV%+0v>T0*@;qJ;WFCPaRFKqeD_qCyO&i`G> zLGHf0f5dxOUVCz7W|e6EAIn3T+>@H~E?XZ863DeVS-AG*{O8+V8x_1hBo!xS&9kUm z+A+nEPf&YN`DDKYgHO-EZEmJ<=(&VgK2uuaApcU%J+*^*XtzRl46gymp!w(8!Knzx^CdiITd`SRm)1aAgPzt^~>x9nu`kAg+J zUruk9+9GE1`hxM4?0Hq<5@pkwuh>0k?RZyZ_kM17?(Ju5d!=`sSgG;3;lbXw7nuu|%wT>r8&14Qpf>x0V;|doKF0_w&mcmqON>8g}+6s>)2NpMT(m)_aEQ zn{Fk4&F*C2tps!;>F&ApY~O4BnZ2uLA7s}0ZpQFzP22ZE5#Q~a{`sdrKJVJ~ zzm+fIg68)7DPbN*4;B5Q0>elb=`&SfK-J0IN{-i%!dj2Nc&zDsJJ+Fji diff --git a/public/assets/js/app.js.zst b/public/assets/js/app.js.zst index e9a5163aae326ac383a688a01ab7bc44f4cdf38d..f9a711706ea4fb17f9545e0c604f4eb06253078a 100644 GIT binary patch literal 1502 zcmdPcs{c1-K7Xqo1DCS~kJ<%^=Zp`WZ+X=3vP~~VX7x|$T6uI`gtDss zo(EoX27Fr>b}?*cSh()d5{2vMCq6nOvQDp6C~D!aGiB-fL}qVmzx}+T_LxwoN2%ud z^>>XH2QFQDJ29Q_Cf~=G-M(#^H8YaRyit>BYrrJjEF88bF@9FDjY2CMH<*eY@LHm?Awtcy?w#;tZ z#q)0k?#sM7m%XX|q5J(U|NZ=`MP5HI;`lP>cK^)(Yt5S8rAjIG&Ixwl{a`7sFH)sz zc71;Cvj5h5m?nk&KJqW+XD@Hphw|kQ9?xAE5Wjn}tM=+;6L;RY;3Fg}ClKs0Icn?m zBSPv&4e~-S`^5iV-I}rBRh9QU@2tsd*S^v&nsTpHK09vJFOS_5XB|s!UL1NU?!N4V zD>~PM(%m;L-Kkv{ttWEn#)l0Lo_DOb&0J!)?POZ>|8MrqtM*+@+CKkqNW~4q-rBfD zCaXo+?w!t_Z?|*$#tvn_x##a~wG1rIyK6l0_^+?$ndg}5>(4tn`=s!54y%LvErb{U zo4f1sb#9X%XD41(*57dCl3KH1c}{(>tjqCTu8z$0vXN%1*4DoGw5Ym$n%E(2l}@E^ zk`hO_o%hHbcyT9c`tiJnum5bgv|7Hbyn#nMXOgrtg(zP|1e|K!1UutUCw>LYBp1pmyYIpSOvYREFL@l?N?sYVH^(5k! z$;{3bRo~t&c&-wC%P;cQ_2nOIH(udpkSJe~?c~VwxTkO4O=->u9R~f9g`2w1$d&pA zFwc3{HK#B;Ft~V%Rm_X0IbYWtZrJnx$jX|`GtoCq=grfc{>>zLb*jisY11>SzXbbd zZF--3Ty5St-Po54ZC^xHw)U(PVEe+V$Y8XluVKN}XI6`OK2_$Qv2wrcfBDQ*gYEIv zp?fArXFV?2ySD6%sk_60;Id<)^R4w<|7&Y6ys+AU*XjGjTb)yUD#Lr$&z*E|(|hTx zx&MCeiSCtQ>@ zH9QhiBbY@e?7VpDo&aBg!olvD)~2qk7Fyl*2Gdv1oG!rjKAUr|+trm{^-WIXGhGc} z4LyCGsi^DizDH77t@mChwr#(h{bS1;`7)?SZo$ZczW+<=WpZ7D?5yfLh3*L(q(=Bvf|Wp zH+IR-GcTG3Dpf4aS8}W1Zf*DOXsF8R%Kv^itFI@@=rPlkh6pa^w(xBxCAZG8>OA!O z&6>ioHm_A_&Roxlw22mZ&`B@h6F|$HTw{4Z}|Fe{Djo^)CnV)m#B!}Fw;qc+- zpL&&3SgP%MwEM=V*1Id+>U~4A6Q=I?+JE(cpjP>28R^51l#WG-U7d9^+k2j{)VApt zxkLVG%;5Wc>dfLr5B)E?epX}rv_dR|eWq2LK&X5Em3*BEl0T$WzWmV*PYPKO_gKqr ziRaJmDL~*ANz0nk2v)WVc$*D_pr*ncl$1*(y>i8zw~mK+J@6vPY&Pkie2TW zcuX(w(-B39M49XEwRW|ZLJfKL6OZUg&)sDGi|54D)ZbPoOTKSoU?%*y-XK6gb< z<~!N#WR19CHR0xhzjqauo;k>Rqxm!2=JK6eH`^b4G5u@CnFo)r9F1$NSSlGdt+_%X zwr1^=$%fC&S+{3i?+i9bFWW|w_0A!@4t g54SN7o0rnsRSFM+XZ{pE7^bc?ontjq#v{{-0GzeyWdHyG literal 1475 zcmdPcs{c3T9p74A1}V1{JlY3@1Z@{EUGPuc-{QLFZ^8As3!?(r8Ll2IIyR^LKI4J% z&>PXrjsh%>O)@OI84fY*Ww^YZ|9}$bR&M(MKkmozFH|fef1P!0;g}${W;L z@}_CWeUtrG*PLpq->ErI^?6a%v!_AdOh4QFjP<(1vqbcM_tK5)_*d607md18e#m_0 zy-wGI**&b&jy<||^6jf9FV$a5Ufo)@a|^5ZWi zbM>i7T-ScA>1t=;?zp^wiLJFc(X>14{e_02peyUr4MQ6({C{RHjhJ?P_Tx`ibe3QG z`hi8-nUooJSUSY(k(t=+2gOJ9rgL%kaQr^~K=U-` zUFUZK8*h~Vi$8tXPy6Ye8O%(nw-1Iqoo%W8%i>?)inUznJM@q0Ca@yHosQ@5~iOyTjtOuFUz%a@_0UZ?g)vn@UPDUJn8qpRdVg z*?LOAW~x}qy-n2LWR#riVc9Ln(;Y@=z zYt6jt{nPV*OTM&wu&!>U%G{aqX>r{@mHl@L8XugPv(KQ>YvnoCN{fVy@bj_^E2dh! ze=yHQ)m4|ZEjBcr#YBqX7Hg4+$ja{7v!*gWZ4t1#Fz3c)yaAIS!na;zMwD$YGs5eeeXY0S3HEDj< za;nsbt zdUWCGq^OsNH-CDkZxmw zc-_iicqI7!wZwz^jn0<*t6K`TOfBr0XMI^!r`P!V*SjBEt{tw7k8;lNT_Powstz2U-MM(CfCYc=Qr~dAGmXoOYm;x zV%EY-C;u-wcwg(wEz#!OS4YDqx=4BL->{)3`d@VAp(}HZw>89UPrG{}<6H!HMaql) z>Y0v{qF2q;I$Lt@l1^A%V`JEXtDSCZ#X5r(54`wt)KE(X_oHW;-=GB>KY0qeW=n%VedFNKn zzps9`+U+u)60MaG*IRLxX-zEGA?IBZ&YkIQulIGX?Jd#mV?H~neD0$o&$y>PoPX5* zxqS1*dfp?&g;SRX79C{f3Q-f+b=*@@Vr%}o);>G`fc)yuRbpH#q+LAzF`sYv?`%}2 zZPYBNyy{qORjZy^>k5b0wmi(9V(JqfO3a@0+A@ekNmD9u`ri%jA01oko_zHHbI&5p yX4`G83JX}muTA-37t_LI-qXGB8ROh^gO`c|TIx=H`pPREe4a|}$=aebcN+lWFTUvj diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css index a0db29e5..7bf0ad02 100644 --- a/resources/css/admin-tokens.css +++ b/resources/css/admin-tokens.css @@ -134,4 +134,30 @@ .token-list button.revoke:hover { background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg)); } + + .token-reveal { + margin-block-end: 1em; + padding: 1em 1.2em; + border: 1px solid var(--clr-border); + border-radius: 16px; + background: light-dark( + oklch(96% 0.08 145deg), + oklch(28% 0.08 145deg) + ); + + input { + width: 100%; + font-family: monospace; + padding: 0.5em 0.7em; + border-radius: 8px; + border: 1px solid var(--clr-border); + } + } + + .scope-checkboxes { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1em; + } } diff --git a/resources/views/admin/tokens/create.blade.php b/resources/views/admin/tokens/create.blade.php new file mode 100644 index 00000000..d4847d1e --- /dev/null +++ b/resources/views/admin/tokens/create.blade.php @@ -0,0 +1,52 @@ +@extends('master') + +@section('title')New Token « Admin CP « @stop + +@section('content') +

Generate a new token

+

Use this for clients that can't complete the IndieAuth authorization flow (e.g. they don't support PKCE) and instead let you paste in a token directly.

+ +
+ {{ csrf_field() }} + +
+ + +
+ +
+ Scope + + +
+ +
+ +
+
+@stop diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php index c9443e29..33806cdd 100644 --- a/resources/views/admin/tokens/index.blade.php +++ b/resources/views/admin/tokens/index.blade.php @@ -4,6 +4,15 @@ @section('content')

Micropub Tokens

+

Generate new token

+ + @if(session('new_token')) +
+

Here's your new token. Copy it now — it won't be shown again.

+ +
+ @endif + @if($tokens->isEmpty())

No tokens have been issued.

@else diff --git a/routes/web.php b/routes/web.php index 03d865b1..a9b46407 100644 --- a/routes/web.php +++ b/routes/web.php @@ -157,6 +157,8 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () { // Micropub Tokens Route::prefix('tokens')->group(function () { Route::get('/', [TokensController::class, 'index']); + Route::get('/create', [TokensController::class, 'create']); + Route::post('/', [TokensController::class, 'store']); Route::put('/{token}/revoke', [TokensController::class, 'revoke']); }); diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php index 0c415296..d6695a15 100644 --- a/tests/Feature/Admin/TokensTest.php +++ b/tests/Feature/Admin/TokensTest.php @@ -37,6 +37,73 @@ class TokensTest extends TestCase $response->assertSeeText($token->client_id); } + #[Test] + public function create_requires_authentication(): void + { + $response = $this->get('/admin/tokens/create'); + $response->assertRedirect(); + } + + #[Test] + public function create_shows_form(): void + { + $user = User::factory()->make(); + + $response = $this->actingAs($user)->get('/admin/tokens/create'); + + $response->assertOk(); + $response->assertSee('name="client_id"', false); + } + + #[Test] + public function store_requires_authentication(): void + { + $response = $this->post('/admin/tokens', [ + 'client_id' => 'https://ia.net/writer', + 'scope' => ['create'], + ]); + + $response->assertRedirect(); + $this->assertDatabaseCount('micropub_tokens', 0); + } + + #[Test] + public function store_creates_a_new_token_and_redirects_with_it_flashed(): void + { + $user = User::factory()->make(); + + $response = $this->actingAs($user)->post('/admin/tokens', [ + 'client_id' => 'https://ia.net/writer', + 'scope' => ['create', 'update'], + ]); + + $response->assertRedirect('/admin/tokens'); + $response->assertSessionHas('new_token'); + + $this->assertDatabaseHas('micropub_tokens', [ + 'client_id' => 'https://ia.net/writer', + 'scope' => 'create update', + 'me' => config('app.url'), + ]); + + $token = $response->getSession()->get('new_token'); + $this->assertNotNull(MicropubToken::findActive($token)); + } + + #[Test] + public function store_requires_at_least_one_scope(): void + { + $user = User::factory()->make(); + + $response = $this->actingAs($user)->post('/admin/tokens', [ + 'client_id' => 'https://ia.net/writer', + 'scope' => [], + ]); + + $response->assertSessionHasErrors('scope'); + $this->assertDatabaseCount('micropub_tokens', 0); + } + #[Test] public function revoke_requires_authentication(): void { From 77998a963e57c1a6557f448f7fd1a6376094e858 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 13 Sep 2026 11:42:28 +0100 Subject: [PATCH 12/15] Fix relative Location header when Micropub creates an article EntryHandler used Article::link, which is deliberately a site-relative path elsewhere in the app, directly as the Micropub response's Location URL. Every other post type it returns (notes, bookmarks, places) already prepends the site URL, so articles were the only case where clients received a relative Location - iA Writer appears to treat that as a local file path and fails to open it after posting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy --- app/Services/Micropub/Handlers/EntryHandler.php | 2 +- tests/Feature/MicropubControllerTest.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index 48bbb550..a19f4d2b 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -37,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface $location = match (true) { isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, - isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link, + isset($dataArray['name']) => config('app.url').resolve(ArticleService::class)->create($dataArray)->link, default => resolve(NoteService::class)->create($dataArray)->uri, }; diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index efcfb6ce..e1795561 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -871,6 +871,8 @@ class MicropubControllerTest extends TestCase 'main' => $content, 'published' => true, ]); + $response->assertHeader('Location'); + $this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location')); } #[Test] From 4aa93d63bbee4cab51ae4bd2bbe5f4448ee4bc77 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 13 Sep 2026 12:12:36 +0100 Subject: [PATCH 13/15] Add Article::uri accessor for the absolute post URL Follow the uri/link convention already used by Note, Bookmark, and Place, rather than concatenating config('app.url') inline where the absolute URL is needed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy --- app/Models/Article.php | 7 +++++++ app/Services/Micropub/Handlers/EntryHandler.php | 2 +- tests/Unit/ArticlesTest.php | 11 +++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/Models/Article.php b/app/Models/Article.php index ab0602d1..9ac2335d 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -93,6 +93,13 @@ class Article extends Model ); } + protected function uri(): Attribute + { + return Attribute::get( + get: fn () => config('app.url').$this->link, + ); + } + /** * Scope a query to only include articles from a particular year/month. */ diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index a19f4d2b..d79a0418 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -37,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface $location = match (true) { isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, - isset($dataArray['name']) => config('app.url').resolve(ArticleService::class)->create($dataArray)->link, + isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri, default => resolve(NoteService::class)->create($dataArray)->uri, }; diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php index fda1abf4..0de3277d 100644 --- a/tests/Unit/ArticlesTest.php +++ b/tests/Unit/ArticlesTest.php @@ -63,6 +63,17 @@ class ArticlesTest extends TestCase ); } + #[Test] + public function uri_is_the_absolute_form_of_the_link(): void + { + $article = Article::create([ + 'title' => 'Test', + 'main' => 'Test', + ]); + + $this->assertEquals(config('app.url').$article->link, $article->uri); + } + #[Test] public function date_scope_returns_expected_articles(): void { From 6727138f43f752e095da074e365693b810f0e8c2 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 19 Sep 2026 12:22:33 +0100 Subject: [PATCH 14/15] Report exceptions from Micropub 500 error paths instead of swallowing them MicropubController's catch-all handlers returned a generic 500 without ever calling report(), so failures never reached laravel.log or Flare (Flare is already wired up via bootstrap/app.php). Widened the final catch to \Throwable so PHP Errors (e.g. TypeError) get the same Micropub-shaped error response and are also reported. Co-Authored-By: Claude Sonnet 5 --- app/Http/Controllers/MicropubController.php | 12 +++++++++--- tests/Feature/MicropubControllerTest.php | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 2df5d432..72242150 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -70,7 +70,9 @@ class MicropubController extends Controller 'error' => 'invalid_request', 'error_description' => 'No known note with given ID', ], 404); - } catch (MicropubUnsupportedModelException) { + } catch (MicropubUnsupportedModelException $e) { + report($e); + return response()->json([ 'error' => 'invalid', 'error_description' => 'This implementation currently only supports the updating of notes', @@ -80,12 +82,16 @@ class MicropubController extends Controller 'error' => 'invalid_request', 'error_description' => $e->getMessage(), ], 400); - } catch (MicropubHandlerException) { + } catch (MicropubHandlerException $e) { + report($e); + return response()->json([ 'error' => 'unsupported_operation', 'error_description' => 'The request could not be processed by this server', ], 500); - } catch (\Exception $e) { + } catch (\Throwable $e) { + report($e); + return response()->json([ 'error' => 'server_error', 'error_description' => 'An error occurred processing the request', diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index e1795561..86ffa972 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; @@ -12,6 +13,7 @@ use App\Models\Note; use App\Models\Place; use App\Models\SyndicationTarget; use Faker\Factory; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; @@ -457,6 +459,11 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void { + $this->mock(ExceptionHandler::class) + ->shouldReceive('report') + ->once() + ->with(\Mockery::type(MicropubHandlerException::class)); + $response = $this->postJson( '/api/post', [ From eb35a0aa2d4fef3b84e8653f9d1a0e7bf8dbcde1 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 19 Sep 2026 17:16:11 +0100 Subject: [PATCH 15/15] Update existing draft articles instead of erroring on a repeat Micropub post If a Micropub h-entry post's title matches an existing article that's still a draft, update that article in place rather than trying to insert a duplicate. If it matches one that's already published, reject the request with a clear error instead of silently colliding. Also set includeTrashed on Article's slug config as a safety net: this model soft-deletes, and Sluggable's uniqueness check ignores trashed rows by default, so a previously-deleted article's title could crash new inserts with a raw unique constraint violation (this is exactly what surfaced in Flare as a UniqueConstraintViolationException on articles_titleurl_unique once the prior swallowed-exception fix shipped). Co-Authored-By: Claude Sonnet 5 --- app/Models/Article.php | 1 + app/Services/ArticleService.php | 21 +++++++- tests/Feature/MicropubControllerTest.php | 61 ++++++++++++++++++++++++ tests/Unit/ArticlesTest.php | 11 +++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/Models/Article.php b/app/Models/Article.php index 9ac2335d..330ff03e 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -40,6 +40,7 @@ class Article extends Model return [ 'titleurl' => [ 'source' => 'title', + 'includeTrashed' => true, ], ]; } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 2372ffb7..ab91f9e4 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -8,12 +8,29 @@ use App\Models\Article; class ArticleService { + /** + * @throws \InvalidArgumentException if a published article already has this title + */ public function create(array $data): Article { - return Article::create([ + $attributes = [ 'title' => $data['name'], 'main' => $data['content'], 'published' => ($data['post-status'] ?? null) !== 'draft', - ]); + ]; + + $existing = Article::where('title', $data['name'])->first(); + + if ($existing !== null) { + if ($existing->published) { + throw new \InvalidArgumentException("An article titled \"{$data['name']}\" has already been published"); + } + + $existing->update($attributes); + + return $existing; + } + + return Article::create($attributes); } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 86ffa972..3fd8b515 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -8,6 +8,7 @@ use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; +use App\Models\Article; use App\Models\Media; use App\Models\Note; use App\Models\Place; @@ -911,4 +912,64 @@ class MicropubControllerTest extends TestCase 'published' => false, ]); } + + #[Test] + public function micropub_client_api_request_updates_an_existing_draft_article_with_the_same_name(): void + { + $draft = Article::create([ + 'title' => 'WireGuard', + 'main' => 'Early draft content', + 'published' => false, + ]); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => ['WireGuard'], + 'content' => ['Finished content'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['response' => 'created']) + ->assertStatus(201); + $this->assertSame(1, Article::where('title', 'WireGuard')->count()); + $this->assertDatabaseHas('articles', [ + 'id' => $draft->id, + 'title' => 'WireGuard', + 'main' => 'Finished content', + 'published' => true, + ]); + } + + #[Test] + public function micropub_client_api_request_errors_when_an_article_with_the_same_name_is_already_published(): void + { + Article::create([ + 'title' => 'WireGuard', + 'main' => 'Published content', + 'published' => true, + ]); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => ['WireGuard'], + 'content' => ['Some other content'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); + $this->assertSame(1, Article::where('title', 'WireGuard')->count()); + } } diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php index 0de3277d..afcc0828 100644 --- a/tests/Unit/ArticlesTest.php +++ b/tests/Unit/ArticlesTest.php @@ -74,6 +74,17 @@ class ArticlesTest extends TestCase $this->assertEquals(config('app.url').$article->link, $article->uri); } + #[Test] + public function slug_is_suffixed_when_a_trashed_article_already_used_it(): void + { + $original = Article::create(['title' => 'My Title', 'main' => 'Content']); + $original->delete(); + + $newArticle = Article::create(['title' => 'My Title', 'main' => 'Other content']); + + $this->assertEquals('my-title-2', $newArticle->titleurl); + } + #[Test] public function date_scope_returns_expected_articles(): void {