Skip to content

Commit

Permalink
Add character count remaining display
Browse files Browse the repository at this point in the history
  • Loading branch information
ChiriVulpes committed Dec 8, 2024
1 parent 69534f9 commit ef06513
Show file tree
Hide file tree
Showing 14 changed files with 198 additions and 66 deletions.
2 changes: 2 additions & 0 deletions src/App.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -56,6 +57,7 @@ async function App (): Promise<App> {
})

await Env.load()
await FormInputLengths.getManifest()

// const path = URL.path ?? URL.hash;
// if (path === AuthView.id) {
Expand Down
3 changes: 3 additions & 0 deletions src/endpoint/manifest/EndpointFormInputLengths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Endpoint from "endpoint/Endpoint"

export default Endpoint("/manifest/form/lengths", "get")
8 changes: 8 additions & 0 deletions src/model/FormInputLengths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import EndpointFormInputLengths from "endpoint/manifest/EndpointFormInputLengths"
import Manifest from "model/Manifest"

export default Manifest({
get () {
return EndpointFormInputLengths.query()
},
})
43 changes: 43 additions & 0 deletions src/model/Manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { ErrorResponse, Response } from "api.fluff4.me"
import Time from "utility/Time"

interface ManifestDefinition<T> {
get (): Promise<Response<T> | ErrorResponse<Response<T>>>
}

interface Manifest<T> {
manifest: T | undefined
getManifest (force?: boolean): Promise<T>
isFresh (manifest?: T): manifest is T
}

function Manifest<T> (definition: ManifestDefinition<T>): Manifest<T> {
let manifestTime: number | undefined
let promise: Promise<T> | undefined

const result: Manifest<T> = {
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
95 changes: 38 additions & 57 deletions src/model/Tags.ts
Original file line number Diff line number Diff line change
@@ -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<ManifestGlobalTags> | 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<Tag | undefined>
export async function resolve (category: string, name: string): Promise<Tag | undefined>
export async function resolve (tags?: string[] | null): Promise<Tag[]>
export async function resolve (tags?: string[] | null | string, name?: string) {
if (!tags?.length)
return []
export async function resolve (tag: string): Promise<Tag | undefined>
export async function resolve (category: string, name: string): Promise<Tag | undefined>
export async function resolve (tags?: string[] | null): Promise<Tag[]>
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
5 changes: 3 additions & 2 deletions src/ui/component/core/TextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ParseSpec | undefined>))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions src/ui/component/core/TextInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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
},
}))

Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions src/ui/component/core/Textarea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/ui/component/core/ext/Input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface InputExtensions {
readonly hint: StringApplicator.Optional<this>
readonly maxLength: State<number | undefined>
readonly length: State<number | undefined>
setMaxLength (maxLength?: number): this
setRequired (required?: boolean): this
/**
* - Sets the `[name]` of this component to `label.for`
Expand All @@ -27,7 +28,9 @@ const Input = Component.Extension((component): Input => {
const hintText = State<string | undefined>(undefined)
const maxLength = State<number | undefined>(undefined)
const length = State<number | undefined>(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) {
Expand All @@ -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)
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/ui/view/account/AccountViewForm.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)))

Expand All @@ -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)))

Expand Down
2 changes: 2 additions & 0 deletions src/ui/view/chapter/ChapterEditForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -32,6 +33,7 @@ export default Component.Builder((component, state: State<Chapter | undefined>,
.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)))

Expand Down
5 changes: 5 additions & 0 deletions src/ui/view/work/WorkEditForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -32,6 +33,7 @@ export default Component.Builder((component, state: State<WorkFull | undefined>)
.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)))

Expand All @@ -41,18 +43,21 @@ export default Component.Builder((component, state: State<WorkFull | undefined>)
.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)))

Expand Down
Loading

0 comments on commit ef06513

Please sign in to comment.