Something went wrong. Try again.
Tea journaling on ATProto (alpha)
Something went wrong. Try again.
17 kB · 350 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351package components
import "fmt"
// ComboSelectConfig generates the x-data attribute for a combo-select Alpine.js component.// entityType: one of "bean", "brewer", "grinder", "recipe", "roaster", "cafe"// apiEndpoint: URL to list user's own records (e.g. "/api/beans")// suggestEndpoint: URL for community suggestions (e.g. "/api/suggestions/beans")// inputName: the hidden input name for the selected rkey// placeholder: input placeholder text// required: whether the field is required// passthrough: if true, value is passed through without create (for recipe refs)func ComboSelectConfig(entityType, apiEndpoint, suggestEndpoint, inputName, placeholder string, required bool, passthrough ...bool) string { requiredStr := "false" if required { requiredStr = "true" } passthroughStr := "false" if len(passthrough) > 0 && passthrough[0] { passthroughStr = "true" }
var formatLabel, formatCreateData, extraFields string switch entityType { case "bean": formatLabel = `(e) => { const n = e.name || e.Name || ''; const o = e.origin || e.Origin || ''; const r = e.roast_level || e.RoastLevel || ''; if (o && r) return n + ' (' + o + ' - ' + r + ')'; if (o) return n + ' (' + o + ')'; return n; }` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.origin) d.origin = s.fields.origin; if (s.fields.roastLevel) d.roast_level = s.fields.roastLevel; if (s.fields.process) d.process = s.fields.process; if (s.fields.roasterName) d._source_roaster_name = s.fields.roasterName; } return d; }` extraFields = `[{name:'origin',label:'Origin',type:'text',placeholder:'e.g. Ethiopia, Colombia'},{name:'roast_level',label:'Roast Level',type:'select',options:['Ultra-Light','Light','Medium-Light','Medium','Medium-Dark','Dark']},{name:'process',label:'Process',type:'text',placeholder:'e.g. Washed, Natural, Honey'}]` case "brewer": formatLabel = `(e) => e.name || e.Name || ''` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields && s.fields.brewerType) d.brewer_type = s.fields.brewerType; return d; }` extraFields = `[{name:'brewer_type',label:'Type',type:'select',options:['pourover','espresso','immersion','mokapot','coldbrew','cupping','other']}]` case "grinder": formatLabel = `(e) => e.name || e.Name || ''` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.grinderType) d.grinder_type = s.fields.grinderType; if (s.fields.burrType) d.burr_type = s.fields.burrType; } return d; }` extraFields = `[{name:'grinder_type',label:'Type',type:'select',options:['Hand','Electric','Portable Electric']},{name:'burr_type',label:'Burr Type',type:'select',options:['Conical','Flat']}]` case "recipe": formatLabel = `(e) => { const n = e.name || e.Name || ''; const bt = e.brewer_type || e.BrewerType || (e.fields && e.fields.brewerType) || ''; return bt ? n + ' (' + bt + ')' : n; }` formatCreateData = `(name) => ({ name })` extraFields = `[]` case "roaster": formatLabel = `(e) => e.name || e.Name || ''` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.location) d.location = s.fields.location; if (s.fields.website) d.website = s.fields.website; } return d; }` extraFields = `[{name:'location',label:'Location',type:'text',placeholder:'e.g. Portland, OR'},{name:'website',label:'Website',type:'text',placeholder:'https://...'}]` case "cafe": formatLabel = `(e) => { const n = e.name || e.Name || ''; const l = e.location || e.Location || ''; return l ? n + ' (' + l + ')' : n; }` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.location) d.location = s.fields.location; if (s.fields.website) d.website = s.fields.website; } return d; }` extraFields = `[{name:'location',label:'Location',type:'text',placeholder:'e.g. Portland, OR'},{name:'website',label:'Website',type:'text',placeholder:'https://...'}]` case "tea": formatLabel = `(e) => { const n = e.name || e.Name || ''; const c = e.category || e.Category || ''; const o = e.origin || e.Origin || ''; if (c && o) return n + ' (' + c + ' · ' + o + ')'; if (c) return n + ' (' + c + ')'; if (o) return n + ' (' + o + ')'; return n; }` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.category) d.category = s.fields.category; if (s.fields.subStyle) d.sub_style = s.fields.subStyle; if (s.fields.origin) d.origin = s.fields.origin; if (s.fields.cultivar) d.cultivar = s.fields.cultivar; } return d; }` extraFields = `[{name:'category',label:'Category',type:'select',options:['green','white','yellow','oolong','black','puerh-sheng','puerh-shou','herbal','blend','other']},{name:'origin',label:'Origin',type:'text',placeholder:'e.g. Taiwan, Yunnan'},{name:'cultivar',label:'Cultivar',type:'text',placeholder:'e.g. Qing Xin'}]` case "oolongBrewer": formatLabel = `(e) => { const n = e.name || e.Name || ''; const st = e.style || e.Style || ''; return st ? n + ' (' + st + ')' : n; }` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.style) d.style = s.fields.style; if (s.fields.material) d.material = s.fields.material; } return d; }` extraFields = `[{name:'style',label:'Style',type:'select',options:['gaiwan','yixing','kyusu','teapot','glass','french-press','tetsubin','other']},{name:'material',label:'Material',type:'text',placeholder:'e.g. porcelain, clay, glass'}]` case "oolongRecipe": formatLabel = `(e) => { const n = e.name || e.Name || ''; const st = e.style || e.Style || ''; return st ? n + ' (' + st + ')' : n; }` formatCreateData = `(name) => ({ name })` extraFields = `[]` case "vendor": formatLabel = `(e) => { const n = e.name || e.Name || ''; const l = e.location || e.Location || ''; return l ? n + ' (' + l + ')' : n; }` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.location) d.location = s.fields.location; if (s.fields.website) d.website = s.fields.website; } return d; }` extraFields = `[{name:'location',label:'Location',type:'text',placeholder:'e.g. Taipei, Taiwan'},{name:'website',label:'Website',type:'text',placeholder:'https://...'}]` case "oolongVessel": formatLabel = `(e) => { const n = e.name || e.Name || ''; const st = e.style || e.Style || ''; return st ? n + ' (' + st + ')' : n; }` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.style) d.style = s.fields.style; if (s.fields.material) d.material = s.fields.material; } return d; }` extraFields = `[{name:'style',label:'Style',type:'select',options:['teapot','mug','jar','matcha-bowl','other']},{name:'material',label:'Material',type:'text',placeholder:'e.g. porcelain, clay, glass'}]` case "oolongInfuser": formatLabel = `(e) => { const n = e.name || e.Name || ''; const st = e.style || e.Style || ''; return st ? n + ' (' + st + ')' : n; }` formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields && s.fields.style) d.style = s.fields.style; return d; }` extraFields = `[{name:'style',label:'Style',type:'select',options:['basket','ball','sock','other']}]` default: formatLabel = `(e) => e.name || e.Name || ''` formatCreateData = `(name) => ({ name })` extraFields = `[]` }
return fmt.Sprintf(`comboSelect({ entityType: '%s', apiEndpoint: '%s', suggestEndpoint: '%s', inputName: '%s', placeholder: '%s', required: %s, passthrough: %s, formatLabel: %s, formatCreateData: %s, extraFields: %s })`, entityType, apiEndpoint, suggestEndpoint, inputName, placeholder, requiredStr, passthroughStr, formatLabel, formatCreateData, extraFields)}
// ComboSelectInput renders the shared input + dropdown markup for combo-select fields.// sectionLabel is the heading shown above the user's own records (e.g. "Your beans").templ ComboSelectInput(sectionLabel string) { <div class="relative"> <input type="text" x-model="query" @input="search()" @focus="open()" @blur="close()" @keydown.escape.prevent="close()" @keydown.arrow-down.prevent="moveDown()" @keydown.arrow-up.prevent="moveUp()" @keydown.enter.prevent="selectHighlighted()" :placeholder="placeholder" class="w-full form-input-lg" autocomplete="off" role="combobox" aria-autocomplete="list" :aria-expanded="isOpen && (allItems.length > 0 || query.trim()) ? 'true' : 'false'" aria-controls="combo-dropdown" aria-label="Search and select" /> <button type="button" x-show="selectedRKey" @click="clear()" class="absolute right-2 top-1/2 -translate-y-1/2 text-placeholder hover:text-muted" x-cloak aria-label="Clear selection" > @IconX() </button> </div> <div id="combo-dropdown" role="listbox" x-show="isOpen && (allItems.length > 0 || query.trim())" x-cloak class="combo-dropdown" @mousedown.prevent> <div x-show="isCreating" x-cloak class="combo-creating">Creating...</div> <template x-if="!isCreating"> <div> <template x-if="userResults.length > 0"> <div> <div class="combo-section-label">{ sectionLabel }</div> <template x-for="(entity, i) in userResults" :key="entity.rkey || entity.RKey"> <div class="combo-item" role="option" :data-highlighted="highlightIndex === i" @click="selectEntity(entity)" @mouseenter="highlightIndex = i" > <span x-text="formatLabel(entity)"></span> </div> </template> </div> </template> <template x-if="closedResults.length > 0"> <div> <div class="combo-section-label">Closed bags</div> <template x-for="(entity, ci) in closedResults" :key="entity.rkey || entity.RKey"> <div class="combo-item opacity-60" role="option" :data-highlighted="highlightIndex === userResults.length + ci" @click="selectEntity(entity)" @mouseenter="highlightIndex = userResults.length + ci" > <span x-text="formatLabel(entity)"></span> </div> </template> </div> </template> <template x-if="communityResults.length > 0"> <div> <div class="combo-section-label">Community</div> <template x-for="(s, j) in communityResults" :key="s.source_uri || j"> <div class="combo-item" role="option" :data-highlighted="highlightIndex === userResults.length + closedResults.length + j" @click="selectSuggestion(s)" @mouseenter="highlightIndex = userResults.length + closedResults.length + j" > <div x-text="s.name"></div> <div class="combo-item-sub"> <span x-show="s.fields?.origin" x-text="s.fields?.origin"></span> <span x-show="s.fields?.origin && s.fields?.roastLevel">· </span> <span x-show="s.fields?.roastLevel" x-text="s.fields?.roastLevel"></span> <span x-show="s.fields?.location" x-text="s.fields?.location"></span> <span x-show="s.count > 1" x-text="' · ' + s.count + ' users'"></span> </div> </div> </template> </div> </template> <template x-if="query.trim() && !exactMatch"> <div class="combo-item-create" role="option" :data-highlighted="highlightIndex === userResults.length + closedResults.length + communityResults.length" @click="createNew()" @mouseenter="highlightIndex = userResults.length + closedResults.length + communityResults.length" > Create "<span x-text="query.trim()"></span>" </div> </template> <template x-if="allItems.length === 0 && query.trim()"> <div class="combo-creating">No matches found</div> </template> </div> </template> </div> <div class="sr-only" aria-live="polite" x-text="allItems.length + ' results available'"></div> <!-- Inline create form with extra details --> <div x-show="showCreateForm" x-transition x-cloak class="mt-2 p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> <p class="text-sm font-medium text-primary mb-2"> Creating: <span x-text="createFormData.name" class="font-semibold"></span> </p> <template x-if="extraFields.length > 0"> <div class="space-y-2"> <template x-for="field in extraFields" :key="field.name"> <div> <template x-if="field.type === 'select'"> <select x-model="createFormData[field.name]" class="w-full form-input text-sm" > <option value="" x-text="field.label + ' (optional)'"></option> <template x-for="opt in field.options" :key="opt"> <option :value="opt" x-text="opt"></option> </template> </select> </template> <template x-if="field.type === 'text'"> <input type="text" :placeholder="field.placeholder || field.label" x-model="createFormData[field.name]" class="w-full form-input text-sm" /> </template> </div> </template> <!-- Roaster picker (bean only) --> <template x-if="entityType === 'bean'"> <div class="mt-1"> <div class="relative"> <input type="text" x-model="roasterQuery" @input="searchRoasters(); roasterDropdownOpen = true" @focus="searchRoasters(); roasterDropdownOpen = true" @blur="setTimeout(() => roasterDropdownOpen = false, 150)" @keydown.escape.prevent="roasterDropdownOpen = false" placeholder="Roaster (optional)" class="w-full form-input text-sm" autocomplete="off" /> <button type="button" x-show="selectedRoasterRKey || creatingNewRoaster" @click="clearRoaster()" class="absolute right-2 top-1/2 -translate-y-1/2 text-placeholder hover:text-muted" x-cloak aria-label="Clear roaster" > @IconX() </button> <!-- Roaster dropdown --> <div x-show="roasterDropdownOpen && (roasterResults.length > 0 || roasterSuggestions.length > 0 || roasterQuery.trim())" x-cloak class="combo-dropdown" @mousedown.prevent> <template x-if="roasterResults.length > 0"> <div> <div class="combo-section-label">Your roasters</div> <template x-for="(r, ri) in roasterResults" :key="r.rkey || r.RKey"> <div class="combo-item" @click="selectRoaster(r)" > <span x-text="r.name || r.Name"></span> </div> </template> </div> </template> <template x-if="roasterSuggestions.length > 0"> <div> <div class="combo-section-label">Community</div> <template x-for="(s, si) in roasterSuggestions" :key="s.source_uri || si"> <div class="combo-item" @click="selectRoasterSuggestion(s)" > <div x-text="s.name"></div> <div class="combo-item-sub"> <span x-show="s.fields?.location" x-text="s.fields?.location"></span> <span x-show="s.count > 1" x-text="' · ' + s.count + ' users'"></span> </div> </div> </template> </div> </template> <template x-if="roasterQuery.trim() && !roasterResults.some(r => (r.name || r.Name || '').toLowerCase() === roasterQuery.trim().toLowerCase()) && !roasterSuggestions.some(s => (s.name || '').toLowerCase() === roasterQuery.trim().toLowerCase())"> <div class="combo-item-create" @click="startCreateRoaster()" > Create "<span x-text="roasterQuery.trim()"></span>" </div> </template> </div> </div> <!-- New roaster detail fields --> <template x-if="creatingNewRoaster"> <div class="mt-2 ml-3 space-y-2 pl-3 rounded-lg py-2 pr-2" style="background: var(--surface-bg);"> <p class="text-xs text-faint"> New roaster: <span x-text="newRoasterName" class="font-medium"></span> </p> <input type="text" x-model="newRoasterLocation" placeholder="Location (optional)" class="w-full form-input text-sm" /> <input type="text" x-model="newRoasterWebsite" placeholder="Website (optional)" class="w-full form-input text-sm" /> </div> </template> </div> </template> <div class="flex gap-2 mt-2"> <button type="button" @click="submitCreateForm()" class="flex-1 btn-primary text-sm py-1.5" :disabled="isCreating" > <span x-show="!isCreating">Save</span> <span x-show="isCreating">Saving...</span> </button> <button type="button" @click="skipCreateDetails()" class="flex-1 btn-secondary text-sm py-1.5" :disabled="isCreating" > Skip details </button> </div> </div> </template> </div>}
// EscapeJS escapes a string for safe embedding in JavaScript string literals.func EscapeJS(s string) string { result := "" for _, c := range s { if c == '\'' { result += "\\'" } else if c == '\\' { result += "\\\\" } else { result += string(c) } } return result}