Something went wrong. Try again.
Tools for the Atmosphere tools.slices.network
quickslice atproto html
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://quickslice-production-cc52.up.railway.app wss://quickslice-production-cc52.up.railway.app https://public.api.bsky.app https://unpkg.com; img-src 'self' https: data: blob:;" /> <title>🐛 Bug Tracker</title> <style> /* CSS Reset */ *, *::before, *::after { box-sizing: border-box; } * { margin: 0; } body { line-height: 1.5; -webkit-font-smoothing: antialiased; } input, button, textarea, select { font: inherit; }
/* Theme */ :root { --bg-primary: #fafaf9; --bg-card: #ffffff; --bg-hover: #f5f5f4; --text-primary: #1c1917; --text-secondary: #78716c; --accent: #7c3aed; --accent-hover: #6d28d9; --border: #e7e5e4; --error-bg: #fef2f2; --error-border: #fecaca; --error-text: #dc2626; --severity-unusable: #dc2626; --severity-broken: #ea580c; --severity-annoying: #ca8a04; --severity-cosmetic: #78716c; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg-primary); color: var(--text-primary); min-height: 100vh; }
#app { max-width: 800px; margin: 0 auto; padding: 1rem; }
/* Header */ header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 0; margin-bottom: 1rem; border-bottom: 1px solid var(--border); }
header h1 { font-size: 1.5rem; font-weight: 700; }
.breadcrumb { display: flex; align-items: center; gap: 0.5rem; }
.breadcrumb a { color: var(--accent); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.user-status { display: flex; align-items: center; gap: 0.75rem; }
/* Buttons */ .btn { padding: 0.5rem 1rem; border: none; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: background-color 0.15s; }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--bg-hover); }
.btn-icon-text { display: inline-flex; align-items: center; gap: 0.375rem; }
.btn-danger { background: #dc2626; color: white; border: none; }
.btn-danger:hover { background: #b91c1c; }
.btn-icon { background: none; border: none; cursor: pointer; padding: 0.25rem 0.5rem; font-size: 1rem; line-height: 1; border-radius: 0.25rem; }
.btn-danger-text { color: #dc2626; }
.btn-danger-text:hover { background: rgba(220, 38, 38, 0.1); }
.btn-info { width: 24px; height: 24px; padding: 0; border-radius: 50%; font-size: 0.875rem; font-weight: 600; background: var(--bg-hover); color: var(--text-secondary); border: 1px solid var(--border); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.btn-info:hover { background: var(--border); color: var(--text-primary); }
/* Cards */ .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin-bottom: 0.75rem; cursor: pointer; transition: box-shadow 0.15s, transform 0.15s; }
.card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); transform: translateY(-1px); }
/* Severity badges */ .badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; text-transform: uppercase; }
.badge-unusable { background: var(--severity-unusable); color: white; } .badge-broken { background: var(--severity-broken); color: white; } .badge-annoying { background: var(--severity-annoying); color: white; } .badge-cosmetic { background: var(--severity-cosmetic); color: white; }
/* Status badges */ .status-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500; }
.status-open { background: #dbeafe; color: #1d4ed8; } .status-inprogress { background: #fef3c7; color: #d97706; } .status-closed { background: #dcfce7; color: #16a34a; } .status-acknowledged { background: #dbeafe; color: #1d4ed8; } .status-fixed { background: #dcfce7; color: #16a34a; } .status-wontfix { background: #f3f4f6; color: #4b5563; } .status-duplicate { background: #fef3c7; color: #d97706; } .status-invalid { background: #fee2e2; color: #dc2626; }
/* Hidden utility */ .hidden { display: none !important; }
/* Facet links */ .facet-link { color: var(--accent); text-decoration: underline; }
.facet-link:hover { color: var(--accent-hover); }
.facet-bold { font-weight: 600; }
.facet-italic { font-style: italic; }
.facet-code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background: var(--bg-hover); padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; }
.facet-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background: var(--bg-hover); padding: 1rem; border-radius: 0.5rem; font-size: 0.875em; overflow-x: auto; margin: 0.5rem 0; white-space: pre; }
.facet-codeblock code { background: none; padding: 0; }
.form-hint { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem; }
/* Loading spinner */ .spinner { width: 24px; height: 24px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-container { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; padding: 3rem; color: var(--text-secondary); }
/* Error banner */ #error-banner { position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error-text); padding: 0.75rem 1rem; border-radius: 0.5rem; display: flex; align-items: center; gap: 0.75rem; max-width: 90%; z-index: 100; }
#error-banner button { background: none; border: none; color: var(--error-text); cursor: pointer; font-size: 1.25rem; line-height: 1; }
/* Overlay (bug detail) */ #overlay { position: fixed; top: 0; right: 0; bottom: 0; width: 100%; max-width: 600px; background: var(--bg-card); box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); z-index: 50; overflow-y: auto; transform: translateX(100%); transition: transform 0.2s ease-out; }
#overlay.open { transform: translateX(0); }
#overlay.hidden { display: block !important; transform: translateX(100%); }
.overlay-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.3); z-index: 40; }
.overlay-header { display: flex; justify-content: space-between; align-items: flex-start; padding: 1.5rem; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg-card); }
.overlay-title { font-size: 1.25rem; font-weight: 600; flex: 1; margin-right: 1rem; }
.overlay-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-secondary); padding: 0; line-height: 1; }
.overlay-close:hover { color: var(--text-primary); }
.overlay-actions { display: flex; align-items: center; gap: 0.5rem; }
.overlay-body { padding: 1.5rem; }
.overlay-section { margin-bottom: 1.5rem; }
.overlay-section h3 { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 0.5rem; }
.overlay-section p { white-space: pre-wrap; overflow-wrap: break-word; word-break: break-word; }
.attachment-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; margin-top: 0.5rem; }
.attachment-image { max-width: 100%; height: auto; border-radius: 0.5rem; cursor: pointer; transition: transform 0.15s; }
.attachment-image:hover { transform: scale(1.02); }
.image-lightbox { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9); display: flex; align-items: center; justify-content: center; z-index: 100; cursor: pointer; }
.image-lightbox img { max-width: 90vw; max-height: 90vh; object-fit: contain; }
/* Modal (submit bug) */ #modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 60; padding: 1rem; }
.modal-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.5); }
.modal-content { position: relative; background: var(--bg-card); border-radius: 0.75rem; width: 100%; max-width: 500px; max-height: 90vh; overflow: visible; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); }
.modal-content.scrollable { overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); }
.modal-header h2 { font-size: 1.125rem; font-weight: 600; }
.modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-secondary); padding: 0; line-height: 1; }
.modal-body { padding: 1.5rem; overflow: visible; }
/* Info modal */ .info-section { margin-bottom: 1.5rem; }
.info-section:last-child { margin-bottom: 0; }
.info-section h3 { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; }
.info-section p { color: var(--text-secondary); line-height: 1.6; }
.info-section a { color: var(--accent); }
.info-section code { background: var(--bg-hover); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; }
.status-list { list-style: none; padding: 0; margin: 0; }
.status-list li { display: flex; align-items: center; gap: 0.75rem; padding: 0.375rem 0; color: var(--text-secondary); }
/* Forms */ .form-group { margin-bottom: 1rem; }
.form-group label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem; }
.form-group .hint { font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 0.25rem; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--bg-card); }
.form-group input:focus, .form-group textarea:focus, .form-group select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); }
.form-group textarea { min-height: 100px; resize: vertical; }
.form-group .error { color: var(--error-text); font-size: 0.75rem; margin-top: 0.25rem; }
.form-group.has-error input, .form-group.has-error textarea, .form-group.has-error select { border-color: var(--error-text); }
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
/* Handle autocomplete */ .handle-autocomplete { position: relative; }
.autocomplete-menu { position: absolute; top: 100%; left: 0; right: 0; margin-top: 4px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.5rem; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); max-height: 240px; overflow-y: auto; z-index: 100; list-style: none; padding: 4px; }
.autocomplete-menu:empty { display: none; }
.autocomplete-item { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 0.375rem; cursor: pointer; }
.autocomplete-item:hover, .autocomplete-item.active { background: var(--bg-hover); }
.autocomplete-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--border); flex-shrink: 0; overflow: hidden; }
.autocomplete-avatar img { width: 100%; height: 100%; object-fit: cover; }
/* User avatars */ .user-avatar { width: 20px; height: 20px; border-radius: 50%; background: var(--border); flex-shrink: 0; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; }
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-avatar:not(:has(img)) { background: var(--accent); color: white; font-weight: 600; font-size: 0.625em; }
.user-avatar-sm { width: 16px; height: 16px; }
.user-avatar-lg { width: 24px; height: 24px; }
.user-avatar-xl { width: 32px; height: 32px; }
.user-avatar-ring { outline: 2px solid var(--border); outline-offset: 1px; }
.user-info { display: inline-flex; align-items: center; gap: 0.375rem; }
.autocomplete-handle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Image upload */ .image-upload { border: 2px dashed var(--border); border-radius: 0.5rem; padding: 1.5rem; text-align: center; cursor: pointer; transition: border-color 0.15s; }
.image-upload:hover { border-color: var(--accent); }
.image-upload input { display: none; }
.image-previews { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; padding: 0.5rem; }
.image-preview { position: relative; }
.image-preview img { max-width: 80px; max-height: 80px; object-fit: contain; border-radius: 0.25rem; }
.image-preview button { position: absolute; top: -0.5rem; right: -0.5rem; width: 1.5rem; height: 1.5rem; border-radius: 50%; background: var(--error-text); color: white; border: none; cursor: pointer; font-size: 0.75rem; line-height: 1; }
/* Namespace grid */ .namespace-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; }
.namespace-card { text-align: center; padding: 1.5rem; }
.namespace-name { font-weight: 600; font-size: 1rem; margin-bottom: 0.25rem; font-family: monospace; }
.namespace-count { color: var(--text-secondary); font-size: 0.875rem; }
/* Filter bar */ .filter-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; gap: 1rem; }
.filter-group { display: flex; gap: 0.5rem; }
.filter-group select { padding: 0.5rem 0.75rem; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--bg-card); }
/* Bug list */ .bug-list { display: flex; flex-direction: column; }
.bug-card { display: flex; flex-direction: column; gap: 0.25rem; }
.bug-card-header { display: flex; align-items: center; gap: 0.75rem; }
.bug-card-title { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bug-card-meta { display: flex; align-items: center; gap: 0.25rem; color: var(--text-secondary); font-size: 0.875rem; }
.comment-count { color: var(--text-secondary); font-size: 0.75rem; display: inline-flex; align-items: center; gap: 0.25rem; margin-left: 0.5rem; }
.comment-count svg { width: 14px; height: 14px; }
.btn-icon svg { width: 16px; height: 16px; }
.load-more { text-align: center; padding: 1rem; }
/* Overlay meta */ .overlay-meta { display: flex; align-items: center; gap: 0.5rem; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1.5rem; }
.text-secondary { color: var(--text-secondary); }
/* Response list */ .response-list { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.75rem; }
.response-card { background: var(--bg-hover); padding: 0.75rem; border-radius: 0.375rem; }
.response-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.25rem; }
.response-meta { color: var(--text-secondary); font-size: 0.75rem; }
.response-message { font-size: 0.875rem; white-space: pre-wrap; overflow-wrap: break-word; word-break: break-word; }
/* Comments */ .comment-card { background: var(--bg-hover); border-radius: 0.5rem; padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
.comment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
.comment-meta { display: flex; align-items: center; gap: 0.25rem; font-size: 0.75rem; color: var(--text-secondary); }
.comment-body { white-space: pre-wrap; word-break: break-word; }
.comment-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.comment-replies { margin-left: 1.5rem; padding-left: 0.75rem; border-left: 2px solid var(--border); }
.comment-form-inline { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.comment-form-inline textarea { width: 100%; min-height: 80px; padding: 0.5rem 0.75rem; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--bg-card); resize: vertical; font-family: inherit; margin-bottom: 0.5rem; }
.comment-form-inline textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); }
.comment-form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
/* Linked issues */ .linked-issues-list { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem; }
.linked-issues-list .card { padding: 0.75rem; margin-bottom: 0; }
.linked-issues-list .card:hover { transform: none; box-shadow: none; }
.linked-issues-list a { color: var(--accent); text-decoration: none; }
.linked-issues-list a:hover { text-decoration: underline; }
/* Repo and issue lists in modal */ .repo-list, .issue-list { display: flex; flex-direction: column; gap: 0.5rem; }
.repo-list .card, .issue-list .card { margin-bottom: 0; }
/* ============================================================================= MOBILE RESPONSIVE STYLES ============================================================================= */ @media (max-width: 640px) { /* Header - stack vertically */ header { flex-direction: column; gap: 0.75rem; text-align: center; }
.breadcrumb { justify-content: center; flex-wrap: wrap; }
.user-status { justify-content: center; }
/* Filter bar - stack vertically */ .filter-bar { flex-direction: column; align-items: stretch; }
.filter-group { width: 100%; }
.filter-group select { width: 100%; }
.filter-bar > .btn-primary { width: 100%; }
/* Touch targets - minimum 44px */ .btn { padding: 0.75rem 1rem; min-height: 44px; }
.btn-info { width: 44px; height: 44px; font-size: 1rem; }
.btn-icon { min-width: 44px; min-height: 44px; }
.overlay-close, .modal-close { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; }
/* Overlay - full screen takeover */ #overlay { max-width: 100%; width: 100%; box-shadow: none; }
.overlay-header { position: relative; padding-right: 3.5rem; }
.overlay-header > .overlay-close { position: absolute; top: 1rem; right: 1rem; }
.overlay-actions { flex-wrap: wrap; }
/* Modal - nearly full screen */ #modal { padding: 0.5rem; }
.modal-content { max-width: 100%; width: 100%; max-height: 100%; border-radius: 0.5rem; }
.modal-body { padding: 1rem; }
.form-actions { flex-direction: column-reverse; }
.form-actions .btn { width: 100%; }
.image-upload { padding: 2rem 1rem; } } </style> </head> <body> <div id="app"> <header id="header"></header> <main id="main"></main> </div> <div id="overlay" class="hidden"></div> <div id="modal" class="hidden"></div> <div id="error-banner" class="hidden"></div>
<!-- Quickslice Client SDK --> <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script>
<!-- Lucide Icons --> <script src="https://unpkg.com/lucide@latest"></script>
<script type="module"> import { parseFacets, renderFacetedText } from './richtext.js';
// Configuration const SERVER_URL = "https://quickslice-production-cc52.up.railway.app"; const CLIENT_ID = "client_RYT1cM7os1OMBtLNFQVlbw"; const PAGE_SIZE = 20;
// State const state = { view: "landing", // "landing" | "list" | "detail" namespace: null, bugUri: null, bugs: [], namespaces: [], responses: [], comments: [], replyingTo: null, editingComment: null, editCommentAttachments: [], commentImages: [], cursor: null, hasMore: true, isLoading: false, viewer: null, filters: { severity: null, status: "open" }, editingBug: null, existingAttachments: [], // Track which existing attachments to keep when editing confirmedNamespace: false, // Track if user confirmed a domain-like namespace handleSuggestions: [], // Handle autocomplete suggestions handleSuggestionIndex: -1, // Currently selected suggestion linkIssueState: { bugUri: null, step: "repos", // "repos" | "issues" repos: [], selectedRepo: null, issues: [], isLoading: false, }, };
// ============================================================================= // HELPERS // =============================================================================
function tangledIcon(size = 16) { return `<svg width="${size}" height="${size}" viewBox="0 0 25 25" fill="currentColor"><path d="m 16.35,24.11 c -0.79,-0.01 -1.38,-0.23 -2.03,-0.63 -0.93,-0.49 -1.64,-1.31 -2.15,-2.22 -0.81,1 -1.89,1.61 -3.1,1.95 -0.51,0.15 -1.41,0.3 -2.91,-0.24 -2.15,-0.72 -3.72,-2.97 -3.54,-5.25 -0.03,-0.95 0.31,-1.88 0.8,-2.67 -1.31,-0.7 -2.37,-1.88 -2.78,-3.32 -0.25,-0.79 -0.24,-1.64 -0.15,-2.45 0.33,-1.92 1.77,-3.58 3.62,-4.18 0.74,-1.68 2.35,-2.94 4.18,-3.19 1.21,-0.17 2.47,0.08 3.53,0.7 1.54,-1.71 4.24,-2.22 6.29,-1.17 1.57,0.75 2.69,2.31 2.96,4.02 1.49,0.6 2.75,1.82 3.24,3.36 0.33,0.96 0.34,2.01 0.13,3 -0.38,1.54 -1.47,2.84 -2.87,3.56 0,0.27 0.9,2.24 0.75,3.73 -0.03,1.86 -1.21,3.62 -2.85,4.48 -0.95,0.56 -2.08,0.55 -3.12,0.54 z m -4.47,-5.35 c 1.32,-0.15 2.19,-1.3 2.86,-2.34 0.32,-0.47 0.56,-1 0.8,-1.51 0.31,0.29 0.58,0.83 1.07,0.96 0.52,0.16 1.13,0.03 1.45,-0.44 0.61,-1.14 0.31,-2.52 -0.05,-3.7 -0.22,-0.68 -0.5,-1.38 -1.05,-1.86 0.12,-0.82 -0.37,-1.66 -1.06,-2.09 -0.59,0.47 -1.49,0.47 -2.06,-0.03 -1.09,1.11 -2.09,1.08 -3.06,0.19 -0.22,-0.2 -0.63,1.21 -2.09,0.41 -0.84,0.7 -1.48,1.38 -2.06,2.35 -0.56,1.05 -1.14,1.98 -1.19,3.11 -0.02,0.66 0.49,1.36 1.2,1.31 0.7,0.06 1.18,-0.63 1.71,-0.92 0.08,0.93 0.17,1.92 0.48,2.83 0.36,1.17 1.63,1.92 2.83,1.75 0.08,-0.01 0.22,-0.02 0.22,-0.02 z m 0.69,-3.5 c -0.64,-0.39 -0.33,-1.25 -0.36,-1.87 0.06,-0.75 0.12,-1.54 0.45,-2.22 0.36,-0.49 1.23,-0.3 1.27,0.33 -0.03,0.63 -0.31,1.25 -0.28,1.91 -0.07,0.54 0.05,1.15 -0.19,1.65 -0.2,0.28 -0.6,0.36 -0.89,0.21 z m -2.81,-0.36 c -0.61,-0.33 -0.41,-1.16 -0.51,-1.73 0.08,-0.67 0.01,-1.51 0.57,-1.98 0.55,-0.38 1.29,0.27 1.03,0.87 -0.27,0.76 -0.09,1.58 -0.09,2.35 -0.1,0.45 -0.59,0.69 -1,0.49 z" transform="translate(-0.43,-0.88)"/></svg>`; }
function esc(str) { if (!str) return ""; const d = document.createElement("div"); d.textContent = str; return d.innerHTML; }
// Image resize utilities function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); }
function getDataUrlSize(dataUrl) { const base64 = dataUrl.split(",")[1]; return Math.ceil((base64.length * 3) / 4); }
function createResizedImage(dataUrl, options) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let scale; if (options.mode === "cover") { scale = Math.max(options.width / img.width, options.height / img.height); } else if (options.mode === "contain") { scale = Math.min(options.width / img.width, options.height / img.height); } else { scale = 1; }
// Don't upscale scale = Math.min(scale, 1);
const w = Math.round(img.width * scale); const h = Math.round(img.height * scale);
const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d"); if (!ctx) return reject(new Error("Failed to get canvas context"));
ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, w, h); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, w, h);
resolve({ dataUrl: canvas.toDataURL("image/jpeg", options.quality), width: w, height: h, }); }; img.onerror = (e) => reject(e); img.src = dataUrl; }); }
async function resizeImage(dataUrl, opts) { // Binary search for optimal quality let bestResult = null; let minQuality = 0; let maxQuality = 101;
while (maxQuality - minQuality > 1) { const quality = Math.round((minQuality + maxQuality) / 2); const result = await createResizedImage(dataUrl, { width: opts.width, height: opts.height, quality: quality / 100, mode: opts.mode, });
const size = getDataUrlSize(result.dataUrl);
if (size < opts.maxSize) { minQuality = quality; bestResult = result; } else { maxQuality = quality; } }
if (!bestResult) { throw new Error("Failed to compress image within size limit"); }
return bestResult; }
function formatTime(iso) { const d = new Date(iso); const now = new Date(); const diff = Math.floor((now - d) / 1000);
if (diff < 60) return "just now"; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); }
function renderAvatar(profile, handle, sizeClass = "") { const avatarUrl = profile?.avatar?.url; const className = `user-avatar${sizeClass ? ` ${sizeClass}` : ""}`; if (avatarUrl) { return `<span class="${className}"><img src="${esc(avatarUrl)}" alt=""></span>`; } const initial = handle ? handle.charAt(0).toUpperCase() : "?"; return `<span class="${className}">${esc(initial)}</span>`; }
function showError(msg) { const el = document.getElementById("error-banner"); el.innerHTML = `<span>${esc(msg)}</span><button onclick="BugsApp.hideError()">×</button>`; el.classList.remove("hidden"); }
function hideError() { document.getElementById("error-banner").classList.add("hidden"); }
function validateNamespace(ns) { return /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/.test(ns); }
// Common TLDs to detect if user entered a domain instead of reverse-domain const COMMON_TLDS = new Set([ // Generic TLDs "com", "org", "net", "info", "biz", "name", "pro", // Popular new gTLDs "app", "dev", "io", "co", "ai", "xyz", "online", "site", "tech", "cloud", "social", "club", "blog", "shop", "store", "news", "live", "media", "email", "digital", "network", "systems", "solutions", "services", "software", "games", // Country codes (most common) "uk", "de", "fr", "nl", "eu", "ru", "cn", "jp", "kr", "au", "ca", "br", "it", "es", "pl", "ch", "at", "be", "se", "no", "dk", "fi", "ie", "nz", "in", "mx", "ar", "za", "sg", "hk", "tw", "id", "th", "my", "ph", "vn", // AT Protocol ecosystem "blue", "fm", "tv", "gg", "me", "us", ]);
function looksLikeDomain(ns) { const parts = ns.split("."); if (parts.length < 2) return null; const lastPart = parts[parts.length - 1]; if (COMMON_TLDS.has(lastPart)) { // Suggest the reversed version return parts.reverse().join("."); } return null; }
function useSuggestedNamespace(suggested) { document.getElementById("bug-namespace").value = suggested; document.getElementById("namespace-error").innerHTML = ""; document.getElementById("bug-namespace").parentElement.classList.remove("has-error"); state.confirmedNamespace = false; }
function confirmNamespace() { state.confirmedNamespace = true; document.getElementById("namespace-error").innerHTML = ""; document.getElementById("bug-namespace").parentElement.classList.remove("has-error"); // Re-trigger form submission document.getElementById("bug-form").dispatchEvent(new Event("submit")); }
function validateNamespaceOnBlur() { const input = document.getElementById("bug-namespace"); const namespace = input.value.trim().toLowerCase(); const errorEl = document.getElementById("namespace-error");
// Clear previous errors errorEl.innerHTML = ""; input.parentElement.classList.remove("has-error");
if (!namespace) return; // Don't validate empty field on blur
// Check format if (!validateNamespace(namespace)) { errorEl.textContent = "Invalid format. Use: word.word (e.g., social.grain)"; input.parentElement.classList.add("has-error"); return; }
// Check if it looks like a domain const suggested = looksLikeDomain(namespace); if (suggested) { errorEl.innerHTML = ` This looks like a domain. Did you mean <strong>${esc(suggested)}</strong>? <div style="margin-top: 0.5rem;"> <button type="button" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.useSuggestedNamespace('${esc(suggested)}')">Use ${esc(suggested)}</button> <button type="button" class="btn btn-secondary" style="margin-left: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.dismissNamespaceSuggestion()">Keep as-is</button> </div> `; input.parentElement.classList.add("has-error"); } }
function dismissNamespaceSuggestion() { state.confirmedNamespace = true; document.getElementById("namespace-error").innerHTML = ""; document.getElementById("bug-namespace").parentElement.classList.remove("has-error"); }
function getSeverityClass(severity) { return `badge-${severity}`; }
function getStatusClass(status) { return `status-${status}`; }
// Derive bug display status from responses // Priority: fixed > wontfix > duplicate > invalid > acknowledged > (none = open) const STATUS_DISPLAY = { open: { label: "Open", cssClass: "status-open" }, acknowledged: { label: "In Progress", cssClass: "status-inprogress" }, fixed: { label: "Closed - Fixed", cssClass: "status-closed" }, wontfix: { label: "Closed - Won't Fix", cssClass: "status-wontfix" }, duplicate: { label: "Closed - Duplicate", cssClass: "status-duplicate" }, invalid: { label: "Closed - Invalid", cssClass: "status-invalid" }, };
const STATUS_PRIORITY = ["fixed", "wontfix", "duplicate", "invalid", "acknowledged"];
function getBugDisplayStatus(bug) { const responses = bug.networkSlicesToolsBugResponseViaBug?.edges?.map((e) => e.node) || []; if (responses.length === 0) { return STATUS_DISPLAY.open; } // Find highest priority status from all responses for (const status of STATUS_PRIORITY) { if (responses.some((r) => r.status === status)) { return STATUS_DISPLAY[status]; } } return STATUS_DISPLAY.open; }
function updateUrl(params) { const url = new URL(window.location); Object.entries(params).forEach(([key, value]) => { if (value === null) { url.searchParams.delete(key); } else { url.searchParams.set(key, value); } }); window.history.pushState({}, "", url); }
function canRespond(bugAuthorDid, bugNamespace) { if (!state.viewer) return false; // Only the domain authority can respond // Namespace: "social.grain" -> required handle: "grain.social" (exact match) if (state.viewer.handle && bugNamespace) { const namespaceDomain = bugNamespace.split(".").reverse().join("."); if (state.viewer.handle === namespaceDomain) return true; } return false; }
function isAuthor(bugAuthorDid) { return state.viewer && state.viewer.did === bugAuthorDid; }
function getRkeyFromUri(uri) { // URI format: at://did:plc:xxx/collection/rkey return uri.split("/").pop(); }
// ============================================================================= // GRAPHQL // =============================================================================
const NAMESPACES_QUERY = ` query { networkSlicesToolsBugAggregated( groupBy: [{ field: namespace }] orderBy: { direction: DESC } ) { namespace count } } `;
const BUGS_QUERY = ` query GetBugs($first: Int!, $after: String, $namespace: String) { networkSlicesToolsBug( where: { namespace: { eq: $namespace } } sortBy: [{ field: createdAt, direction: DESC }] first: $first after: $after ) { edges { node { uri did title description descriptionFacets { index { byteStart byteEnd } features { __typename ... on AppBskyRichtextFacetLink { uri } ... on NetworkSlicesToolsRichtextFacetLink { uri } } } stepsToReproduce stepsToReproduceFacets { index { byteStart byteEnd } features { __typename ... on AppBskyRichtextFacetLink { uri } ... on NetworkSlicesToolsRichtextFacetLink { uri } } } severity appUsed namespace createdAt actorHandle appBskyActorProfileByDid { avatar { url(preset: "avatar") } } attachments { ... on NetworkSlicesToolsDefsImages { images { alt image { ref mimeType size url(preset: "feed_fullsize") } } } } networkSlicesToolsBugResponseViaBug { edges { node { status createdAt } } } networkSlicesToolsBugIssueViaBug { edges { node { uri issue issueResolved { ... on ShTangledRepoIssue { uri title repo repoResolved { ... on ShTangledRepo { name actorHandle } } } } } } } networkSlicesToolsBugCommentViaBug(where: { parent: { isNull: true } }) { totalCount } } } pageInfo { hasNextPage endCursor } } } `;
const RESPONSES_QUERY = ` query GetResponses($bugUri: String!) { networkSlicesToolsBugResponse( where: { bug: { eq: $bugUri } } sortBy: [{ field: createdAt, direction: ASC }] ) { edges { node { uri did status message messageFacets { index { byteStart byteEnd } features { __typename ... on AppBskyRichtextFacetLink { uri } ... on NetworkSlicesToolsRichtextFacetLink { uri } } } actorHandle appBskyActorProfileByDid { avatar { url(preset: "avatar") } } createdAt } } } } `;
const CREATE_BUG_MUTATION = ` mutation CreateBug($input: NetworkSlicesToolsBugInput!) { createNetworkSlicesToolsBug(input: $input) { uri } } `;
const UPLOAD_BLOB_MUTATION = ` mutation UploadBlob($data: String!, $mimeType: String!) { uploadBlob(data: $data, mimeType: $mimeType) { ref mimeType size } } `;
const CREATE_RESPONSE_MUTATION = ` mutation CreateResponse($input: NetworkSlicesToolsBugResponseInput!) { createNetworkSlicesToolsBugResponse(input: $input) { uri } } `;
const COMMENTS_QUERY = ` query GetComments($bugUri: String!) { networkSlicesToolsBugComment( where: { bug: { eq: $bugUri } } sortBy: [{ field: createdAt, direction: ASC }] ) { edges { node { uri did actorHandle appBskyActorProfileByDid { avatar { url(preset: "avatar") } } body bodyFacets { index { byteStart byteEnd } features { __typename ... on AppBskyRichtextFacetLink { uri } ... on NetworkSlicesToolsRichtextFacetLink { uri } } } parent attachments { ... on NetworkSlicesToolsDefsImages { images { alt image { ref mimeType size url } } } } createdAt } } } } `;
const CREATE_COMMENT_MUTATION = ` mutation CreateComment($input: NetworkSlicesToolsBugCommentInput!) { createNetworkSlicesToolsBugComment(input: $input) { uri } } `;
const UPDATE_COMMENT_MUTATION = ` mutation UpdateComment($rkey: String!, $input: NetworkSlicesToolsBugCommentInput!) { updateNetworkSlicesToolsBugComment(rkey: $rkey, input: $input) { uri } } `;
const DELETE_COMMENT_MUTATION = ` mutation DeleteComment($rkey: String!) { deleteNetworkSlicesToolsBugComment(rkey: $rkey) { uri } } `;
const UPDATE_BUG_MUTATION = ` mutation UpdateBug($rkey: String!, $input: NetworkSlicesToolsBugInput!) { updateNetworkSlicesToolsBug(rkey: $rkey, input: $input) { uri } } `;
const DELETE_BUG_MUTATION = ` mutation DeleteBug($rkey: String!) { deleteNetworkSlicesToolsBug(rkey: $rkey) { uri } } `;
const DELETE_RESPONSE_MUTATION = ` mutation DeleteResponse($rkey: String!) { deleteNetworkSlicesToolsBugResponse(rkey: $rkey) { uri } } `;
const VIEWER_REPOS_QUERY = ` query { viewer { shTangledRepoByDid(sortBy: [{ field: createdAt, direction: DESC }]) { edges { node { uri name description actorHandle } } } } } `;
const REPO_ISSUES_QUERY = ` query GetRepoIssues($repoUri: String!) { shTangledRepoIssue( where: { repo: { eq: $repoUri } } sortBy: [{ field: createdAt, direction: DESC }] ) { edges { node { uri title createdAt } } } } `;
const CREATE_TANGLED_ISSUE_MUTATION = ` mutation CreateTangledIssue($input: ShTangledRepoIssueInput!) { createShTangledRepoIssue(input: $input) { uri } } `;
const CREATE_BUG_ISSUE_MUTATION = ` mutation CreateBugIssue($input: NetworkSlicesToolsBugIssueInput!) { createNetworkSlicesToolsBugIssue(input: $input) { uri } } `;
const DELETE_BUG_ISSUE_MUTATION = ` mutation DeleteBugIssue($rkey: String!) { deleteNetworkSlicesToolsBugIssue(rkey: $rkey) { uri } } `;
// ============================================================================= // DATA FETCHING // =============================================================================
async function gqlQuery(query, variables = {}) { const res = await fetch(`${SERVER_URL}/graphql`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json(); if (json.errors) throw new Error(json.errors[0].message);
return json.data; }
async function gqlMutation(query, variables = {}) { if (!client) { throw new Error("Not authenticated"); } return await client.mutate(query, variables); }
async function fetchNamespaces() { const data = await gqlQuery(NAMESPACES_QUERY); return data.networkSlicesToolsBugAggregated || []; }
async function fetchBugs(namespace, cursor = null) { const variables = { first: PAGE_SIZE, after: cursor, namespace }; const data = await gqlQuery(BUGS_QUERY, variables); return data.networkSlicesToolsBug; }
async function fetchResponses(bugUri) { const data = await gqlQuery(RESPONSES_QUERY, { bugUri }); return data.networkSlicesToolsBugResponse?.edges.map((e) => e.node) || []; }
async function fetchComments(bugUri) { const data = await gqlQuery(COMMENTS_QUERY, { bugUri }); return data.networkSlicesToolsBugComment?.edges.map((e) => e.node) || []; }
function groupComments(comments) { const topLevel = comments.filter((c) => !c.parent); const replies = comments.filter((c) => c.parent);
return topLevel.map((comment) => ({ ...comment, replies: replies.filter((r) => r.parent === comment.uri), })); }
function canComment() { return !!state.viewer; }
function canEditComment(commentDid) { return state.viewer && state.viewer.did === commentDid; }
// ============================================================================= // RENDERING - LANDING // =============================================================================
function renderLanding() { if (state.isLoading) { return ` <div class="loading-container"> <div class="spinner"></div> <span>Loading namespaces...</span> </div> `; }
if (state.namespaces.length === 0) { return ` <div class="loading-container"> <p>No bug reports yet.</p> <button class="btn btn-primary" onclick="BugsApp.handleReportBug()">Report First Bug</button> </div> `; }
return ` <div class="filter-bar" style="justify-content: flex-end;"><button class="btn btn-primary" onclick="BugsApp.handleReportBug()">Report Bug</button></div> <div class="namespace-grid"> ${state.namespaces .map( (ns) => ` <div class="card namespace-card" onclick="BugsApp.navigateToNamespace('${esc(ns.namespace)}')"> <div class="namespace-name">${esc(ns.namespace)}</div> <div class="namespace-count">${ns.count} bug${ns.count !== 1 ? "s" : ""}</div> </div> `, ) .join("")} </div> `; }
function navigateToNamespace(ns) { state.namespace = ns; state.view = "list"; state.bugs = []; state.cursor = null; state.hasMore = true; updateUrl({ ns, bug: null }); render(); loadBugs(); }
// ============================================================================= // RENDERING - BUG LIST // =============================================================================
function renderBugList() { let content = "";
// Filter bar content += ` <div class="filter-bar"> <div class="filter-group"> <select onchange="BugsApp.handleSeverityFilter(this.value)"> <option value="">All Severities</option> <option value="unusable" ${state.filters.severity === "unusable" ? "selected" : ""}>Unusable</option> <option value="broken" ${state.filters.severity === "broken" ? "selected" : ""}>Broken</option> <option value="annoying" ${state.filters.severity === "annoying" ? "selected" : ""}>Annoying</option> <option value="cosmetic" ${state.filters.severity === "cosmetic" ? "selected" : ""}>Cosmetic</option> </select> </div> <button class="btn btn-primary" onclick="BugsApp.handleReportBug()">Report Bug</button> </div> `;
// Bug cards if (state.isLoading && state.bugs.length === 0) { content += ` <div class="loading-container"> <div class="spinner"></div> <span>Loading bugs...</span> </div> `; } else if (state.bugs.length === 0) { content += ` <div class="loading-container"> <p>No bugs reported for ${esc(state.namespace)}</p> </div> `; } else { const filteredBugs = state.bugs.filter((bug) => { if (state.filters.severity && bug.severity !== state.filters.severity) return false; return true; });
content += `<div class="bug-list">`; content += filteredBugs.map((bug) => renderBugCard(bug)).join(""); content += `</div>`;
// Load more if (state.hasMore) { content += ` <div class="load-more"> <button class="btn btn-secondary" onclick="BugsApp.loadMoreBugs()" ${state.isLoading ? "disabled" : ""}> ${state.isLoading ? '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span>' : "Load More"} </button> </div> `; } }
return content; }
function renderBugCard(bug) { const displayStatus = getBugDisplayStatus(bug); const commentCount = bug.networkSlicesToolsBugCommentViaBug?.totalCount || 0; return ` <div class="card bug-card" onclick="BugsApp.openBugDetail('${esc(bug.uri)}')"> <div class="bug-card-header"> <span class="badge ${getSeverityClass(bug.severity)}">${esc(bug.severity)}</span> <span class="status-badge ${displayStatus.cssClass}">${esc(displayStatus.label)}</span> <span class="bug-card-title">${esc(bug.title)}</span> </div> <div class="bug-card-meta"> <span class="user-info">${renderAvatar(bug.appBskyActorProfileByDid, bug.actorHandle, "user-avatar-sm")}@${esc(bug.actorHandle)}</span> · ${formatTime(bug.createdAt)}${commentCount > 0 ? `<span class="comment-count"><i data-lucide="message-circle"></i> ${commentCount}</span>` : ""} </div> </div> `; }
function handleSeverityFilter(value) { state.filters.severity = value || null; render(); }
async function loadMoreBugs() { if (state.isLoading || !state.hasMore) return; state.isLoading = true; render();
try { const data = await fetchBugs(state.namespace, state.cursor); state.bugs = [...state.bugs, ...data.edges.map((e) => e.node)]; state.cursor = data.pageInfo.endCursor; state.hasMore = data.pageInfo.hasNextPage; } catch (err) { console.error("Failed to load more:", err); showError(`Failed to load: ${err.message}`); } finally { state.isLoading = false; render(); } }
function openBugDetail(uri) { state.bugUri = uri; state.view = "detail"; updateUrl({ bug: uri }); renderOverlay(); }
// ============================================================================= // RENDERING - OVERLAY (BUG DETAIL) // =============================================================================
async function renderOverlay() { const overlay = document.getElementById("overlay"); const bug = state.bugs.find((b) => b.uri === state.bugUri);
if (!bug) { overlay.classList.add("hidden"); return; }
// Show loading state overlay.innerHTML = ` <div class="overlay-header"> <span class="overlay-title">Loading...</span> <button class="overlay-close" onclick="BugsApp.closeOverlay()">×</button> </div> <div class="overlay-body"> <div class="loading-container"> <div class="spinner"></div> </div> </div> `; overlay.classList.remove("hidden"); setTimeout(() => overlay.classList.add("open"), 10);
// Add backdrop let backdrop = document.querySelector(".overlay-backdrop"); if (!backdrop) { backdrop = document.createElement("div"); backdrop.className = "overlay-backdrop"; backdrop.onclick = closeOverlay; document.body.appendChild(backdrop); }
// Fetch responses and comments try { [state.responses, state.comments] = await Promise.all([ fetchResponses(bug.uri), fetchComments(bug.uri), ]); } catch (err) { console.error("Failed to load data:", err); state.responses = []; state.comments = []; }
// Render full content const canAddResponse = canRespond(bug.did, bug.namespace); const canEdit = isAuthor(bug.did); const displayStatus = getBugDisplayStatus(bug);
overlay.innerHTML = ` <div class="overlay-header"> <div> <span class="badge ${getSeverityClass(bug.severity)}">${esc(bug.severity)}</span> <span class="status-badge ${displayStatus.cssClass}">${esc(displayStatus.label)}</span> <h2 class="overlay-title">${esc(bug.title)}</h2> </div> <button class="overlay-close" onclick="BugsApp.closeOverlay()">×</button> </div> <div class="overlay-body"> <div class="overlay-meta"> <span class="user-info">${renderAvatar(bug.appBskyActorProfileByDid, bug.actorHandle)}@${esc(bug.actorHandle)}</span> <span>·</span> <span>${formatTime(bug.createdAt)}</span> </div>
<div class="overlay-actions" style="margin-bottom: 1.5rem;"> <button class="btn btn-secondary" onclick="BugsApp.shareBug()">Share</button> ${canAddResponse ? `<button class="btn btn-secondary btn-icon-text" onclick="BugsApp.openLinkIssueModal('${esc(bug.uri)}')">${tangledIcon(16)} Link Issue</button>` : ""} ${ canEdit ? ` <button class="btn btn-secondary" onclick="BugsApp.openEditModal('${esc(bug.uri)}')">Edit</button> <button class="btn btn-danger" onclick="BugsApp.handleDeleteBug('${esc(bug.uri)}')">Delete</button> ` : "" } </div>
<div class="overlay-section"> <h3>Description</h3> <p>${renderFacetedText(bug.description, bug.descriptionFacets, { escapeHtml: esc })}</p> </div>
<div class="overlay-section"> <h3>Steps to Reproduce</h3> <p>${renderFacetedText(bug.stepsToReproduce, bug.stepsToReproduceFacets, { escapeHtml: esc })}</p> </div>
${ bug.appUsed ? ` <div class="overlay-section"> <h3>App Used</h3> <p>${esc(bug.appUsed)}</p> </div> ` : "" }
${renderAttachments(bug.attachments)}
<div class="overlay-section"> <h3>Comments (${state.comments.filter((c) => !c.parent).length})</h3> ${renderComments()} </div>
<div class="overlay-section"> <h3>Responses (${state.responses.length})</h3> ${state.responses.length === 0 ? "<p class='text-secondary'>No responses yet.</p>" : ""} <div class="response-list"> ${state.responses.map((r) => renderResponse(r)).join("")} </div> </div>
${renderLinkedIssues(bug, canAddResponse)}
${canAddResponse ? renderResponseForm() : ""} </div> `; lucide.createIcons(); }
function updateOverlayContent() { const overlay = document.getElementById("overlay"); const bug = state.bugs.find((b) => b.uri === state.bugUri); if (!bug) return;
const canAddResponse = canRespond(bug.did, bug.namespace); const canEdit = isAuthor(bug.did); const displayStatus = getBugDisplayStatus(bug);
overlay.innerHTML = ` <div class="overlay-header"> <div> <span class="badge ${getSeverityClass(bug.severity)}">${esc(bug.severity)}</span> <span class="status-badge ${displayStatus.cssClass}">${esc(displayStatus.label)}</span> <h2 class="overlay-title">${esc(bug.title)}</h2> </div> <button class="overlay-close" onclick="BugsApp.closeOverlay()">×</button> </div> <div class="overlay-body"> <div class="overlay-meta"> <span class="user-info">${renderAvatar(bug.appBskyActorProfileByDid, bug.actorHandle)}@${esc(bug.actorHandle)}</span> <span>·</span> <span>${formatTime(bug.createdAt)}</span> </div>
<div class="overlay-actions" style="margin-bottom: 1.5rem;"> <button class="btn btn-secondary" onclick="BugsApp.shareBug()">Share</button> ${canAddResponse ? `<button class="btn btn-secondary btn-icon-text" onclick="BugsApp.openLinkIssueModal('${esc(bug.uri)}')">${tangledIcon(16)} Link Issue</button>` : ""} ${ canEdit ? ` <button class="btn btn-secondary" onclick="BugsApp.openEditModal('${esc(bug.uri)}')">Edit</button> <button class="btn btn-danger" onclick="BugsApp.handleDeleteBug('${esc(bug.uri)}')">Delete</button> ` : "" } </div>
<div class="overlay-section"> <h3>Description</h3> <p>${renderFacetedText(bug.description, bug.descriptionFacets, { escapeHtml: esc })}</p> </div>
<div class="overlay-section"> <h3>Steps to Reproduce</h3> <p>${renderFacetedText(bug.stepsToReproduce, bug.stepsToReproduceFacets, { escapeHtml: esc })}</p> </div>
${ bug.appUsed ? ` <div class="overlay-section"> <h3>App Used</h3> <p>${esc(bug.appUsed)}</p> </div> ` : "" }
${renderAttachments(bug.attachments)}
<div class="overlay-section"> <h3>Comments (${state.comments.filter((c) => !c.parent).length})</h3> ${renderComments()} </div>
<div class="overlay-section"> <h3>Responses (${state.responses.length})</h3> ${state.responses.length === 0 ? "<p class='text-secondary'>No responses yet.</p>" : ""} <div class="response-list"> ${state.responses.map((r) => renderResponse(r)).join("")} </div> </div>
${renderLinkedIssues(bug, canAddResponse)}
${canAddResponse ? renderResponseForm() : ""} </div> `; lucide.createIcons(); }
async function openLinkIssueModal(bugUri) { state.linkIssueState = { bugUri, step: "repos", repos: [], selectedRepo: null, issues: [], isLoading: true, };
renderLinkIssueModal();
try { const data = await gqlMutation(VIEWER_REPOS_QUERY, {}); state.linkIssueState.repos = data.viewer?.shTangledRepoByDid?.edges?.map((e) => e.node) || []; } catch (err) { console.error("Failed to fetch repos:", err); showError(`Failed to load repos: ${err.message}`); } finally { state.linkIssueState.isLoading = false; renderLinkIssueModal(); } }
function renderLinkIssueModal() { const modal = document.getElementById("modal"); const { step, repos, selectedRepo, issues, isLoading } = state.linkIssueState;
let content = "";
if (step === "repos") { content = ` <div class="modal-header"> <h2 style="display: flex; align-items: center; gap: 0.5rem;">${tangledIcon(20)} Link to Tangled Issue</h2> <button class="modal-close" onclick="BugsApp.closeLinkIssueModal()">×</button> </div> <div class="modal-body"> ${ isLoading ? ` <div class="loading-container"> <div class="spinner"></div> <span>Loading repos...</span> </div> ` : repos.length === 0 ? ` <div class="loading-container"> <p>No Tangled repos found.</p> <p class="text-secondary">Create a repo on <a href="https://tangled.sh" target="_blank">tangled.sh</a> first.</p> </div> ` : ` <div class="form-group"> <label for="repo-select">Select a repository:</label> <select id="repo-select" onchange="if(this.value) BugsApp.selectRepoForLinking(this.value)"> <option value="">Choose a repo...</option> ${repos .map( (repo) => ` <option value="${esc(repo.uri)}">${esc(repo.name)}</option> `, ) .join("")} </select> </div> ` } </div> `; } else if (step === "issues") { const bug = state.bugs.find((b) => b.uri === state.linkIssueState.bugUri); content = ` <div class="modal-header"> <h2 style="display: flex; align-items: center; gap: 0.5rem;">${tangledIcon(20)} Link to Tangled Issue</h2> <button class="modal-close" onclick="BugsApp.closeLinkIssueModal()">×</button> </div> <div class="modal-body"> <button class="btn btn-secondary" onclick="BugsApp.goBackToRepos()" style="margin-bottom: 1rem;">← Back to repos</button> <p style="margin-bottom: 0.5rem;"><strong>${esc(selectedRepo.name)}</strong></p>
<button class="btn btn-primary" style="width: 100%; margin-bottom: 1rem;" onclick="BugsApp.createAndLinkIssue()" ${isLoading ? "disabled" : ""}> + Create New Issue from Bug </button>
${ isLoading ? ` <div class="loading-container"> <div class="spinner"></div> <span>Loading issues...</span> </div> ` : issues.length === 0 ? ` <p class="text-secondary">No existing issues in this repo.</p> ` : ` <p class="text-secondary" style="margin-bottom: 0.5rem;">Or link to existing issue:</p> <div class="issue-list"> ${issues .map( (issue) => ` <div class="card" style="cursor: pointer;" onclick="BugsApp.linkToExistingIssue('${esc(issue.uri)}')"> <div style="font-weight: 500;">${esc(issue.title)}</div> <div class="text-secondary" style="font-size: 0.75rem;">${formatTime(issue.createdAt)}</div> </div> `, ) .join("")} </div> ` } </div> `; }
modal.innerHTML = ` <div class="modal-backdrop" onclick="BugsApp.closeLinkIssueModal()"></div> <div class="modal-content scrollable"> ${content} </div> `; modal.classList.remove("hidden"); }
function closeLinkIssueModal() { state.linkIssueState = { bugUri: null, step: "repos", repos: [], selectedRepo: null, issues: [], isLoading: false, }; closeModal(); }
async function selectRepoForLinking(repoUri) { const repo = state.linkIssueState.repos.find((r) => r.uri === repoUri); state.linkIssueState.selectedRepo = repo; state.linkIssueState.step = "issues"; state.linkIssueState.isLoading = true; state.linkIssueState.issues = [];
renderLinkIssueModal();
try { const data = await gqlQuery(REPO_ISSUES_QUERY, { repoUri }); state.linkIssueState.issues = data.shTangledRepoIssue?.edges?.map((e) => e.node) || []; } catch (err) { console.error("Failed to fetch issues:", err); showError(`Failed to load issues: ${err.message}`); } finally { state.linkIssueState.isLoading = false; renderLinkIssueModal(); } }
function goBackToRepos() { state.linkIssueState.step = "repos"; state.linkIssueState.selectedRepo = null; state.linkIssueState.issues = []; renderLinkIssueModal(); }
async function createAndLinkIssue() { const { bugUri, selectedRepo } = state.linkIssueState; const bug = state.bugs.find((b) => b.uri === bugUri);
if (!bug || !selectedRepo) return;
state.linkIssueState.isLoading = true; renderLinkIssueModal();
try { // Build issue body from bug data const bugUrl = `https://tools.slices.network/bugs?ns=${encodeURIComponent(bug.namespace)}&bug=${encodeURIComponent(bug.uri)}`; const issueBody = `**Description:**${bug.description}
**Steps to Reproduce:**${bug.stepsToReproduce}
---Linked from bug: ${bugUrl}`;
// Create the Tangled issue const issueInput = { repo: selectedRepo.uri, title: bug.title, body: issueBody, createdAt: new Date().toISOString(), };
const issueResult = await gqlMutation(CREATE_TANGLED_ISSUE_MUTATION, { input: issueInput, }); const newIssueUri = issueResult.createShTangledRepoIssue.uri;
// Create the bug.issue link const linkInput = { bug: bugUri, issue: newIssueUri, createdAt: new Date().toISOString(), };
await gqlMutation(CREATE_BUG_ISSUE_MUTATION, { input: linkInput });
// Close modal and refresh closeLinkIssueModal();
// Refresh bugs to get updated linked issues await loadBugs();
// Re-open the overlay to show the new linked issue if (state.bugUri) { renderOverlay(); }
showSuccess("Issue created and linked!"); } catch (err) { console.error("Failed to create and link issue:", err); showError(`Failed to create issue: ${err.message}`); state.linkIssueState.isLoading = false; renderLinkIssueModal(); } }
async function linkToExistingIssue(issueUri) { const { bugUri } = state.linkIssueState;
if (!bugUri) return;
state.linkIssueState.isLoading = true; renderLinkIssueModal();
try { const linkInput = { bug: bugUri, issue: issueUri, createdAt: new Date().toISOString(), };
await gqlMutation(CREATE_BUG_ISSUE_MUTATION, { input: linkInput });
closeLinkIssueModal();
// Refresh bugs to get updated linked issues await loadBugs();
// Re-open the overlay to show the linked issue if (state.bugUri) { renderOverlay(); }
showSuccess("Issue linked!"); } catch (err) { console.error("Failed to link issue:", err); showError(`Failed to link issue: ${err.message}`); state.linkIssueState.isLoading = false; renderLinkIssueModal(); } }
function getTangledIssueUrl(issue) { if (!issue.issueResolved) return null; const resolved = issue.issueResolved; if (!resolved.repoResolved) return null;
const handle = resolved.repoResolved.actorHandle; const repoName = resolved.repoResolved.name;
// Tangled doesn't support linking to specific issues by rkey yet, // so link to the repo's issues page instead return `https://tangled.sh/${handle}/${repoName}/issues`; }
function renderLinkedIssues(bug, canUnlink) { const linkedIssues = bug.networkSlicesToolsBugIssueViaBug?.edges?.map((e) => e.node) || [];
if (linkedIssues.length === 0) { return ""; }
return ` <div class="overlay-section"> <h3>Linked Issues (${linkedIssues.length})</h3> <div class="linked-issues-list"> ${linkedIssues .map((link) => { const issue = link.issueResolved; const url = getTangledIssueUrl(link); const title = issue?.title || "Unknown Issue"; const repoName = issue?.repoResolved?.name || "Unknown Repo";
return ` <div class="card" style="display: flex; justify-content: space-between; align-items: center; cursor: default;"> <div> <div style="font-weight: 500;">${esc(title)}</div> <div class="text-secondary" style="font-size: 0.75rem;"> ${esc(repoName)} ${url ? ` · <a href="${esc(url)}" target="_blank" onclick="event.stopPropagation()">View on Tangled</a>` : ""} </div> </div> ${canUnlink ? `<button class="btn-icon btn-danger-text" onclick="BugsApp.unlinkIssue('${esc(link.uri)}')" title="Unlink">×</button>` : ""} </div> `; }) .join("")} </div> </div> `; }
async function unlinkIssue(linkUri) { if (!confirm("Unlink this issue?")) { return; }
try { const rkey = getRkeyFromUri(linkUri); await gqlMutation(DELETE_BUG_ISSUE_MUTATION, { rkey });
// Refresh bugs to update linked issues await loadBugs();
// Re-render overlay if (state.bugUri) { renderOverlay(); }
showSuccess("Issue unlinked!"); } catch (err) { console.error("Failed to unlink issue:", err); showError(`Failed to unlink: ${err.message}`); } }
function renderAttachments(attachments) { if (!attachments || !attachments.images || attachments.images.length === 0) { return ""; } const images = attachments.images; return ` <div class="overlay-section"> <h3>Attachments (${images.length})</h3> <div class="attachment-gallery"> ${images .map( (img) => ` <img class="attachment-image" src="${esc(img.image.url)}" alt="${esc(img.alt || "Bug attachment")}" onclick="BugsApp.openLightbox('${esc(img.image.url)}')" /> `, ) .join("")} </div> </div> `; }
function openLightbox(imageUrl) { const lightbox = document.createElement("div"); lightbox.className = "image-lightbox"; lightbox.innerHTML = `<img src="${imageUrl}" alt="Full size image" />`; lightbox.onclick = () => lightbox.remove(); document.body.appendChild(lightbox); }
function renderResponse(response) { const isResponseAuthor = state.viewer && state.viewer.did === response.did; return ` <div class="response-card"> <div class="response-header"> <span class="status-badge ${getStatusClass(response.status)}">${esc(response.status)}</span> <span class="response-meta"><span class="user-info">${renderAvatar(response.appBskyActorProfileByDid, response.actorHandle, "user-avatar-sm")}@${esc(response.actorHandle)}</span> · ${formatTime(response.createdAt)}</span> ${isResponseAuthor ? `<button class="btn-icon btn-danger-text" onclick="BugsApp.handleDeleteResponse('${esc(response.uri)}')" title="Delete response">×</button>` : ""} </div> ${response.message ? `<p class="response-message">${renderFacetedText(response.message, response.messageFacets, { escapeHtml: esc })}</p>` : ""} </div> `; }
function renderResponseForm() { return ` <div class="overlay-section"> <h3>Add Response</h3> <form onsubmit="BugsApp.handleSubmitResponse(event)"> <div class="form-group"> <label for="response-status">Status</label> <select id="response-status" required> <option value="">Select status...</option> <option value="acknowledged">Acknowledged</option> <option value="fixed">Fixed</option> <option value="wontfix">Won't Fix</option> <option value="duplicate">Duplicate</option> <option value="invalid">Invalid</option> </select> </div> <div class="form-group"> <label for="response-message">Message (optional)</label> <textarea id="response-message" placeholder="Add context or a link to the fix..."></textarea> </div> <div class="form-actions"> <button type="submit" class="btn btn-primary">Submit Response</button> </div> </form> </div> `; }
function renderComment(comment, isReply = false) { const canEdit = canEditComment(comment.did); const isEditing = state.editingComment === comment.uri;
if (isEditing) { const attachmentPreviews = state.editCommentAttachments.length > 0 ? ` <div class="image-previews" style="margin-bottom: 0.5rem;"> ${state.editCommentAttachments .map( (img, i) => ` <div class="image-preview"> <img src="${esc(img.image?.url)}" alt="${esc(img.alt || "")}" style="max-width: 80px; max-height: 80px; width: auto; height: auto;"> <button type="button" onclick="BugsApp.removeEditCommentAttachment(${i})">×</button> </div> `, ) .join("")} </div> ` : "";
return ` <div class="comment-card" data-uri="${esc(comment.uri)}"> <div class="comment-form-inline" style="border-top: none; margin-top: 0; padding-top: 0;"> <form onsubmit="BugsApp.handleSaveEditComment(event, '${esc(comment.uri)}')"> <textarea id="edit-comment-${esc(comment.uri)}" required>${esc(comment.body)}</textarea> ${attachmentPreviews} <div class="comment-form-actions"> <button type="button" class="btn btn-secondary" onclick="BugsApp.cancelEditComment()">Cancel</button> <button type="submit" class="btn btn-primary">Save</button> </div> </form> </div> </div> `; }
return ` <div class="comment-card" data-uri="${esc(comment.uri)}"> <div class="comment-header"> <span class="comment-meta"><span class="user-info">${renderAvatar(comment.appBskyActorProfileByDid, comment.actorHandle, "user-avatar-sm")}@${esc(comment.actorHandle)}</span> · ${formatTime(comment.createdAt)}</span> ${ canEdit ? ` <div> <button class="btn-icon" onclick="BugsApp.startEditComment('${esc(comment.uri)}')" title="Edit"><i data-lucide="pencil"></i></button> <button class="btn-icon btn-danger-text" onclick="BugsApp.handleDeleteComment('${esc(comment.uri)}')" title="Delete"><i data-lucide="trash-2"></i></button> </div> ` : "" } </div> <p class="comment-body">${renderFacetedText(comment.body, comment.bodyFacets, { escapeHtml: esc })}</p> ${renderCommentAttachments(comment.attachments)} ${ !isReply && canComment() ? ` <div class="comment-actions"> <button class="btn btn-secondary" onclick="BugsApp.showReplyForm('${esc(comment.uri)}')">Reply</button> </div> ` : "" } ${state.replyingTo === comment.uri ? renderReplyForm(comment.uri) : ""} </div> `; }
function renderCommentAttachments(attachments) { if (!attachments || !attachments.images || attachments.images.length === 0) { return ""; } return ` <div class="attachment-gallery" style="margin-top: 0.5rem;"> ${attachments.images .map( (img) => ` <img class="attachment-image" src="${esc(img.image.url)}" alt="${esc(img.alt || "Comment attachment")}" onclick="BugsApp.openLightbox('${esc(img.image.url)}')" /> `, ) .join("")} </div> `; }
function renderReplyForm(parentUri) { return ` <div class="comment-form-inline"> <form onsubmit="BugsApp.handleSubmitComment(event, '${esc(parentUri)}')"> <textarea id="reply-body" placeholder="Write a reply..." required></textarea> ${renderCommentImagePreviews()} <div class="comment-form-actions"> <button type="button" class="btn btn-secondary" onclick="BugsApp.cancelReply()">Cancel</button> <input type="file" id="reply-images" accept="image/*" multiple onchange="BugsApp.handleCommentImageSelect(event)" style="display: none;"> <button type="button" class="btn btn-secondary btn-icon" onclick="document.getElementById('reply-images').click()" title="Add images"> <i data-lucide="image"></i> </button> <button type="submit" class="btn btn-primary">Reply</button> </div> </form> </div> `; }
function renderComments() { const grouped = groupComments(state.comments);
if (grouped.length === 0 && !canComment()) { return `<p class="text-secondary">No comments yet.</p>`; }
return ` <div class="comments-list"> ${grouped .map( (comment) => ` ${renderComment(comment)} ${ comment.replies.length > 0 ? ` <div class="comment-replies"> ${comment.replies.map((reply) => renderComment(reply, true)).join("")} </div> ` : "" } `, ) .join("")} </div> ${canComment() ? renderCommentForm() : ""} `; }
function renderCommentForm() { return ` <div class="comment-form-inline" style="border-top: none; margin-top: 1rem;"> <form onsubmit="BugsApp.handleSubmitComment(event)"> <textarea id="comment-body" placeholder="Add a comment..." required></textarea> ${renderCommentImagePreviews()} <div class="comment-form-actions"> <input type="file" id="comment-images" accept="image/*" multiple onchange="BugsApp.handleCommentImageSelect(event)" style="display: none;"> <button type="button" class="btn btn-secondary btn-icon" onclick="document.getElementById('comment-images').click()" title="Add images"> <i data-lucide="image"></i> </button> <button type="submit" class="btn btn-primary">Comment</button> </div> </form> </div> `; }
function renderCommentImagePreviews() { if (state.commentImages.length === 0) return ""; return ` <div class="image-previews"> ${state.commentImages .map( (img, i) => ` <div class="image-preview"> <img src="${img.preview}" alt="Preview"> <button type="button" onclick="BugsApp.removeCommentImage(${i})">×</button> </div> `, ) .join("")} </div> `; }
async function handleCommentImageSelect(event) { const files = Array.from(event.target.files); event.target.value = "";
for (const file of files) { try { const dataUrl = await readFileAsDataURL(file); const resized = await resizeImage(dataUrl, { width: 2000, height: 2000, maxSize: 900000, mode: "contain", }); state.commentImages.push({ file, preview: resized.dataUrl, }); updateOverlayContent(); } catch (err) { showError(`Failed to process ${file.name}: ${err.message}`); } } }
function removeCommentImage(index) { state.commentImages.splice(index, 1); updateOverlayContent(); }
function showReplyForm(commentUri) { state.replyingTo = commentUri; updateOverlayContent(); }
function cancelReply() { state.replyingTo = null; state.commentImages = []; updateOverlayContent(); }
function startEditComment(commentUri) { state.editingComment = commentUri; const comment = state.comments.find((c) => c.uri === commentUri); // Keep full image objects to preserve blob references state.editCommentAttachments = comment?.attachments?.images || []; updateOverlayContent(); }
function cancelEditComment() { state.editingComment = null; state.editCommentAttachments = []; updateOverlayContent(); }
function removeEditCommentAttachment(index) { state.editCommentAttachments.splice(index, 1); updateOverlayContent(); }
async function handleSaveEditComment(event, commentUri) { event.preventDefault();
const textarea = document.getElementById(`edit-comment-${commentUri}`); const newBody = textarea.value.trim();
if (!newBody) return;
const comment = state.comments.find((c) => c.uri === commentUri); if (!comment) return;
const submitBtn = event.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.textContent = "Saving...";
try { const rkey = getRkeyFromUri(commentUri);
// Parse facets for body const bodyParsed = parseFacets(newBody);
const input = { bug: state.bugUri, body: bodyParsed.text, ...(bodyParsed.facets && { bodyFacets: bodyParsed.facets }), createdAt: comment.createdAt, ...(comment.parent && { parent: comment.parent }), };
// Include remaining attachments if (state.editCommentAttachments.length > 0) { input.attachments = { $type: "network.slices.tools.defs#images", images: state.editCommentAttachments.map((img) => ({ alt: img.alt || "", image: { $type: "blob", ref: { $link: img.image.ref }, mimeType: img.image.mimeType, size: img.image.size, }, })), }; }
await gqlMutation(UPDATE_COMMENT_MUTATION, { rkey, input });
state.editingComment = null; state.editCommentAttachments = []; state.comments = await fetchComments(state.bugUri); updateOverlayContent(); showSuccess("Comment updated!"); } catch (err) { console.error("Failed to update comment:", err); showError(`Failed to update: ${err.message}`); submitBtn.disabled = false; submitBtn.textContent = "Save"; } }
async function handleSubmitComment(event, parentUri = null) { event.preventDefault();
const textareaId = parentUri ? "reply-body" : "comment-body"; const textarea = document.getElementById(textareaId); const body = textarea.value.trim();
if (!body) return;
const submitBtn = event.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.textContent = parentUri ? "Replying..." : "Posting...";
try { // Upload images if any let attachments = null; if (state.commentImages.length > 0) { const uploadedImages = []; for (const img of state.commentImages) { const base64Data = img.preview.split(",")[1]; const uploadResult = await gqlMutation(UPLOAD_BLOB_MUTATION, { data: base64Data, mimeType: "image/jpeg", }); if (uploadResult.uploadBlob) { uploadedImages.push({ alt: "", image: { $type: "blob", ref: { $link: uploadResult.uploadBlob.ref }, mimeType: uploadResult.uploadBlob.mimeType, size: uploadResult.uploadBlob.size, }, }); } } attachments = { $type: "network.slices.tools.defs#images", images: uploadedImages, }; }
// Parse facets for body const bodyParsed = parseFacets(body);
const input = { bug: state.bugUri, body: bodyParsed.text, ...(bodyParsed.facets && { bodyFacets: bodyParsed.facets }), createdAt: new Date().toISOString(), ...(parentUri && { parent: parentUri }), ...(attachments && { attachments }), };
await gqlMutation(CREATE_COMMENT_MUTATION, { input });
state.replyingTo = null; state.commentImages = []; state.comments = await fetchComments(state.bugUri);
// Update comment count in bugs list (top-level only) const bugIndex = state.bugs.findIndex((b) => b.uri === state.bugUri); if (bugIndex !== -1) { if (!state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug) { state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug = { totalCount: 0 }; } state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug.totalCount = state.comments.filter((c) => !c.parent).length; }
updateOverlayContent(); render(); showSuccess(parentUri ? "Reply added!" : "Comment added!"); } catch (err) { console.error("Failed to add comment:", err); showError(`Failed to add comment: ${err.message}`); submitBtn.disabled = false; submitBtn.textContent = parentUri ? "Reply" : "Comment"; } }
async function handleDeleteComment(commentUri) { if (!confirm("Delete this comment?")) return;
try { const rkey = getRkeyFromUri(commentUri); await gqlMutation(DELETE_COMMENT_MUTATION, { rkey });
state.comments = state.comments.filter((c) => c.uri !== commentUri);
// Update comment count in bugs list (top-level only) const bugIndex = state.bugs.findIndex((b) => b.uri === state.bugUri); if (bugIndex !== -1 && state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug) { state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug.totalCount = state.comments.filter((c) => !c.parent).length; }
updateOverlayContent(); render(); showSuccess("Comment deleted!"); } catch (err) { console.error("Failed to delete comment:", err); showError(`Failed to delete: ${err.message}`); } }
function closeOverlay() { const overlay = document.getElementById("overlay"); overlay.classList.remove("open"); setTimeout(() => overlay.classList.add("hidden"), 200);
const backdrop = document.querySelector(".overlay-backdrop"); if (backdrop) backdrop.remove();
state.bugUri = null; state.view = "list"; state.responses = []; state.comments = []; state.replyingTo = null; state.editingComment = null; state.commentImages = []; updateUrl({ bug: null }); }
async function handleSubmitResponse(event) { event.preventDefault();
const status = document.getElementById("response-status").value; const message = document.getElementById("response-message").value.trim(); const bug = state.bugs.find((b) => b.uri === state.bugUri);
if (!bug) return;
const submitBtn = event.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.textContent = "Submitting...";
try { // Parse facets for message if present const messageParsed = message ? parseFacets(message) : { text: null, facets: null };
const input = { bug: bug.uri, status, createdAt: new Date().toISOString(), ...(messageParsed.text && { message: messageParsed.text }), ...(messageParsed.facets && { messageFacets: messageParsed.facets }), };
await gqlMutation(CREATE_RESPONSE_MUTATION, { input });
// Refresh responses state.responses = await fetchResponses(bug.uri);
// Update bug's embedded responses for status display const bugIndex = state.bugs.findIndex((b) => b.uri === bug.uri); if (bugIndex !== -1) { state.bugs[bugIndex].networkSlicesToolsBugResponseViaBug = { edges: state.responses.map((r) => ({ node: { status: r.status, createdAt: r.createdAt }, })), }; }
renderOverlay(); render(); // Update list to show new status
showSuccess("Response added!"); } catch (err) { console.error("Submit response failed:", err); showError(`Failed to submit: ${err.message}`); } finally { submitBtn.disabled = false; submitBtn.textContent = "Submit Response"; } }
// ============================================================================= // RENDERING - MODAL (SUBMIT BUG) // =============================================================================
function openSubmitModal() { if (!state.viewer) { login(); return; }
const modal = document.getElementById("modal"); modal.innerHTML = ` <div class="modal-backdrop" onclick="BugsApp.closeModal()"></div> <div class="modal-content scrollable"> <div class="modal-header"> <h2>Report Bug</h2> <button class="modal-close" onclick="BugsApp.closeModal()">×</button> </div> <div class="modal-body"> <form id="bug-form" onsubmit="BugsApp.handleSubmitBug(event)"> <div class="form-group"> <label for="bug-title">Title *</label> <input type="text" id="bug-title" required maxlength="100" placeholder="Brief description of the issue"> </div>
<div class="form-group"> <label for="bug-namespace">Namespace *</label> <p class="hint">e.g., social.grain, app.bsky, fm.teal</p> <input type="text" id="bug-namespace" required placeholder="com.example" value="${esc(state.namespace || "")}" onblur="BugsApp.validateNamespaceOnBlur()"> <div class="error" id="namespace-error"></div> </div>
<div class="form-group"> <label for="bug-description">Description *</label> <textarea id="bug-description" required maxlength="3000" placeholder="What happened? What did you expect?"></textarea> <div class="form-hint">Supports **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`</div> </div>
<div class="form-group"> <label for="bug-steps">Steps to Reproduce *</label> <textarea id="bug-steps" required maxlength="1500" placeholder="1. Go to...\n2. Click on...\n3. See error"></textarea> <div class="form-hint">Supports **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`</div> </div>
<div class="form-group"> <label for="bug-severity">Severity *</label> <select id="bug-severity" required> <option value="">Select severity...</option> <option value="cosmetic">Cosmetic - Visual issue, doesn't affect function</option> <option value="annoying">Annoying - Works but frustrating</option> <option value="broken">Broken - Feature doesn't work correctly</option> <option value="unusable">Unusable - Can't use the app at all</option> </select> </div>
<div class="form-group"> <label for="bug-app">App Used (optional)</label> <input type="text" id="bug-app" maxlength="300" placeholder="e.g., Bluesky iOS, grain.social"> </div>
<div class="form-group"> <label>Screenshots (optional)</label> <div class="image-upload" onclick="document.getElementById('bug-images').click()"> <input type="file" id="bug-images" accept="image/*" multiple onchange="BugsApp.handleImageSelect(event)"> <p>Click to upload images</p> </div> <div class="image-previews" id="image-previews"></div> </div>
<div class="form-actions"> <button type="button" class="btn btn-secondary" onclick="BugsApp.closeModal()">Cancel</button> <button type="submit" class="btn btn-primary" id="submit-bug-btn">Submit Bug</button> </div> </form> </div> </div> `; modal.classList.remove("hidden"); }
function closeModal() { const modal = document.getElementById("modal"); modal.classList.add("hidden"); modal.innerHTML = ""; state.pendingImages = []; state.editingBug = null; state.existingAttachments = []; }
function openInfoModal() { const modal = document.getElementById("modal"); modal.innerHTML = ` <div class="modal-backdrop" onclick="BugsApp.closeModal()"></div> <div class="modal-content scrollable"> <div class="modal-header"> <h2>How Bug Tracker Works</h2> <button class="modal-close" onclick="BugsApp.closeModal()">×</button> </div> <div class="modal-body"> <div class="info-section"> <h3>Reporting Bugs</h3> <p>Anyone with an <a href="https://internethandle.org/" target="_blank">internet handle</a> can report bugs. Each bug belongs to a <strong>namespace</strong> (like <code>social.grain</code> or <code>fm.teal</code>) which identifies the project.</p> </div>
<div class="info-section"> <h3>Responding to Bugs</h3> <p>Only the domain authority can respond to bugs for their namespace. Your handle must exactly match the namespace domain (e.g., <code>@grain.social</code> can respond to <code>social.grain</code> bugs).</p> </div>
<div class="info-section"> <h3>Comments</h3> <p>Anyone logged in can add comments to discuss bugs, ask questions, or provide additional information. Comments support replies for threaded conversations and can include image attachments.</p> </div>
<div class="info-section"> <h3>Bug Statuses</h3> <ul class="status-list"> <li><span class="status-badge status-open">Open</span> No response yet</li> <li><span class="status-badge status-inprogress">In Progress</span> Acknowledged by maintainer</li> <li><span class="status-badge status-closed">Closed - Fixed</span> Bug has been resolved</li> <li><span class="status-badge status-wontfix">Closed - Won't Fix</span> Not planned to fix</li> <li><span class="status-badge status-duplicate">Closed - Duplicate</span> Already reported</li> <li><span class="status-badge status-invalid">Closed - Invalid</span> Not a valid bug</li> </ul> </div>
<div class="info-section"> <h3>Built on ATmosphere</h3> <p>This bug tracker is built on the <a href="https://atproto.com/" target="_blank">AT Protocol</a>. Your bugs and responses are stored in your personal data repository, giving you ownership of your data.</p> </div>
<div class="info-section"> <h3>Lexicons</h3> <p>The bug tracker uses the following <a href="https://tangled.sh/slices.network/tools/tree/main/lexicons" target="_blank">lexicon schemas</a>:</p> <div style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem;"> <div> <code style="font-weight: 600;">network.slices.tools.bug</code> <div style="color: var(--text-secondary); font-size: 0.875rem;">Bug reports with title, description, severity, and attachments</div> </div> <div> <code style="font-weight: 600;">network.slices.tools.bug.response</code> <div style="color: var(--text-secondary); font-size: 0.875rem;">Official responses from namespace maintainers</div> </div> <div> <code style="font-weight: 600;">network.slices.tools.bug.comment</code> <div style="color: var(--text-secondary); font-size: 0.875rem;">Discussion comments with optional replies and attachments</div> </div> <div> <code style="font-weight: 600;">network.slices.tools.bug.issue</code> <div style="color: var(--text-secondary); font-size: 0.875rem;">Links between bugs and Tangled repository issues</div> </div> </div> </div> </div> </div> `; modal.classList.remove("hidden"); }
function openEditModal(bugUri) { const bug = state.bugs.find((b) => b.uri === bugUri); if (!bug) return;
state.editingBug = bug; // Copy existing attachments so we can track removals state.existingAttachments = bug.attachments?.images ? [...bug.attachments.images] : [];
const modal = document.getElementById("modal"); modal.innerHTML = ` <div class="modal-backdrop" onclick="BugsApp.closeModal()"></div> <div class="modal-content scrollable"> <div class="modal-header"> <h2>Edit Bug</h2> <button class="modal-close" onclick="BugsApp.closeModal()">×</button> </div> <div class="modal-body"> <form id="bug-form" onsubmit="BugsApp.handleEditBug(event)"> <div class="form-group"> <label for="bug-title">Title *</label> <input type="text" id="bug-title" required maxlength="100" value="${esc(bug.title)}"> </div>
<div class="form-group"> <label for="bug-namespace">Namespace *</label> <p class="hint">e.g., social.grain, app.bsky, fm.teal</p> <input type="text" id="bug-namespace" required value="${esc(bug.namespace)}" onblur="BugsApp.validateNamespaceOnBlur()"> <div class="error" id="namespace-error"></div> </div>
<div class="form-group"> <label for="bug-description">Description *</label> <textarea id="bug-description" required maxlength="3000">${esc(bug.description)}</textarea> <div class="form-hint">Supports **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`</div> </div>
<div class="form-group"> <label for="bug-steps">Steps to Reproduce *</label> <textarea id="bug-steps" required maxlength="1500">${esc(bug.stepsToReproduce)}</textarea> <div class="form-hint">Supports **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`</div> </div>
<div class="form-group"> <label for="bug-severity">Severity *</label> <select id="bug-severity" required> <option value="cosmetic" ${bug.severity === "cosmetic" ? "selected" : ""}>Cosmetic - Visual issue, doesn't affect function</option> <option value="annoying" ${bug.severity === "annoying" ? "selected" : ""}>Annoying - Works but frustrating</option> <option value="broken" ${bug.severity === "broken" ? "selected" : ""}>Broken - Feature doesn't work correctly</option> <option value="unusable" ${bug.severity === "unusable" ? "selected" : ""}>Unusable - Can't use the app at all</option> </select> </div>
<div class="form-group"> <label for="bug-app">App Used (optional)</label> <input type="text" id="bug-app" maxlength="300" value="${esc(bug.appUsed || "")}"> </div>
<div class="form-group"> <label>Attachments</label> <div id="edit-attachments-list"></div> <div class="image-upload" onclick="document.getElementById('edit-images').click()"> <input type="file" id="edit-images" accept="image/*" multiple onchange="BugsApp.handleEditImageSelect(event)"> <p>Click to add images</p> </div> <div id="edit-new-images"></div> </div>
<div class="form-actions"> <button type="button" class="btn btn-secondary" onclick="BugsApp.closeModal()">Cancel</button> <button type="submit" class="btn btn-primary" id="submit-bug-btn">Save Changes</button> </div> </form> </div> </div> `; modal.classList.remove("hidden"); renderEditAttachments(); }
function renderEditAttachments() { const container = document.getElementById("edit-attachments-list"); if (!container) return;
if (state.existingAttachments.length === 0) { container.innerHTML = '<p class="text-secondary">No attachments</p>'; return; }
container.innerHTML = ` <div class="image-previews"> ${state.existingAttachments .map( (img, index) => ` <div class="image-preview"> <img src="${esc(img.image.url)}" alt="${esc(img.alt || "Attachment")}"> <button type="button" class="remove-image" onclick="BugsApp.removeExistingAttachment(${index})">×</button> </div> `, ) .join("")} </div> `; }
function removeExistingAttachment(index) { state.existingAttachments.splice(index, 1); renderEditAttachments(); }
async function handleEditImageSelect(event) { const files = Array.from(event.target.files); event.target.value = "";
for (const file of files) { const totalImages = state.existingAttachments.length + state.pendingImages.length; if (totalImages >= 4) { showError("Maximum 4 images allowed"); return; }
try { const dataUrl = await readFileAsDataURL(file); const resized = await resizeImage(dataUrl, { width: 2000, height: 2000, maxSize: 900000, mode: "contain", }); state.pendingImages.push({ file, dataUrl: resized.dataUrl, }); renderEditNewImages(); } catch (err) { showError(`Failed to process ${file.name}: ${err.message}`); } } }
function renderEditNewImages() { const container = document.getElementById("edit-new-images"); if (!container) return;
if (state.pendingImages.length === 0) { container.innerHTML = ""; return; }
container.innerHTML = ` <div class="image-previews"> ${state.pendingImages .map( (img, index) => ` <div class="image-preview"> <img src="${img.dataUrl}" alt="New attachment"> <button type="button" class="remove-image" onclick="BugsApp.removeEditPendingImage(${index})">×</button> </div> `, ) .join("")} </div> `; }
function removeEditPendingImage(index) { state.pendingImages.splice(index, 1); renderEditNewImages(); }
async function handleEditBug(event) { event.preventDefault();
const bug = state.editingBug; if (!bug) return;
const title = document.getElementById("bug-title").value.trim(); const namespace = document.getElementById("bug-namespace").value.trim().toLowerCase(); const description = document.getElementById("bug-description").value.trim(); const steps = document.getElementById("bug-steps").value.trim(); const severity = document.getElementById("bug-severity").value; const appUsed = document.getElementById("bug-app").value.trim();
// Clear previous errors document.getElementById("namespace-error").innerHTML = ""; document.getElementById("bug-namespace").parentElement.classList.remove("has-error");
// Validate namespace format if (!validateNamespace(namespace)) { document.getElementById("namespace-error").textContent = "Invalid format. Use: word.word (e.g., social.grain)"; document.getElementById("bug-namespace").parentElement.classList.add("has-error"); return; }
// Check if it looks like a domain (ends with TLD) const suggested = looksLikeDomain(namespace); if (suggested && !state.confirmedNamespace) { document.getElementById("namespace-error").innerHTML = ` This looks like a domain. Did you mean <strong>${esc(suggested)}</strong>? <div style="margin-top: 0.5rem;"> <button type="button" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.useSuggestedNamespace('${esc(suggested)}')">Use ${esc(suggested)}</button> <button type="button" class="btn btn-secondary" style="margin-left: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.confirmNamespace()">Keep as-is</button> </div> `; document.getElementById("bug-namespace").parentElement.classList.add("has-error"); return; }
// Reset confirmed flag for next submission state.confirmedNamespace = false;
const submitBtn = document.getElementById("submit-bug-btn"); submitBtn.disabled = true; submitBtn.textContent = "Saving...";
try { const rkey = getRkeyFromUri(bug.uri);
// Upload new images if any const uploadedImages = []; for (const pending of state.pendingImages) { const base64Data = pending.dataUrl.split(",")[1]; const uploadResult = await gqlMutation(UPLOAD_BLOB_MUTATION, { data: base64Data, mimeType: "image/jpeg", });
if (uploadResult.uploadBlob) { uploadedImages.push({ alt: "", image: { $type: "blob", ref: { $link: uploadResult.uploadBlob.ref }, mimeType: uploadResult.uploadBlob.mimeType, size: uploadResult.uploadBlob.size, }, }); } }
// Build attachments from existing + new images const existingFormatted = state.existingAttachments.map((img) => ({ alt: img.alt || "", image: { $type: "blob", ref: { $link: img.image.ref }, mimeType: img.image.mimeType, size: img.image.size, }, }));
const allImages = [...existingFormatted, ...uploadedImages]; let attachments = null; if (allImages.length > 0) { attachments = { $type: "network.slices.tools.defs#images", images: allImages, }; }
// Parse facets for description and steps const descriptionParsed = parseFacets(description); const stepsParsed = parseFacets(steps);
const input = { title, namespace, description: descriptionParsed.text, ...(descriptionParsed.facets && { descriptionFacets: descriptionParsed.facets }), stepsToReproduce: stepsParsed.text, ...(stepsParsed.facets && { stepsToReproduceFacets: stepsParsed.facets }), severity, createdAt: bug.createdAt, // Keep original createdAt ...(appUsed && { appUsed }), ...(attachments && { attachments }), };
await gqlMutation(UPDATE_BUG_MUTATION, { rkey, input });
closeModal();
// Re-fetch bugs to get fresh data with proper image URLs state.bugs = []; state.cursor = null; state.hasMore = true; await loadBugs();
// Re-render overlay with updated data renderOverlay(); render();
showSuccess("Bug updated successfully!"); } catch (err) { console.error("Update failed:", err); showError(`Failed to update: ${err.message}`); } finally { submitBtn.disabled = false; submitBtn.textContent = "Save Changes"; } }
async function handleDeleteBug(bugUri) { if (!confirm("Are you sure you want to delete this bug? This cannot be undone.")) { return; }
try { // Delete user's own comments on this bug first const comments = await fetchComments(bugUri); const myComments = comments.filter((c) => c.did === state.viewer.did); for (const comment of myComments) { const commentRkey = getRkeyFromUri(comment.uri); await gqlMutation(DELETE_COMMENT_MUTATION, { rkey: commentRkey }); }
const rkey = getRkeyFromUri(bugUri); await gqlMutation(DELETE_BUG_MUTATION, { rkey });
// Close overlay and go back to list closeOverlay(); state.bugUri = null; state.view = "list";
// Remove from local state state.bugs = state.bugs.filter((b) => b.uri !== bugUri);
// Update URL const url = new URL(window.location); url.searchParams.delete("bug"); history.pushState({}, "", url);
render(); showSuccess("Bug deleted successfully!"); } catch (err) { console.error("Delete failed:", err); showError(`Failed to delete: ${err.message}`); } }
async function shareBug() { const url = window.location.href; try { await navigator.clipboard.writeText(url); showSuccess("Link copied to clipboard!"); } catch (err) { // Fallback for older browsers prompt("Copy this link:", url); } }
async function handleDeleteResponse(responseUri) { if (!confirm("Delete this response?")) { return; }
try { const rkey = getRkeyFromUri(responseUri); await gqlMutation(DELETE_RESPONSE_MUTATION, { rkey });
// Remove from local state state.responses = state.responses.filter((r) => r.uri !== responseUri);
// Update bug's embedded responses for status display const bug = state.bugs.find((b) => b.uri === state.bugUri); if (bug) { bug.networkSlicesToolsBugResponseViaBug = { edges: state.responses.map((r) => ({ node: { status: r.status, createdAt: r.createdAt }, })), }; }
// Re-render overlay and list renderOverlay(); render(); // Update list to show new status
showSuccess("Response deleted!"); } catch (err) { console.error("Delete response failed:", err); showError(`Failed to delete: ${err.message}`); } }
// Image handling state.pendingImages = [];
async function handleImageSelect(event) { const files = Array.from(event.target.files); event.target.value = "";
for (const file of files) { try { const dataUrl = await readFileAsDataURL(file); const resized = await resizeImage(dataUrl, { width: 2000, height: 2000, maxSize: 900000, mode: "contain", }); state.pendingImages.push({ file, dataUrl: resized.dataUrl, }); renderImagePreviews(); } catch (err) { showError(`Failed to process ${file.name}: ${err.message}`); } } }
function renderImagePreviews() { const previews = document.getElementById("image-previews"); if (!previews) return;
previews.innerHTML = state.pendingImages .map( (img, i) => ` <div class="image-preview"> <img src="${img.dataUrl}" alt="Preview"> <button type="button" onclick="BugsApp.removeImage(${i})">×</button> </div> `, ) .join(""); }
function removeImage(index) { state.pendingImages.splice(index, 1); renderImagePreviews(); }
async function handleSubmitBug(event) { event.preventDefault();
const title = document.getElementById("bug-title").value.trim(); const namespace = document.getElementById("bug-namespace").value.trim().toLowerCase(); const description = document.getElementById("bug-description").value.trim(); const steps = document.getElementById("bug-steps").value.trim(); const severity = document.getElementById("bug-severity").value; const appUsed = document.getElementById("bug-app").value.trim();
// Clear previous errors document.getElementById("namespace-error").innerHTML = ""; document.getElementById("bug-namespace").parentElement.classList.remove("has-error");
// Validate namespace format if (!validateNamespace(namespace)) { document.getElementById("namespace-error").textContent = "Invalid format. Use: word.word (e.g., social.grain)"; document.getElementById("bug-namespace").parentElement.classList.add("has-error"); return; }
// Check if it looks like a domain (ends with TLD) const suggested = looksLikeDomain(namespace); if (suggested && !state.confirmedNamespace) { document.getElementById("namespace-error").innerHTML = ` This looks like a domain. Did you mean <strong>${esc(suggested)}</strong>? <div style="margin-top: 0.5rem;"> <button type="button" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.useSuggestedNamespace('${esc(suggested)}')">Use ${esc(suggested)}</button> <button type="button" class="btn btn-secondary" style="margin-left: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.confirmNamespace()">Keep as-is</button> </div> `; document.getElementById("bug-namespace").parentElement.classList.add("has-error"); return; }
// Reset confirmed flag for next submission state.confirmedNamespace = false;
const submitBtn = document.getElementById("submit-bug-btn"); submitBtn.disabled = true; submitBtn.textContent = "Submitting...";
try { // Upload images if any let attachments = null; if (state.pendingImages.length > 0) { const uploadedImages = []; for (const img of state.pendingImages) { const base64Data = img.dataUrl.split(",")[1]; const uploadResult = await gqlMutation(UPLOAD_BLOB_MUTATION, { data: base64Data, mimeType: "image/jpeg", }); if (uploadResult.uploadBlob) { uploadedImages.push({ image: { $type: "blob", ref: { $link: uploadResult.uploadBlob.ref }, mimeType: uploadResult.uploadBlob.mimeType, size: uploadResult.uploadBlob.size, }, alt: "", }); } } attachments = { $type: "network.slices.tools.defs#images", images: uploadedImages, }; }
// Parse facets for description and steps const descriptionParsed = parseFacets(description); const stepsParsed = parseFacets(steps);
// Create bug const input = { title, namespace, description: descriptionParsed.text, ...(descriptionParsed.facets && { descriptionFacets: descriptionParsed.facets }), stepsToReproduce: stepsParsed.text, ...(stepsParsed.facets && { stepsToReproduceFacets: stepsParsed.facets }), severity, createdAt: new Date().toISOString(), ...(appUsed && { appUsed }), ...(attachments && { attachments }), };
await gqlMutation(CREATE_BUG_MUTATION, { input });
closeModal();
// Navigate to the namespace and reload state.namespace = namespace; state.view = "list"; state.bugs = []; updateUrl({ ns: namespace, bug: null }); await loadBugs();
showSuccess("Bug reported successfully!"); } catch (err) { console.error("Submit failed:", err); showError(`Failed to submit: ${err.message}`); } finally { submitBtn.disabled = false; submitBtn.textContent = "Submit Bug"; } }
function showSuccess(msg) { // Reuse error banner with different styling const el = document.getElementById("error-banner"); el.style.background = "#dcfce7"; el.style.borderColor = "#86efac"; el.style.color = "#16a34a"; el.innerHTML = `<span>✓ ${esc(msg)}</span><button style="color: #16a34a" onclick="BugsApp.hideError()">×</button>`; el.classList.remove("hidden"); setTimeout(hideError, 3000); }
// ============================================================================= // MAIN // =============================================================================
async function main() { // Handle OAuth callback first await handleOAuthCallback();
parseUrl(); window.addEventListener("popstate", () => { parseUrl(); render(); if (state.view === "landing") loadNamespaces(); if (state.view === "list") loadBugs(); });
// Check auth await checkAuth(); render();
if (state.view === "landing") { await loadNamespaces(); } else if (state.view === "list" || state.view === "detail") { await loadBugs(); } }
function parseUrl() { const params = new URLSearchParams(window.location.search); state.namespace = params.get("ns"); state.bugUri = params.get("bug"); state.view = state.namespace ? (state.bugUri ? "detail" : "list") : "landing"; }
async function loadNamespaces() { state.isLoading = true; render();
try { state.namespaces = await fetchNamespaces(); } catch (err) { console.error("Failed to load namespaces:", err); showError(`Failed to load: ${err.message}`); } finally { state.isLoading = false; render(); } }
async function loadBugs() { state.isLoading = true; render();
try { const data = await fetchBugs(state.namespace, null); state.bugs = data.edges.map((e) => e.node); state.cursor = data.pageInfo.endCursor; state.hasMore = data.pageInfo.hasNextPage; } catch (err) { console.error("Failed to load bugs:", err); showError(`Failed to load: ${err.message}`); } finally { state.isLoading = false; render(); } }
function render() { renderHeaderComponent();
const main = document.getElementById("main"); switch (state.view) { case "landing": main.innerHTML = renderLanding(); break; case "list": case "detail": main.innerHTML = renderBugList(); if (state.view === "detail" && state.bugUri) { renderOverlay(); } break; } lucide.createIcons(); }
function renderHeaderComponent() { const header = document.getElementById("header");
let left = `<h1>🐛 Bug Tracker</h1> <button class="btn-info" onclick="BugsApp.openInfoModal()" title="How it works">?</button>`; if (state.namespace) { left = ` <div class="breadcrumb"> <a href="?" onclick="BugsApp.navigateHome(event)">🐛 Bug Tracker</a> <button class="btn-info" onclick="BugsApp.openInfoModal()" title="How it works">?</button> <span>/</span> <span>${esc(state.namespace)}</span> </div> `; }
const right = state.viewer ? `<div class="user-status"> ${renderAvatar(state.viewer.appBskyActorProfileByDid, state.viewer.handle, "user-avatar-xl user-avatar-ring")} <button class="btn-icon" onclick="BugsApp.logout()" title="Logout"><i data-lucide="log-out"></i></button> </div>` : `<button class="btn btn-primary" onclick="BugsApp.login()">Login</button>`;
header.innerHTML = `${left}<div class="user-status">${right}</div>`; }
function navigateHome(event) { event.preventDefault(); state.namespace = null; state.bugUri = null; state.view = "landing"; state.bugs = []; updateUrl({ ns: null, bug: null }); render(); loadNamespaces(); }
// ============================================================================= // OAUTH // =============================================================================
let client = null;
async function initClient() { if (!client) { client = await QuicksliceClient.createQuicksliceClient({ server: SERVER_URL, clientId: CLIENT_ID, scope: "atproto repo:network.slices.tools.bug repo:network.slices.tools.bug.response repo:network.slices.tools.bug.comment repo:network.slices.tools.bug.issue repo:sh.tangled.repo.issue blob:image/*", }); } return client; }
async function handleOAuthCallback() { const params = new URLSearchParams(window.location.search); if (params.has("code") || params.has("state")) { try { await initClient(); await client.handleRedirectCallback(); // Clean up URL window.history.replaceState({}, "", window.location.pathname); } catch (err) { console.error("OAuth callback error:", err); showError(`Authentication failed: ${err.message}`); } } }
// Handle autocomplete let handleInputTimeout = null;
async function handleHandleInput(event) { const query = event.target.value.trim();
// Debounce clearTimeout(handleInputTimeout); if (!query || query.length < 2) { state.handleSuggestions = []; state.handleSuggestionIndex = -1; renderHandleSuggestions(); return; }
handleInputTimeout = setTimeout(async () => { try { const url = new URL( "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead", ); url.searchParams.set("q", query); url.searchParams.set("limit", "5");
const res = await fetch(url); if (!res.ok) return;
const json = await res.json(); state.handleSuggestions = json.actors || []; state.handleSuggestionIndex = -1; renderHandleSuggestions(); } catch (err) { console.error("Handle search failed:", err); } }, 200); }
function handleHandleKeydown(event) { if (state.handleSuggestions.length === 0) return;
switch (event.key) { case "ArrowDown": event.preventDefault(); state.handleSuggestionIndex = Math.min( state.handleSuggestionIndex + 1, state.handleSuggestions.length - 1, ); renderHandleSuggestions(); break;
case "ArrowUp": event.preventDefault(); state.handleSuggestionIndex = Math.max(state.handleSuggestionIndex - 1, 0); renderHandleSuggestions(); break;
case "Enter": if (state.handleSuggestionIndex >= 0) { event.preventDefault(); selectHandleSuggestion(state.handleSuggestionIndex); } break;
case "Escape": event.preventDefault(); clearHandleSuggestions(); break; } }
function renderHandleSuggestions() { const menu = document.getElementById("handle-suggestions"); if (!menu) return;
if (state.handleSuggestions.length === 0) { menu.innerHTML = ""; return; }
menu.innerHTML = state.handleSuggestions .map( (actor, i) => ` <li class="autocomplete-item ${i === state.handleSuggestionIndex ? "active" : ""}" onmousedown="BugsApp.selectHandleSuggestion(${i})"> <div class="autocomplete-avatar"> ${actor.avatar ? `<img src="${esc(actor.avatar)}" alt="">` : ""} </div> <span class="autocomplete-handle">${esc(actor.handle)}</span> </li> `, ) .join(""); }
function selectHandleSuggestion(index) { const actor = state.handleSuggestions[index]; if (!actor) return;
const input = document.getElementById("login-handle"); if (input) input.value = actor.handle;
clearHandleSuggestions(); }
function clearHandleSuggestions() { state.handleSuggestions = []; state.handleSuggestionIndex = -1; const menu = document.getElementById("handle-suggestions"); if (menu) menu.innerHTML = ""; }
function handleReportBug() { if (state.viewer) { openSubmitModal(); } else { login(); } }
function login() { // Show login modal to get handle const modal = document.getElementById("modal"); modal.innerHTML = ` <div class="modal-backdrop" onclick="BugsApp.closeModal()"></div> <div class="modal-content"> <div class="modal-header"> <h2>Login</h2> <button class="modal-close" onclick="BugsApp.closeModal()">×</button> </div> <div class="modal-body"> <form onsubmit="BugsApp.handleLogin(event)"> <div class="form-group handle-autocomplete"> <label for="login-handle">Sign in with your <a href="https://internethandle.org/" target="_blank">internet handle</a></label> <input type="text" id="login-handle" required placeholder="you.bsky.social" autocomplete="off" data-1p-ignore oninput="BugsApp.handleHandleInput(event)" onkeydown="BugsApp.handleHandleKeydown(event)" onfocusout="setTimeout(() => BugsApp.clearHandleSuggestions(), 150)"> <ul id="handle-suggestions" class="autocomplete-menu"></ul> </div> <div class="form-actions"> <button type="button" class="btn btn-secondary" onclick="BugsApp.closeModal()">Cancel</button> <button type="submit" class="btn btn-primary">Login</button> </div> </form> </div> </div> `; modal.classList.remove("hidden"); state.handleSuggestions = []; state.handleSuggestionIndex = -1; }
async function handleLogin(event) { event.preventDefault(); const handle = document.getElementById("login-handle").value.trim(); if (!handle) { showError("Please enter your handle"); return; }
try { await initClient(); await client.loginWithRedirect({ handle, scope: "atproto repo:network.slices.tools.bug repo:network.slices.tools.bug.response repo:network.slices.tools.bug.comment repo:network.slices.tools.bug.issue repo:sh.tangled.repo.issue blob:image/*", }); } catch (err) { console.error("Login failed:", err); showError(`Login failed: ${err.message}`); } }
function logout() { if (client) { client.logout(); } state.viewer = null; render(); }
async function checkAuth() { try { await initClient(); if (await client.isAuthenticated()) { const data = await client.query( `query { viewer { did handle appBskyActorProfileByDid { avatar { url(preset: "avatar") } } } }`, ); state.viewer = data?.viewer; } else { state.viewer = null; } } catch (err) { console.error("checkAuth error:", err); state.viewer = null; } }
// Expose functions via namespace for inline event handlers // (required because script type="module" scopes everything to the module) window.BugsApp = { // Error display hideError, // Namespace suggestions useSuggestedNamespace, dismissNamespaceSuggestion, confirmNamespace, validateNamespaceOnBlur, // Navigation navigateToNamespace, navigateHome, // Bug list handleReportBug, handleSeverityFilter, loadMoreBugs, openBugDetail, // Overlay/modal closeOverlay, closeModal, openInfoModal, // Bug actions shareBug, openEditModal, handleDeleteBug, handleSubmitBug, handleEditBug, // Images handleImageSelect, handleEditImageSelect, removeImage, removeExistingAttachment, removeEditPendingImage, openLightbox, // Link issue modal openLinkIssueModal, closeLinkIssueModal, selectRepoForLinking, goBackToRepos, createAndLinkIssue, linkToExistingIssue, unlinkIssue, // Comments handleSubmitComment, handleDeleteComment, startEditComment, cancelEditComment, handleSaveEditComment, removeEditCommentAttachment, showReplyForm, cancelReply, handleCommentImageSelect, removeCommentImage, // Responses handleSubmitResponse, handleDeleteResponse, // Auth login, logout, handleLogin, handleHandleInput, handleHandleKeydown, selectHandleSuggestion, clearHandleSuggestions, };
main(); </script> </body></html>