From 66bac03e3078e4e781d2d3903c05ad66a883a354 Mon Sep 17 00:00:00 2001 From: wukko <me@wukko.me> Date: Mon, 22 Jul 2024 14:33:43 +0600 Subject: [PATCH] web/dialogs: add picker dialog & clean up small dialog --- web/i18n/en/a11y/dialog.json | 3 + web/i18n/en/dialog.json | 9 +- .../dialog/DialogBackdropClose.svelte | 18 ++ .../components/dialog/DialogButtons.svelte | 61 +++++ web/src/components/dialog/DialogHolder.svelte | 107 +++++++- web/src/components/dialog/PickerDialog.svelte | 231 ++++++++++++++++++ web/src/components/dialog/PickerItem.svelte | 65 +++++ web/src/components/dialog/SmallDialog.svelte | 176 ++----------- .../save/buttons/DownloadButton.svelte | 93 ++++--- web/src/lib/download.ts | 9 + web/src/lib/types/dialog.ts | 11 +- 11 files changed, 584 insertions(+), 199 deletions(-) create mode 100644 web/i18n/en/a11y/dialog.json create mode 100644 web/src/components/dialog/DialogBackdropClose.svelte create mode 100644 web/src/components/dialog/DialogButtons.svelte create mode 100644 web/src/components/dialog/PickerDialog.svelte create mode 100644 web/src/components/dialog/PickerItem.svelte create mode 100644 web/src/lib/download.ts diff --git a/web/i18n/en/a11y/dialog.json b/web/i18n/en/a11y/dialog.json new file mode 100644 index 00000000..451d74cf --- /dev/null +++ b/web/i18n/en/a11y/dialog.json @@ -0,0 +1,3 @@ +{ + "picker.item.generic": "media thumbnail" +} diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json index b57fae5b..bfe30bec 100644 --- a/web/i18n/en/dialog.json +++ b/web/i18n/en/dialog.json @@ -2,7 +2,14 @@ "button.gotit": "got it", "button.cancel": "cancel", "button.reset": "reset", + "button.done": "done", + "button.downloadAudio": "download audio", "reset.title": "reset all settings?", - "reset.body": "are you sure you want to reset all settings? this action is immediate and irreversible." + "reset.body": "are you sure you want to reset all settings? this action is immediate and irreversible.", + + "picker.title": "select what to save", + "picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.", + "picker.description.phone": "press an item to save it. images can also be saved with a long press.", + "picker.description.ios": "press an item to save it with a shortcut. images can also be saved with a long press." } diff --git a/web/src/components/dialog/DialogBackdropClose.svelte b/web/src/components/dialog/DialogBackdropClose.svelte new file mode 100644 index 00000000..7c290533 --- /dev/null +++ b/web/src/components/dialog/DialogBackdropClose.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + export let closeFunc: () => void; +</script> + +<div + id="dialog-backdrop-close" + aria-hidden="true" + on:click={() => closeFunc()} +></div> + +<style> + #dialog-backdrop-close { + position: inherit; + height: 100%; + width: 100%; + z-index: -1; + } +</style> diff --git a/web/src/components/dialog/DialogButtons.svelte b/web/src/components/dialog/DialogButtons.svelte new file mode 100644 index 00000000..5f086a78 --- /dev/null +++ b/web/src/components/dialog/DialogButtons.svelte @@ -0,0 +1,61 @@ +<script lang="ts"> + import type { DialogButton } from "$lib/types/dialog"; + + export let buttons: DialogButton[]; + export let closeFunc: () => void; +</script> + +<div class="popup-buttons"> + {#each buttons as button} + <button + class="button popup-button {button.color}" + class:active={button.main} + on:click={async () => { + await button.action(); + closeFunc(); + }} + > + {button.text} + </button> + {/each} +</div> + +<style> + .popup-buttons { + display: flex; + flex-direction: row; + width: 100%; + gap: calc(var(--padding) / 2); + overflow: scroll; + border-radius: var(--border-radius); + min-height: 40px; + } + + .popup-button { + width: 100%; + height: 40px; + } + + .popup-button.red { + background-color: var(--red); + color: var(--white); + } + + .popup-button:not(.active) { + background-color: var(--button-elevated); + } + + .popup-button:not(.active):active { + background-color: var(--button-elevated-hover); + } + + .popup-button:not(:focus-visible) { + box-shadow: none; + } + + @media (hover: hover) { + .popup-button:not(.active):hover { + background-color: var(--button-elevated-hover); + } + } +</style> diff --git a/web/src/components/dialog/DialogHolder.svelte b/web/src/components/dialog/DialogHolder.svelte index 7a9550fe..61503fcd 100644 --- a/web/src/components/dialog/DialogHolder.svelte +++ b/web/src/components/dialog/DialogHolder.svelte @@ -1,7 +1,9 @@ <script lang="ts"> - import SmallDialog from "$components/dialog/SmallDialog.svelte"; import dialogs from "$lib/dialogs"; + import SmallDialog from "$components/dialog/SmallDialog.svelte"; + import PickerDialog from "$components/dialog/PickerDialog.svelte"; + $: backdropVisible = $dialogs.length > 0; </script> @@ -18,11 +20,56 @@ buttons={dialog.buttons} /> {/if} + + {#if dialog.type === "picker"} + <PickerDialog + id={dialog.id} + items={dialog.items} + buttons={dialog.buttons} + /> + {/if} {/each} <div id="dialog-backdrop" class:visible={backdropVisible}></div> </div> <style> + :global(dialog) { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: none; + + max-height: 100%; + max-width: 100%; + height: 100%; + width: 100%; + margin: 0; + padding: 0; + border: none; + pointer-events: all; + + inset-inline-start: unset; + inset-inline-end: unset; + + overflow: hidden; + } + + :global(dialog:modal) { + inset-block-start: 0; + inset-block-end: 0; + } + + :global(dialog:modal::backdrop) { + display: none; + } + + @media screen and (max-width: 535px) { + :global(dialog) { + justify-content: end; + } + } + #dialog-holder { position: absolute; padding-top: env(safe-area-inset-bottom); @@ -60,4 +107,62 @@ backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } + + :global(.open .dialog-body) { + animation: modal-in 0.35s; + } + + :global(.closing .dialog-body) { + animation: modal-out 0.15s; + opacity: 0; + } + + @media screen and (max-width: 535px) { + :global(.open .dialog-body) { + animation: modal-in-mobile 0.4s; + } + } + + @keyframes modal-in { + from { + transform: scale(0.8); + opacity: 0; + } + 30% { + opacity: 1; + } + 50% { + transform: scale(1.005); + } + 100% { + transform: scale(1); + } + } + + @keyframes modal-out { + from { + opacity: 1; + } + to { + opacity: 0; + transform: scale(0.9); + visibility: hidden; + } + } + + @keyframes modal-in-mobile { + from { + transform: translateY(200px); + opacity: 0; + } + 30% { + opacity: 1; + } + 50% { + transform: translateY(-5px); + } + 100% { + transform: translateY(0px); + } + } </style> diff --git a/web/src/components/dialog/PickerDialog.svelte b/web/src/components/dialog/PickerDialog.svelte new file mode 100644 index 00000000..5c5c0d61 --- /dev/null +++ b/web/src/components/dialog/PickerDialog.svelte @@ -0,0 +1,231 @@ +<script lang="ts"> + import { tick } from "svelte"; + import { device } from "$lib/device"; + import { killDialog } from "$lib/dialogs"; + import { t } from "$lib/i18n/translations"; + + import type { Optional } from "$lib/types/generic"; + import type { DialogButton } from "$lib/types/dialog"; + import type { DialogPickerItem } from "$lib/types/dialog"; + + import PickerItem from "$components/dialog/PickerItem.svelte"; + import DialogButtons from "$components/dialog/DialogButtons.svelte"; + import DialogBackdropClose from "$components/dialog/DialogBackdropClose.svelte"; + + import IconBoxMultiple from "@tabler/icons-svelte/IconBoxMultiple.svelte"; + + export let id: string; + export let items: Optional<DialogPickerItem[]>; + export let buttons: Optional<DialogButton[]>; + + let dialogDescription = "dialog.picker.description."; + + if (device.is.iOS) { + dialogDescription += "ios"; + } else if (device.is.mobile) { + dialogDescription += "mobile"; + } else { + dialogDescription += "desktop"; + } + + let dialogParent: HTMLDialogElement; + + let closing = false; + let open = false; + + const close = () => { + if (dialogParent) { + closing = true; + open = false; + setTimeout(() => { + dialogParent.close(); + killDialog(); + }, 150); + } + }; + + $: if (dialogParent) { + dialogParent.showModal(); + tick().then(() => { + open = true; + }); + } +</script> + +<dialog + id="dialog-{id}" + bind:this={dialogParent} + class:closing + class:open + class:three-columns={items && items.length <= 3} +> + <div class="dialog-body picker-dialog"> + <div class="popup-header"> + <div class="popup-title-container"> + <IconBoxMultiple /> + <h2 class="popup-title" tabindex="-1"> + {$t("dialog.picker.title")} + </h2> + </div> + <div class="subtext popup-description"> + {$t(dialogDescription)} + </div> + </div> + <div class="picker-body"> + {#if items} + {#each items as item} + <PickerItem {item} /> + {/each} + {/if} + </div> + {#if buttons} + <DialogButtons {buttons} closeFunc={close} /> + {/if} + </div> + + <DialogBackdropClose closeFunc={close} /> +</dialog> + +<style> + .picker-dialog { + --dialog-padding: 18px; + --picker-item-size: 120px; + + display: flex; + flex-direction: column; + align-items: center; + gap: var(--padding); + max-height: calc( + 90% - env(safe-area-inset-bottom) - env(safe-area-inset-top) + ); + + width: auto; + background: var(--popup-bg); + box-shadow: + 0 0 0 2px var(--popup-stroke) inset, + 0 0 60px 10px var(--popup-bg); + padding: var(--dialog-padding); + position: relative; + will-change: transform; + + border-radius: 29px; + } + + .popup-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + max-width: calc(var(--picker-item-size) * 4); + } + + .popup-title-container { + display: flex; + flex-direction: row; + align-items: center; + gap: calc(var(--padding) / 2); + color: var(--secondary); + } + + .popup-title-container :global(svg) { + height: 21px; + width: 21px; + } + + .popup-title { + font-size: 18px; + line-height: 1.1; + } + + .popup-description { + font-size: 13px; + padding: 0; + } + + .popup-title:focus-visible { + box-shadow: none !important; + } + + .picker-body { + overflow-y: scroll; + display: grid; + justify-items: center; + grid-template-columns: 1fr 1fr 1fr 1fr; + } + + .three-columns .picker-body { + grid-template-columns: 1fr 1fr 1fr; + } + + .three-columns .popup-header { + max-width: calc(var(--picker-item-size) * 3); + } + + :global(.picker-item) { + width: var(--picker-item-size); + height: var(--picker-item-size); + } + + @media screen and (max-width: 535px) { + .picker-dialog { + margin-bottom: calc( + var(--dialog-padding) + env(safe-area-inset-bottom) + ); + box-shadow: 0 0 0 2px var(--popup-stroke) inset; + } + + .picker-body { + grid-template-columns: 1fr 1fr 1fr; + } + + .popup-header { + max-width: calc(var(--picker-item-size) * 3); + } + } + + @media screen and (max-width: 400px) { + .picker-dialog { + --picker-item-size: 115px; + } + } + + @media screen and (max-width: 380px) { + .picker-dialog { + --picker-item-size: 110px; + } + } + + @media screen and (max-width: 365px) { + .picker-dialog { + --picker-item-size: 105px; + } + } + + @media screen and (max-width: 350px) { + .picker-dialog { + --picker-item-size: 100px; + } + } + + @media screen and (max-width: 335px) { + .picker-body, + .three-columns .picker-body { + grid-template-columns: 1fr 1fr; + } + + .popup-header { + max-width: calc(var(--picker-item-size) * 3); + } + } + + @media screen and (max-width: 255px) { + .picker-dialog { + --picker-item-size: 120px; + } + + .picker-body, + .three-columns .picker-body { + grid-template-columns: 1fr; + } + } +</style> diff --git a/web/src/components/dialog/PickerItem.svelte b/web/src/components/dialog/PickerItem.svelte new file mode 100644 index 00000000..b56abcc9 --- /dev/null +++ b/web/src/components/dialog/PickerItem.svelte @@ -0,0 +1,65 @@ +<script lang="ts"> + import { t } from "$lib/i18n/translations"; + + import { downloadFile } from "$lib/download"; + import type { DialogPickerItem } from "$lib/types/dialog"; + + import Skeleton from "$components/misc/Skeleton.svelte"; + + export let item: DialogPickerItem; + + let imageLoaded = false; +</script> + +<button + class="picker-item" + on:click={() => { + downloadFile(item.url); + }} +> + <img + class="picker-image" + src={item.thumb ? item.thumb : item.url} + class:loading={!imageLoaded} + on:load={() => (imageLoaded = true)} + alt={$t("a11y.dialog.picker.item.generic")} + height="100" + width="100" + /> + <Skeleton class="picker-image" hidden={imageLoaded} /> +</button> + +<style> + .picker-item { + background: none; + padding: 2px; + box-shadow: none; + border-radius: calc(var(--border-radius) / 2 + 2px); + } + + :global(.picker-image) { + display: block; + width: 100%; + height: 100%; + + aspect-ratio: 1/1; + pointer-events: all; + + object-fit: cover; + border-radius: calc(var(--border-radius) / 2); + } + + .picker-image.loading { + display: none; + } + + .picker-item:active .picker-image { + opacity: 0.8; + } + + @media (hover: hover) { + .picker-item:hover .picker-image { + opacity: 0.8; + } + } +</style> diff --git a/web/src/components/dialog/SmallDialog.svelte b/web/src/components/dialog/SmallDialog.svelte index 3a4cdddf..b2951d62 100644 --- a/web/src/components/dialog/SmallDialog.svelte +++ b/web/src/components/dialog/SmallDialog.svelte @@ -1,12 +1,14 @@ <script lang="ts"> import { tick } from "svelte"; - import { killDialog } from "$lib/dialogs"; - import type { DialogButton, SmallDialogIcons } from "$lib/types/dialog"; - import type { MeowbaltEmotions } from "$lib/types/meowbalt"; + import type { Optional } from "$lib/types/generic"; + import type { MeowbaltEmotions } from "$lib/types/meowbalt"; + import type { DialogButton, SmallDialogIcons } from "$lib/types/dialog"; import Meowbalt from "$components/misc/Meowbalt.svelte"; + import DialogButtons from "$components/dialog/DialogButtons.svelte"; + import DialogBackdropClose from "$components/dialog/DialogBackdropClose.svelte"; import IconAlertTriangle from "@tabler/icons-svelte/IconAlertTriangle.svelte"; @@ -16,7 +18,7 @@ export let title: string = ""; export let bodyText: string = ""; export let bodySubText: string = ""; - export let buttons: DialogButton[]; + export let buttons: Optional<DialogButton[]>; let dialogParent: HTMLDialogElement; @@ -58,7 +60,7 @@ </div> {/if} {#if title} - <h2 id="popup-title" tabindex="-1">{title}</h2> + <h2 class="popup-title" tabindex="-1">{title}</h2> {/if} </div> {/if} @@ -69,57 +71,15 @@ <div class="subtext">{bodySubText}</div> {/if} </div> - <div class="popup-buttons"> - {#each buttons as button} - <button - class="button popup-button {button.color}" - class:active={button.main} - on:click={async () => { - await button.action(); - close(); - }} - > - {button.text} - </button> - {/each} - </div> + {#if buttons} + <DialogButtons {buttons} closeFunc={close} /> + {/if} </div> - <div id="dialog-backdrop-close" aria-hidden="true" on:click={() => close()}></div> + <DialogBackdropClose closeFunc={close} /> </dialog> <style> - dialog { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background: none; - - max-height: 100%; - max-width: 100%; - height: 100%; - width: 100%; - margin: 0; - padding: 0; - border: none; - pointer-events: all; - - inset-inline-start: unset; - inset-inline-end: unset; - - overflow: hidden; - } - - dialog:modal { - inset-block-start: 0; - inset-block-end: 0; - } - - dialog:modal::backdrop { - display: none; - } - .small-dialog, .popup-body { display: flex; @@ -132,34 +92,25 @@ } .small-dialog { - --small-dialog-padding: 18px; + --dialog-padding: 18px; align-items: center; text-align: center; max-width: 340px; width: calc( - 100% - var(--padding) * 2 - var(--small-dialog-padding) * 2 + 100% - var(--padding) * 2 - var(--dialog-padding) * 2 ); background: var(--popup-bg); box-shadow: 0 0 0 2px var(--popup-stroke) inset, 0 0 60px 10px var(--popup-bg); - padding: var(--small-dialog-padding); + padding: var(--dialog-padding); margin: var(--padding); border-radius: 29px; position: relative; will-change: transform; } - .open .small-dialog { - animation: modal-in 0.35s; - } - - .closing .small-dialog { - animation: modal-out 0.15s; - opacity: 0; - } - .small-dialog.meowbalt-visible { padding-top: calc(var(--padding) * 4); } @@ -169,7 +120,7 @@ top: -120px; } - .popup-header h2 { + .popup-title { color: var(--secondary); font-size: 19px; } @@ -191,109 +142,14 @@ } .body-text:focus-visible, - h2:focus-visible { + .popup-title:focus-visible { box-shadow: none !important; } - .popup-buttons { - display: flex; - flex-direction: row; - width: 100%; - gap: calc(var(--padding) / 2); - overflow: scroll; - border-radius: var(--border-radius); - } - - .popup-button { - width: 100%; - height: 40px; - } - - .popup-button.red { - background-color: var(--red); - color: var(--white); - } - - .popup-button:not(.active) { - background-color: var(--button-elevated); - } - - .popup-button:not(.active):active { - background-color: var(--button-elevated-hover); - } - - .popup-button:not(:focus-visible) { - box-shadow: none; - } - - @media (hover: hover) { - .popup-button:not(.active):hover { - background-color: var(--button-elevated-hover); - } - } - - #dialog-backdrop-close { - position: inherit; - height: 100%; - width: 100%; - z-index: -1; - } - - @keyframes modal-in { - from { - transform: scale(0.8); - opacity: 0; - } - 30% { - opacity: 1; - } - 50% { - transform: scale(1.005); - } - 100% { - transform: scale(1); - } - } - - @keyframes modal-out { - from { - opacity: 1; - } - to { - opacity: 0; - transform: scale(0.9); - visibility: hidden; - } - } - @media screen and (max-width: 535px) { - dialog { - justify-content: end; - } - .small-dialog { margin-bottom: calc(var(--padding) + env(safe-area-inset-bottom)); box-shadow: 0 0 0 2px var(--popup-stroke) inset; } - - .open .small-dialog { - animation: modal-in-mobile 0.4s; - } - - @keyframes modal-in-mobile { - from { - transform: translateY(200px); - opacity: 0; - } - 30% { - opacity: 1; - } - 50% { - transform: translateY(-5px); - } - 100% { - transform: translateY(0px); - } - } } </style> diff --git a/web/src/components/save/buttons/DownloadButton.svelte b/web/src/components/save/buttons/DownloadButton.svelte index 55657ddf..fe5f26a0 100644 --- a/web/src/components/save/buttons/DownloadButton.svelte +++ b/web/src/components/save/buttons/DownloadButton.svelte @@ -2,49 +2,50 @@ import "@fontsource-variable/noto-sans-mono"; import API from "$lib/api"; - import { device } from "$lib/device"; - import { t } from "$lib/i18n/translations"; - import { createDialog } from "$lib/dialogs"; + import { downloadFile } from "$lib/download"; + import type { DialogInfo } from "$lib/types/dialog"; export let url: string; export let isDisabled = false; $: buttonText = ">>"; - $: buttonAltText = $t('a11y.save.download'); + $: buttonAltText = $t("a11y.save.download"); $: isDisabled = false; let defaultErrorPopup: DialogInfo = { id: "save-error", type: "small", meowbalt: "error", - buttons: [{ - text: $t("dialog.button.gotit"), - main: true, - action: () => {}, - }] - } + buttons: [ + { + text: $t("dialog.button.gotit"), + main: true, + action: () => {}, + }, + ], + }; const changeDownloadButton = (state: string) => { isDisabled = true; switch (state) { case "think": buttonText = "..."; - buttonAltText = $t('a11y.save.downloadThink'); + buttonAltText = $t("a11y.save.downloadThink"); break; case "check": buttonText = "..?"; - buttonAltText = $t('a11y.save.downloadCheck'); + buttonAltText = $t("a11y.save.downloadCheck"); break; case "done": buttonText = ">>>"; - buttonAltText = $t('a11y.save.downloadDone'); + buttonAltText = $t("a11y.save.downloadDone"); break; case "error": buttonText = "!!"; - buttonAltText = $t('a11y.save.downloadError'); + buttonAltText = $t("a11y.save.downloadError"); break; } }; @@ -53,18 +54,10 @@ setTimeout(() => { buttonText = ">>"; isDisabled = false; - buttonAltText = $t('a11y.save.download'); + buttonAltText = $t("a11y.save.download"); }, 2500); }; - const downloadFile = (url: string) => { - if (device.is.iOS) { - return navigator?.share({ url }).catch(() => {}); - } else { - return window.open(url, "_blank"); - } - }; - // alerts are temporary, we don't have an error popup yet >_< export const download = async (link: string) => { changeDownloadButton("think"); @@ -76,9 +69,9 @@ restoreDownloadButton(); return createDialog({ - ...defaultErrorPopup as DialogInfo, - bodyText: "couldn't access the api" - }) + ...(defaultErrorPopup as DialogInfo), + bodyText: "couldn't access the api", + }); } if (response.status === "error" || response.status === "rate-limit") { @@ -86,9 +79,9 @@ restoreDownloadButton(); return createDialog({ - ...defaultErrorPopup as DialogInfo, - bodyText: response.text - }) + ...(defaultErrorPopup as DialogInfo), + bodyText: response.text, + }); } if (response.status === "redirect") { @@ -113,19 +106,49 @@ restoreDownloadButton(); return createDialog({ - ...defaultErrorPopup as DialogInfo, - bodyText: "couldn't probe the stream" - }) + ...(defaultErrorPopup as DialogInfo), + bodyText: "couldn't probe the stream", + }); } } + if (response.status === "picker") { + restoreDownloadButton(); + + let pickerButtons = [ + { + text: $t("dialog.button.done"), + main: true, + action: () => {}, + }, + ]; + + if (response.audio) { + const pickerAudio = response.audio; + pickerButtons.unshift({ + text: $t("dialog.button.downloadAudio"), + main: false, + action: () => { + downloadFile(pickerAudio); + }, + }); + } + + return createDialog({ + id: "download-picker", + type: "picker", + items: response.picker, + buttons: pickerButtons, + }); + } + changeDownloadButton("error"); restoreDownloadButton(); return createDialog({ - ...defaultErrorPopup as DialogInfo, - bodyText: "unknown/unsupported status" - }) + ...(defaultErrorPopup as DialogInfo), + bodyText: "unknown/unsupported status", + }); }; </script> diff --git a/web/src/lib/download.ts b/web/src/lib/download.ts new file mode 100644 index 00000000..e08320ef --- /dev/null +++ b/web/src/lib/download.ts @@ -0,0 +1,9 @@ +import { device } from "$lib/device"; + +export const downloadFile = (url: string) => { + if (device.is.iOS) { + return navigator?.share({ url }).catch(() => {}); + } else { + return window.open(url, "_blank"); + } +}; diff --git a/web/src/lib/types/dialog.ts b/web/src/lib/types/dialog.ts index 6630fd6f..f6d2439d 100644 --- a/web/src/lib/types/dialog.ts +++ b/web/src/lib/types/dialog.ts @@ -9,13 +9,20 @@ export type DialogButton = { export type SmallDialogIcons = "warn-red"; +export type DialogPickerItem = { + type?: 'photo' | 'video', + url: string, + thumb?: string, +} + export type DialogInfo = { id: string, - type: "small", + type: "small" | "picker", meowbalt?: MeowbaltEmotions, icon?: SmallDialogIcons, title?: string, bodyText?: string, bodySubText?: string, - buttons: DialogButton[], + buttons?: DialogButton[], + items?: DialogPickerItem[], }