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[],
 }