···141141}
142142143143// Home page
144144-func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) {
145145- // Check if user is authenticated
146146- didStr, err := atproto.GetAuthenticatedDID(r.Context())
147147- isAuthenticated := err == nil && didStr != ""
148148-149149- // Fetch user profile for authenticated users
150150- var userProfile *bff.UserProfile
151151- if isAuthenticated {
152152- userProfile = h.getUserProfile(r.Context(), didStr)
153153- }
154154-155155- // Don't fetch feed items here - let them load async via HTMX
156156- if err := bff.RenderHome(w, isAuthenticated, didStr, userProfile, nil); err != nil {
157157- http.Error(w, "Failed to render page", http.StatusInternalServerError)
158158- log.Error().Err(err).Msg("Failed to render home page")
159159- }
160160-}
161144162145// Community feed partial (loaded async via HTMX)
163146func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) {
···455438}
456439457440// List all brews
458458-func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) {
459459- // Require authentication
460460- _, authenticated := h.getAtprotoStore(r)
461461- if !authenticated {
462462- http.Redirect(w, r, "/login", http.StatusFound)
463463- return
464464- }
465465-466466- didStr, _ := atproto.GetAuthenticatedDID(r.Context())
467467- userProfile := h.getUserProfile(r.Context(), didStr)
468468-469469- // Don't fetch brews here - let them load async via HTMX
470470- if err := bff.RenderBrewList(w, nil, authenticated, didStr, userProfile); err != nil {
471471- http.Error(w, "Failed to render page", http.StatusInternalServerError)
472472- log.Error().Err(err).Msg("Failed to render brew list page")
473473- }
474474-}
475441476442// Show new brew form
477477-func (h *Handler) HandleBrewNew(w http.ResponseWriter, r *http.Request) {
478478- // Require authentication
479479- _, authenticated := h.getAtprotoStore(r)
480480- if !authenticated {
481481- http.Redirect(w, r, "/login", http.StatusFound)
482482- return
483483- }
484484-485485- didStr, _ := atproto.GetAuthenticatedDID(r.Context())
486486- userProfile := h.getUserProfile(r.Context(), didStr)
487487-488488- // Don't fetch data from PDS - client will populate dropdowns from cache
489489- // This makes the page load much faster
490490- if err := bff.RenderBrewForm(w, nil, nil, nil, nil, nil, authenticated, didStr, userProfile); err != nil {
491491- http.Error(w, "Failed to render page", http.StatusInternalServerError)
492492- log.Error().Err(err).Msg("Failed to render brew form")
493493- }
494494-}
495443496444// Show brew view page
497497-func (h *Handler) HandleBrewView(w http.ResponseWriter, r *http.Request) {
498498- rkey := validateRKey(w, r.PathValue("id"))
499499- if rkey == "" {
500500- return
501501- }
502502-503503- // Check if owner (DID or handle) is specified in query params
504504- owner := r.URL.Query().Get("owner")
505505-506506- // Check authentication
507507- didStr, err := atproto.GetAuthenticatedDID(r.Context())
508508- isAuthenticated := err == nil && didStr != ""
509509-510510- var userProfile *bff.UserProfile
511511- if isAuthenticated {
512512- userProfile = h.getUserProfile(r.Context(), didStr)
513513- }
514514-515515- var brew *models.Brew
516516- var brewOwnerDID string
517517- var isOwner bool
518518-519519- if owner != "" {
520520- // Viewing someone else's brew - use public client
521521- publicClient := atproto.NewPublicClient()
522522-523523- // Resolve owner to DID if it's a handle
524524- if strings.HasPrefix(owner, "did:") {
525525- brewOwnerDID = owner
526526- } else {
527527- resolved, err := publicClient.ResolveHandle(r.Context(), owner)
528528- if err != nil {
529529- log.Warn().Err(err).Str("handle", owner).Msg("Failed to resolve handle for brew view")
530530- http.Error(w, "User not found", http.StatusNotFound)
531531- return
532532- }
533533- brewOwnerDID = resolved
534534- }
535535-536536- // Fetch the brew record from the owner's PDS
537537- record, err := publicClient.GetRecord(r.Context(), brewOwnerDID, atproto.NSIDBrew, rkey)
538538- if err != nil {
539539- log.Error().Err(err).Str("did", brewOwnerDID).Str("rkey", rkey).Msg("Failed to get brew record")
540540- http.Error(w, "Brew not found", http.StatusNotFound)
541541- return
542542- }
543543-544544- // Convert record to brew
545545- brew, err = atproto.RecordToBrew(record.Value, record.URI)
546546- if err != nil {
547547- log.Error().Err(err).Msg("Failed to convert brew record")
548548- http.Error(w, "Failed to load brew", http.StatusInternalServerError)
549549- return
550550- }
551551-552552- // Resolve references (bean, grinder, brewer)
553553- if err := h.resolveBrewReferences(r.Context(), brew, brewOwnerDID, record.Value); err != nil {
554554- log.Warn().Err(err).Msg("Failed to resolve some brew references")
555555- // Don't fail the request, just log the warning
556556- }
557557-558558- // Check if viewing user is the owner
559559- isOwner = isAuthenticated && didStr == brewOwnerDID
560560- } else {
561561- // Viewing own brew - require authentication
562562- store, authenticated := h.getAtprotoStore(r)
563563- if !authenticated {
564564- http.Redirect(w, r, "/login", http.StatusFound)
565565- return
566566- }
567567-568568- brew, err = store.GetBrewByRKey(r.Context(), rkey)
569569- if err != nil {
570570- http.Error(w, "Brew not found", http.StatusNotFound)
571571- log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view")
572572- return
573573- }
574574-575575- brewOwnerDID = didStr
576576- isOwner = true
577577- }
578578-579579- if err := bff.RenderBrewView(w, brew, isAuthenticated, didStr, userProfile, isOwner); err != nil {
580580- http.Error(w, "Failed to render page", http.StatusInternalServerError)
581581- log.Error().Err(err).Msg("Failed to render brew view")
582582- }
583583-}
584445585446// resolveBrewReferences resolves bean, grinder, and brewer references for a brew
586447func (h *Handler) resolveBrewReferences(ctx context.Context, brew *models.Brew, ownerDID string, record map[string]interface{}) error {
···630491}
631492632493// Show edit brew form
633633-func (h *Handler) HandleBrewEdit(w http.ResponseWriter, r *http.Request) {
634634- rkey := validateRKey(w, r.PathValue("id"))
635635- if rkey == "" {
636636- return
637637- }
638638-639639- // Require authentication
640640- store, authenticated := h.getAtprotoStore(r)
641641- if !authenticated {
642642- http.Redirect(w, r, "/login", http.StatusFound)
643643- return
644644- }
645645-646646- didStr, _ := atproto.GetAuthenticatedDID(r.Context())
647647- userProfile := h.getUserProfile(r.Context(), didStr)
648648-649649- brew, err := store.GetBrewByRKey(r.Context(), rkey)
650650- if err != nil {
651651- http.Error(w, "Brew not found", http.StatusNotFound)
652652- log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for edit")
653653- return
654654- }
655655-656656- // Don't fetch dropdown data from PDS - client will populate from cache
657657- // This makes the page load much faster
658658- if err := bff.RenderBrewForm(w, nil, nil, nil, nil, brew, authenticated, didStr, userProfile); err != nil {
659659- http.Error(w, "Failed to render page", http.StatusInternalServerError)
660660- log.Error().Err(err).Msg("Failed to render brew edit form")
661661- }
662662-}
663494664495// maxPours is the maximum number of pours allowed in a single brew
665496const maxPours = 100
···771602 return
772603 }
773604774774- if err := r.ParseForm(); err != nil {
775775- http.Error(w, "Invalid form data", http.StatusBadRequest)
776776- return
777777- }
605605+ // Check content type - support both JSON and form data
606606+ contentType := r.Header.Get("Content-Type")
607607+ isJSON := strings.Contains(contentType, "application/json")
608608+609609+ var req models.CreateBrewRequest
778610779779- // Validate input
780780- temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r)
781781- if len(validationErrs) > 0 {
782782- // Return first validation error
783783- http.Error(w, validationErrs[0].Message, http.StatusBadRequest)
784784- return
785785- }
611611+ if isJSON {
612612+ // Parse JSON body
613613+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
614614+ http.Error(w, "Invalid JSON body", http.StatusBadRequest)
615615+ return
616616+ }
617617+ } else {
618618+ // Parse form data (legacy support)
619619+ if err := r.ParseForm(); err != nil {
620620+ http.Error(w, "Invalid form data", http.StatusBadRequest)
621621+ return
622622+ }
623623+624624+ // Validate input
625625+ temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r)
626626+ if len(validationErrs) > 0 {
627627+ // Return first validation error
628628+ http.Error(w, validationErrs[0].Message, http.StatusBadRequest)
629629+ return
630630+ }
631631+632632+ // Validate required fields
633633+ beanRKey := r.FormValue("bean_rkey")
634634+ if beanRKey == "" {
635635+ http.Error(w, "Bean selection is required", http.StatusBadRequest)
636636+ return
637637+ }
638638+ if !atproto.ValidateRKey(beanRKey) {
639639+ http.Error(w, "Invalid bean selection", http.StatusBadRequest)
640640+ return
641641+ }
786642787787- // Validate required fields
788788- beanRKey := r.FormValue("bean_rkey")
789789- if beanRKey == "" {
790790- http.Error(w, "Bean selection is required", http.StatusBadRequest)
791791- return
792792- }
793793- if !atproto.ValidateRKey(beanRKey) {
794794- http.Error(w, "Invalid bean selection", http.StatusBadRequest)
795795- return
796796- }
643643+ // Validate optional rkeys
644644+ grinderRKey := r.FormValue("grinder_rkey")
645645+ if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" {
646646+ http.Error(w, errMsg, http.StatusBadRequest)
647647+ return
648648+ }
649649+ brewerRKey := r.FormValue("brewer_rkey")
650650+ if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" {
651651+ http.Error(w, errMsg, http.StatusBadRequest)
652652+ return
653653+ }
797654798798- // Validate optional rkeys
799799- grinderRKey := r.FormValue("grinder_rkey")
800800- if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" {
801801- http.Error(w, errMsg, http.StatusBadRequest)
802802- return
803803- }
804804- brewerRKey := r.FormValue("brewer_rkey")
805805- if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" {
806806- http.Error(w, errMsg, http.StatusBadRequest)
807807- return
655655+ req = models.CreateBrewRequest{
656656+ BeanRKey: beanRKey,
657657+ Method: r.FormValue("method"),
658658+ Temperature: temperature,
659659+ WaterAmount: waterAmount,
660660+ CoffeeAmount: coffeeAmount,
661661+ TimeSeconds: timeSeconds,
662662+ GrindSize: r.FormValue("grind_size"),
663663+ GrinderRKey: grinderRKey,
664664+ BrewerRKey: brewerRKey,
665665+ TastingNotes: r.FormValue("tasting_notes"),
666666+ Rating: rating,
667667+ Pours: pours,
668668+ }
808669 }
809670810810- req := &models.CreateBrewRequest{
811811- BeanRKey: beanRKey,
812812- Method: r.FormValue("method"),
813813- Temperature: temperature,
814814- WaterAmount: waterAmount,
815815- CoffeeAmount: coffeeAmount,
816816- TimeSeconds: timeSeconds,
817817- GrindSize: r.FormValue("grind_size"),
818818- GrinderRKey: grinderRKey,
819819- BrewerRKey: brewerRKey,
820820- TastingNotes: r.FormValue("tasting_notes"),
821821- Rating: rating,
822822- Pours: pours,
671671+ // Validate JSON request
672672+ if isJSON {
673673+ if req.BeanRKey == "" {
674674+ http.Error(w, "Bean selection is required", http.StatusBadRequest)
675675+ return
676676+ }
677677+ if !atproto.ValidateRKey(req.BeanRKey) {
678678+ http.Error(w, "Invalid bean selection", http.StatusBadRequest)
679679+ return
680680+ }
681681+ if errMsg := validateOptionalRKey(req.GrinderRKey, "Grinder selection"); errMsg != "" {
682682+ http.Error(w, errMsg, http.StatusBadRequest)
683683+ return
684684+ }
685685+ if errMsg := validateOptionalRKey(req.BrewerRKey, "Brewer selection"); errMsg != "" {
686686+ http.Error(w, errMsg, http.StatusBadRequest)
687687+ return
688688+ }
823689 }
824690825825- _, err := store.CreateBrew(r.Context(), req, 1) // User ID not used with atproto
691691+ brew, err := store.CreateBrew(r.Context(), &req, 1) // User ID not used with atproto
826692 if err != nil {
827693 http.Error(w, "Failed to create brew", http.StatusInternalServerError)
828694 log.Error().Err(err).Msg("Failed to create brew")
829695 return
830696 }
831697832832- // Redirect to brew list
833833- w.Header().Set("HX-Redirect", "/brews")
834834- w.WriteHeader(http.StatusOK)
698698+ // Return JSON for API calls, redirect for HTMX
699699+ if isJSON {
700700+ w.Header().Set("Content-Type", "application/json")
701701+ if err := json.NewEncoder(w).Encode(brew); err != nil {
702702+ log.Error().Err(err).Msg("Failed to encode brew response")
703703+ }
704704+ } else {
705705+ // Redirect to brew list
706706+ w.Header().Set("HX-Redirect", "/brews")
707707+ w.WriteHeader(http.StatusOK)
708708+ }
835709}
836710837711// Update existing brew
···848722 return
849723 }
850724851851- if err := r.ParseForm(); err != nil {
852852- http.Error(w, "Invalid form data", http.StatusBadRequest)
853853- return
854854- }
725725+ // Check content type - support both JSON and form data
726726+ contentType := r.Header.Get("Content-Type")
727727+ isJSON := strings.Contains(contentType, "application/json")
855728856856- // Validate input
857857- temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r)
858858- if len(validationErrs) > 0 {
859859- http.Error(w, validationErrs[0].Message, http.StatusBadRequest)
860860- return
861861- }
729729+ var req models.CreateBrewRequest
862730863863- // Validate required fields
864864- beanRKey := r.FormValue("bean_rkey")
865865- if beanRKey == "" {
866866- http.Error(w, "Bean selection is required", http.StatusBadRequest)
867867- return
868868- }
869869- if !atproto.ValidateRKey(beanRKey) {
870870- http.Error(w, "Invalid bean selection", http.StatusBadRequest)
871871- return
872872- }
731731+ if isJSON {
732732+ // Parse JSON body
733733+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
734734+ http.Error(w, "Invalid JSON body", http.StatusBadRequest)
735735+ return
736736+ }
737737+ } else {
738738+ // Parse form data (legacy support)
739739+ if err := r.ParseForm(); err != nil {
740740+ http.Error(w, "Invalid form data", http.StatusBadRequest)
741741+ return
742742+ }
873743874874- // Validate optional rkeys
875875- grinderRKey := r.FormValue("grinder_rkey")
876876- if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" {
877877- http.Error(w, errMsg, http.StatusBadRequest)
878878- return
879879- }
880880- brewerRKey := r.FormValue("brewer_rkey")
881881- if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" {
882882- http.Error(w, errMsg, http.StatusBadRequest)
883883- return
744744+ // Validate input
745745+ temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r)
746746+ if len(validationErrs) > 0 {
747747+ http.Error(w, validationErrs[0].Message, http.StatusBadRequest)
748748+ return
749749+ }
750750+751751+ // Validate required fields
752752+ beanRKey := r.FormValue("bean_rkey")
753753+ if beanRKey == "" {
754754+ http.Error(w, "Bean selection is required", http.StatusBadRequest)
755755+ return
756756+ }
757757+ if !atproto.ValidateRKey(beanRKey) {
758758+ http.Error(w, "Invalid bean selection", http.StatusBadRequest)
759759+ return
760760+ }
761761+762762+ // Validate optional rkeys
763763+ grinderRKey := r.FormValue("grinder_rkey")
764764+ if errMsg := validateOptionalRKey(grinderRKey, "Grinder selection"); errMsg != "" {
765765+ http.Error(w, errMsg, http.StatusBadRequest)
766766+ return
767767+ }
768768+ brewerRKey := r.FormValue("brewer_rkey")
769769+ if errMsg := validateOptionalRKey(brewerRKey, "Brewer selection"); errMsg != "" {
770770+ http.Error(w, errMsg, http.StatusBadRequest)
771771+ return
772772+ }
773773+774774+ req = models.CreateBrewRequest{
775775+ BeanRKey: beanRKey,
776776+ Method: r.FormValue("method"),
777777+ Temperature: temperature,
778778+ WaterAmount: waterAmount,
779779+ CoffeeAmount: coffeeAmount,
780780+ TimeSeconds: timeSeconds,
781781+ GrindSize: r.FormValue("grind_size"),
782782+ GrinderRKey: grinderRKey,
783783+ BrewerRKey: brewerRKey,
784784+ TastingNotes: r.FormValue("tasting_notes"),
785785+ Rating: rating,
786786+ Pours: pours,
787787+ }
884788 }
885789886886- req := &models.CreateBrewRequest{
887887- BeanRKey: beanRKey,
888888- Method: r.FormValue("method"),
889889- Temperature: temperature,
890890- WaterAmount: waterAmount,
891891- CoffeeAmount: coffeeAmount,
892892- TimeSeconds: timeSeconds,
893893- GrindSize: r.FormValue("grind_size"),
894894- GrinderRKey: grinderRKey,
895895- BrewerRKey: brewerRKey,
896896- TastingNotes: r.FormValue("tasting_notes"),
897897- Rating: rating,
898898- Pours: pours,
790790+ // Validate JSON request
791791+ if isJSON {
792792+ if req.BeanRKey == "" {
793793+ http.Error(w, "Bean selection is required", http.StatusBadRequest)
794794+ return
795795+ }
796796+ if !atproto.ValidateRKey(req.BeanRKey) {
797797+ http.Error(w, "Invalid bean selection", http.StatusBadRequest)
798798+ return
799799+ }
800800+ if errMsg := validateOptionalRKey(req.GrinderRKey, "Grinder selection"); errMsg != "" {
801801+ http.Error(w, errMsg, http.StatusBadRequest)
802802+ return
803803+ }
804804+ if errMsg := validateOptionalRKey(req.BrewerRKey, "Brewer selection"); errMsg != "" {
805805+ http.Error(w, errMsg, http.StatusBadRequest)
806806+ return
807807+ }
899808 }
900809901901- err := store.UpdateBrewByRKey(r.Context(), rkey, req)
810810+ err := store.UpdateBrewByRKey(r.Context(), rkey, &req)
902811 if err != nil {
903812 http.Error(w, "Failed to update brew", http.StatusInternalServerError)
904813 log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brew")
905814 return
906815 }
907816908908- // Redirect to brew list
909909- w.Header().Set("HX-Redirect", "/brews")
910910- w.WriteHeader(http.StatusOK)
817817+ // Return JSON for API calls, redirect for HTMX
818818+ if isJSON {
819819+ w.WriteHeader(http.StatusOK)
820820+ } else {
821821+ // Redirect to brew list
822822+ w.Header().Set("HX-Redirect", "/brews")
823823+ w.WriteHeader(http.StatusOK)
824824+ }
911825}
912826913827// Delete brew
···934848}
935849936850// Export brews as JSON
937937-func (h *Handler) HandleBrewExport(w http.ResponseWriter, r *http.Request) {
938938- // Require authentication
939939- store, authenticated := h.getAtprotoStore(r)
940940- if !authenticated {
941941- http.Error(w, "Authentication required", http.StatusUnauthorized)
851851+852852+// Get a public brew by AT-URI
853853+func (h *Handler) HandleBrewGetPublic(w http.ResponseWriter, r *http.Request) {
854854+ atURI := r.URL.Query().Get("uri")
855855+ if atURI == "" {
856856+ http.Error(w, "Missing 'uri' query parameter", http.StatusBadRequest)
857857+ return
858858+ }
859859+860860+ // Parse AT-URI to extract DID, collection, and rkey
861861+ components, err := atproto.ResolveATURI(atURI)
862862+ if err != nil {
863863+ http.Error(w, "Invalid AT-URI", http.StatusBadRequest)
864864+ log.Error().Err(err).Str("uri", atURI).Msg("Failed to parse AT-URI")
865865+ return
866866+ }
867867+868868+ // Fetch the record from the user's PDS using PublicClient
869869+ publicClient := atproto.NewPublicClient()
870870+ recordEntry, err := publicClient.GetRecord(r.Context(), components.DID, components.Collection, components.RKey)
871871+ if err != nil {
872872+ http.Error(w, "Failed to fetch brew", http.StatusInternalServerError)
873873+ log.Error().Err(err).Str("uri", atURI).Msg("Failed to fetch public brew")
942874 return
943875 }
944876945945- brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto
877877+ // Convert the record to a Brew model
878878+ brew, err := atproto.RecordToBrew(recordEntry.Value, atURI)
946879 if err != nil {
947947- http.Error(w, "Failed to fetch brews", http.StatusInternalServerError)
948948- log.Error().Err(err).Msg("Failed to list brews for export")
880880+ http.Error(w, "Failed to parse brew record", http.StatusInternalServerError)
881881+ log.Error().Err(err).Str("uri", atURI).Msg("Failed to convert record to brew")
949882 return
950883 }
951884952952- w.Header().Set("Content-Type", "application/json")
953953- w.Header().Set("Content-Disposition", "attachment; filename=arabica-brews.json")
885885+ // Fetch referenced entities (bean, grinder, brewer) if they exist
886886+ if brew.BeanRKey != "" {
887887+ beanURI := atproto.BuildATURI(components.DID, atproto.NSIDBean, brew.BeanRKey)
888888+ beanRecord, err := publicClient.GetRecord(r.Context(), components.DID, atproto.NSIDBean, brew.BeanRKey)
889889+ if err == nil {
890890+ bean, err := atproto.RecordToBean(beanRecord.Value, beanURI)
891891+ if err == nil {
892892+ brew.Bean = bean
893893+894894+ // Fetch roaster if referenced
895895+ if bean.RoasterRKey != "" {
896896+ roasterURI := atproto.BuildATURI(components.DID, atproto.NSIDRoaster, bean.RoasterRKey)
897897+ roasterRecord, err := publicClient.GetRecord(r.Context(), components.DID, atproto.NSIDRoaster, bean.RoasterRKey)
898898+ if err == nil {
899899+ roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterURI)
900900+ if err == nil {
901901+ brew.Bean.Roaster = roaster
902902+ }
903903+ }
904904+ }
905905+ }
906906+ }
907907+ }
908908+909909+ if brew.GrinderRKey != "" {
910910+ grinderURI := atproto.BuildATURI(components.DID, atproto.NSIDGrinder, brew.GrinderRKey)
911911+ grinderRecord, err := publicClient.GetRecord(r.Context(), components.DID, atproto.NSIDGrinder, brew.GrinderRKey)
912912+ if err == nil {
913913+ grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderURI)
914914+ if err == nil {
915915+ brew.GrinderObj = grinder
916916+ }
917917+ }
918918+ }
954919955955- encoder := json.NewEncoder(w)
956956- encoder.SetIndent("", " ")
957957- if err := encoder.Encode(brews); err != nil {
958958- log.Error().Err(err).Msg("Failed to encode brews for export")
920920+ if brew.BrewerRKey != "" {
921921+ brewerURI := atproto.BuildATURI(components.DID, atproto.NSIDBrewer, brew.BrewerRKey)
922922+ brewerRecord, err := publicClient.GetRecord(r.Context(), components.DID, atproto.NSIDBrewer, brew.BrewerRKey)
923923+ if err == nil {
924924+ brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerURI)
925925+ if err == nil {
926926+ brew.BrewerObj = brewer
927927+ }
928928+ }
959929 }
930930+931931+ w.Header().Set("Content-Type", "application/json")
932932+ json.NewEncoder(w).Encode(brew)
960933}
961934962935// API endpoint to list all user data (beans, roasters, grinders, brewers, brews)
···11361109}
1137111011381111// Manage page
11391139-func (h *Handler) HandleManage(w http.ResponseWriter, r *http.Request) {
11401140- // Require authentication
11411141- _, authenticated := h.getAtprotoStore(r)
11421142- if !authenticated {
11431143- http.Redirect(w, r, "/login", http.StatusFound)
11441144- return
11451145- }
11461146-11471147- didStr, _ := atproto.GetAuthenticatedDID(r.Context())
11481148- userProfile := h.getUserProfile(r.Context(), didStr)
11491149-11501150- // Don't fetch data here - let it load async via HTMX
11511151- if err := bff.RenderManage(w, nil, nil, nil, nil, authenticated, didStr, userProfile); err != nil {
11521152- http.Error(w, "Failed to render page", http.StatusInternalServerError)
11531153- log.Error().Err(err).Msg("Failed to render manage page")
11541154- }
11551155-}
1156111211571113// Bean update/delete handlers
11581114func (h *Handler) HandleBeanUpdate(w http.ResponseWriter, r *http.Request) {
···14951451}
1496145214971453// About page
14981498-func (h *Handler) HandleAbout(w http.ResponseWriter, r *http.Request) {
14991499- // Check if user is authenticated
15001500- didStr, err := atproto.GetAuthenticatedDID(r.Context())
15011501- isAuthenticated := err == nil && didStr != ""
15021502-15031503- var userProfile *bff.UserProfile
15041504- if isAuthenticated {
15051505- userProfile = h.getUserProfile(r.Context(), didStr)
15061506- }
15071507-15081508- data := &bff.PageData{
15091509- Title: "About",
15101510- IsAuthenticated: isAuthenticated,
15111511- UserDID: didStr,
15121512- UserProfile: userProfile,
15131513- }
15141514-15151515- if err := bff.RenderTemplate(w, r, "about.tmpl", data); err != nil {
15161516- http.Error(w, "Failed to render page", http.StatusInternalServerError)
15171517- log.Error().Err(err).Msg("Failed to render about page")
15181518- }
15191519-}
1520145415211455// Terms of Service page
15221522-func (h *Handler) HandleTerms(w http.ResponseWriter, r *http.Request) {
15231523- // Check if user is authenticated
15241524- didStr, err := atproto.GetAuthenticatedDID(r.Context())
15251525- isAuthenticated := err == nil && didStr != ""
15261526-15271527- var userProfile *bff.UserProfile
15281528- if isAuthenticated {
15291529- userProfile = h.getUserProfile(r.Context(), didStr)
15301530- }
15311531-15321532- data := &bff.PageData{
15331533- Title: "Terms of Service",
15341534- IsAuthenticated: isAuthenticated,
15351535- UserDID: didStr,
15361536- UserProfile: userProfile,
15371537- }
15381538-15391539- if err := bff.RenderTemplate(w, r, "terms.tmpl", data); err != nil {
15401540- http.Error(w, "Failed to render page", http.StatusInternalServerError)
15411541- log.Error().Err(err).Msg("Failed to render terms page")
15421542- }
15431543-}
1544145615451457// fetchAllData is a helper that fetches all data types in parallel using errgroup.
15461458// This is used by handlers that need beans, roasters, grinders, and brewers.
···15791491}
1580149215811493// HandleProfile displays a user's public profile with their brews and gear
15821582-func (h *Handler) HandleProfile(w http.ResponseWriter, r *http.Request) {
15831583- actor := r.PathValue("actor")
15841584- if actor == "" {
15851585- http.Error(w, "Actor parameter is required", http.StatusBadRequest)
15861586- return
15871587- }
15881588-15891589- ctx := r.Context()
15901590- publicClient := atproto.NewPublicClient()
15911591-15921592- // Determine if actor is a DID or handle
15931593- var did string
15941594- var err error
15951595-15961596- if strings.HasPrefix(actor, "did:") {
15971597- // It's already a DID
15981598- did = actor
15991599- } else {
16001600- // It's a handle, resolve to DID
16011601- did, err = publicClient.ResolveHandle(ctx, actor)
16021602- if err != nil {
16031603- log.Warn().Err(err).Str("handle", actor).Msg("Failed to resolve handle")
16041604- http.Error(w, "User not found", http.StatusNotFound)
16051605- return
16061606- }
16071607-16081608- // Redirect to canonical URL with handle (we'll get the handle from profile)
16091609- // For now, continue with the DID we have
16101610- }
16111611-16121612- // Fetch profile
16131613- profile, err := publicClient.GetProfile(ctx, did)
16141614- if err != nil {
16151615- log.Warn().Err(err).Str("did", did).Msg("Failed to fetch profile")
16161616- http.Error(w, "User not found", http.StatusNotFound)
16171617- return
16181618- }
16191619-16201620- // If the URL used a DID but we have the handle, redirect to the canonical handle URL
16211621- if strings.HasPrefix(actor, "did:") && profile.Handle != "" {
16221622- http.Redirect(w, r, "/profile/"+profile.Handle, http.StatusFound)
16231623- return
16241624- }
16251625-16261626- // Fetch all user data in parallel
16271627- g, gCtx := errgroup.WithContext(ctx)
16281628-16291629- var brews []*models.Brew
16301630- var beans []*models.Bean
16311631- var roasters []*models.Roaster
16321632- var grinders []*models.Grinder
16331633- var brewers []*models.Brewer
16341634-16351635- // Maps for resolving references
16361636- var beanMap map[string]*models.Bean
16371637- var beanRoasterRefMap map[string]string
16381638- var roasterMap map[string]*models.Roaster
16391639- var brewerMap map[string]*models.Brewer
16401640- var grinderMap map[string]*models.Grinder
16411641-16421642- // Fetch beans
16431643- g.Go(func() error {
16441644- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBean, 100)
16451645- if err != nil {
16461646- return err
16471647- }
16481648- beanMap = make(map[string]*models.Bean)
16491649- beanRoasterRefMap = make(map[string]string)
16501650- beans = make([]*models.Bean, 0, len(output.Records))
16511651- for _, record := range output.Records {
16521652- bean, err := atproto.RecordToBean(record.Value, record.URI)
16531653- if err != nil {
16541654- continue
16551655- }
16561656- beans = append(beans, bean)
16571657- beanMap[record.URI] = bean
16581658- if roasterRef, ok := record.Value["roasterRef"].(string); ok && roasterRef != "" {
16591659- beanRoasterRefMap[record.URI] = roasterRef
16601660- }
16611661- }
16621662- return nil
16631663- })
16641664-16651665- // Fetch roasters
16661666- g.Go(func() error {
16671667- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDRoaster, 100)
16681668- if err != nil {
16691669- return err
16701670- }
16711671- roasterMap = make(map[string]*models.Roaster)
16721672- roasters = make([]*models.Roaster, 0, len(output.Records))
16731673- for _, record := range output.Records {
16741674- roaster, err := atproto.RecordToRoaster(record.Value, record.URI)
16751675- if err != nil {
16761676- continue
16771677- }
16781678- roasters = append(roasters, roaster)
16791679- roasterMap[record.URI] = roaster
16801680- }
16811681- return nil
16821682- })
16831683-16841684- // Fetch grinders
16851685- g.Go(func() error {
16861686- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDGrinder, 100)
16871687- if err != nil {
16881688- return err
16891689- }
16901690- grinderMap = make(map[string]*models.Grinder)
16911691- grinders = make([]*models.Grinder, 0, len(output.Records))
16921692- for _, record := range output.Records {
16931693- grinder, err := atproto.RecordToGrinder(record.Value, record.URI)
16941694- if err != nil {
16951695- continue
16961696- }
16971697- grinders = append(grinders, grinder)
16981698- grinderMap[record.URI] = grinder
16991699- }
17001700- return nil
17011701- })
17021702-17031703- // Fetch brewers
17041704- g.Go(func() error {
17051705- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrewer, 100)
17061706- if err != nil {
17071707- return err
17081708- }
17091709- brewerMap = make(map[string]*models.Brewer)
17101710- brewers = make([]*models.Brewer, 0, len(output.Records))
17111711- for _, record := range output.Records {
17121712- brewer, err := atproto.RecordToBrewer(record.Value, record.URI)
17131713- if err != nil {
17141714- continue
17151715- }
17161716- brewers = append(brewers, brewer)
17171717- brewerMap[record.URI] = brewer
17181718- }
17191719- return nil
17201720- })
17211721-17221722- // Fetch brews
17231723- g.Go(func() error {
17241724- output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrew, 100)
17251725- if err != nil {
17261726- return err
17271727- }
17281728- brews = make([]*models.Brew, 0, len(output.Records))
17291729- for _, record := range output.Records {
17301730- brew, err := atproto.RecordToBrew(record.Value, record.URI)
17311731- if err != nil {
17321732- continue
17331733- }
17341734- // Store the raw record for reference resolution later
17351735- brew.BeanRKey = "" // Will be resolved after all data is fetched
17361736- if beanRef, ok := record.Value["beanRef"].(string); ok {
17371737- brew.BeanRKey = beanRef
17381738- }
17391739- if grinderRef, ok := record.Value["grinderRef"].(string); ok {
17401740- brew.GrinderRKey = grinderRef
17411741- }
17421742- if brewerRef, ok := record.Value["brewerRef"].(string); ok {
17431743- brew.BrewerRKey = brewerRef
17441744- }
17451745- brews = append(brews, brew)
17461746- }
17471747- return nil
17481748- })
17491749-17501750- if err := g.Wait(); err != nil {
17511751- log.Error().Err(err).Str("did", did).Msg("Failed to fetch user data")
17521752- http.Error(w, "Failed to load profile data", http.StatusInternalServerError)
17531753- return
17541754- }
17551755-17561756- // Resolve references for beans (roaster refs)
17571757- for _, bean := range beans {
17581758- if roasterRef, found := beanRoasterRefMap[atproto.BuildATURI(did, atproto.NSIDBean, bean.RKey)]; found {
17591759- if roaster, found := roasterMap[roasterRef]; found {
17601760- bean.Roaster = roaster
17611761- }
17621762- }
17631763- }
17641764-17651765- // Resolve references for brews
17661766- for _, brew := range brews {
17671767- // Resolve bean reference
17681768- if brew.BeanRKey != "" {
17691769- if bean, found := beanMap[brew.BeanRKey]; found {
17701770- brew.Bean = bean
17711771- }
17721772- }
17731773- // Resolve grinder reference
17741774- if brew.GrinderRKey != "" {
17751775- if grinder, found := grinderMap[brew.GrinderRKey]; found {
17761776- brew.GrinderObj = grinder
17771777- }
17781778- }
17791779- // Resolve brewer reference
17801780- if brew.BrewerRKey != "" {
17811781- if brewer, found := brewerMap[brew.BrewerRKey]; found {
17821782- brew.BrewerObj = brewer
17831783- }
17841784- }
17851785- }
17861786-17871787- // Sort brews in reverse chronological order (newest first)
17881788- sort.Slice(brews, func(i, j int) bool {
17891789- return brews[i].CreatedAt.After(brews[j].CreatedAt)
17901790- })
17911791-17921792- // Check if current user is authenticated (for nav bar state)
17931793- didStr, err := atproto.GetAuthenticatedDID(ctx)
17941794- isAuthenticated := err == nil && didStr != ""
17951795-17961796- var userProfile *bff.UserProfile
17971797- if isAuthenticated {
17981798- userProfile = h.getUserProfile(ctx, didStr)
17991799- }
18001800-18011801- // Check if the viewing user is the profile owner
18021802- isOwnProfile := isAuthenticated && didStr == did
18031803-18041804- // Render profile page
18051805- if err := bff.RenderProfile(w, profile, brews, beans, roasters, grinders, brewers, isAuthenticated, didStr, userProfile, isOwnProfile); err != nil {
18061806- http.Error(w, "Failed to render page", http.StatusInternalServerError)
18071807- log.Error().Err(err).Msg("Failed to render profile page")
18081808- }
18091809-}
1810149418111495// HandleProfilePartial returns profile data content (loaded async via HTMX)
18121496func (h *Handler) HandleProfilePartial(w http.ResponseWriter, r *http.Request) {
+7-17
internal/routing/routing.go
···4848 // API endpoint for feed (JSON)
4949 mux.HandleFunc("GET /api/feed-json", h.HandleFeedAPI)
50505151+ // API endpoint for fetching public brew by AT-URI
5252+ mux.HandleFunc("GET /api/brew", h.HandleBrewGetPublic)
5353+5154 // API endpoint for profile data (JSON for Svelte)
5255 mux.HandleFunc("GET /api/profile-json/{actor}", h.HandleProfileAPI)
53565454- // HTMX partials (loaded async via HTMX)
5757+ // HTMX partials (legacy - being phased out)
5558 // These return HTML fragments and should only be accessed via HTMX
5959+ // Still used by manage page and some dynamic content
5660 mux.Handle("GET /api/feed", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleFeedPartial)))
5761 mux.Handle("GET /api/brews", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleBrewListPartial)))
5862 mux.Handle("GET /api/manage", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleManagePartial)))
5963 mux.Handle("GET /api/profile/{actor}", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleProfilePartial)))
60646161- // Old page routes (commented out - now handled by Svelte SPA)
6262- // mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match
6363- // mux.HandleFunc("GET /about", h.HandleAbout)
6464- // mux.HandleFunc("GET /terms", h.HandleTerms)
6565- // mux.HandleFunc("GET /manage", h.HandleManage)
6666- // mux.HandleFunc("GET /brews", h.HandleBrewList)
6767- // mux.HandleFunc("GET /brews/new", h.HandleBrewNew)
6868- // mux.HandleFunc("GET /brews/{id}", h.HandleBrewView)
6969- // mux.HandleFunc("GET /brews/{id}/edit", h.HandleBrewEdit)
7070-7171- // API routes for brews (POST/PUT/DELETE still needed by Svelte)
6565+ // Brew CRUD API routes (used by Svelte SPA)
7266 mux.Handle("POST /brews", cop.Handler(http.HandlerFunc(h.HandleBrewCreate)))
7367 mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewUpdate)))
7468 mux.Handle("DELETE /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewDelete)))
7575- // mux.HandleFunc("GET /brews/export", h.HandleBrewExport)
76697777- // API routes for CRUD operations
7070+ // Entity CRUD API routes (used by Svelte SPA)
7871 mux.Handle("POST /api/beans", cop.Handler(http.HandlerFunc(h.HandleBeanCreate)))
7972 mux.Handle("PUT /api/beans/{id}", cop.Handler(http.HandlerFunc(h.HandleBeanUpdate)))
8073 mux.Handle("DELETE /api/beans/{id}", cop.Handler(http.HandlerFunc(h.HandleBeanDelete)))
···9083 mux.Handle("POST /api/brewers", cop.Handler(http.HandlerFunc(h.HandleBrewerCreate)))
9184 mux.Handle("PUT /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerUpdate)))
9285 mux.Handle("DELETE /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerDelete)))
9393-9494- // Profile routes (public user profiles) - commented out, handled by SPA
9595- // mux.HandleFunc("GET /profile/{actor}", h.HandleProfile)
96869787 // Static files (must come after specific routes)
9888 fs := http.FileServer(http.Dir("web/static"))
-12
templates/404.tmpl
···11-{{define "content"}}
22-<div class="max-w-4xl mx-auto">
33- <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300">
44- <div class="text-6xl mb-4 font-bold text-brown-800">404</div>
55- <h2 class="text-2xl font-bold text-brown-900 mb-4">Page Not Found</h2>
66- <p class="text-brown-700 mb-6">The page you're looking for doesn't exist or has been moved.</p>
77- <a href="/" class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-lg hover:shadow-xl">
88- Back to Home
99- </a>
1010- </div>
1111-</div>
1212-{{end}}
-112
templates/about.tmpl
···11-{{template "layout" .}}
22-33-{{define "content"}}
44-<div class="max-w-3xl mx-auto">
55- <div class="flex items-center gap-3 mb-8">
66- <button
77- data-back-button
88- data-fallback="/"
99- class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer">
1010- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
1111- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
1212- </svg>
1313- </button>
1414- <h1 class="text-4xl font-bold text-brown-900">About Arabica</h1>
1515- <span class="text-sm bg-amber-400 text-brown-900 px-3 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>
1616- </div>
1717-1818- <div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-6 rounded-r-lg">
1919- <p class="text-sm text-brown-800">
2020- <strong>Alpha Software:</strong> Arabica is currently in early development. Features may change, and data structures could be modified in future updates.
2121- </p>
2222- </div>
2323-2424- <div class="prose prose-lg max-w-none space-y-6">
2525- <section>
2626- <h2 class="text-2xl font-semibold text-brown-900 mb-4">Your Coffee Journey, Your Data</h2>
2727- <p class="text-brown-800 leading-relaxed">
2828- Arabica is a coffee brew tracking application built on the AT Protocol, a decentralized social networking protocol.
2929- Unlike traditional apps where your data is locked in a company's database, Arabica stores your brew logs,
3030- coffee beans, and equipment information in <strong>your own Personal Data Server (PDS)</strong>.
3131- </p>
3232- </section>
3333-3434- <section class="bg-gradient-to-br from-brown-100 to-brown-200 p-6 rounded-xl border border-brown-300">
3535- <h3 class="text-xl font-semibold text-brown-900 mb-3">What Makes Arabica Different?</h3>
3636- <ul class="list-disc list-inside space-y-2 text-brown-800">
3737- <li><strong>You own your data</strong> - All your brew logs live in your PDS, not our servers</li>
3838- <li><strong>Portable identity</strong> - Switch PDS providers anytime without losing your data</li>
3939- <li><strong>Privacy by design</strong> - You control who sees your brews</li>
4040- <li><strong>Open protocol</strong> - Built on the AT Protocol, the same technology powering Bluesky</li>
4141- </ul>
4242- </section>
4343-4444- <section>
4545- <h2 class="text-2xl font-semibold text-brown-900 mb-4">Features</h2>
4646- <div class="grid md:grid-cols-2 gap-4">
4747- <div class="bg-gradient-to-br from-brown-50 to-brown-100 border border-brown-200 p-4 rounded-lg shadow-md">
4848- <h4 class="font-semibold text-brown-900 mb-2">Track Your Brews</h4>
4949- <p class="text-brown-700 text-sm">Log every detail: beans, grind size, water temp, brew time, and tasting notes</p>
5050- </div>
5151- <div class="bg-gradient-to-br from-brown-50 to-brown-100 border border-brown-200 p-4 rounded-lg shadow-md">
5252- <h4 class="font-semibold text-brown-900 mb-2">Manage Equipment</h4>
5353- <p class="text-brown-700 text-sm">Keep track of your grinders, brewers, beans, and roasters</p>
5454- </div>
5555- <div class="bg-gradient-to-br from-brown-50 to-brown-100 border border-brown-200 p-4 rounded-lg shadow-md">
5656- <h4 class="font-semibold text-brown-900 mb-2">Community Feed</h4>
5757- <p class="text-brown-700 text-sm">Share your best brews with the community (coming soon: likes and comments!)</p>
5858- </div>
5959- <div class="bg-gradient-to-br from-brown-50 to-brown-100 border border-brown-200 p-4 rounded-lg shadow-md">
6060- <h4 class="font-semibold text-brown-900 mb-2">Export Your Data</h4>
6161- <p class="text-brown-700 text-sm">Export all your brews anytime in JSON format</p>
6262- </div>
6363- </div>
6464- </section>
6565-6666- <section>
6767- <h2 class="text-2xl font-semibold text-brown-900 mb-4">The AT Protocol Advantage</h2>
6868- <p class="text-brown-800 leading-relaxed">
6969- The AT Protocol is a decentralized social networking protocol that gives users true ownership of their data
7070- and identity. When you use Arabica:
7171- </p>
7272- <ul class="list-disc list-inside space-y-2 text-brown-800 mt-3">
7373- <li>Your brew data is stored as ATProto records in collections on your PDS</li>
7474- <li>You authenticate via OAuth with your PDS, not with us</li>
7575- <li>References between records (like linking a brew to a bean) use AT-URIs</li>
7676- <li>Your identity is portable - change PDS providers without losing your account</li>
7777- </ul>
7878- </section>
7979-8080- <section class="bg-gradient-to-br from-amber-50 to-brown-100 border-2 border-brown-300 p-6 rounded-xl shadow-lg">
8181- <h3 class="text-xl font-semibold text-brown-900 mb-3">Getting Started</h3>
8282- <p class="text-brown-800 mb-4">
8383- To use Arabica, you'll need an account on a PDS that supports the AT Protocol.
8484- The easiest way is to create a Bluesky account at <a href="https://bsky.app" class="text-brown-700 hover:underline font-medium" target="_blank" rel="noopener noreferrer">bsky.app</a>.
8585- </p>
8686- <p class="text-brown-800">
8787- Once you have an account, simply log in with your handle (e.g., yourname.bsky.social) and start tracking your brews!
8888- </p>
8989- </section>
9090-9191- <section>
9292- <h2 class="text-2xl font-semibold text-brown-900 mb-4">Open Source</h2>
9393- <p class="text-brown-800 leading-relaxed">
9494- Arabica is open source software. You can view the code, contribute, or even run your own instance.
9595- Visit our <a href="https://github.com/ptdewey/arabica" class="text-brown-700 hover:underline font-medium" target="_blank" rel="noopener noreferrer">GitHub repository</a> to learn more.
9696- </p>
9797- </section>
9898- </div>
9999-100100- <div class="mt-12 text-center">
101101- {{if not .IsAuthenticated}}
102102- <a href="/login" class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white px-8 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg hover:shadow-xl">
103103- Get Started
104104- </a>
105105- {{else}}
106106- <a href="/brews/new" class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white px-8 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg hover:shadow-xl">
107107- Log Your Next Brew
108108- </a>
109109- {{end}}
110110- </div>
111111-</div>
112112-{{end}}
···11-{{template "layout" .}}
22-33-{{define "content"}}
44-<div class="max-w-3xl mx-auto">
55- <div class="flex items-center gap-3 mb-8">
66- <button
77- data-back-button
88- data-fallback="/"
99- class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer">
1010- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
1111- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
1212- </svg>
1313- </button>
1414- <h1 class="text-4xl font-bold text-brown-800">Terms of Service</h1>
1515- </div>
1616-1717- <div class="prose prose-lg max-w-none space-y-6">
1818- <section class="bg-green-50 border border-green-200 p-6 rounded-lg mb-8">
1919- <h2 class="text-2xl font-semibold text-green-900 mb-4">The Simple Truth</h2>
2020- <p class="text-gray-800 text-lg leading-relaxed">
2121- <strong>You own all of your data.</strong> Period. Your brew logs, coffee beans, equipment information,
2222- and any other data you create in Arabica belongs to you and is stored in your Personal Data Server (PDS),
2323- not on our servers.
2424- </p>
2525- </section>
2626-2727- <section>
2828- <h2 class="text-2xl font-semibold text-brown-800 mb-4">1. Your Data Ownership</h2>
2929- <p class="text-gray-700 leading-relaxed">
3030- All data you create through Arabica is stored in your AT Protocol Personal Data Server (PDS).
3131- Arabica acts as an interface to your PDS but does not own, claim rights to, or permanently store your data.
3232- </p>
3333- <ul class="list-disc list-inside space-y-2 text-gray-700 mt-3">
3434- <li>You retain full ownership and control of your data</li>
3535- <li>You can export your data at any time</li>
3636- <li>You can delete your data at any time</li>
3737- <li>You can switch PDS providers without losing your data</li>
3838- <li>You can stop using Arabica and your data remains in your PDS</li>
3939- </ul>
4040- </section>
4141-4242- <section>
4343- <h2 class="text-2xl font-semibold text-brown-800 mb-4">2. What We Store</h2>
4444- <p class="text-gray-700 leading-relaxed mb-3">
4545- Arabica's servers store minimal data necessary for the application to function:
4646- </p>
4747- <ul class="list-disc list-inside space-y-2 text-gray-700">
4848- <li><strong>Session information</strong> - Temporary authentication tokens to keep you logged in</li>
4949- <li><strong>Feed registry</strong> - List of users who've opted into the community feed</li>
5050- <li><strong>Temporary cache</strong> - Short-lived cache (5 minutes) of your data to improve performance</li>
5151- </ul>
5252- <p class="text-gray-700 leading-relaxed mt-3">
5353- We do <strong>not</strong> store your brew logs, beans, equipment, or any other user-generated content
5454- on our servers. That data lives exclusively in your PDS.
5555- </p>
5656- </section>
5757-5858- <section>
5959- <h2 class="text-2xl font-semibold text-brown-800 mb-4">3. Authentication</h2>
6060- <p class="text-gray-700 leading-relaxed">
6161- Arabica uses OAuth to authenticate with your PDS. We never see or store your PDS password.
6262- Authentication is handled between your browser and your PDS, with Arabica receiving only
6363- temporary access tokens to read and write data on your behalf.
6464- </p>
6565- </section>
6666-6767- <section>
6868- <h2 class="text-2xl font-semibold text-brown-800 mb-4">4. Community Feed</h2>
6969- <p class="text-gray-700 leading-relaxed">
7070- If you opt into the community feed, Arabica will periodically read your public brew records
7171- from your PDS to display them to other users. This is done by:
7272- </p>
7373- <ul class="list-disc list-inside space-y-2 text-gray-700 mt-3">
7474- <li>Making public API calls to your PDS</li>
7575- <li>Temporarily caching brew data for feed display</li>
7676- <li>Not storing your data permanently on our servers</li>
7777- </ul>
7878- <p class="text-gray-700 leading-relaxed mt-3">
7979- You can opt out of the community feed at any time, and we'll stop reading your brews.
8080- </p>
8181- </section>
8282-8383- <section>
8484- <h2 class="text-2xl font-semibold text-brown-800 mb-4">5. Service Availability</h2>
8585- <p class="text-gray-700 leading-relaxed">
8686- Arabica is provided "as is" without warranties of any kind. We make reasonable efforts to keep
8787- the service running but do not guarantee uptime or availability. Since your data is stored in
8888- your PDS (not our servers), you won't lose your data if Arabica goes offline.
8989- </p>
9090- </section>
9191-9292- <section>
9393- <h2 class="text-2xl font-semibold text-brown-800 mb-4">6. Privacy</h2>
9494- <p class="text-gray-700 leading-relaxed">
9595- We respect your privacy and follow these principles:
9696- </p>
9797- <ul class="list-disc list-inside space-y-2 text-gray-700 mt-3">
9898- <li>We don't sell your data (because we don't have it to sell!)</li>
9999- <li>We don't track you across websites</li>
100100- <li>We use minimal analytics to understand service usage</li>
101101- <li>We don't share your data with third parties</li>
102102- <li>Your PDS and the AT Protocol control the privacy of your brew data</li>
103103- </ul>
104104- </section>
105105-106106- <section>
107107- <h2 class="text-2xl font-semibold text-brown-800 mb-4">7. Open Source</h2>
108108- <p class="text-gray-700 leading-relaxed">
109109- Arabica is open source software. You can review the code, run your own instance, or contribute
110110- improvements. The transparency of open source means you can verify that we're handling your data
111111- as described in these terms.
112112- </p>
113113- </section>
114114-115115- <section>
116116- <h2 class="text-2xl font-semibold text-brown-800 mb-4">8. Changes to Terms</h2>
117117- <p class="text-gray-700 leading-relaxed">
118118- We may update these terms occasionally. If we make significant changes, we'll notify users through
119119- the application. Continued use of Arabica after changes constitutes acceptance of the new terms.
120120- </p>
121121- </section>
122122-123123- <section>
124124- <h2 class="text-2xl font-semibold text-brown-800 mb-4">9. Acceptable Use</h2>
125125- <p class="text-gray-700 leading-relaxed">
126126- Please use Arabica responsibly:
127127- </p>
128128- <ul class="list-disc list-inside space-y-2 text-gray-700 mt-3">
129129- <li>Don't attempt to access other users' data without permission</li>
130130- <li>Don't abuse the service with excessive API requests</li>
131131- <li>Don't use Arabica for illegal purposes</li>
132132- <li>Be respectful in community interactions</li>
133133- </ul>
134134- </section>
135135-136136- <section>
137137- <h2 class="text-2xl font-semibold text-brown-800 mb-4">10. Contact</h2>
138138- <p class="text-gray-700 leading-relaxed">
139139- Questions about these terms? You can reach us through our GitHub repository or by email at
140140- <a href="mailto:mail@arabica.systems" class="text-blue-600 hover:underline">mail@arabica.systems</a>.
141141- </p>
142142- </section>
143143-144144- <section class="bg-gray-100 p-6 rounded-lg mt-8">
145145- <p class="text-sm text-gray-600">
146146- <strong>Last Updated:</strong> January 2026<br>
147147- <strong>Effective Date:</strong> January 2026
148148- </p>
149149- </section>
150150- </div>
151151-152152- <div class="mt-12 text-center">
153153- <a href="/" class="text-blue-600 hover:underline">Back to Home</a>
154154- </div>
155155-</div>
156156-{{end}}
···11-/**
22- * Smart back button implementation for Arabica
33- * Handles browser history navigation with intelligent fallbacks
44- */
55-66-/**
77- * Initialize a back button with smart navigation
88- * @param {HTMLElement} button - The back button element
99- */
1010-function initBackButton(button) {
1111- if (!button) return;
1212-1313- button.addEventListener('click', function(e) {
1414- e.preventDefault();
1515- handleBackNavigation(button);
1616- });
1717-}
1818-1919-/**
2020- * Handle back navigation with fallback logic
2121- * @param {HTMLElement} button - The back button element
2222- */
2323-function handleBackNavigation(button) {
2424- const fallbackUrl = button.getAttribute('data-fallback') || '/brews';
2525- const referrer = document.referrer;
2626- const currentUrl = window.location.href;
2727-2828- // Check if there's actual browser history to go back to
2929- // We can't directly check history.length in a reliable way across browsers,
3030- // but we can check if the referrer is from the same origin
3131- const hasSameOriginReferrer = referrer &&
3232- referrer.startsWith(window.location.origin) &&
3333- referrer !== currentUrl;
3434-3535- if (hasSameOriginReferrer) {
3636- // Safe to use history.back() - we came from within the app
3737- window.history.back();
3838- } else {
3939- // No referrer or external referrer - use fallback
4040- // This handles direct links, external referrers, and bookmarks
4141- window.location.href = fallbackUrl;
4242- }
4343-}
4444-4545-/**
4646- * Initialize all back buttons on the page
4747- */
4848-function initAllBackButtons() {
4949- const buttons = document.querySelectorAll('[data-back-button]');
5050- buttons.forEach(initBackButton);
5151-}
5252-5353-// Initialize on DOM load
5454-if (document.readyState === 'loading') {
5555- document.addEventListener('DOMContentLoaded', initAllBackButtons);
5656-} else {
5757- initAllBackButtons();
5858-}
5959-6060-// Re-initialize after HTMX swaps (for dynamic content)
6161-if (document.body) {
6262- document.body.addEventListener('htmx:afterSwap', function() {
6363- initAllBackButtons();
6464- });
6565-}
-368
web/static/js/brew-form.js
···11-/**
22- * Alpine.js component for the brew form
33- * Manages pours, new entity modals, and form state
44- * Populates dropdowns from client-side cache for faster UX
55- */
66-function brewForm() {
77- return {
88- // Modal state (matching manage page)
99- showBeanForm: false,
1010- showGrinderForm: false,
1111- showBrewerForm: false,
1212- editingBean: null,
1313- editingGrinder: null,
1414- editingBrewer: null,
1515-1616- // Form data (matching manage page with snake_case)
1717- beanForm: {
1818- name: "",
1919- origin: "",
2020- roast_level: "",
2121- process: "",
2222- description: "",
2323- roaster_rkey: "",
2424- },
2525- grinderForm: { name: "", grinder_type: "", burr_type: "", notes: "" },
2626- brewerForm: { name: "", brewer_type: "", description: "" },
2727-2828- // Brew form specific
2929- rating: 5,
3030- pours: [],
3131-3232- // Dropdown data
3333- beans: [],
3434- grinders: [],
3535- brewers: [],
3636- roasters: [],
3737- dataLoaded: false,
3838-3939- async init() {
4040- // Load existing pours if editing
4141- // $el is now the parent div, so find the form element
4242- const formEl = this.$el.querySelector("form");
4343- const poursData = formEl?.getAttribute("data-pours");
4444- if (poursData) {
4545- try {
4646- this.pours = JSON.parse(poursData);
4747- } catch (e) {
4848- console.error("Failed to parse pours data:", e);
4949- this.pours = [];
5050- }
5151- }
5252-5353- // Populate dropdowns from cache using stale-while-revalidate pattern
5454- await this.loadDropdownData();
5555- },
5656-5757- async loadDropdownData(forceRefresh = false) {
5858- if (!window.ArabicaCache) {
5959- console.warn("ArabicaCache not available");
6060- return;
6161- }
6262-6363- // If forcing refresh, always get fresh data
6464- if (forceRefresh) {
6565- try {
6666- const freshData = await window.ArabicaCache.refreshCache(true);
6767- if (freshData) {
6868- this.applyData(freshData);
6969- }
7070- } catch (e) {
7171- console.error("Failed to refresh dropdown data:", e);
7272- }
7373- return;
7474- }
7575-7676- // First, try to immediately populate from cached data (sync)
7777- // This prevents flickering by showing data instantly
7878- const cachedData = window.ArabicaCache.getCachedData();
7979- if (cachedData) {
8080- this.applyData(cachedData);
8181- }
8282-8383- // Then refresh in background if cache is stale
8484- if (!window.ArabicaCache.isCacheValid()) {
8585- try {
8686- const freshData = await window.ArabicaCache.refreshCache();
8787- if (freshData) {
8888- this.applyData(freshData);
8989- }
9090- } catch (e) {
9191- console.error("Failed to refresh dropdown data:", e);
9292- // We already have cached data displayed, so this is non-fatal
9393- }
9494- }
9595- },
9696-9797- applyData(data) {
9898- this.beans = data.beans || [];
9999- this.grinders = data.grinders || [];
100100- this.brewers = data.brewers || [];
101101- this.roasters = data.roasters || [];
102102- this.dataLoaded = true;
103103-104104- // Populate the select elements
105105- this.populateDropdowns();
106106- },
107107-108108- populateDropdowns() {
109109- // Get the current selected values (from server-rendered form when editing)
110110- // Use document.querySelector to ensure we find the form selects, not modal selects
111111- const beanSelect = document.querySelector('form select[name="bean_rkey"]');
112112- const grinderSelect = document.querySelector('form select[name="grinder_rkey"]');
113113- const brewerSelect = document.querySelector('form select[name="brewer_rkey"]');
114114-115115- const selectedBean = beanSelect?.value || "";
116116- const selectedGrinder = grinderSelect?.value || "";
117117- const selectedBrewer = brewerSelect?.value || "";
118118-119119- // Populate beans - using DOM methods to prevent XSS
120120- if (beanSelect && this.beans.length > 0) {
121121- // Clear existing options
122122- beanSelect.innerHTML = "";
123123-124124- // Add placeholder
125125- const placeholderOption = document.createElement("option");
126126- placeholderOption.value = "";
127127- placeholderOption.textContent = "Select a bean...";
128128- beanSelect.appendChild(placeholderOption);
129129-130130- // Add bean options
131131- this.beans.forEach((bean) => {
132132- const option = document.createElement("option");
133133- option.value = bean.rkey || bean.RKey;
134134- const roasterName = bean.Roaster?.Name || bean.roaster?.name || "";
135135- const roasterSuffix = roasterName ? ` - ${roasterName}` : "";
136136- // Using textContent ensures all user input is safely escaped
137137- option.textContent = `${bean.Name || bean.name} (${bean.Origin || bean.origin} - ${bean.RoastLevel || bean.roast_level})${roasterSuffix}`;
138138- option.className = "truncate";
139139- if ((bean.rkey || bean.RKey) === selectedBean) {
140140- option.selected = true;
141141- }
142142- beanSelect.appendChild(option);
143143- });
144144- }
145145-146146- // Populate grinders - using DOM methods to prevent XSS
147147- if (grinderSelect && this.grinders.length > 0) {
148148- // Clear existing options
149149- grinderSelect.innerHTML = "";
150150-151151- // Add placeholder
152152- const placeholderOption = document.createElement("option");
153153- placeholderOption.value = "";
154154- placeholderOption.textContent = "Select a grinder...";
155155- grinderSelect.appendChild(placeholderOption);
156156-157157- // Add grinder options
158158- this.grinders.forEach((grinder) => {
159159- const option = document.createElement("option");
160160- option.value = grinder.rkey || grinder.RKey;
161161- // Using textContent ensures all user input is safely escaped
162162- option.textContent = grinder.Name || grinder.name;
163163- option.className = "truncate";
164164- if ((grinder.rkey || grinder.RKey) === selectedGrinder) {
165165- option.selected = true;
166166- }
167167- grinderSelect.appendChild(option);
168168- });
169169- }
170170-171171- // Populate brewers - using DOM methods to prevent XSS
172172- if (brewerSelect && this.brewers.length > 0) {
173173- // Clear existing options
174174- brewerSelect.innerHTML = "";
175175-176176- // Add placeholder
177177- const placeholderOption = document.createElement("option");
178178- placeholderOption.value = "";
179179- placeholderOption.textContent = "Select brew method...";
180180- brewerSelect.appendChild(placeholderOption);
181181-182182- // Add brewer options
183183- this.brewers.forEach((brewer) => {
184184- const option = document.createElement("option");
185185- option.value = brewer.rkey || brewer.RKey;
186186- // Using textContent ensures all user input is safely escaped
187187- option.textContent = brewer.Name || brewer.name;
188188- option.className = "truncate";
189189- if ((brewer.rkey || brewer.RKey) === selectedBrewer) {
190190- option.selected = true;
191191- }
192192- brewerSelect.appendChild(option);
193193- });
194194- }
195195-196196- // Populate roasters in new bean modal - using DOM methods to prevent XSS
197197- const roasterSelect = document.querySelector('select[name="roaster_rkey_modal"]');
198198- if (roasterSelect && this.roasters.length > 0) {
199199- // Clear existing options
200200- roasterSelect.innerHTML = "";
201201-202202- // Add placeholder
203203- const placeholderOption = document.createElement("option");
204204- placeholderOption.value = "";
205205- placeholderOption.textContent = "No roaster";
206206- roasterSelect.appendChild(placeholderOption);
207207-208208- // Add roaster options
209209- this.roasters.forEach((roaster) => {
210210- const option = document.createElement("option");
211211- option.value = roaster.rkey || roaster.RKey;
212212- // Using textContent ensures all user input is safely escaped
213213- option.textContent = roaster.Name || roaster.name;
214214- roasterSelect.appendChild(option);
215215- });
216216- }
217217- },
218218-219219- addPour() {
220220- this.pours.push({ water: "", time: "" });
221221- },
222222-223223- removePour(index) {
224224- this.pours.splice(index, 1);
225225- },
226226-227227- async saveBean() {
228228- if (!this.beanForm.name || !this.beanForm.origin) {
229229- alert("Bean name and origin are required");
230230- return;
231231- }
232232-233233- const response = await fetch("/api/beans", {
234234- method: "POST",
235235- headers: {
236236- "Content-Type": "application/json",
237237- },
238238- body: JSON.stringify(this.beanForm),
239239- });
240240-241241- if (response.ok) {
242242- const newBean = await response.json();
243243-244244- // Invalidate cache and refresh data in one call
245245- let freshData = null;
246246- if (window.ArabicaCache) {
247247- freshData = await window.ArabicaCache.invalidateAndRefresh();
248248- }
249249-250250- // Apply the fresh data to update dropdowns
251251- if (freshData) {
252252- this.applyData(freshData);
253253- }
254254-255255- // Select the new bean
256256- const beanSelect = document.querySelector('form select[name="bean_rkey"]');
257257- if (beanSelect && newBean.rkey) {
258258- beanSelect.value = newBean.rkey;
259259- }
260260-261261- // Close modal and reset form
262262- this.showBeanForm = false;
263263- this.beanForm = {
264264- name: "",
265265- origin: "",
266266- roast_level: "",
267267- process: "",
268268- description: "",
269269- roaster_rkey: "",
270270- };
271271- } else {
272272- const errorText = await response.text();
273273- alert("Failed to add bean: " + errorText);
274274- }
275275- },
276276-277277- async saveGrinder() {
278278- if (!this.grinderForm.name) {
279279- alert("Grinder name is required");
280280- return;
281281- }
282282-283283- const response = await fetch("/api/grinders", {
284284- method: "POST",
285285- headers: {
286286- "Content-Type": "application/json",
287287- },
288288- body: JSON.stringify(this.grinderForm),
289289- });
290290-291291- if (response.ok) {
292292- const newGrinder = await response.json();
293293-294294- // Invalidate cache and refresh data in one call
295295- let freshData = null;
296296- if (window.ArabicaCache) {
297297- freshData = await window.ArabicaCache.invalidateAndRefresh();
298298- }
299299-300300- // Apply the fresh data to update dropdowns
301301- if (freshData) {
302302- this.applyData(freshData);
303303- }
304304-305305- // Select the new grinder
306306- const grinderSelect = document.querySelector('form select[name="grinder_rkey"]');
307307- if (grinderSelect && newGrinder.rkey) {
308308- grinderSelect.value = newGrinder.rkey;
309309- }
310310-311311- // Close modal and reset form
312312- this.showGrinderForm = false;
313313- this.grinderForm = {
314314- name: "",
315315- grinder_type: "",
316316- burr_type: "",
317317- notes: "",
318318- };
319319- } else {
320320- const errorText = await response.text();
321321- alert("Failed to add grinder: " + errorText);
322322- }
323323- },
324324-325325- async saveBrewer() {
326326- if (!this.brewerForm.name) {
327327- alert("Brewer name is required");
328328- return;
329329- }
330330-331331- const response = await fetch("/api/brewers", {
332332- method: "POST",
333333- headers: {
334334- "Content-Type": "application/json",
335335- },
336336- body: JSON.stringify(this.brewerForm),
337337- });
338338-339339- if (response.ok) {
340340- const newBrewer = await response.json();
341341-342342- // Invalidate cache and refresh data in one call
343343- let freshData = null;
344344- if (window.ArabicaCache) {
345345- freshData = await window.ArabicaCache.invalidateAndRefresh();
346346- }
347347-348348- // Apply the fresh data to update dropdowns
349349- if (freshData) {
350350- this.applyData(freshData);
351351- }
352352-353353- // Select the new brewer
354354- const brewerSelect = document.querySelector('form select[name="brewer_rkey"]');
355355- if (brewerSelect && newBrewer.rkey) {
356356- brewerSelect.value = newBrewer.rkey;
357357- }
358358-359359- // Close modal and reset form
360360- this.showBrewerForm = false;
361361- this.brewerForm = { name: "", brewer_type: "", description: "" };
362362- } else {
363363- const errorText = await response.text();
364364- alert("Failed to add brewer: " + errorText);
365365- }
366366- },
367367- };
368368-}
-320
web/static/js/data-cache.js
···11-/**
22- * Client-side data cache for Arabica
33- * Caches beans, roasters, grinders, and brewers in localStorage
44- * to reduce PDS round-trips on page loads.
55- */
66-77-const CACHE_KEY = "arabica_data_cache";
88-const CACHE_VERSION = 1;
99-const CACHE_TTL_MS = 30 * 1000; // 30 seconds (shorter for multi-device sync)
1010-const REFRESH_INTERVAL_MS = 30 * 1000; // 30 seconds
1111-1212-// Module state
1313-let refreshTimer = null;
1414-let isRefreshing = false;
1515-let listeners = [];
1616-1717-/**
1818- * Get the current cache from localStorage
1919- */
2020-function getCache() {
2121- try {
2222- const raw = localStorage.getItem(CACHE_KEY);
2323- if (!raw) return null;
2424-2525- const cache = JSON.parse(raw);
2626-2727- // Check version
2828- if (cache.version !== CACHE_VERSION) {
2929- localStorage.removeItem(CACHE_KEY);
3030- return null;
3131- }
3232-3333- return cache;
3434- } catch (e) {
3535- console.warn("Failed to read cache:", e);
3636- localStorage.removeItem(CACHE_KEY);
3737- return null;
3838- }
3939-}
4040-4141-/**
4242- * Save data to the cache
4343- * Stores the user DID alongside the data for validation
4444- */
4545-function setCache(data) {
4646- try {
4747- const cache = {
4848- version: CACHE_VERSION,
4949- timestamp: Date.now(),
5050- did: data.did || null, // Store user DID for cache validation
5151- data: data,
5252- };
5353- localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
5454- } catch (e) {
5555- console.warn("Failed to write cache:", e);
5656- }
5757-}
5858-5959-/**
6060- * Get the DID stored in the cache
6161- */
6262-function getCachedDID() {
6363- const cache = getCache();
6464- return cache?.did || null;
6565-}
6666-6767-/**
6868- * Check if cache is valid (exists and not expired)
6969- */
7070-function isCacheValid() {
7171- const cache = getCache();
7272- if (!cache) return false;
7373-7474- const age = Date.now() - cache.timestamp;
7575- return age < CACHE_TTL_MS;
7676-}
7777-7878-/**
7979- * Get the current user's DID from the page
8080- */
8181-function getCurrentUserDID() {
8282- return document.body?.dataset?.userDid || null;
8383-}
8484-8585-/**
8686- * Get cached data if available and valid for the current user
8787- */
8888-function getCachedData() {
8989- const cache = getCache();
9090- if (!cache) return null;
9191-9292- // Validate that cached data belongs to the current user
9393- const currentDID = getCurrentUserDID();
9494- const cachedDID = cache.did;
9595-9696- // If we have both DIDs and they don't match, cache is invalid
9797- if (currentDID && cachedDID && currentDID !== cachedDID) {
9898- console.log("Cache belongs to different user, invalidating");
9999- invalidateCache();
100100- return null;
101101- }
102102-103103- // Return data even if expired - caller can decide to refresh
104104- return cache.data;
105105-}
106106-107107-/**
108108- * Fetch fresh data from the API
109109- */
110110-async function fetchFreshData() {
111111- const response = await fetch("/api/data", {
112112- credentials: "same-origin",
113113- });
114114-115115- if (!response.ok) {
116116- throw new Error(`Failed to fetch data: ${response.status}`);
117117- }
118118-119119- return await response.json();
120120-}
121121-122122-/**
123123- * Refresh the cache from the API
124124- * Returns the fresh data
125125- * @param {boolean} force - If true, always fetch fresh data even if a refresh is in progress
126126- */
127127-async function refreshCache(force = false) {
128128- if (isRefreshing) {
129129- // Wait for existing refresh to complete
130130- await new Promise((resolve) => {
131131- const checkInterval = setInterval(() => {
132132- if (!isRefreshing) {
133133- clearInterval(checkInterval);
134134- resolve();
135135- }
136136- }, 100);
137137- });
138138-139139- // If not forcing, return the cached data from the completed refresh
140140- if (!force) {
141141- return getCachedData();
142142- }
143143- // Otherwise, continue to do a new refresh with fresh data
144144- }
145145-146146- isRefreshing = true;
147147- try {
148148- const data = await fetchFreshData();
149149-150150- // Check if user changed (different DID)
151151- const cachedDID = getCachedDID();
152152- if (cachedDID && data.did && cachedDID !== data.did) {
153153- console.log("User changed, clearing stale cache");
154154- invalidateCache();
155155- }
156156-157157- setCache(data);
158158- notifyListeners(data);
159159- return data;
160160- } finally {
161161- isRefreshing = false;
162162- }
163163-}
164164-165165-/**
166166- * Get data - returns cached if valid, otherwise fetches fresh
167167- * @param {boolean} forceRefresh - Force a refresh even if cache is valid
168168- */
169169-async function getData(forceRefresh = false) {
170170- if (!forceRefresh && isCacheValid()) {
171171- return getCachedData();
172172- }
173173-174174- // Try to get cached data while refreshing
175175- const cached = getCachedData();
176176-177177- try {
178178- return await refreshCache();
179179- } catch (e) {
180180- console.warn("Failed to refresh cache:", e);
181181- // Return stale data if available
182182- if (cached) {
183183- return cached;
184184- }
185185- throw e;
186186- }
187187-}
188188-189189-/**
190190- * Invalidate the cache (call after CRUD operations)
191191- */
192192-function invalidateCache() {
193193- localStorage.removeItem(CACHE_KEY);
194194-}
195195-196196-/**
197197- * Invalidate and immediately refresh the cache
198198- * Forces a fresh fetch even if a background refresh is in progress
199199- */
200200-async function invalidateAndRefresh() {
201201- invalidateCache();
202202- return await refreshCache(true);
203203-}
204204-205205-/**
206206- * Register a listener for cache updates
207207- * @param {function} callback - Called with new data when cache is refreshed
208208- */
209209-function addListener(callback) {
210210- listeners.push(callback);
211211-}
212212-213213-/**
214214- * Remove a listener
215215- */
216216-function removeListener(callback) {
217217- listeners = listeners.filter((l) => l !== callback);
218218-}
219219-220220-/**
221221- * Notify all listeners of new data
222222- */
223223-function notifyListeners(data) {
224224- listeners.forEach((callback) => {
225225- try {
226226- callback(data);
227227- } catch (e) {
228228- console.warn("Cache listener error:", e);
229229- }
230230- });
231231-}
232232-233233-/**
234234- * Start periodic background refresh
235235- */
236236-function startPeriodicRefresh() {
237237- if (refreshTimer) return;
238238-239239- refreshTimer = setInterval(async () => {
240240- try {
241241- await refreshCache();
242242- } catch (e) {
243243- console.warn("Periodic refresh failed:", e);
244244- }
245245- }, REFRESH_INTERVAL_MS);
246246-}
247247-248248-/**
249249- * Stop periodic background refresh
250250- */
251251-function stopPeriodicRefresh() {
252252- if (refreshTimer) {
253253- clearInterval(refreshTimer);
254254- refreshTimer = null;
255255- }
256256-}
257257-258258-/**
259259- * Initialize the cache - call on page load
260260- * Preloads data if not cached, starts periodic refresh
261261- */
262262-async function init() {
263263- // Start periodic refresh
264264- startPeriodicRefresh();
265265-266266- // Preload if cache is empty or expired
267267- if (!isCacheValid()) {
268268- try {
269269- await refreshCache();
270270- } catch (e) {
271271- console.warn("Initial cache load failed:", e);
272272- }
273273- }
274274-275275- // Refresh when user returns to tab/app (handles multi-device sync)
276276- document.addEventListener("visibilitychange", () => {
277277- if (document.visibilityState === "visible" && !isCacheValid()) {
278278- refreshCache().catch((e) =>
279279- console.warn("Visibility refresh failed:", e),
280280- );
281281- }
282282- });
283283-284284- // For iOS PWA: refresh on focus
285285- window.addEventListener("focus", () => {
286286- if (!isCacheValid()) {
287287- refreshCache().catch((e) => console.warn("Focus refresh failed:", e));
288288- }
289289- });
290290-291291- // Refresh on page show (back button, bfcache restore)
292292- window.addEventListener("pageshow", (event) => {
293293- if (event.persisted && !isCacheValid()) {
294294- refreshCache().catch((e) => console.warn("Pageshow refresh failed:", e));
295295- }
296296- });
297297-}
298298-299299-/**
300300- * Preload cache - useful to call after login
301301- */
302302-async function preload() {
303303- return await refreshCache();
304304-}
305305-306306-// Export as global for use in other scripts
307307-window.ArabicaCache = {
308308- getData,
309309- getCachedData,
310310- refreshCache,
311311- invalidateCache,
312312- invalidateAndRefresh,
313313- addListener,
314314- removeListener,
315315- startPeriodicRefresh,
316316- stopPeriodicRefresh,
317317- init,
318318- preload,
319319- isCacheValid,
320320-};
-153
web/static/js/handle-autocomplete.js
···11-/**
22- * Handle autocomplete for AT Protocol login
33- * Provides typeahead search for Bluesky handles
44- */
55-(function () {
66- const input = document.getElementById("handle");
77- const results = document.getElementById("autocomplete-results");
88-99- // Exit early if elements don't exist (user might be authenticated)
1010- if (!input || !results) return;
1111-1212- let debounceTimeout;
1313- let abortController;
1414-1515- function debounce(func, wait) {
1616- return function executedFunction(...args) {
1717- const later = () => {
1818- clearTimeout(debounceTimeout);
1919- func(...args);
2020- };
2121- clearTimeout(debounceTimeout);
2222- debounceTimeout = setTimeout(later, wait);
2323- };
2424- }
2525-2626- async function searchActors(query) {
2727- // Need at least 3 characters to search
2828- if (query.length < 3) {
2929- results.classList.add("hidden");
3030- results.innerHTML = "";
3131- return;
3232- }
3333-3434- // Cancel previous request
3535- if (abortController) {
3636- abortController.abort();
3737- }
3838- abortController = new AbortController();
3939-4040- try {
4141- const response = await fetch(
4242- `/api/search-actors?q=${encodeURIComponent(query)}`,
4343- {
4444- signal: abortController.signal,
4545- },
4646- );
4747-4848- if (!response.ok) {
4949- results.classList.add("hidden");
5050- results.innerHTML = "";
5151- return;
5252- }
5353-5454- const data = await response.json();
5555-5656- if (!data.actors || data.actors.length === 0) {
5757- results.innerHTML =
5858- '<div class="px-4 py-3 text-sm text-gray-500">No accounts found</div>';
5959- results.classList.remove("hidden");
6060- return;
6161- }
6262-6363- // Clear previous results
6464- results.innerHTML = "";
6565-6666- // Create actor elements using DOM methods to prevent XSS
6767- data.actors.forEach((actor) => {
6868- const avatarUrl = actor.avatar || "/static/icon-placeholder.svg";
6969- const displayName = actor.displayName || actor.handle;
7070-7171- // Create container div
7272- const resultDiv = document.createElement("div");
7373- resultDiv.className =
7474- "handle-result px-3 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2";
7575- resultDiv.setAttribute("data-handle", actor.handle);
7676-7777- // Create avatar image
7878- const img = document.createElement("img");
7979- // Validate URL scheme to prevent javascript: URLs
8080- if (
8181- avatarUrl &&
8282- (avatarUrl.startsWith("https://") || avatarUrl.startsWith("/static/"))
8383- ) {
8484- img.src = avatarUrl;
8585- } else {
8686- img.src = "/static/icon-placeholder.svg";
8787- }
8888- img.alt = ""; // Empty alt for decorative images
8989- img.width = 32;
9090- img.height = 32;
9191- img.className = "w-6 h-6 rounded-full object-cover flex-shrink-0";
9292- img.addEventListener("error", function () {
9393- this.src = "/static/icon-placeholder.svg";
9494- });
9595-9696- // Create text container
9797- const textContainer = document.createElement("div");
9898- textContainer.className = "flex-1 min-w-0";
9999-100100- // Create display name element
101101- const nameDiv = document.createElement("div");
102102- nameDiv.className = "font-medium text-sm text-gray-900 truncate";
103103- nameDiv.textContent = displayName; // textContent auto-escapes
104104-105105- // Create handle element
106106- const handleDiv = document.createElement("div");
107107- handleDiv.className = "text-xs text-gray-500 truncate";
108108- handleDiv.textContent = "@" + actor.handle; // textContent auto-escapes
109109-110110- // Assemble the elements
111111- textContainer.appendChild(nameDiv);
112112- textContainer.appendChild(handleDiv);
113113- resultDiv.appendChild(img);
114114- resultDiv.appendChild(textContainer);
115115-116116- // Add click handler
117117- resultDiv.addEventListener("click", function () {
118118- input.value = actor.handle; // Use the actual handle from data, not DOM
119119- results.classList.add("hidden");
120120- results.innerHTML = "";
121121- });
122122-123123- results.appendChild(resultDiv);
124124- });
125125-126126- results.classList.remove("hidden");
127127- } catch (error) {
128128- if (error.name !== "AbortError") {
129129- console.error("Error searching actors:", error);
130130- }
131131- }
132132- }
133133-134134- const debouncedSearch = debounce(searchActors, 300);
135135-136136- input.addEventListener("input", function (e) {
137137- debouncedSearch(e.target.value);
138138- });
139139-140140- // Hide results when clicking outside
141141- document.addEventListener("click", function (e) {
142142- if (!input.contains(e.target) && !results.contains(e.target)) {
143143- results.classList.add("hidden");
144144- }
145145- });
146146-147147- // Show results again when input is focused
148148- input.addEventListener("focus", function () {
149149- if (results.innerHTML && input.value.length >= 3) {
150150- results.classList.remove("hidden");
151151- }
152152- });
153153-})();