diff --git a/src/App.ts b/src/App.ts index a990088..dc14c75 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,4 +1,5 @@ import quilt from "lang/en-nz" +import FormInputLengths from "model/FormInputLengths" import Session from "model/Session" import Navigator from "navigation/Navigate" import style from "style" @@ -56,6 +57,7 @@ async function App (): Promise { }) await Env.load() + await FormInputLengths.getManifest() // const path = URL.path ?? URL.hash; // if (path === AuthView.id) { diff --git a/src/endpoint/manifest/EndpointFormInputLengths.ts b/src/endpoint/manifest/EndpointFormInputLengths.ts new file mode 100644 index 0000000..c8f4596 --- /dev/null +++ b/src/endpoint/manifest/EndpointFormInputLengths.ts @@ -0,0 +1,3 @@ +import Endpoint from "endpoint/Endpoint" + +export default Endpoint("/manifest/form/lengths", "get") diff --git a/src/model/FormInputLengths.ts b/src/model/FormInputLengths.ts new file mode 100644 index 0000000..077b9d8 --- /dev/null +++ b/src/model/FormInputLengths.ts @@ -0,0 +1,8 @@ +import EndpointFormInputLengths from "endpoint/manifest/EndpointFormInputLengths" +import Manifest from "model/Manifest" + +export default Manifest({ + get () { + return EndpointFormInputLengths.query() + }, +}) diff --git a/src/model/Manifest.ts b/src/model/Manifest.ts new file mode 100644 index 0000000..285ac30 --- /dev/null +++ b/src/model/Manifest.ts @@ -0,0 +1,43 @@ +import type { ErrorResponse, Response } from "api.fluff4.me" +import Time from "utility/Time" + +interface ManifestDefinition { + get (): Promise | ErrorResponse>> +} + +interface Manifest { + manifest: T | undefined + getManifest (force?: boolean): Promise + isFresh (manifest?: T): manifest is T +} + +function Manifest (definition: ManifestDefinition): Manifest { + let manifestTime: number | undefined + let promise: Promise | undefined + + const result: Manifest = { + manifest: undefined, + isFresh (manifest?: T): manifest is T { + return !!manifest && Date.now() - (manifestTime ?? 0) < Time.minutes(5) + }, + async getManifest (force?: boolean) { + // don't re-request the tag manifest if it was requested less than 5 minutes ago + if (!force && result.isFresh(result.manifest)) + return result.manifest + + return promise ??= (async () => { + const response = await definition.get() + if (response instanceof Error) + throw response + + result.manifest = response.data + manifestTime = Date.now() + promise = undefined + return result.manifest + })() + }, + } + return result +} + +export default Manifest diff --git a/src/model/Tags.ts b/src/model/Tags.ts index d261795..811b224 100644 --- a/src/model/Tags.ts +++ b/src/model/Tags.ts @@ -1,73 +1,54 @@ -import type { ManifestGlobalTags, Tag } from "api.fluff4.me" +import type { Tag } from "api.fluff4.me" import EndpointTagManifest from "endpoint/tag/EndpointTagManifest" -import Time from "utility/Time" +import Manifest from "model/Manifest" -namespace Tags { +const Tags = Object.assign( + Manifest({ + get () { + return EndpointTagManifest.query() + }, + }), + { resolve }, +) - let manifestTime: number | undefined - export let manifest: ManifestGlobalTags | undefined - let promise: Promise | undefined - - function manifestIsFresh (manifest?: ManifestGlobalTags): manifest is ManifestGlobalTags { - return !!manifest && Date.now() - (manifestTime ?? 0) < Time.minutes(5) - } - - export async function getManifest (force = false) { - // don't re-request the tag manifest if it was requested less than 5 minutes ago - if (!force && manifestIsFresh(manifest)) - return manifest - - return promise ??= (async () => { - const response = await EndpointTagManifest.query() - if (response instanceof Error) - throw response - - manifest = response.data - manifestTime = Date.now() - promise = undefined - return manifest - })() - } +export default Tags - export async function resolve (tag: string): Promise - export async function resolve (category: string, name: string): Promise - export async function resolve (tags?: string[] | null): Promise - export async function resolve (tags?: string[] | null | string, name?: string) { - if (!tags?.length) - return [] +export async function resolve (tag: string): Promise +export async function resolve (category: string, name: string): Promise +export async function resolve (tags?: string[] | null): Promise +export async function resolve (tags?: string[] | null | string, name?: string) { + if (!tags?.length) + return [] - if (Array.isArray(tags)) - return resolveInternal(tags) + if (Array.isArray(tags)) + return resolveInternal(tags) - const tag = name ? `${tags}: ${name}` : tags - const [result] = await resolveInternal([tag]) - return result as Tag | undefined - } + const tag = name ? `${tags}: ${name}` : tags + const [result] = await resolveInternal([tag]) + return result as Tag | undefined +} - async function resolveInternal (tags: string[]) { - const result: Tag[] = [] +async function resolveInternal (tags: string[]) { + const result: Tag[] = [] - let manifest = await getManifest() + let manifest = await Tags.getManifest() + resolveTags() + if (result.length !== tags.length && !Tags.isFresh()) { + manifest = await Tags.getManifest(true) resolveTags() - if (result.length !== tags.length && !manifestIsFresh()) { - manifest = await getManifest(true) - resolveTags() - } + } - return result + return result - function resolveTags () { - result.splice(0, Infinity) - for (const tagString of tags) { - const tag = manifest.tags[tagString] - if (!tag) - continue + function resolveTags () { + result.splice(0, Infinity) + for (const tagString of tags) { + const tag = manifest.tags[tagString] + if (!tag) + continue - result.push(tag) - } + result.push(tag) } } } - -export default Tags diff --git a/src/ui/component/core/TextEditor.ts b/src/ui/component/core/TextEditor.ts index 74a6c3a..48faf64 100644 --- a/src/ui/component/core/TextEditor.ts +++ b/src/ui/component/core/TextEditor.ts @@ -640,7 +640,7 @@ const markdownParser = new MarkdownParser(schema, markdown, Objects.filterNullis ...Object.entries(markdownHTMLNodeRegistry) .toObject(([tokenType, spec]) => [tokenType, ({ block: tokenType, - getAttrs: (token) => (token as FluffToken).nodeAttrs ?? {}, + getAttrs: token => (token as FluffToken).nodeAttrs ?? {}, } satisfies ParseSpec)]), } satisfies Record)) @@ -1176,7 +1176,7 @@ const TextEditor = Component.Builder((component): TextEditor => { .subviewTransition(viewTransitionName) .style("text-editor") .style.bind(isFullscreen, "text-editor--fullscreen") - .event.subscribe("click", (event) => { + .event.subscribe("click", event => { const target = Component.get(event.target) if (target !== toolbar && !target?.is(TextEditor)) return @@ -1410,6 +1410,7 @@ const TextEditor = Component.Builder((component): TextEditor => { return const body = !doc ? "" : markdownSerializer.serialize(doc) + editor.length.value = body.length if (body === editor.default.state.value) return clearLocal() diff --git a/src/ui/component/core/TextInput.ts b/src/ui/component/core/TextInput.ts index 75e2f05..34673bc 100644 --- a/src/ui/component/core/TextInput.ts +++ b/src/ui/component/core/TextInput.ts @@ -44,6 +44,7 @@ const TextInput = Component.Builder("input", (component): TextInput => { if (input.value === "") { input.value = value ?? "" input.state.value = value ?? "" + input.length.value = value?.length ?? 0 } }), placeholder: StringApplicator(input, value => { @@ -63,6 +64,7 @@ const TextInput = Component.Builder("input", (component): TextInput => { set: (value: string) => { (input.element as HTMLInputElement).value = value input.state.value = value + input.length.value = value.length }, })) @@ -91,6 +93,7 @@ const TextInput = Component.Builder("input", (component): TextInput => { if (shouldIgnoreInputEvent) return input.state.value = input.value + input.length.value = input.value.length }) return input diff --git a/src/ui/component/core/Textarea.ts b/src/ui/component/core/Textarea.ts index d8445b3..733d559 100644 --- a/src/ui/component/core/Textarea.ts +++ b/src/ui/component/core/Textarea.ts @@ -35,6 +35,7 @@ const Textarea = Component.Builder((component): Textarea => { if (input.value === "") { input.value = value ?? "" input.state.value = value ?? "" + input.length.value = value?.length ?? 0 } }), placeholder: StringApplicator(input, value => { @@ -57,12 +58,16 @@ const Textarea = Component.Builder((component): Textarea => { set: (value: string) => { input.element.textContent = value input.state.value = value + input.length.value = value.length }, })) - input.event.subscribe(["input", "change"], event => { - if (shouldIgnoreInputEvent) return - input.state.value = input.value + input.onRooted(input => { + input.event.subscribe(["input", "change"], event => { + if (shouldIgnoreInputEvent) return + input.state.value = input.value + input.length.value = input.value.length + }) }) return input diff --git a/src/ui/component/core/ext/Input.ts b/src/ui/component/core/ext/Input.ts index 3d81a24..99f02ab 100644 --- a/src/ui/component/core/ext/Input.ts +++ b/src/ui/component/core/ext/Input.ts @@ -12,6 +12,7 @@ export interface InputExtensions { readonly hint: StringApplicator.Optional readonly maxLength: State readonly length: State + setMaxLength (maxLength?: number): this setRequired (required?: boolean): this /** * - Sets the `[name]` of this component to `label.for` @@ -27,7 +28,9 @@ const Input = Component.Extension((component): Input => { const hintText = State(undefined) const maxLength = State(undefined) const length = State(undefined) - const hasPopover = State.Map(component, [hintText, maxLength], (hintText, maxLength) => !!hintText || !!maxLength) + const unusedPercent = State.MapManual([length, maxLength], (length, maxLength) => length === undefined || !maxLength ? undefined : 1 - length / maxLength) + const unusedChars = State.MapManual([length, maxLength], (length, maxLength) => length === undefined || !maxLength ? undefined : maxLength - length) + const hasPopover = State.MapManual([hintText, maxLength], (hintText, maxLength) => !!hintText || !!maxLength) let popover: Popover | undefined hasPopover.subscribeManual(hasPopover => { if (!hasPopover) { @@ -51,7 +54,14 @@ const Input = Component.Extension((component): Input => { Slot.using(maxLength, (slot, maxLength) => !maxLength ? undefined : Component() - .style("input-popover-max-length")) + .style("input-popover-max-length") + .append(Component() + .style("input-popover-max-length-icon") + .style.bind(unusedPercent.mapManual(p => (p ?? 0) < 0), "input-popover-max-length-icon--overflowing")) + .append(Component() + .style("input-popover-max-length-text") + .text.bind(unusedChars.mapManual(chars => chars === undefined ? "" : `${chars}`))) + .style.bindVariable("progress", unusedPercent)) .appendTo(popover) }) .tweak(popoverInitialiser, component) @@ -66,6 +76,10 @@ const Input = Component.Extension((component): Input => { hint: StringApplicator(component, value => hintText.value = value), maxLength, length, + setMaxLength (newLength) { + maxLength.value = newLength + return component + }, setRequired: (required = true) => { component.attributes.toggle(required, "required") component.required.value = required diff --git a/src/ui/view/account/AccountViewForm.ts b/src/ui/view/account/AccountViewForm.ts index e66391a..0aea874 100644 --- a/src/ui/view/account/AccountViewForm.ts +++ b/src/ui/view/account/AccountViewForm.ts @@ -1,6 +1,7 @@ import EndpointAuthorCreate from "endpoint/author/EndpointAuthorCreate" import EndpointAuthorUpdate from "endpoint/author/EndpointAuthorUpdate" import quilt from "lang/en-nz" +import FormInputLengths from "model/FormInputLengths" import Session from "model/Session" import Component from "ui/Component" import Block from "ui/component/core/Block" @@ -31,6 +32,7 @@ export default Component.Builder((component, type: AccountViewFormType) => { .setRequired() .default.bind(Session.Auth.author.map(component, author => author?.name)) .hint.use("view/account/name/hint") + .setMaxLength(FormInputLengths.manifest?.author.name) table.label(label => label.text.use("shared/form/name/label")) .content((content, label) => content.append(nameInput.setLabel(label))) @@ -40,12 +42,14 @@ export default Component.Builder((component, type: AccountViewFormType) => { .default.bind(Session.Auth.author.map(component, author => author?.vanity)) .filter(filterVanity) .hint.use("view/account/vanity/hint") + .setMaxLength(FormInputLengths.manifest?.author.vanity) table.label(label => label.text.use("shared/form/vanity/label")) .content((content, label) => content.append(vanityInput.setLabel(label))) const descriptionInput = TextEditor() .default.bind(Session.Auth.author.map(component, author => author?.description.body)) .hint.use("view/account/description/hint") + .setMaxLength(FormInputLengths.manifest?.author.description) table.label(label => label.text.use("shared/form/description/label")) .content((content, label) => content.append(descriptionInput.setLabel(label))) diff --git a/src/ui/view/chapter/ChapterEditForm.ts b/src/ui/view/chapter/ChapterEditForm.ts index 4be7ce8..015a5d3 100644 --- a/src/ui/view/chapter/ChapterEditForm.ts +++ b/src/ui/view/chapter/ChapterEditForm.ts @@ -3,6 +3,7 @@ import EndpointChapterCreate from "endpoint/chapter/EndpointChapterCreate" import EndpointChapterUpdate from "endpoint/chapter/EndpointChapterUpdate" import type { WorkParams } from "endpoint/work/EndpointWorkGet" import quilt from "lang/en-nz" +import FormInputLengths from "model/FormInputLengths" import Session from "model/Session" import Component from "ui/Component" import Block from "ui/component/core/Block" @@ -32,6 +33,7 @@ export default Component.Builder((component, state: State, .setRequired() .default.bind(state.map(component, work => work?.name)) .hint.use("view/chapter-edit/shared/form/name/hint") + .setMaxLength(FormInputLengths.manifest?.chapter.name) table.label(label => label.text.use("view/chapter-edit/shared/form/name/label")) .content((content, label) => content.append(nameInput.setLabel(label))) diff --git a/src/ui/view/work/WorkEditForm.ts b/src/ui/view/work/WorkEditForm.ts index 652a662..4a279ab 100644 --- a/src/ui/view/work/WorkEditForm.ts +++ b/src/ui/view/work/WorkEditForm.ts @@ -2,6 +2,7 @@ import type { WorkFull } from "api.fluff4.me" import EndpointWorkCreate from "endpoint/work/EndpointWorkCreate" import EndpointWorkUpdate from "endpoint/work/EndpointWorkUpdate" import quilt from "lang/en-nz" +import FormInputLengths from "model/FormInputLengths" import Session from "model/Session" import Component from "ui/Component" import Block from "ui/component/core/Block" @@ -32,6 +33,7 @@ export default Component.Builder((component, state: State) .setRequired() .default.bind(state.map(component, work => work?.name)) .hint.use("view/work-edit/shared/form/name/hint") + .setMaxLength(FormInputLengths.manifest?.work.name) table.label(label => label.text.use("view/work-edit/shared/form/name/label")) .content((content, label) => content.append(nameInput.setLabel(label))) @@ -41,18 +43,21 @@ export default Component.Builder((component, state: State) .default.bind(state.map(component, work => work?.vanity)) .filter(filterVanity) .hint.use("view/work-edit/shared/form/vanity/hint") + .setMaxLength(FormInputLengths.manifest?.work.vanity) table.label(label => label.text.use("view/work-edit/shared/form/vanity/label")) .content((content, label) => content.append(vanityInput.setLabel(label))) const descriptionInput = Textarea() .default.bind(state.map(component, work => work?.description)) .hint.use("view/work-edit/shared/form/description/hint") + .setMaxLength(FormInputLengths.manifest?.work.description) table.label(label => label.text.use("view/work-edit/shared/form/description/label")) .content((content, label) => content.append(descriptionInput.setLabel(label))) const synopsisInput = TextEditor() .default.bind(state.map(component, work => work?.synopsis.body)) .hint.use("view/work-edit/shared/form/synopsis/hint") + .setMaxLength(FormInputLengths.manifest?.work.synopsis) table.label(label => label.text.use("view/work-edit/shared/form/synopsis/label")) .content((content, label) => content.append(synopsisInput.setLabel(label))) diff --git a/style/component/core/input.chiri b/style/component/core/input.chiri index 0c3178e..25385cc 100644 --- a/style/component/core/input.chiri +++ b/style/component/core/input.chiri @@ -3,8 +3,6 @@ &-popover: #after: .popover max-width: calc($space-5 * 4) - %font-1 - %italic %margin-0 %padding-0-3 %margin-left-3 @@ -14,4 +12,66 @@ %border-radius-0 &-hint-text: + %italic + %font-1 + &-max-length: + %flex + %gap-1 + %align-items-centre + colour: color-mix(in lch, blue, red 50%) + $progress: 1 + &-icon: + %block + %size-em + @before: + %block + %border-2 + %border-radius-100 + size: .75em + clip-path: + polygon( + 50% 50%, + + calc(50% + 75% * cos(calc($progress * 360deg + 90deg))) + calc(50% - 75% * sin(calc($progress * 360deg + 90deg))), + + calc(50% + 75% * cos(calc(min($progress, 0.875) * 360deg + 90deg))) + calc(50% - 75% * sin(calc(min($progress, 0.875) * 360deg + 90deg))), + + calc(50% + 75% * cos(calc(min($progress, 0.625) * 360deg + 90deg))) + calc(50% - 75% * sin(calc(min($progress, 0.625) * 360deg + 90deg))), + + calc(50% + 75% * cos(calc(min($progress, 0.375) * 360deg + 90deg))) + calc(50% - 75% * sin(calc(min($progress, 0.375) * 360deg + 90deg))), + + calc(50% + 75% * cos(calc(min($progress, 0.125) * 360deg + 90deg))) + calc(50% - 75% * sin(calc(min($progress, 0.125) * 360deg + 90deg))), + + 50% 0% + ) + + &--overflowing: + #after: .input-popover-max-length-icon + %grid + %stack + @before, @after: + %stack-self + %background-currentcolour + %height-em + margin-top: -0.05em + margin-left: 0.4em + transform-origin: center center + width: 0.1em + clip-path: none + @before: + %borderless + %border-radius-0 + %rotate-45 + @after: + %rotate-135 + + &-text: + %font-2 + %relative + top: -1px diff --git a/style/component/core/text-area.chiri b/style/component/core/text-area.chiri index 1934a78..a301043 100644 --- a/style/component/core/text-area.chiri +++ b/style/component/core/text-area.chiri @@ -1,2 +1,3 @@ .text-area: + %wrap-anywhere %white-space-pre-wrap