diff --git a/lang/en-nz.quilt b/lang/en-nz.quilt index 58a293d..d30444b 100644 --- a/lang/en-nz.quilt +++ b/lang/en-nz.quilt @@ -25,6 +25,10 @@ create: Create! cancel: Cancel confirm: Confirm +## toast +failed-to-save: Failed to save {0} +saved: Saved {0} + ## form name/label: Name vanity/label: Vanity @@ -148,6 +152,9 @@ pronouns/hint: The pronouns that others should refer to you with. These will app action/logout: Log Out action/delete: Delete Account +toast/failed-to-save=shared/toast/failed-to-save | account data +toast/saved=shared/toast/saved | account data + ## account/auth service/accessibility/connect: connect {0} service/accessibility/disconnect: disconnect {0} account {1} @@ -189,7 +196,11 @@ action/label/delete=shared/action/delete ## work-edit -### shared/form +### shared +toast/failed-to-save=shared/toast/failed-to-save +toast/saved=shared/toast/saved + +#### form name/label=shared/form/name/label name/hint: The name of your work. This does not have to be unique. vanity/label=shared/form/vanity/label @@ -224,7 +235,11 @@ action/label/delete=shared/action/delete ## chapter-edit -### shared/form +### shared +toast/failed-to-save=shared/toast/failed-to-save | chapter +toast/saved=shared/toast/saved | chapter + +#### form name/label=shared/form/name/label name/hint: The name of this chapter. This does not have to be unique. body/label=shared/form/body/label diff --git a/src/App.ts b/src/App.ts index f4ac65d..f0fdd11 100644 --- a/src/App.ts +++ b/src/App.ts @@ -4,6 +4,7 @@ import Session from "model/Session" import Navigator from "navigation/Navigate" import style from "style" import Component from "ui/Component" +import ToastList from "ui/component/core/toast/ToastList" import Masthead from "ui/component/Masthead" import InputBus from "ui/InputBus" import FocusListener from "ui/utility/FocusListener" @@ -93,6 +94,7 @@ async function App (): Promise { const app: App = Component() .style("app") .append(masthead, masthead.sidebar, content) + .append(ToastList()) .extend(app => ({ navigate: Navigator(app), view, diff --git a/src/navigation/Navigate.ts b/src/navigation/Navigate.ts index bb7a6cc..a51deeb 100644 --- a/src/navigation/Navigate.ts +++ b/src/navigation/Navigate.ts @@ -102,7 +102,6 @@ function Navigator (app: App): Navigator { window.addEventListener("popstate", navigate.fromURL) Object.assign(window, { navigate }) - return navigate } diff --git a/src/ui/component/core/Button.ts b/src/ui/component/core/Button.ts index 4cc8c30..1fc8061 100644 --- a/src/ui/component/core/Button.ts +++ b/src/ui/component/core/Button.ts @@ -1,13 +1,10 @@ import Component from "ui/Component" -import type { ComponentName } from "ui/utility/StyleManipulator" +import type { ComponentNameType } from "ui/utility/StyleManipulator" import type { UnsubscribeState } from "utility/State" import State from "utility/State" -type ButtonType = keyof { [KEY in ComponentName as KEY extends `button-type-${infer TYPE}--${string}` ? TYPE - : KEY extends `button-type-${infer TYPE}` ? TYPE - : never]: string[] } - -type ButtonIcon = keyof { [KEY in ComponentName as KEY extends `button-icon-${infer TYPE}` ? TYPE : never]: string[] } +type ButtonType = ComponentNameType<"button-type"> +type ButtonIcon = ComponentNameType<"button-icon"> interface ButtonTypeManipulator { (...buttonTypes: ButtonType[]): HOST diff --git a/src/ui/component/core/toast/Toast.ts b/src/ui/component/core/toast/Toast.ts new file mode 100644 index 0000000..91ea7da --- /dev/null +++ b/src/ui/component/core/toast/Toast.ts @@ -0,0 +1,42 @@ +import type { ErrorResponse } from "api.fluff4.me" +import Component from "ui/Component" +import type { ToastComponent } from "ui/component/core/toast/ToastList" +import type { Quilt } from "ui/utility/StringApplicator" + +interface Toast { + duration: number + initialise: (toast: ToastComponent, ...params: PARAMS) => any +} + +function Toast (toast: Toast) { + return toast +} + +export default Toast + +export const TOAST_SUCCESS = Toast({ + duration: 2000, + initialise (toast, translation: Quilt.SimpleKey | Quilt.Handler) { + toast.title.text.use(translation) + }, +}) + +function isErrorResponse (error: Error): error is ErrorResponse { + return (error as ErrorResponse).headers !== undefined +} + +export const TOAST_ERROR = Toast({ + duration: 5000, + initialise (toast, translation: Quilt.SimpleKey | Quilt.Handler, error: Error) { + toast.title.text.use(translation) + if (!isErrorResponse(error) || !error.detail) + toast.content.text.set(error.message) + else + toast.content + .append(Component() + .style("toast-error-type") + .text.set(error.message)) + .text.append(": ") + .text.append(error.detail) + }, +}) diff --git a/src/ui/component/core/toast/ToastList.ts b/src/ui/component/core/toast/ToastList.ts new file mode 100644 index 0000000..5bd8994 --- /dev/null +++ b/src/ui/component/core/toast/ToastList.ts @@ -0,0 +1,120 @@ +import Component from "ui/Component" +import type Toast from "ui/component/core/toast/Toast" +import type { ComponentNameType } from "ui/utility/StyleManipulator" +import Async from "utility/Async" +import Task from "utility/Task" +import Time from "utility/Time" + +declare global { + export const toast: ToastList +} + +//////////////////////////////////// +//#region Toast Component + +interface ToastTypeManipulator { + (...toastTypes: ToastType[]): HOST + remove (...toastTypes: ToastType[]): HOST +} + +interface ToastExtensions { + readonly title: Component + readonly content: Component + readonly type: ToastTypeManipulator +} + +export interface ToastComponent extends Component, ToastExtensions { } + +type ToastType = ComponentNameType<"toast-type"> + +const ToastComponent = Component.Builder((component): ToastComponent => { + const title = Component() + .style("toast-title") + .appendTo(component) + + return component + .style("toast") + .extend(toast => ({ + title, + content: undefined!, + type: Object.assign( + (...types: ToastType[]) => { + for (const type of types) + toast.style(`toast-type-${type}`) + return toast + }, + { + remove (...types: ToastType[]) { + for (const type of types) + toast.style.remove(`toast-type-${type}`) + return toast + }, + }, + ), + })) + .extendJIT("content", toast => Component() + .style("toast-content") + .appendTo(toast)) +}).setName("Toast") + +//#endregion +//////////////////////////////////// + +//////////////////////////////////// +//#region Toast List + +interface ToastListExtensions extends Record(toast: Toast, ...params: PARAMS) => ToastComponent> { + +} + +interface ToastList extends Component, ToastListExtensions { } + +const ToastList = Component.Builder((component): ToastList => { + const toasts: ToastList = component + .style("toast-list") + .extend(toasts => ({ + info: add.bind(null, "info"), + success: add.bind(null, "success"), + warning: add.bind(null, "warning"), + })) + + Object.assign(window, { toast: toasts }) + return toasts + + function add (type: ToastType, toast: Toast, ...params: PARAMS) { + const component = ToastComponent() + .type(type) + .style("toast--measuring") + .tweak(toast.initialise, ...params) + + void lifecycle(toast, component) + + return component + } + + async function lifecycle (toast: Toast, component: ToastComponent) { + const wrapper = Component().style("toast-wrapper").appendTo(toasts) + component.style("toast--measuring").appendTo(wrapper) + await Task.yield() + const rect = component.rect.value + component.style.remove("toast--measuring") + wrapper.style.setProperty("height", `${rect.height}px`) + + await Async.sleep(toast.duration) + + component.style("toast--hide") + wrapper.style.removeProperties("height") + await Promise.race([ + new Promise(resolve => component.event.subscribe("animationend", resolve)), + Async.sleep(Time.seconds(1)), + ]) + + return + wrapper.remove() + } +}) + +//#endregion +//////////////////////////////////// + +export default ToastList diff --git a/src/ui/utility/StyleManipulator.ts b/src/ui/utility/StyleManipulator.ts index fe0b6ee..af322e3 100644 --- a/src/ui/utility/StyleManipulator.ts +++ b/src/ui/utility/StyleManipulator.ts @@ -4,6 +4,9 @@ import type State from "utility/State" import type { UnsubscribeState } from "utility/State" export type ComponentName = keyof typeof style +export type ComponentNameType = keyof { [KEY in ComponentName as KEY extends `${PREFIX}-${infer TYPE}--${string}` ? TYPE + : KEY extends `${PREFIX}-${infer TYPE}` ? TYPE + : never]: string[] } interface StyleManipulatorFunctions { get (): ComponentName[] diff --git a/src/ui/view/account/AccountViewForm.ts b/src/ui/view/account/AccountViewForm.ts index 5ef33f6..63b95f1 100644 --- a/src/ui/view/account/AccountViewForm.ts +++ b/src/ui/view/account/AccountViewForm.ts @@ -10,6 +10,7 @@ import LabelledTable from "ui/component/core/LabelledTable" import LabelledTextInputBlock from "ui/component/core/LabelledTextInputBlock" import TextEditor from "ui/component/core/TextEditor" import TextInput from "ui/component/core/TextInput" +import { TOAST_ERROR, TOAST_SUCCESS } from "ui/component/core/toast/Toast" import VanityInput from "ui/component/VanityInput" type AccountViewFormType = @@ -93,10 +94,12 @@ export default Component.Builder((component, type: AccountViewFormType) => { }) if (response instanceof Error) { + toast.warning(TOAST_ERROR, "view/account/toast/failed-to-save", response) console.error(response) return } + toast.success(TOAST_SUCCESS, "view/account/toast/saved") Session.setAuthor(response.data) }) diff --git a/src/ui/view/chapter/ChapterEditForm.ts b/src/ui/view/chapter/ChapterEditForm.ts index 015a5d3..20c349a 100644 --- a/src/ui/view/chapter/ChapterEditForm.ts +++ b/src/ui/view/chapter/ChapterEditForm.ts @@ -11,6 +11,7 @@ import Form from "ui/component/core/Form" import LabelledTable from "ui/component/core/LabelledTable" import TextEditor from "ui/component/core/TextEditor" import TextInput from "ui/component/core/TextInput" +import { TOAST_ERROR, TOAST_SUCCESS } from "ui/component/core/toast/Toast" import type State from "utility/State" export default Component.Builder((component, state: State, workParams: WorkParams) => { @@ -82,10 +83,12 @@ export default Component.Builder((component, state: State, })() if (response instanceof Error) { + toast.warning(TOAST_ERROR, "view/chapter-edit/shared/toast/failed-to-save", response) console.error(response) return } + toast.success(TOAST_SUCCESS, "view/chapter-edit/shared/toast/saved") state.value = response?.data }) diff --git a/src/ui/view/work/WorkEditForm.ts b/src/ui/view/work/WorkEditForm.ts index 4a279ab..964cb43 100644 --- a/src/ui/view/work/WorkEditForm.ts +++ b/src/ui/view/work/WorkEditForm.ts @@ -11,6 +11,7 @@ import LabelledTable from "ui/component/core/LabelledTable" import Textarea from "ui/component/core/Textarea" import TextEditor from "ui/component/core/TextEditor" import TextInput from "ui/component/core/TextInput" +import { TOAST_ERROR, TOAST_SUCCESS } from "ui/component/core/toast/Toast" import type State from "utility/State" export default Component.Builder((component, state: State) => { @@ -64,12 +65,14 @@ export default Component.Builder((component, state: State) form.event.subscribe("submit", async event => { event.preventDefault() + const name = nameInput.value + const response = await (() => { switch (type) { case "create": return EndpointWorkCreate.query({ body: { - name: nameInput.value, + name, vanity: vanityInput.value, description: descriptionInput.value, synopsis: synopsisInput.useMarkdown(), @@ -90,7 +93,7 @@ export default Component.Builder((component, state: State) vanity: state.value.vanity, }, body: { - name: nameInput.value, + name, vanity: vanityInput.value, description: descriptionInput.value, synopsis: synopsisInput.useMarkdown(), @@ -101,10 +104,12 @@ export default Component.Builder((component, state: State) })() if (response instanceof Error) { + toast.warning(TOAST_ERROR, quilt => quilt["view/work-edit/shared/toast/failed-to-save"](name), response) console.error(response) return } + toast.success(TOAST_SUCCESS, quilt => quilt["view/work-edit/shared/toast/saved"](name)) state.value = response?.data }) diff --git a/style/component/core/toast.chiri b/style/component/core/toast.chiri new file mode 100644 index 0000000..1558cbd --- /dev/null +++ b/style/component/core/toast.chiri @@ -0,0 +1,77 @@ +.toast-list: + %no-pointer-events + %fixed + %right-5 + %bottom-4 + %flex + %flex-column + %justify-content-end + %align-items-end + %gap-3 + width: calc(100vw - $space-5) + +$$toast-background!colour +$$toast-background-highlight!colour + +.toast: + %absolute + %bottom-0 + %right-0 + %flex + %flex-column + %gap-1 + %padding-3-4 + %border-1 + %box-shadow-1 + %border-radius-2 + %colour-0 + %width-fit + %align-self-end + %pointer-events + $toast-background: $background-interact-4 + $toast-background-highlight: hsl(from $toast-background h s calc(l + 6)) + $border-colour: hsl(from $toast-background h s calc(l + 12)) + background: radial-gradient(ellipse at left 20% top 20%, $toast-background-highlight, $toast-background) + + &--measuring: + %no-pointer-events + %transparent + %fixed + %bottom-0 + %right-0 + + #animate .5s ease-out: + from: + %translate-down-5 + %transparent + + &-wrapper: + %relative + %block + %height-0 + %width-100 + #transition: #{transition("height")} + + &--hide: + #animate .3s ease-in forwards: + to: + %translate-right-5 + %transparent + + &-title: + %weight-bold + + &-content: + %font-1 + %margin-bottom-1 + + &-error-type: + %weight-bold + + &-type-info: + + &-type-success: + $toast-background: $colour-success-bg + + &-type-warning: + $toast-background: $colour-warning-bg diff --git a/style/index.chiri b/style/index.chiri index bd3208d..f877e23 100644 --- a/style/index.chiri +++ b/style/index.chiri @@ -47,6 +47,7 @@ component/core/text-editor component/core/form component/core/paginator + component/core/toast component/vanity-input component/flag component/masthead diff --git a/style/var/colour.chiri b/style/var/colour.chiri index eb083fb..2ee9857 100644 --- a/style/var/colour.chiri +++ b/style/var/colour.chiri @@ -3,6 +3,10 @@ blue: "#69b3e2 blue-saturated: "#0173ba pink: "#d94e8f + success: "#56d37a + success-bg: "#204731 + warning: "#a8403e + warning-bg: "#4f2322 #each colours as var name, var colour: $colour-#{name}: #{colour}