web/dialogs: add picker dialog & clean up small dialog

This commit is contained in:
wukko 2024-07-22 14:33:43 +06:00
parent 24b783e5fb
commit 66bac03e30
No known key found for this signature in database
GPG key ID: 3E30B3F26C7B4AA2
11 changed files with 584 additions and 199 deletions

View file

@ -0,0 +1,3 @@
{
"picker.item.generic": "media thumbnail"
}

View file

@ -2,7 +2,14 @@
"button.gotit": "got it", "button.gotit": "got it",
"button.cancel": "cancel", "button.cancel": "cancel",
"button.reset": "reset", "button.reset": "reset",
"button.done": "done",
"button.downloadAudio": "download audio",
"reset.title": "reset all settings?", "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."
} }

View file

@ -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>

View file

@ -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>

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import SmallDialog from "$components/dialog/SmallDialog.svelte";
import dialogs from "$lib/dialogs"; import dialogs from "$lib/dialogs";
import SmallDialog from "$components/dialog/SmallDialog.svelte";
import PickerDialog from "$components/dialog/PickerDialog.svelte";
$: backdropVisible = $dialogs.length > 0; $: backdropVisible = $dialogs.length > 0;
</script> </script>
@ -18,11 +20,56 @@
buttons={dialog.buttons} buttons={dialog.buttons}
/> />
{/if} {/if}
{#if dialog.type === "picker"}
<PickerDialog
id={dialog.id}
items={dialog.items}
buttons={dialog.buttons}
/>
{/if}
{/each} {/each}
<div id="dialog-backdrop" class:visible={backdropVisible}></div> <div id="dialog-backdrop" class:visible={backdropVisible}></div>
</div> </div>
<style> <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 { #dialog-holder {
position: absolute; position: absolute;
padding-top: env(safe-area-inset-bottom); padding-top: env(safe-area-inset-bottom);
@ -60,4 +107,62 @@
backdrop-filter: none !important; backdrop-filter: none !important;
-webkit-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> </style>

View file

@ -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>

View file

@ -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>

View file

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import { killDialog } from "$lib/dialogs"; 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 { 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 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"; import IconAlertTriangle from "@tabler/icons-svelte/IconAlertTriangle.svelte";
@ -16,7 +18,7 @@
export let title: string = ""; export let title: string = "";
export let bodyText: string = ""; export let bodyText: string = "";
export let bodySubText: string = ""; export let bodySubText: string = "";
export let buttons: DialogButton[]; export let buttons: Optional<DialogButton[]>;
let dialogParent: HTMLDialogElement; let dialogParent: HTMLDialogElement;
@ -58,7 +60,7 @@
</div> </div>
{/if} {/if}
{#if title} {#if title}
<h2 id="popup-title" tabindex="-1">{title}</h2> <h2 class="popup-title" tabindex="-1">{title}</h2>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -69,57 +71,15 @@
<div class="subtext">{bodySubText}</div> <div class="subtext">{bodySubText}</div>
{/if} {/if}
</div> </div>
<div class="popup-buttons"> {#if buttons}
{#each buttons as button} <DialogButtons {buttons} closeFunc={close} />
<button {/if}
class="button popup-button {button.color}"
class:active={button.main}
on:click={async () => {
await button.action();
close();
}}
>
{button.text}
</button>
{/each}
</div>
</div> </div>
<div id="dialog-backdrop-close" aria-hidden="true" on:click={() => close()}></div> <DialogBackdropClose closeFunc={close} />
</dialog> </dialog>
<style> <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, .small-dialog,
.popup-body { .popup-body {
display: flex; display: flex;
@ -132,34 +92,25 @@
} }
.small-dialog { .small-dialog {
--small-dialog-padding: 18px; --dialog-padding: 18px;
align-items: center; align-items: center;
text-align: center; text-align: center;
max-width: 340px; max-width: 340px;
width: calc( width: calc(
100% - var(--padding) * 2 - var(--small-dialog-padding) * 2 100% - var(--padding) * 2 - var(--dialog-padding) * 2
); );
background: var(--popup-bg); background: var(--popup-bg);
box-shadow: box-shadow:
0 0 0 2px var(--popup-stroke) inset, 0 0 0 2px var(--popup-stroke) inset,
0 0 60px 10px var(--popup-bg); 0 0 60px 10px var(--popup-bg);
padding: var(--small-dialog-padding); padding: var(--dialog-padding);
margin: var(--padding); margin: var(--padding);
border-radius: 29px; border-radius: 29px;
position: relative; position: relative;
will-change: transform; 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 { .small-dialog.meowbalt-visible {
padding-top: calc(var(--padding) * 4); padding-top: calc(var(--padding) * 4);
} }
@ -169,7 +120,7 @@
top: -120px; top: -120px;
} }
.popup-header h2 { .popup-title {
color: var(--secondary); color: var(--secondary);
font-size: 19px; font-size: 19px;
} }
@ -191,109 +142,14 @@
} }
.body-text:focus-visible, .body-text:focus-visible,
h2:focus-visible { .popup-title:focus-visible {
box-shadow: none !important; 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) { @media screen and (max-width: 535px) {
dialog {
justify-content: end;
}
.small-dialog { .small-dialog {
margin-bottom: calc(var(--padding) + env(safe-area-inset-bottom)); margin-bottom: calc(var(--padding) + env(safe-area-inset-bottom));
box-shadow: 0 0 0 2px var(--popup-stroke) inset; 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> </style>

View file

@ -2,49 +2,50 @@
import "@fontsource-variable/noto-sans-mono"; import "@fontsource-variable/noto-sans-mono";
import API from "$lib/api"; import API from "$lib/api";
import { device } from "$lib/device";
import { t } from "$lib/i18n/translations"; import { t } from "$lib/i18n/translations";
import { createDialog } from "$lib/dialogs"; import { createDialog } from "$lib/dialogs";
import { downloadFile } from "$lib/download";
import type { DialogInfo } from "$lib/types/dialog"; import type { DialogInfo } from "$lib/types/dialog";
export let url: string; export let url: string;
export let isDisabled = false; export let isDisabled = false;
$: buttonText = ">>"; $: buttonText = ">>";
$: buttonAltText = $t('a11y.save.download'); $: buttonAltText = $t("a11y.save.download");
$: isDisabled = false; $: isDisabled = false;
let defaultErrorPopup: DialogInfo = { let defaultErrorPopup: DialogInfo = {
id: "save-error", id: "save-error",
type: "small", type: "small",
meowbalt: "error", meowbalt: "error",
buttons: [{ buttons: [
{
text: $t("dialog.button.gotit"), text: $t("dialog.button.gotit"),
main: true, main: true,
action: () => {}, action: () => {},
}] },
} ],
};
const changeDownloadButton = (state: string) => { const changeDownloadButton = (state: string) => {
isDisabled = true; isDisabled = true;
switch (state) { switch (state) {
case "think": case "think":
buttonText = "..."; buttonText = "...";
buttonAltText = $t('a11y.save.downloadThink'); buttonAltText = $t("a11y.save.downloadThink");
break; break;
case "check": case "check":
buttonText = "..?"; buttonText = "..?";
buttonAltText = $t('a11y.save.downloadCheck'); buttonAltText = $t("a11y.save.downloadCheck");
break; break;
case "done": case "done":
buttonText = ">>>"; buttonText = ">>>";
buttonAltText = $t('a11y.save.downloadDone'); buttonAltText = $t("a11y.save.downloadDone");
break; break;
case "error": case "error":
buttonText = "!!"; buttonText = "!!";
buttonAltText = $t('a11y.save.downloadError'); buttonAltText = $t("a11y.save.downloadError");
break; break;
} }
}; };
@ -53,18 +54,10 @@
setTimeout(() => { setTimeout(() => {
buttonText = ">>"; buttonText = ">>";
isDisabled = false; isDisabled = false;
buttonAltText = $t('a11y.save.download'); buttonAltText = $t("a11y.save.download");
}, 2500); }, 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 >_< // alerts are temporary, we don't have an error popup yet >_<
export const download = async (link: string) => { export const download = async (link: string) => {
changeDownloadButton("think"); changeDownloadButton("think");
@ -76,9 +69,9 @@
restoreDownloadButton(); restoreDownloadButton();
return createDialog({ return createDialog({
...defaultErrorPopup as DialogInfo, ...(defaultErrorPopup as DialogInfo),
bodyText: "couldn't access the api" bodyText: "couldn't access the api",
}) });
} }
if (response.status === "error" || response.status === "rate-limit") { if (response.status === "error" || response.status === "rate-limit") {
@ -86,9 +79,9 @@
restoreDownloadButton(); restoreDownloadButton();
return createDialog({ return createDialog({
...defaultErrorPopup as DialogInfo, ...(defaultErrorPopup as DialogInfo),
bodyText: response.text bodyText: response.text,
}) });
} }
if (response.status === "redirect") { if (response.status === "redirect") {
@ -113,19 +106,49 @@
restoreDownloadButton(); restoreDownloadButton();
return createDialog({ return createDialog({
...defaultErrorPopup as DialogInfo, ...(defaultErrorPopup as DialogInfo),
bodyText: "couldn't probe the stream" 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"); changeDownloadButton("error");
restoreDownloadButton(); restoreDownloadButton();
return createDialog({ return createDialog({
...defaultErrorPopup as DialogInfo, ...(defaultErrorPopup as DialogInfo),
bodyText: "unknown/unsupported status" bodyText: "unknown/unsupported status",
}) });
}; };
</script> </script>

9
web/src/lib/download.ts Normal file
View file

@ -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");
}
};

View file

@ -9,13 +9,20 @@ export type DialogButton = {
export type SmallDialogIcons = "warn-red"; export type SmallDialogIcons = "warn-red";
export type DialogPickerItem = {
type?: 'photo' | 'video',
url: string,
thumb?: string,
}
export type DialogInfo = { export type DialogInfo = {
id: string, id: string,
type: "small", type: "small" | "picker",
meowbalt?: MeowbaltEmotions, meowbalt?: MeowbaltEmotions,
icon?: SmallDialogIcons, icon?: SmallDialogIcons,
title?: string, title?: string,
bodyText?: string, bodyText?: string,
bodySubText?: string, bodySubText?: string,
buttons: DialogButton[], buttons?: DialogButton[],
items?: DialogPickerItem[],
} }