diff --git a/.dockerignore b/.dockerignore index ac2851ee..b04dde4a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,7 @@ !.git !proto !scripts/run-clustered.sh +!dashboard # Re-exclude host build artifact directories **/bin diff --git a/.github/workflows/dashboard b/.github/workflows/dashboard new file mode 100644 index 00000000..9defc499 --- /dev/null +++ b/.github/workflows/dashboard @@ -0,0 +1,34 @@ +name: Build dashboard Docker image + +on: + push: + branches: [main] + paths: + - 'dashboard/' + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + packages: write + if: github.repository == 'xSke/PluralKit' + steps: + - uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.CR_PAT }} + - uses: actions/checkout@v2 + - run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + - uses: docker/build-push-action@v2 + with: + # https://github.com/docker/build-push-action/issues/378 + context: . + file: Dockerfile.dashboard + push: true + tags: | + ghcr.io/pluralkit/dashboard:${{ env.BRANCH_NAME }} + ghcr.io/pluralkit/dashboard:${{ github.sha }} + ghcr.io/pluralkit/dashboard:latest + cache-from: type=registry,ref=ghcr.io/pluralkit/dashboard:${{ env.BRANCH_NAME }} + cache-to: type=inline diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard new file mode 100644 index 00000000..8a048b69 --- /dev/null +++ b/Dockerfile.dashboard @@ -0,0 +1,19 @@ +FROM alpine:latest as builder + +RUN apk add nodejs-current yarn go git + +COPY dashboard/ /build +COPY .git/ /build/.git + +WORKDIR /build + +RUN yarn install --frozen-lockfile +RUN yarn build + +RUN sh -c 'go build -ldflags "-X main.version=$(git rev-parse HEAD)"' + +FROM alpine:latest + +COPY --from=builder /build/dashboard /bin/dashboard + +ENTRYPOINT /bin/dashboard \ No newline at end of file diff --git a/PluralKit.API/APIJsonExt.cs b/PluralKit.API/APIJsonExt.cs index 91386bc3..d906ff2a 100644 --- a/PluralKit.API/APIJsonExt.cs +++ b/PluralKit.API/APIJsonExt.cs @@ -25,6 +25,18 @@ public static class APIJsonExt return o; } + + public static JObject EmbedJson(string title, string type) + { + var o = new JObject(); + + o.Add("type", "rich"); + o.Add("provider_name", "PluralKit " + type); + o.Add("provider_url", "https://pluralkit.me"); + o.Add("title", title); + + return o; + } } public struct FrontersReturnNew diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index 01eee1e2..495f4407 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -97,6 +97,21 @@ public class GroupControllerV2: PKControllerBase return Ok(group.ToJson(ContextFor(group), system.Hid)); } + [HttpGet("groups/{groupRef}/oembed.json")] + public async Task GroupEmbed(string groupRef) + { + var group = await ResolveGroup(groupRef); + if (group == null) + throw Errors.GroupNotFound; + var system = await _repo.GetSystem(group.System); + + var name = group.NameFor(LookupContext.ByNonOwner); + if (system.Name != null) + name += $" ({system.Name})"; + + return Ok(APIJsonExt.EmbedJson(name, "Group")); + } + [HttpPatch("groups/{groupRef}")] public async Task DoGroupPatch(string groupRef, [FromBody] JObject data) { diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs index dfa1eb87..b4ad7fb9 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -79,6 +79,21 @@ public class MemberControllerV2: PKControllerBase return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid)); } + [HttpGet("members/{memberRef}/oembed.json")] + public async Task MemberEmbed(string memberRef) + { + var member = await ResolveMember(memberRef); + if (member == null) + throw Errors.MemberNotFound; + var system = await _repo.GetSystem(member.System); + + var name = member.NameFor(LookupContext.ByNonOwner); + if (system.Name != null) + name += $" ({system.Name})"; + + return Ok(APIJsonExt.EmbedJson(name, "Member")); + } + [HttpPatch("members/{memberRef}")] public async Task DoMemberPatch(string memberRef, [FromBody] JObject data) { diff --git a/PluralKit.API/Controllers/v2/SystemControllerV2.cs b/PluralKit.API/Controllers/v2/SystemControllerV2.cs index 4842e0e9..8990a98e 100644 --- a/PluralKit.API/Controllers/v2/SystemControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SystemControllerV2.cs @@ -20,6 +20,16 @@ public class SystemControllerV2: PKControllerBase return Ok(system.ToJson(ContextFor(system))); } + [HttpGet("{systemRef}/oembed.json")] + public async Task SystemEmbed(string systemRef) + { + var system = await ResolveSystem(systemRef); + if (system == null) + throw Errors.SystemNotFound; + + return Ok(APIJsonExt.EmbedJson(system.Name ?? $"System with ID `{system.Hid}`", "System")); + } + [HttpPatch("{systemRef}")] public async Task DoSystemPatch(string systemRef, [FromBody] JObject data) { diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 65167f26..421b2a8d 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -72,7 +72,8 @@ public class EmbedService .Thumbnail(new Embed.EmbedThumbnail(system.AvatarUrl.TryGetCleanCdnUrl())) .Footer(new Embed.EmbedFooter( $"System ID: {system.Hid} | Created on {system.Created.FormatZoned(cctx.Zone)}")) - .Color(color); + .Color(color) + .Url($"https://dash.pluralkit.me/profile/s/{system.Hid}"); if (system.DescriptionPrivacy.CanAccess(ctx)) eb.Image(new Embed.EmbedImage(system.BannerImage)); @@ -179,8 +180,7 @@ public class EmbedService .ToListAsync(); var eb = new EmbedBuilder() - // TODO: add URL of website when that's up - .Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl())) + .Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}")) // .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray) .Color(color) .Footer(new Embed.EmbedFooter( @@ -264,7 +264,7 @@ public class EmbedService } var eb = new EmbedBuilder() - .Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx))) + .Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"https://dash.pluralkit.me/profile/g/{target.Hid}")) .Color(color); eb.Footer(new Embed.EmbedFooter($"System ID: {system.Hid} | Group ID: {target.Hid}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}")); diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 00000000..176e82f5 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +dashboard \ No newline at end of file diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 00000000..278cb1af --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,12 @@ +# PluralKit Dashboard + +This project is built using [Vite](https://vitejs.dev/), using the svelte-ts template. + +Some of the other stuff used to get this working: +* sveltestrap (https://sveltestrap.js.org/) +* svelte-navigator (https://github.com/mefechoel/svelte-navigator) +* svelte-toggle (https://github.com/metonym/svelte-toggle) +* svelecte (https://mskocik.github.io/svelecte/) +* svelte-icons (https://github.com/Introvertuous/svelte-icons) +* discord-markdown (https://github.com/brussell98/discord-markdown) +* moment (https://momentjs.com/) diff --git a/dashboard/go.mod b/dashboard/go.mod new file mode 100644 index 00000000..a646e295 --- /dev/null +++ b/dashboard/go.mod @@ -0,0 +1,5 @@ +module dashboard + +go 1.18 + +require github.com/go-chi/chi v1.5.4 // indirect diff --git a/dashboard/go.sum b/dashboard/go.sum new file mode 100644 index 00000000..874ed9a3 --- /dev/null +++ b/dashboard/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 00000000..cadbfaf4 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,16 @@ + + + + + + + PluralKit | home + + + + + +
+ + + diff --git a/dashboard/main.go b/dashboard/main.go new file mode 100644 index 00000000..2d416749 --- /dev/null +++ b/dashboard/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/go-chi/chi" +) + +//go:embed dist/* +var fs embed.FS + +type entity struct { + AvatarURL *string `json:"avatar_url"` + IconURL *string `json:"icon_url"` + Description *string `json:"description"` + Color *string `json:"color"` +} + +var baseURL = "https://api.pluralkit.me/v2" + +var version = "dev" + +const defaultEmbed = ` ` + +func main() { + r := chi.NewRouter() + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("X-PluralKit-Version", version) + next.ServeHTTP(rw, r) + }) + }) + + r.NotFound(notFoundHandler) + + r.Get("/profile/{type}/{id}", func(rw http.ResponseWriter, r *http.Request) { + defer func() { + if a := recover(); a != nil { + notFoundHandler(rw, r) + return + } + }() + createEmbed(rw, r) + }) + + http.ListenAndServe(":8080", r) +} + +func notFoundHandler(rw http.ResponseWriter, r *http.Request) { + var data []byte + var err error + + // lol + if strings.HasSuffix(r.URL.Path, ".js") { + data, err = fs.ReadFile("dist" + r.URL.Path) + rw.Header().Add("content-type", "application/javascript") + } else if strings.HasSuffix(r.URL.Path, ".css") { + data, err = fs.ReadFile("dist" + r.URL.Path) + rw.Header().Add("content-type", "text/css") + } else if strings.HasSuffix(r.URL.Path, ".map") { + data, err = fs.ReadFile("dist" + r.URL.Path) + } else { + data, err = fs.ReadFile("dist/index.html") + rw.Header().Add("content-type", "text/html") + data = []byte(strings.Replace(string(data), ``, defaultEmbed, 1)) + } + + if err != nil { + panic(err) + } + + rw.Write(data) +} + +// explanation for createEmbed: +// we don't care about errors, we just want to return a HTML page as soon as possible +// `panic(nil)` is caught by upstream, which then returns the raw HTML page + +func createEmbed(rw http.ResponseWriter, r *http.Request) { + entityType := chi.URLParam(r, "type") + id := chi.URLParam(r, "id") + + var path string + + switch entityType { + case "s": + path = "/systems/" + id + case "m": + path = "/members/" + id + case "g": + path = "/groups/" + id + default: + panic(nil) + } + + res, err := http.Get(baseURL + path) + if err != nil { + panic(nil) + } + if res.StatusCode != 200 { + panic(nil) + } + + var data entity + body, _ := io.ReadAll(res.Body) + err = json.Unmarshal(body, &data) + if err != nil { + panic(nil) + } + + text := fmt.Sprintf(`%s`, baseURL, path, "\n") + + if data.AvatarURL != nil { + text += fmt.Sprintf(`%s`, *data.AvatarURL, "\n") + } else if data.IconURL != nil { + text += fmt.Sprintf(`%s`, *data.IconURL, "\n") + } + + if data.Description != nil { + text += fmt.Sprintf(`%s`, *data.Description, "\n") + } + + if data.Color != nil { + text += fmt.Sprintf(`%s`, *data.Color, "\n") + } + + html, err := fs.ReadFile("dist/index.html") + if err != nil { + panic(nil) + } + html = []byte(strings.Replace(string(html), ``, text, 1)) + + rw.Header().Add("content-type", "text/html") + rw.Write(html) +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 00000000..6ca8e164 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,41 @@ +{ + "name": "pluralkit-dashboard", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^1.0.0-next.30", + "@tsconfig/svelte": "^2.0.1", + "svelte": "^3.44.0", + "svelte-check": "^2.2.7", + "svelte-toggle": "^3.1.0", + "tslib": "^2.3.1", + "typescript": "^4.4.4", + "vite": "^2.7.0" + }, + "dependencies": { + "@sentry/browser": "^6.19.5", + "@sentry/tracing": "^6.19.5", + "@types/twemoji": "^12.1.2", + "axios": "^0.24.0", + "bootstrap": "^5.1.3", + "bootstrap-dark-5": "^1.1.3", + "discord-markdown": "^2.5.1", + "gh-pages": "^3.2.3", + "import": "^0.0.6", + "moment": "^2.29.1", + "sass": "^1.52.2", + "svelecte": "^3.4.5", + "svelte-autosize": "^1.0.1", + "svelte-icons": "^2.1.0", + "svelte-navigator": "^3.1.5", + "svelte-preprocess": "^4.10.6", + "sveltestrap": "^5.6.3", + "twemoji": "^13.1.0" + } +} diff --git a/dashboard/public/myriad.png b/dashboard/public/myriad.png new file mode 100644 index 00000000..d58bd01a Binary files /dev/null and b/dashboard/public/myriad.png differ diff --git a/dashboard/src/App.svelte b/dashboard/src/App.svelte new file mode 100644 index 00000000..469c1a34 --- /dev/null +++ b/dashboard/src/App.svelte @@ -0,0 +1,78 @@ + + + + + + + + + + + + + +
+ + Please provide a system ID in the URL. + + + + Please provide a member ID in the URL. + + + + Please provide a group ID in the URL. + + + + \ No newline at end of file diff --git a/dashboard/src/api/errors.ts b/dashboard/src/api/errors.ts new file mode 100644 index 00000000..c6b4ff70 --- /dev/null +++ b/dashboard/src/api/errors.ts @@ -0,0 +1,28 @@ +enum ErrorType { + Unknown = 0, + InvalidToken = 401, + NotFound = 404, + InternalServerError = 500, +} + +interface ApiError { + code: number, + type: ErrorType, + message?: string, + data?: any, +} + +export function parse(code: number, data?: any): ApiError { + var type = ErrorType[ErrorType[code]] ?? ErrorType.Unknown; + if (code >= 500) type = ErrorType.InternalServerError; + + var err: ApiError = { code, type }; + + if (data) { + var d = data; + err.message = d.message; + err.data = d; + } + + return err; +} diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts new file mode 100644 index 00000000..260bb0b2 --- /dev/null +++ b/dashboard/src/api/index.ts @@ -0,0 +1,58 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/browser'; + +const baseUrl = () => localStorage.isBeta ? "https://api.beta.pluralkit.me" : "https://api.pluralkit.me"; + +const methods = ['get', 'post', 'delete', 'patch', 'put']; +const noop = () => {}; + +const scheduled = []; +const runAPI = () => { + if (scheduled.length == 0) return; + const {axiosData, res, rej} = scheduled.shift(); + axios(axiosData) + .then((resp) => res(parseData(resp.status, resp.data))) + .catch((err) => { + Sentry.captureException("Fetch error", err); + rej(err); + }); +} + +setInterval(runAPI, 500); + +export default function() { + const route = []; + const handler = { + get(_, name) { + if (route.length == 0 && name != "private") + route.push("v2"); + if (methods.includes(name)) { + return ({ data = undefined, auth = true, token = null, query = null } = {}) => new Promise((res, rej) => scheduled.push({ res, rej, axiosData: { + url: baseUrl() + "/" + route.join("/") + (query ? `?${Object.keys(query).map(x => `${x}=${query[x]}`).join("&")}` : ""), + method: name, + headers: { + authorization: token ?? (auth ? localStorage.getItem("pk-token") : undefined), + "content-type": name == "get" ? undefined : "application/json" + }, + data: !!data ? JSON.stringify(data) : undefined, + validateStatus: () => true, + }})); + } + route.push(name); + return new Proxy(noop, handler); + }, + apply(target, _, args) { + route.push(...args.filter(x => x != null)); + return new Proxy(noop, handler); + } + } + return new Proxy(noop, handler); +} + +import * as errors from './errors'; + +function parseData(code: number, data: any) { + if (code == 200) return data; + if (code == 204) return; + throw errors.parse(code, data); +} \ No newline at end of file diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts new file mode 100644 index 00000000..3aaea446 --- /dev/null +++ b/dashboard/src/api/types.ts @@ -0,0 +1,88 @@ +interface SystemPrivacy { + description_privacy?: string, + member_list_privacy?: string, + front_privacy?: string, + front_history_privacy?: string, + group_list_privacy?: string +} + +export interface System { + id?: string; + uuid?: string; + name?: string; + description?: string; + tag?: string; + avatar_url?: string; + banner?: string; + timezone?: string; + created?: string; + privacy?: SystemPrivacy; + color?: string; +} + +export interface Config { + timezone: string; + pings_enabled: boolean; + member_default_private?: boolean; + group_default_private?: boolean; + show_private_info?: boolean; + member_limit: number; + group_limit: number; + description_templates: string[]; +} + +export interface MemberPrivacy { + visibility?: string, + description_privacy?: string, + name_privacy?: string, + birthday_privacy?: string, + pronoun_privacy?: string, + avatar_privacy?: string, + metadata_privacy?: string +} + +interface proxytag { + prefix?: string, + suffix?: string +} + +export interface Member { + id?: string; + uuid?: string; + name?: string; + display_name?: string; + color?: string; + birthday?: string; + pronouns?: string; + avatar_url?: string; + banner?: string; + description?: string; + created?: string; + keep_proxy?: boolean + system?: string; + proxy_tags?: Array; + privacy?: MemberPrivacy +} + +export interface GroupPrivacy { + description_privacy?: string, + icon_privacy?: string, + list_privacy?: string, + visibility?: string, + name_privacy?: string, + metadata_privacy?: string +} + +export interface Group { + id?: string; + uuid?: string; + name?: string; + display_name?: string; + description?: string; + icon?: string; + banner?: string; + color?: string; + privacy?: GroupPrivacy; + created?: string; + members?: string[]; +} \ No newline at end of file diff --git a/dashboard/src/assets/default_avatar.png b/dashboard/src/assets/default_avatar.png new file mode 100644 index 00000000..753346aa Binary files /dev/null and b/dashboard/src/assets/default_avatar.png differ diff --git a/dashboard/src/lib/CardsHeader.svelte b/dashboard/src/lib/CardsHeader.svelte new file mode 100644 index 00000000..2ae903fd --- /dev/null +++ b/dashboard/src/lib/CardsHeader.svelte @@ -0,0 +1,63 @@ + + + +
+
+ +
+ {@html htmlName} ({item.id}) +
+
+ {#if loading} +
+ {/if} + {#if item && (item.avatar_url || item.icon)} + {if (event.key === "Enter") avatarOpen = true}} on:click={toggleAvatarModal} class="rounded-circle avatar" src={icon_url} alt={altText} /> + {:else} + icon (default) + {/if} +
+ +
+ {altText} +
+
+
\ No newline at end of file diff --git a/dashboard/src/lib/ListPagination.svelte b/dashboard/src/lib/ListPagination.svelte new file mode 100644 index 00000000..65392242 --- /dev/null +++ b/dashboard/src/lib/ListPagination.svelte @@ -0,0 +1,73 @@ + +{#if pageAmount > 1} + + {#if currentPage !== 1} + + {e.preventDefault(); currentPage -= 1}}> + + {:else} + + + + {/if} + {#if currentPage > 2} + + {e.preventDefault(); currentPage = 1}}>1 + + {/if} + {#if currentPage === 4} + + {e.preventDefault(); currentPage = 2}}>2 + + {/if} + {#if currentPage > 4} + + ... + + {/if} + {#if currentPage > 1} + + {e.preventDefault(); currentPage -= 1}}>{currentPage - 1} + + {/if} + + {currentPage} + + {#if currentPage < pageAmount} + + {e.preventDefault(); currentPage += 1}}>{currentPage + 1} + + {/if} + {#if currentPage < pageAmount - 3} + + ... + + {/if} + {#if currentPage === pageAmount - 3} + + {e.preventDefault(); currentPage = pageAmount - 1}}>{pageAmount - 1} + + {/if} + {#if currentPage < pageAmount - 1} + + { e.preventDefault(); currentPage = pageAmount}}>{pageAmount} + + {/if} + {#if currentPage !== pageAmount} + + {e.preventDefault(); currentPage += 1}}> + + {:else} + + + + {/if} + +{/if} \ No newline at end of file diff --git a/dashboard/src/lib/Navigation.svelte b/dashboard/src/lib/Navigation.svelte new file mode 100644 index 00000000..16a00e5d --- /dev/null +++ b/dashboard/src/lib/Navigation.svelte @@ -0,0 +1,67 @@ + + + PluralKit + + + + + + + \ No newline at end of file diff --git a/dashboard/src/lib/group/Body.svelte b/dashboard/src/lib/group/Body.svelte new file mode 100644 index 00000000..61ec682a --- /dev/null +++ b/dashboard/src/lib/group/Body.svelte @@ -0,0 +1,127 @@ + + + +{#if !editMode && !memberMode} + + {#if group.id} + + ID: {group.id} + + {/if} + {#if group.name} + + Name: {group.name} + + {/if} + {#if group.display_name} + + Display Name: {@html htmlDisplayName} + + {/if} + {#if group.created && !isPublic} + + Created: {created} + + {/if} + {#if group.color} + + Color: {group.color} + + {/if} + {#if group.banner} + + Banner: + +
+ {`Group +
+
+ + {/if} + {#if group.privacy} + + Privacy: + + + Edit privacy + + + + + + + {/if} +
+
+ Description:
+ {@html htmlDescription && htmlDescription} +
+{#if (group.banner && ((settings && settings.appearance.banner_bottom) || !settings))} +group banner +{/if} +{#if !isPublic} + +{#if isMainDash}{/if} +{/if} +{#if !isPage} + + {:else if !isPublic} + + {/if} +{:else if editMode} + +{:else if memberMode} + +{/if} +
\ No newline at end of file diff --git a/dashboard/src/lib/group/Edit.svelte b/dashboard/src/lib/group/Edit.svelte new file mode 100644 index 00000000..c975b5d9 --- /dev/null +++ b/dashboard/src/lib/group/Edit.svelte @@ -0,0 +1,150 @@ + + +{#each err as error} + {@html error} +{/each} + + + + + + + +