Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: roaster details in create/edit bean modal

authored by

Patrick Dewey and committed by tangled.org 33fecd97 652849a7

+182 -15
+32
internal/handlers/entities.go
··· 225 225 return 226 226 } 227 227 228 + // If a new roaster name was provided and no existing roaster selected, create it 229 + if newRoasterName := r.FormValue("new_roaster_name"); newRoasterName != "" && req.RoasterRKey == "" { 230 + roaster, roasterErr := store.CreateRoaster(r.Context(), &models.CreateRoasterRequest{ 231 + Name: newRoasterName, 232 + Location: r.FormValue("new_roaster_location"), 233 + Website: r.FormValue("new_roaster_website"), 234 + }) 235 + if roasterErr != nil { 236 + log.Error().Err(roasterErr).Str("name", newRoasterName).Msg("Failed to create roaster for bean") 237 + handleStoreError(w, roasterErr, "Failed to create roaster") 238 + return 239 + } 240 + req.RoasterRKey = roaster.RKey 241 + log.Info().Str("roaster_rkey", roaster.RKey).Str("name", newRoasterName).Msg("Auto-created roaster for bean") 242 + } 243 + 228 244 // Validate optional roaster rkey 229 245 if errMsg := validateOptionalRKey(req.RoasterRKey, "Roaster selection"); errMsg != "" { 230 246 log.Warn().Str("roaster_rkey", req.RoasterRKey).Msg("Bean create: invalid roaster rkey") ··· 527 543 log.Warn().Err(err).Str("rkey", rkey).Msg("Bean update validation failed") 528 544 http.Error(w, err.Error(), http.StatusBadRequest) 529 545 return 546 + } 547 + 548 + // If a new roaster name was provided and no existing roaster selected, create it 549 + if newRoasterName := r.FormValue("new_roaster_name"); newRoasterName != "" && req.RoasterRKey == "" { 550 + roaster, roasterErr := store.CreateRoaster(r.Context(), &models.CreateRoasterRequest{ 551 + Name: newRoasterName, 552 + Location: r.FormValue("new_roaster_location"), 553 + Website: r.FormValue("new_roaster_website"), 554 + }) 555 + if roasterErr != nil { 556 + log.Error().Err(roasterErr).Str("name", newRoasterName).Msg("Failed to create roaster for bean update") 557 + handleStoreError(w, roasterErr, "Failed to create roaster") 558 + return 559 + } 560 + req.RoasterRKey = roaster.RKey 561 + log.Info().Str("roaster_rkey", roaster.RKey).Str("name", newRoasterName).Msg("Auto-created roaster for bean update") 530 562 } 531 563 532 564 // Validate optional roaster rkey
+150 -15
internal/web/components/dialog_modals.templ
··· 94 94 placeholder="Variety (e.g. SL28, Typica, Gesha)" 95 95 class="w-full form-input" 96 96 /> 97 - <select 98 - name="roaster_rkey" 99 - class="w-full form-input" 100 - > 101 - <option value="">Select Roaster (Optional)</option> 102 - for _, roaster := range roasters { 103 - <option 104 - value={ roaster.RKey } 105 - if bean != nil && bean.RoasterRKey == roaster.RKey { 106 - selected 107 - } 97 + <div x-data={ roasterPickerInit(bean, roasters) } class="relative"> 98 + <input 99 + type="text" 100 + x-model="query" 101 + x-show="!showDetails" 102 + @input="filter()" 103 + @focus="showDropdown = true" 104 + @blur="setTimeout(() => showDropdown = false, 150)" 105 + @keydown.escape.prevent="showDropdown = false" 106 + placeholder="Search or create roaster (optional)" 107 + class="w-full form-input" 108 + autocomplete="off" 109 + /> 110 + <input type="hidden" name="roaster_rkey" :value="selectedRKey"/> 111 + <input type="hidden" name="new_roaster_name" :value="newRoasterName"/> 112 + <input type="hidden" name="new_roaster_location" :value="newRoasterLocation"/> 113 + <input type="hidden" name="new_roaster_website" :value="newRoasterWebsite"/> 114 + <button 115 + type="button" 116 + x-show="(selectedRKey || newRoasterName) && !showDetails" 117 + @click="clear()" 118 + class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600 text-sm" 119 + x-cloak 120 + > 121 + &times; 122 + </button> 123 + <div x-show="showDropdown && !showDetails" x-cloak class="absolute z-10 w-full mt-1 max-h-48 overflow-y-auto rounded-lg shadow-lg" style="background: var(--card-bg, #fff); border: 1px solid var(--surface-border, #d4c4a8);"> 124 + <template x-for="r in filtered" :key="r.rkey"> 125 + <div 126 + class="px-3 py-2 cursor-pointer text-sm hover:bg-brown-100" 127 + @mousedown.prevent="selectRoaster(r)" 128 + x-text="r.name" 129 + ></div> 130 + </template> 131 + <template x-if="query.trim() && !exactMatch"> 132 + <div 133 + class="px-3 py-2 cursor-pointer text-sm font-medium border-t hover:bg-brown-100" 134 + style="border-color: var(--surface-border, #d4c4a8);" 135 + @mousedown.prevent="startCreate()" 136 + > 137 + Create "<span x-text="query.trim()"></span>" 138 + </div> 139 + </template> 140 + </div> 141 + <!-- Inline details for new roaster --> 142 + <div x-show="showDetails" x-transition x-cloak class="p-3 rounded-lg space-y-2" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 143 + <p class="text-sm font-medium text-brown-900"> 144 + New roaster: <span x-text="newRoasterName" class="font-semibold"></span> 145 + </p> 146 + <input 147 + type="text" 148 + x-model="newRoasterLocation" 149 + placeholder="Location (optional)" 150 + class="w-full form-input text-sm" 151 + /> 152 + <input 153 + type="url" 154 + x-model="newRoasterWebsite" 155 + placeholder="Website (optional)" 156 + class="w-full form-input text-sm" 157 + /> 158 + <button 159 + type="button" 160 + @click="cancelCreate()" 161 + class="text-xs text-brown-500 hover:text-brown-700" 108 162 > 109 - { roaster.Name } 110 - </option> 111 - } 112 - </select> 163 + Cancel 164 + </button> 165 + </div> 166 + </div> 113 167 <select 114 168 name="roast_level" 115 169 class="w-full form-input" ··· 784 838 </template> 785 839 </div> 786 840 </dialog> 841 + } 842 + 843 + // roasterPickerInit generates Alpine.js x-data for the inline roaster picker. 844 + func roasterPickerInit(bean *models.Bean, roasters []models.Roaster) string { 845 + // Build JSON array of roasters for Alpine 846 + var items []string 847 + for _, r := range roasters { 848 + items = append(items, fmt.Sprintf(`{rkey:'%s',name:'%s'}`, r.RKey, strings.ReplaceAll(r.Name, "'", "\\'"))) 849 + } 850 + allRoasters := "[" + strings.Join(items, ",") + "]" 851 + 852 + // Pre-select if editing a bean with a roaster 853 + selectedRKey := "" 854 + selectedName := "" 855 + if bean != nil && bean.RoasterRKey != "" { 856 + selectedRKey = bean.RoasterRKey 857 + if bean.Roaster != nil { 858 + selectedName = strings.ReplaceAll(bean.Roaster.Name, "'", "\\'") 859 + } else { 860 + // Find name from roasters list 861 + for _, r := range roasters { 862 + if r.RKey == bean.RoasterRKey { 863 + selectedName = strings.ReplaceAll(r.Name, "'", "\\'") 864 + break 865 + } 866 + } 867 + } 868 + } 869 + 870 + return fmt.Sprintf(`{ 871 + allRoasters: %s, 872 + filtered: %s, 873 + query: '%s', 874 + selectedRKey: '%s', 875 + newRoasterName: '', 876 + newRoasterLocation: '', 877 + newRoasterWebsite: '', 878 + showDropdown: false, 879 + showDetails: false, 880 + get exactMatch() { 881 + const q = this.query.trim().toLowerCase(); 882 + return this.allRoasters.some(r => r.name.toLowerCase() === q); 883 + }, 884 + filter() { 885 + const q = this.query.trim().toLowerCase(); 886 + this.selectedRKey = ''; 887 + this.newRoasterName = ''; 888 + if (!q) { this.filtered = this.allRoasters.slice(0, 10); return; } 889 + this.filtered = this.allRoasters.filter(r => r.name.toLowerCase().includes(q)); 890 + }, 891 + selectRoaster(r) { 892 + this.selectedRKey = r.rkey; 893 + this.newRoasterName = ''; 894 + this.newRoasterLocation = ''; 895 + this.newRoasterWebsite = ''; 896 + this.query = r.name; 897 + this.showDropdown = false; 898 + this.showDetails = false; 899 + }, 900 + startCreate() { 901 + this.newRoasterName = this.query.trim(); 902 + this.selectedRKey = ''; 903 + this.showDropdown = false; 904 + this.showDetails = true; 905 + }, 906 + cancelCreate() { 907 + this.newRoasterName = ''; 908 + this.newRoasterLocation = ''; 909 + this.newRoasterWebsite = ''; 910 + this.showDetails = false; 911 + }, 912 + clear() { 913 + this.query = ''; 914 + this.selectedRKey = ''; 915 + this.newRoasterName = ''; 916 + this.newRoasterLocation = ''; 917 + this.newRoasterWebsite = ''; 918 + this.showDetails = false; 919 + this.filtered = this.allRoasters.slice(0, 10); 920 + } 921 + }`, allRoasters, allRoasters, selectedName, selectedRKey) 787 922 } 788 923 789 924 // beanRatingInitData returns Alpine.js x-data for the rating toggle.