This repository has no description
0

Configure Feed

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

appview: update larger element on htmx handlers

also see
https://discord.com/channels/1361963801993285692/1361991850327674932/1516041992570929285

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
date (Jul 23, 2026, 8:56 PM +0900) commit d1d6a330 parent 9b9a2aeb change-id tuvnvtnm
+196 -182
+41 -43
appview/accountmigration/accountmigration.go
··· 71 71 if err := s.pages.AccountMigrateFromGitHub(w, pages.AccountMigrateFromGitHubParams{ 72 72 BaseParams: pages.BaseParamsFromContext(r.Context()), 73 73 Knots: knots, 74 - Repos: []pages.RepoImportParams{ 75 - { 76 - SourceKind: pages.RepoImportSourceGitHub, 77 - CloneUrl: "https://github.com/boltlessengineer/rest.nvim", 78 - Name: "rest.nvim", 79 - Description: "A very fast, powerful, extensible and asynchronous Neovim HTTP client written in Lua.", 80 - Website: "https://tangled.org", 81 - Topics: []string{"lua", "neovim", "curl", "http-client", "nvim", "neovim-plugin", "rest-client"}, 82 - Selected: true, 83 - }, 84 - { 85 - SourceKind: pages.RepoImportSourceGitHub, 86 - CloneUrl: "https://github.com/boltlessengineer/dot", 87 - Name: "super-long-repo-name-fddasfsafdsfasdfaasdfasdfasfasdfasfdasdfasdfsadfsadfasdfaasdfasfasdfdsafd00000s", 88 - Description: "dotfiles", 89 - Website: "https://tangled.org", 90 - Topics: []string{}, 91 - Selected: true, 92 - }, 93 - }, 94 74 }); err != nil { 95 75 s.logger.Error("failed to render", "err", err) 96 76 } ··· 117 97 } 118 98 119 99 func (s *AccountMigration) listGitHubRepos(w http.ResponseWriter, r *http.Request) { 100 + var notice string 101 + var params pages.AccountMigrateRepoListParams 102 + defer func() { 103 + s.pages.Notice(w, "listghrepos-error", notice) 104 + s.pages.AccountMigrateRepoListFragment(w, params) 105 + }() 106 + 120 107 user := s.oauth.GetMultiAccountUser(r) 121 108 122 109 username := strings.TrimSpace(r.FormValue("username")) 123 110 if username == "" { 124 - s.pages.Notice(w, "migrate-error", "GitHub username is required.") 111 + notice = "GitHub username is required." 125 112 return 126 113 } 127 114 ··· 132 119 endpoint := fmt.Sprintf("https://api.github.com/users/%s/repos?%s", url.PathEscape(username), query.Encode()) 133 120 req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint, nil) 134 121 if err != nil { 135 - s.pages.Notice(w, "migrate-error", "Failed to build GitHub request.") 122 + notice = "Failed to build GitHub request." 136 123 return 137 124 } 138 125 req.Header.Set("Accept", "application/vnd.github+json") 139 126 140 127 resp, err := http.DefaultClient.Do(req) 141 128 if err != nil { 142 - s.logger.Error("github list repos failed", "username", username, "err", err) 143 - s.pages.Notice(w, "migrate-error", "Failed to reach GitHub. Try again.") 129 + s.logger.Error("github list repos request failed", "username", username, "err", err) 130 + notice = "Failed to reach GitHub. Try again." 144 131 return 145 132 } 146 133 defer resp.Body.Close() 147 134 148 135 if resp.StatusCode != http.StatusOK { 149 - s.pages.Notice(w, "migrate-error", fmt.Sprintf("GitHub returned %d. Check the username.", resp.StatusCode)) 136 + notice = fmt.Sprintf("GitHub returned %d. Check the username.", resp.StatusCode) 150 137 return 151 138 } 152 139 153 140 var githubRepos []githubUserRepo 154 141 if err := json.NewDecoder(resp.Body).Decode(&githubRepos); err != nil { 155 142 s.logger.Error("decode github response failed", "err", err) 156 - s.pages.Notice(w, "migrate-error", "Failed to parse GitHub response.") 143 + notice = "Failed to parse GitHub response." 157 144 return 158 145 } 159 146 ··· 197 184 return b.PushedAt.Compare(a.PushedAt) 198 185 }) 199 186 200 - if err := s.pages.AccountMigrateRepoListFragment(w, pages.AccountMigrateRepoListParams{ 187 + params = pages.AccountMigrateRepoListParams{ 201 188 Repos: importRepos, 202 189 Knots: knots, 203 - }); err != nil { 204 - s.logger.Error("render repo-list fragment failed", "err", err) 205 190 } 191 + // render is deferred 206 192 } 207 193 208 194 func (s *AccountMigration) startMigration(w http.ResponseWriter, r *http.Request) { 195 + var notice string 196 + defer func() { 197 + s.pages.Notice(w, "migration-error", notice) 198 + }() 199 + 209 200 user := s.oauth.GetMultiAccountUser(r) 210 201 211 202 if err := r.ParseForm(); err != nil { 212 - s.pages.Notice(w, "migrate-error", "Invalid form submission.") 203 + notice = "Invalid form submission." 213 204 return 214 205 } 215 206 216 207 knots, err := s.enforcer.GetKnotsForUser(user.Did) 217 208 if err != nil { 218 209 s.logger.Error("knots lookup failed", "did", user.Did, "err", err) 219 - s.pages.Notice(w, "migrate-error", "Failed to look up your knots.") 210 + notice = "Failed to look up your knots." 220 211 return 221 212 } 222 213 allowed := sets.Collect(slices.Values(knots)) 223 214 224 215 sessionId := s.oauth.GetSessIdFromCookie(r) 225 216 if sessionId == "" { 226 - s.pages.Notice(w, "migrate-error", "Session expired. Log in again.") 217 + notice = "Session expired. Log in again." 227 218 return 228 219 } 229 220 230 221 count, err := strconv.Atoi(r.FormValue("count")) 231 222 if err != nil || count <= 0 { 232 - s.pages.Notice(w, "migrate-error", "Invalid form submission.") 223 + notice = "Invalid form submission." 233 224 return 234 225 } 235 226 ··· 251 242 website := strings.TrimSpace(r.FormValue(fmt.Sprintf("website_%d", i))) 252 243 topics := r.FormValue(fmt.Sprintf("topics_%d", i)) 253 244 254 - if cloneUrl == "" || knot == "" || name == "" { 255 - s.pages.Notice(w, "migrate-error", "Each selected row needs a name, clone URL, and knot.") 245 + if cloneUrl == "" { 246 + notice = fmt.Sprintf("Row %d: clone URL is missing.", i+1) 247 + return 248 + } 249 + if knot == "" { 250 + notice = fmt.Sprintf("Row %d: knot is missing.", i+1) 251 + return 252 + } 253 + if name == "" { 254 + notice = fmt.Sprintf("Row %d: name is missing.", i+1) 256 255 return 257 256 } 258 257 if err := models.ValidateRepoName(name); err != nil { 259 - s.pages.Notice(w, "migrate-error", fmt.Sprintf("Row %d: %s", i+1, err.Error())) 258 + notice = fmt.Sprintf("Row %d: %s", i+1, err.Error()) 260 259 return 261 260 } 262 261 if len([]rune(desc)) > 140 { 263 - s.pages.Notice(w, "migrate-error", fmt.Sprintf("Row %d: description must be 140 characters or fewer.", i+1)) 262 + notice = fmt.Sprintf("Row %d: description must be 140 characters or fewer.", i+1) 264 263 return 265 264 } 266 265 if !allowed.Contains(knot) { 267 - s.pages.Notice(w, "migrate-error", fmt.Sprintf("You are not a member of knot %q.", knot)) 266 + notice = fmt.Sprintf("Row %d: You are not a member of knot %q.", i+1, knot) 268 267 return 269 268 } 270 269 if seen.Contains(name) { 271 - s.pages.Notice(w, "migrate-error", fmt.Sprintf("Duplicate repository name %q in selection.", name)) 270 + notice = fmt.Sprintf("Row %d: Duplicate repository name %q in selection.", i+1, name) 272 271 return 273 272 } 274 273 seen.Insert(name) ··· 300 299 } 301 300 302 301 if len(rows) == 0 { 303 - s.pages.Notice(w, "migrate-error", "Pick at least one repo.") 302 + notice = "Pick at least one repo." 304 303 return 305 304 } 306 305 ··· 308 307 309 308 if err := db.InsertGitRepoMigrations(r.Context(), s.db, rows); err != nil { 310 309 s.logger.Error("insert migrations failed", "err", err) 311 - s.pages.Notice(w, "migrate-error", "Failed to enqueue migrations.") 310 + notice = "Failed to enqueue migrations." 312 311 return 313 312 } 314 313 315 - w.Header().Set("HX-Redirect", "/settings/migration") 316 - http.Redirect(w, r, "/settings/migration", http.StatusSeeOther) 314 + s.pages.HxRedirect(w, "/settings/migration") 317 315 } 318 316 319 317 func (s *AccountMigration) progressRows(w http.ResponseWriter, r *http.Request) {
+1 -2
appview/pages/pages.go
··· 2058 2058 type AccountMigrateFromGitHubParams struct { 2059 2059 BaseParams 2060 2060 Knots []string 2061 - Repos []RepoImportParams 2062 2061 } 2063 2062 2064 2063 type RepoImportSource string ··· 2092 2091 } 2093 2092 2094 2093 func (p *Pages) AccountMigrateRepoListFragment(w io.Writer, params AccountMigrateRepoListParams) error { 2095 - return p.executePlain("account/migrate/fragments/repoList", w, params) 2094 + return p.executePlain("account/migrate/fragments/step2", w, params) 2096 2095 } 2097 2096 2098 2097 type AccountMigrateProgressParams struct {
-93
appview/pages/templates/account/migrate/fragments/repoList.html
··· 1 - {{ define "account/migrate/fragments/repoList" }} 2 - {{ if not .Repos }} 3 - <p class="text-gray-500 dark:text-gray-400 mb-4">No importable repositories found.</p> 4 - {{ else }} 5 - <form action="/account/migrate/start" method="post" class="flex flex-col gap-3"> 6 - <input type="hidden" name="count" value="{{ len .Repos }}" /> 7 - 8 - <div class="w-full border border-gray-200 dark:border-gray-700 rounded rouded-sm divide-y divide-gray-200 dark:divide-gray-700"> 9 - {{ range $i, $repo := .Repos }} 10 - <details class="group"> 11 - <summary class="p-2 pl-3 flex items-center justify-between gap-2 cursor-pointer"> 12 - <div class="flex gap-2 min-w-0"> 13 - <label class="text-base py-0 min-w-0 flex items-center gap-2"> 14 - <input type="checkbox" id="selected_{{ $i }}" name="selected_{{ $i }}" value="1" {{ if $repo.Selected }}checked{{ end }} class="shrink-0" /> 15 - <span class="min-w-0 truncate">{{ $repo.Name }}</span> 16 - </label> 17 - {{ if gt .Stars 0 }} 18 - <div class="flex items-center gap-1"> 19 - {{ i "star" "size-3 fill-current" }} 20 - <span class="text-sm">{{ scaleFmt .Stars }}</span> 21 - </div> 22 - {{ end }} 23 - </div> 24 - 25 - <div class="flex items-center gap-2 shrink-0"> 26 - <!-- 27 - <span class="hidden sm:block text-gray-600 dark:text-gray-400"> 28 - Updated 2 days ago 29 - </span> 30 - --> 31 - <div class="p-1"> 32 - {{ i "chevron-right" "size-4 group-open:hidden" }} 33 - {{ i "chevron-down" "size-4 hidden group-open:block" }} 34 - </div> 35 - </div> 36 - </summary> 37 - <div class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 pt-2 pb-4 px-3 flex flex-col gap-4"> 38 - <input type="hidden" name="website_{{ $i }}" value="{{ $repo.Website }}" /> 39 - <input type="hidden" name="topics_{{ $i }}" value="{{ join $repo.Topics "," }}" /> 40 - 41 - <label class="text-base py-0 space-y-1.5"> 42 - <span>Clone URL</span> 43 - <input type="text" name="clone_url_{{ $i }}" value="{{ $repo.CloneUrl }}" disabled class="w-full px-2 py-1" /> 44 - </label> 45 - 46 - <label class="text-base py-0 space-y-1.5"> 47 - <span>Name</span> 48 - <input type="text" name="name_{{ $i }}" value="{{ $repo.Name }}" required placeholder="repository-name" class="bg-white dark:bg-gray-900 w-full px-2 py-1" /> 49 - </label> 50 - 51 - <label class="text-base py-0 space-y-1.5"> 52 - <span>Description</span> 53 - <textarea 54 - name="description_{{ $i }}" 55 - placeholder="repository description" 56 - class="bg-white dark:bg-gray-900 w-full px-2 py-1" 57 - >{{ $repo.Description }}</textarea> 58 - </label> 59 - 60 - <fieldset class="space-y-1"> 61 - <legend class="text-base">Select a knot to migrate into</legend> 62 - <div class="w-full space-y-2"> 63 - {{ range $.Knots }} 64 - <label class="py-0 block flex items-center gap-2"> 65 - <input type="radio" name="knot_{{ $i }}" value="{{ . }}" required {{if eq (len $.Knots) 1}}checked{{end}} /> 66 - <span class="lowercase py-0.5">{{ . }}</span> 67 - </label> 68 - {{ else }} 69 - <p class="dark:text-white">No knots available.</p> 70 - {{ end }} 71 - </div> 72 - </fieldset> 73 - </div> 74 - </details> 75 - {{ end }} 76 - </div> 77 - 78 - <div class="pt-3 flex justify-end"> 79 - <button type="submit" class="btn-create flex items-center gap-2" {{ if (not .Repos) }}disabled{{ end }}> 80 - {{ i "import" "size-4" }} 81 - {{ if gt (len .Repos) 1 }} 82 - Migrate {{ len .Repos }} Repositories 83 - {{ else }} 84 - Migrate Repository 85 - {{ end }} 86 - <span id="spinner" class="group"> 87 - {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 - </span> 89 - </button> 90 - </div> 91 - </form> 92 - {{ end }} 93 - {{ end }}
+133
appview/pages/templates/account/migrate/fragments/step2.html
··· 1 + {{ define "account/migrate/fragments/step2" }} 2 + {{- $isPreview := eq .Repos nil -}} 3 + <div 4 + id="migration-step-2" 5 + class="flex relative border-l border-gray-200 dark:border-gray-700 ml-3 pl-7" 6 + > 7 + <div class="absolute -left-3 -top-0"> 8 + {{ template "account/migrate/fragments/stepIndicator" 2 }} 9 + </div> 10 + 11 + <!-- Content column --> 12 + <div class="flex-1 min-w-0 pb-12 space-y-3"> 13 + <div class="flex flex-wrap items-center justify-between gap-3"> 14 + <h2 class="text-lg font-semibold uppercase">Choose &amp; Configure Repositories</h2> 15 + <div class="ml-auto h-7 flex items-center"> 16 + <button 17 + class="btn-flat flex items-center gap-2" 18 + onclick="document.querySelectorAll('input[name^=selected_]').forEach(c => c.checked = !c.checked)" 19 + {{ if $isPreview }}disabled{{ end }} 20 + > 21 + {{ i "check-check" "size-4" }} 22 + Select all repositories 23 + </button> 24 + </div> 25 + </div> 26 + {{ if $isPreview }} 27 + <p class="text-gray-500 dark:text-gray-400 mb-4">No repositories yet, connect your account first.</p> 28 + {{ else if gt (len .Repos) 0 }} 29 + <form 30 + hx-post="/account/migrate/start" 31 + hx-swap="none" 32 + class="flex flex-col gap-3" 33 + > 34 + <input type="hidden" name="count" value="{{ len .Repos }}" /> 35 + 36 + <div class="w-full border border-gray-200 dark:border-gray-700 rounded rouded-sm divide-y divide-gray-200 dark:divide-gray-700"> 37 + {{ range $i, $repo := .Repos }} 38 + {{ template "repo-row" (list $i $repo $) }} 39 + {{ end }} 40 + </div> 41 + 42 + <div class="pt-3 flex justify-between items-center"> 43 + <span id="migrate-error" class="error"></span> 44 + <button type="submit" class="btn-create flex items-center gap-2" {{ if (not .Repos) }}disabled{{ end }}> 45 + {{ i "import" "size-4" }} 46 + {{ if gt (len .Repos) 1 }} 47 + Migrate Repositories 48 + {{ else }} 49 + Migrate Repository 50 + {{ end }} 51 + <span id="spinner" class="group"> 52 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </span> 54 + </button> 55 + </div> 56 + </form> 57 + {{ else }} 58 + <p class="text-gray-500 dark:text-gray-400 mb-4">No importable repositories found.</p> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }} 63 + 64 + {{ define "repo-row" }} 65 + {{- $i := index . 0 }} 66 + {{- $repo := index . 1 }} 67 + {{- $root := index . 2 }} 68 + <details class="group"> 69 + <summary class="p-2 pl-3 flex items-center justify-between gap-2 cursor-pointer"> 70 + <div class="flex gap-2 min-w-0"> 71 + <label class="text-base py-0 min-w-0 flex items-center gap-2"> 72 + <input type="checkbox" id="selected_{{ $i }}" name="selected_{{ $i }}" value="1" {{ if $repo.Selected }}checked{{ end }} class="shrink-0" /> 73 + <span class="min-w-0 truncate">{{ $repo.Name }}</span> 74 + </label> 75 + {{ if gt $repo.Stars 0 }} 76 + <div class="flex items-center gap-1"> 77 + {{ i "star" "size-3 fill-current" }} 78 + <span class="text-sm">{{ scaleFmt $repo.Stars }}</span> 79 + </div> 80 + {{ end }} 81 + </div> 82 + 83 + <div class="flex items-center gap-2 shrink-0"> 84 + <!-- 85 + <span class="hidden sm:block text-gray-600 dark:text-gray-400"> 86 + Updated 2 days ago 87 + </span> 88 + --> 89 + <div class="p-1"> 90 + {{ i "chevron-right" "size-4 group-open:hidden" }} 91 + {{ i "chevron-down" "size-4 hidden group-open:block" }} 92 + </div> 93 + </div> 94 + </summary> 95 + <div class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 pt-2 pb-4 px-3 flex flex-col gap-4"> 96 + <input type="hidden" name="website_{{ $i }}" value="{{ $repo.Website }}" /> 97 + <input type="hidden" name="topics_{{ $i }}" value="{{ join $repo.Topics "," }}" /> 98 + 99 + <label class="text-base py-0 space-y-1.5"> 100 + <span>Clone URL</span> 101 + <input type="text" name="clone_url_{{ $i }}" value="{{ $repo.CloneUrl }}" readonly class="w-full px-2 py-1" /> 102 + </label> 103 + 104 + <label class="text-base py-0 space-y-1.5"> 105 + <span>Name</span> 106 + <input type="text" name="name_{{ $i }}" value="{{ $repo.Name }}" required placeholder="repository-name" class="bg-white dark:bg-gray-900 w-full px-2 py-1" /> 107 + </label> 108 + 109 + <label class="text-base py-0 space-y-1.5"> 110 + <span>Description</span> 111 + <textarea 112 + name="description_{{ $i }}" 113 + placeholder="repository description" 114 + class="bg-white dark:bg-gray-900 w-full px-2 py-1" 115 + >{{ $repo.Description }}</textarea> 116 + </label> 117 + 118 + <fieldset class="space-y-1"> 119 + <legend class="text-base">Select a knot to migrate into</legend> 120 + <div class="w-full space-y-2"> 121 + {{ range $root.Knots }} 122 + <label class="py-0 block flex items-center gap-2"> 123 + <input type="radio" name="knot_{{ $i }}" value="{{ . }}" required {{if eq (len $root.Knots) 1}}checked{{end}} /> 124 + <span class="lowercase py-0.5">{{ . }}</span> 125 + </label> 126 + {{ else }} 127 + <p class="dark:text-white">No knots available.</p> 128 + {{ end }} 129 + </div> 130 + </fieldset> 131 + </div> 132 + </details> 133 + {{ end }}
+5
appview/pages/templates/account/migrate/fragments/stepIndicator.html
··· 1 + {{ define "account/migrate/fragments/stepIndicator" }} 2 + <div class="size-7 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center font-medium"> 3 + {{.}} 4 + </div> 5 + {{ end }}
+16 -44
appview/pages/templates/account/migrate/fromGitHub.html
··· 14 14 <p>Migration covers your repos and commit history.<br>Issues and PRs aren't part of the import yet.</p> 15 15 </div> 16 16 <div class="bg-white dark:bg-gray-800 p-8 pb-0 rounded w-full mx-auto drop-shadow-sm dark:text-white"> 17 - {{ template "step-1" . }} 18 - {{ template "step-2" . }} 19 - 20 - <span id="migrate-error" class="error"></span> 21 - 22 17 {{ if not .Knots }} 23 18 <p class="text-gray-500 dark:text-gray-400"> 24 19 You aren't a member of any knot yet. Join or register a knot first. 25 20 </p> 21 + {{ else }} 22 + <div id="migration-steps"> 23 + {{ template "step-1" . }} 24 + {{ template "step-2" . }} 25 + </div> 26 26 {{ end }} 27 27 28 28 </div> 29 29 {{ end }} 30 30 31 31 {{ define "step-1" }} 32 - <div class="flex relative border-l border-gray-200 dark:border-gray-700 ml-3 pl-7"> 32 + <div 33 + id="migration-step-1" 34 + class="flex relative border-l border-gray-200 dark:border-gray-700 ml-3 pl-7" 35 + > 33 36 <div class="absolute -left-3 -top-0"> 34 - {{ template "numberCircle" 1 }} 37 + {{ template "account/migrate/fragments/stepIndicator" 1 }} 35 38 </div> 36 39 37 40 <!-- Content column --> 38 41 <form 39 42 hx-post="/account/migrate/listGitHubRepos" 40 - hx-target="#source-repos" 41 - hx-swap="innerHTML" 43 + hx-target="#migration-step-2" 44 + hx-swap="outerHTML" 42 45 class="flex-1 min-w-0 pb-12 space-y-3" 43 46 > 44 - 45 47 <h2 class="text-lg font-semibold uppercase dark:text-white">Connect Your Account</h2> 46 48 <div class="flex gap-3 w-full"> 47 49 <input ··· 57 59 {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 60 </button> 59 61 </div> 62 + <div> 63 + <span id="listghrepos-error" class="error"></span> 64 + </div> 60 65 </form> 61 66 </div> 62 67 {{ end }} 63 68 64 69 {{ define "step-2" }} 65 - <div class="flex relative border-l border-gray-200 dark:border-gray-700 ml-3 pl-7"> 66 - <div class="absolute -left-3 -top-0"> 67 - {{ template "numberCircle" 2 }} 68 - </div> 69 - 70 - <!-- Content column --> 71 - <div class="flex-1 min-w-0 pb-12 space-y-3"> 72 - <div class="flex flex-wrap items-center justify-between gap-3"> 73 - <h2 class="text-lg font-semibold uppercase">Choose &amp; Configure Repositories</h2> 74 - <div class="ml-auto h-7 flex items-center"> 75 - <button 76 - class="btn-flat flex items-center gap-2" 77 - onclick="document.querySelectorAll('input[name^=selected_]').forEach(c => c.checked = !c.checked)" 78 - > 79 - {{ i "check-check" "size-4" }} 80 - Select all repositories 81 - </button> 82 - </div> 83 - </div> 84 - <div id="source-repos"> 85 - <!-- 86 - <p class="text-gray-500 dark:text-gray-400 mb-4">No repositories yet, connect your account first</p> 87 - --> 88 - 89 - {{ template "account/migrate/fragments/repoList" . }} 90 - </div> 91 - </div> 92 - </div> 93 - {{ end }} 94 - 95 - {{ define "numberCircle" }} 96 - <div class="size-7 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center font-medium"> 97 - {{.}} 98 - </div> 70 + {{ template "account/migrate/fragments/step2" }} 99 71 {{ end }}