css fixes, playlist management, fixed pwa offline caching
MREADME.md
@@ -7,7 +7,7 @@ Pico Pixel Player is a lightweight, self-hostable audio player with an [ElysiaJS
- **Easy Setup**: Configure with just a `.env` file
- **Responsive UI**: Works seamlessly on desktop and mobile
- **Offline Capable**: Progressive Web App (PWA) support and local caching of files
- **Transcoding**: Optionally transcode files at a selected bitrate to your client
- **Transcoding**: Optionally transcode files at a selected bitrate and format to your client
- **Directory Listing**: Browse files in their original directory structure
## Screenshot
Mwebclient/src/App.tsx
@@ -11,7 +11,7 @@ import { getSrc } from "./offline";
import { MediaFormat } from "music-server-shared/types";
import PlaylistManager from "./components/PlaylistManager";
//TODO: codec choice as enum
//TODO: dont store playlists with id
//TODO: remember scroll position for parent directories
//TODO: when downloading dirs, maybe put all subfiles in a "downloading" state to not accidentally trigger them twice
//TODO: better touch controls?
@@ -24,6 +24,7 @@ import PlaylistManager from "./components/PlaylistManager";
//TODO: delete confirmation?
//TODO: nicer errors when song can't be played because server is down
//TODO: setting export/import
//TODO: video support
const App: Component = () => {
const [showFileBrowser, setShowFileBrowser] = createSignal(false);
@@ -169,13 +170,11 @@ const App: Component = () => {
// Update progress when the time updates
function updateProgress() {
if (audioElement) {
setPlayerState({
...playerState(),
currentTime: audioElement.currentTime,
buffered: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
});
}
setPlayerState({
...playerState(),
currentTime: audioElement.currentTime,
buffered: audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
});
}
// Initialize app
@@ -225,6 +224,24 @@ const App: Component = () => {
audioElement.addEventListener("ended", playNext);
audioElement.addEventListener("timeupdate", updateProgress);
audioElement.addEventListener("durationchange", updateProgress);
audioElement.addEventListener("pause", () =>
setPlayerState({
...playerState(),
isPlaying: false,
}),
);
audioElement.addEventListener("play", () =>
setPlayerState({
...playerState(),
isPlaying: true,
}),
);
audioElement.addEventListener("volumechange", () =>
setPlayerState({
...playerState(),
volume: audioElement.volume,
}),
);
window.addEventListener("hashchange", () => {
setCurrentDir(decodeURI(window.location.hash.slice(1)));
@@ -316,7 +333,13 @@ const App: Component = () => {
setOptions={setOptions}
/>
<PlaylistManager showPlaylistManager={showPlaylistManager} setShowPlaylistManager={setShowPlaylistManager} />
<PlaylistManager
showPlaylistManager={showPlaylistManager}
setShowPlaylistManager={setShowPlaylistManager}
playlist={playlist}
setPlaylist={setPlaylist}
playTrack={playTrack}
/>
</div>
);
};
Awebclient/src/components/Dialog.css
@@ -0,0 +1,24 @@
dialog {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
width: 800px;
max-width: 90vw;
padding: 0;
::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
& > div,
& > form {
display: flex;
gap: 0.5rem;
flex-direction: column;
background-color: var(--color-a);
padding: 1.5rem;
max-height: 90vh;
}
}
Mwebclient/src/components/DownloadButton.css
@@ -1,6 +1,5 @@
.download-item {
cursor: pointer;
margin-right: 1rem;
& > span {
text-overflow: ellipsis;
overflow: hidden;
Mwebclient/src/components/DownloadButton.tsx
@@ -1,6 +1,6 @@
import "./DownloadButton.css";
import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
import { deleteItem, download, signalMap, type SignalMapEntry } from "../offline";
import { deleteFile, download, signalMap, type SignalMapEntry } from "../offline";
import { createSignal, type Accessor, type ResourceActions } from "solid-js";
import type { AppOptions } from "../types";
import { formatBytes, joinPath } from "../utils";
@@ -30,7 +30,7 @@ export function DownloadButton(props: DownloadButtonProps) {
onClick={(event) => {
event.stopPropagation();
if (!props.isOffline()) download(props.item, props.currentDir(), props.options(), props.fetchFiles);
else deleteItem(props.item, props.currentDir(), props.fetchFiles);
else deleteFile(props.item, props.currentDir(), props.fetchFiles);
}}
>
{(() => {
Mwebclient/src/components/FileBrowser.css
@@ -38,7 +38,7 @@
}
.file-list-wrapper {
position: relative;
position: relative; /*for loading screen*/
flex: 1;
min-height: 0;
display: flex;
@@ -54,29 +54,20 @@
& > .file-list {
overflow-y: auto;
padding: 0.5rem;
& > .loading-spinner {
position: absolute;
}
display: flex;
flex-direction: column;
gap: 0.25rem;
& > .directory-item,
& > .file-item {
& > svg {
flex-shrink: 0;
margin-right: 1rem;
}
padding: 0.5rem;
margin-bottom: 0.25rem;
background-color: var(--color-c);
display: flex;
gap: 1.5rem;
align-items: center;
&:hover {
background-color: var(--color-c-highlight);
}
& > span {
min-width: 0;
}
}
& > .directory-item {
Mwebclient/src/components/OptionsModal.css
@@ -4,7 +4,6 @@
justify-content: space-between;
& > input[type="number"] {
background: none;
width: 5rem;
}
}
Mwebclient/src/components/OptionsModal.tsx
@@ -29,7 +29,7 @@ export default function OptionsModal(props: OptionsModalProps) {
function handleSave() {
if (!formElement.reportValidity()) return;
toast("Saved changes", "sucess");
toast("Saved changes", "success");
props.setOptions(newOptions());
handleClose();
}
@@ -52,6 +52,7 @@ export default function OptionsModal(props: OptionsModalProps) {
<dialog ref={dialogElement} onClose={handleClose}>
<form class="option-content" ref={formElement}>
<div class="title">Player Options</div>
<hr />
<div>
<label for="remember-dir-checkbox">Remember last directory:</label>
<input
Mwebclient/src/components/Playlist.css
@@ -3,11 +3,19 @@
flex-grow: 1;
flex-basis: 0;
flex-direction: column;
.save-dialog {
max-width: 300px;
.save-buttons {
display: flex;
gap: 0.5rem;
justify-content: end;
}
}
}
.playlist-header {
display: flex;
gap: 0.5rem;
gap: 1.5rem;
align-items: center;
padding: 0.5rem;
font-weight: bold;
@@ -40,11 +48,9 @@
.playlist-item {
padding: 0.5rem;
background-color: var(--color-c);
border-width: 0;
border-color: var(--color-d);
display: flex;
align-items: center;
gap: 0.5rem;
gap: 1.5rem;
div {
display: flex;
@@ -66,8 +72,8 @@
}
.playlist-drag-btn {
margin-right: 1rem;
cursor: grab;
height: 100%;
}
.playlist-item.drop-target-top-border,
Mwebclient/src/components/Playlist.tsx
@@ -4,6 +4,7 @@ import type { PlayerState, PlaylistItem } from "../types";
import { formatTime, toast } from "../utils";
import { ClearSVG, DownloadSVG, DragSVG, SaveSVG } from "./SVG";
import Toastify from "toastify-js";
import { savePlaylist } from "../offline";
interface PlaylistProps {
currentDir: Accessor<string>;
@@ -152,30 +153,12 @@ export default function Playlist(props: PlaylistProps) {
document.body.removeChild(a);
}
async function browserDownloadAll() {
let progressToast = undefined;
try {
toast("Started playlist download", "info");
const playlist = props.playlist();
const containerElement = document.createElement("div");
containerElement.style = "display:flex;flex-direction:column;";
containerElement.textContent = "Download playlist...";
const progressElement = document.createElement("progress");
progressElement.value = 0;
progressElement.max = playlist.length;
containerElement.appendChild(progressElement);
progressToast = Toastify({
node: containerElement,
duration: -1,
close: false,
});
progressToast.showToast();
const filePaths = playlist.map((item) => {
return item.path;
});
const form = document.createElement("form");
form.method = "POST";
form.action = "/download-playlist";
@@ -190,8 +173,6 @@ export default function Playlist(props: PlaylistProps) {
document.body.removeChild(form);
} catch (e) {
toast("Playlist items download failed", "error", e);
} finally {
progressToast?.hideToast();
}
}
@@ -210,12 +191,51 @@ export default function Playlist(props: PlaylistProps) {
});
});
let dialogElement!: HTMLDialogElement;
let playlistNameInput!: HTMLInputElement;
let allowOverwriteCheckbox!: HTMLInputElement;
async function save() {
try {
if (!playlistNameInput.reportValidity()) return;
await savePlaylist(
{ name: playlistNameInput.value, items: props.playlist() },
allowOverwriteCheckbox.checked,
);
closeDialog();
} catch (e) {
if (e instanceof Error && e.name === "ConstraintError") {
toast("Playlist with this name already exists", "error");
} else {
toast("Saving playlist failed", "error", e);
}
}
}
function closeDialog() {
dialogElement.close();
playlistNameInput.value = "";
allowOverwriteCheckbox.checked = false;
}
return (
<div class="playlist-container">
<dialog class="save-dialog" ref={dialogElement}>
<form>
<input ref={playlistNameInput} required type="text" placeholder="Playlist name" />
<div class="save-buttons">
<label for="overwrite-checkbox">allow overwrite</label>
<input ref={allowOverwriteCheckbox} id="overwrite-checkbox" type="checkbox" />
<button onClick={save}>Save</button>
<button onClick={closeDialog}>Cancel</button>
</div>
</form>
</dialog>
<div class="playlist-header">
<span>Playlist ({props.playlist().length} tracks)</span>
<div class="spacer" />
<button title="Save playlist">
<button title="Save playlist" onClick={() => dialogElement.showModal()}>
<SaveSVG />
</button>
<button
Awebclient/src/components/PlaylistManager.css
@@ -0,0 +1,35 @@
.playlistmanager-header {
display: flex;
gap: 1.5rem;
}
.playlistmanager-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: auto;
position: relative;
min-height: 10rem;
& > .playlistmanager-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
background-color: var(--color-c);
&:hover {
background-color: var(--color-c-highlight);
}
& > span {
overflow: hidden;
text-overflow: ellipsis;
}
& > .playlistmanager-buttons {
display: flex;
gap: 0.5rem;
justify-content: end;
align-items: center;
}
}
}
Awebclient/src/components/PlaylistManager.tsx
@@ -0,0 +1,179 @@
import { createEffect, createResource, createSignal, For, Show, Signal, type Accessor, type Setter } from "solid-js";
import "./PlaylistManager.css";
import "./Dialog.css";
import "toastify-js/src/toastify.css";
import type { Playlist, PlaylistItem } from "../types";
import { formatTime, generateUUID, sleep, toast } from "../utils";
import { ClearSVG, DeleteSVG, DownloadSVG, UploadSVG } from "./SVG";
import { deletePlaylist, loadPlaylists, savePlaylist } from "../offline";
import { LoadingScreen } from "./LoadingScreen";
interface PlaylistManagerProps {
showPlaylistManager: Accessor<boolean>;
setShowPlaylistManager: Setter<boolean>;
playlist: Accessor<PlaylistItem[]>;
setPlaylist: Setter<PlaylistItem[]>;
playTrack: (track: PlaylistItem) => void;
}
export default function PlaylistManager(props: PlaylistManagerProps) {
const [playlists, fetchPlaylists] = createResource<Playlist[]>(
async () => {
return await loadPlaylists();
},
{ initialValue: [] },
);
const [loadingActions, setLoadingActions] = createSignal(0);
let dialogElement!: HTMLDialogElement;
createEffect(() => {
if (props.showPlaylistManager()) {
dialogElement.showModal();
fetchPlaylists.refetch();
}
});
function handleClose() {
dialogElement.close();
props.setShowPlaylistManager(false);
}
function getTotalDuration(playlist: Playlist) {
if (playlist.items.length === 0) return 0;
//for some reason "duration" is a string at runtime...
return playlist.items.map((item) => Number.parseFloat(item.duration as unknown as string)).reduce((a, b) => a + b);
}
function exportPlaylists() {
toast("Exporting playlists...", "info");
const blob = new Blob([JSON.stringify(playlists())], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "pico-playlists.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}
function importPlaylists() {
const input = document.createElement("input");
input.type = "file";
input.onchange = async (event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
try {
toast("Importing playlists...", "info");
setLoadingActions((prev) => prev + 1);
const decoder = new TextDecoder();
const imports = JSON.parse(decoder.decode(await file.bytes())) as Playlist[];
for (const imported of imports) {
await savePlaylist(imported, true);
}
setLoadingActions((prev) => prev - 1);
fetchPlaylists.refetch();
} catch (e) {
toast("Importing playlists failed", "error", e);
}
}
};
input.click();
}
return (
<dialog ref={dialogElement} onClose={handleClose}>
<div>
<div class="playlistmanager-header title">
Playlists
<div class="spacer" />
<button title="Export playlists" onClick={exportPlaylists}>
<DownloadSVG />
</button>
<button title="Import playlists" onClick={importPlaylists}>
<UploadSVG />
</button>
<button title="Close dialog" onClick={handleClose}>
<ClearSVG />
</button>
</div>
<hr />
<div class="playlistmanager-content">
<Show when={playlists.loading || loadingActions() > 0}>
<LoadingScreen />
</Show>
<Show when={!playlists.loading}>
<For each={playlists()}>
{(item) => {
const [showSongs, setShowSongs] = createSignal(false);
return (
<div class="playlistmanager-item">
<span>{item.name}</span>
<hr />
<div class="playlistmanager-buttons">
<span>
{item.items.length} Items ({formatTime(getTotalDuration(item))})
</span>
<button
title="Show playlist items"
onClick={() => {
setShowSongs(!showSongs());
}}
>
Show
</button>
<button
title="Replace current playlist with this one"
onClick={() => {
props.setPlaylist(item.items);
if (item.items.length > 0) props.playTrack(item.items[0]);
handleClose();
}}
>
Play
</button>
<button
title="Add items from this playlist to current one"
onClick={() => props.setPlaylist(props.playlist().concat(item.items))}
>
Queue
</button>
<button
title="Delete playlist"
onClick={async () => {
setLoadingActions((prev) => prev + 1);
try {
await deletePlaylist(item.name);
fetchPlaylists.refetch();
} finally {
setLoadingActions((prev) => prev - 1);
}
}}
>
<DeleteSVG />
</button>
</div>
<Show when={showSongs()}>
<For each={item.items}>
{(item) => {
return (
<>
{item.name}
<br />
</>
);
}}
</For>
</Show>
</div>
);
}}
</For>
</Show>
</div>
</div>
</dialog>
);
}
Mwebclient/src/components/SVG.tsx
@@ -303,6 +303,26 @@ export function DownloadSVG() {
);
}
export function UploadSVG() {
const [hovered, setHovered] = createSignal();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<path
fill={hovered() ? highlightColor : defaultColor}
d="M5 10H4V8h1V7h1V6h1V5h1V4h1V3h1V2h1V1h2v1h1v1h1v1h1v1h1v1h1v1h1v1h1v2h-1v1h-2v-1h-1V9h-1V8h-1v9h-4V8H9v1H8v1H7v1H5zM2 20h20v3H2z"
/>
</svg>
);
}
export function SpinnerSVG() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
Mwebclient/src/index.tsx
@@ -3,7 +3,7 @@ import { render } from "solid-js/web";
import "./style.css";
import App from "./App";
import { fillSignalCache } from "./offline";
import { loadDataFromDB } from "./offline";
const root = document.getElementById("root");
if (!(root instanceof HTMLElement)) {
@@ -11,7 +11,7 @@ if (!(root instanceof HTMLElement)) {
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
);
}
fillSignalCache().then(() => {
loadDataFromDB().then(() => {
root.innerHTML = "";
render(() => <App />, root);
});
Mwebclient/src/offline.ts
@@ -1,5 +1,5 @@
import type { AudioFile, Directory, FileListing } from "music-server-shared/types";
import type { AppOptions } from "./types";
import type { AppOptions, Playlist } from "./types";
import { joinPath, toast } from "./utils";
import { createSignal, type ResourceActions, type Signal } from "solid-js";
@@ -13,8 +13,9 @@ interface Progress {
export type SignalMapEntry = undefined | Progress | "DirectoryProgress" | "CachedFile";
export const signalMap = new Map<string, Signal<SignalMapEntry>>();
const metaDataMap = new Map<string, number>();
const playlists: Playlist[] = [];
export async function fillSignalCache() {
export async function loadDataFromDB() {
try {
const db = await openDB();
const files = await db.listFiles(FileMetadataStore);
@@ -182,6 +183,22 @@ export async function getSrc(path: string, options: AppOptions) {
return joinPath("/download", path);
}
export async function loadPlaylists() {
const db = await openDB();
return await db.listPlaylists(PlaylistStore);
}
export async function savePlaylist(playlist: Playlist, overwrite: boolean) {
const db = await openDB();
if(overwrite) await db.put(PlaylistStore, playlist);
else await db.add(PlaylistStore, playlist);
}
export async function deletePlaylist(name: string) {
const db = await openDB();
await db.delete(PlaylistStore, name);
}
async function downloadFile(
path: string,
progressSignal: Signal<SignalMapEntry>,
@@ -199,7 +216,7 @@ async function downloadFile(
let res: Response;
if (options.enableTranscoding) {
res = await fetch(`${joinPath("/transcode", path)}?bitrate=${options.bitrate}`);
res = await fetch(`${joinPath("/transcode", path)}?bitrate=${options.bitrate}&format=${options.format}`);
} else {
res = await fetch(joinPath("/download", path));
}
@@ -242,7 +259,7 @@ async function downloadFile(
}
}
export async function deleteItem(
export async function deleteFile(
item: AudioFile | Directory,
basePath: string,
fetchFiles: ResourceActions<FileListing>, //allows triggering reloads in the file view
@@ -258,7 +275,7 @@ export async function deleteItem(
const newBasePath = joinPath(basePath, item.name);
const result = listing ?? listOfflineFiles(newBasePath, true);
for (const childItem of result) {
deleteItem(childItem, newBasePath, fetchFiles, "files" in childItem ? childItem.files : undefined);
deleteFile(childItem, newBasePath, fetchFiles, "files" in childItem ? childItem.files : undefined);
}
}
@@ -323,6 +340,7 @@ interface StoredCover {
const FileBlobStore = "fileblobs";
const FileMetadataStore = "filemetadata";
const CoverStore = "covers";
const PlaylistStore = "playlists";
async function openDB() {
return new Promise<DatabaseWrapper>((resolve, reject) => {
@@ -334,20 +352,26 @@ async function openDB() {
if (!db.objectStoreNames.contains(FileMetadataStore))
db.createObjectStore(FileMetadataStore, { keyPath: "path" });
if (!db.objectStoreNames.contains(CoverStore)) db.createObjectStore(CoverStore, { keyPath: "path" });
if (!db.objectStoreNames.contains(PlaylistStore)) db.createObjectStore(PlaylistStore, { keyPath: "name" });
};
req.onsuccess = () => resolve(wrapDB(req.result));
});
}
interface DatabaseWrapper {
get(store: typeof CoverStore, key: string): Promise<unknown>;
get(store: typeof FileBlobStore, key: string): Promise<unknown>;
get(store: typeof CoverStore | typeof FileBlobStore, key: string): Promise<unknown>;
put(store: typeof FileBlobStore, value: StoredFileBlob): Promise<void>;
put(store: typeof FileMetadataStore, value: StoredFileMetadata): Promise<void>;
put(store: typeof CoverStore, value: StoredCover): Promise<void>;
delete(store: string, key: string): Promise<void>;
put(store: typeof PlaylistStore, value: Playlist): Promise<void>;
add(store: typeof PlaylistStore, value: Playlist): Promise<void>;
delete(
store: typeof FileBlobStore | typeof CoverStore | typeof FileMetadataStore | typeof PlaylistStore,
key: string,
): Promise<void>;
clear(store: string): Promise<void>;
listFiles(store: typeof FileMetadataStore): Promise<StoredFileMetadata[]>;
listPlaylists(store: typeof PlaylistStore): Promise<Playlist[]>;
}
function wrapDB(db: IDBDatabase): DatabaseWrapper {
@@ -366,6 +390,13 @@ function wrapDB(db: IDBDatabase): DatabaseWrapper {
req.onsuccess = () => res();
req.onerror = () => rej(req.error);
}),
add: (store: string, value: unknown) =>
new Promise<void>((res, rej) => {
const tx = db.transaction(store, "readwrite");
const req: IDBRequest<IDBValidKey> = tx.objectStore(store).add(value);
req.onsuccess = () => res();
req.onerror = () => rej(req.error);
}),
delete: (store: string, key: string) =>
new Promise<void>((res, rej) => {
const tx = db.transaction(store, "readwrite");
@@ -387,6 +418,13 @@ function wrapDB(db: IDBDatabase): DatabaseWrapper {
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}),
listPlaylists: (store: typeof PlaylistStore) =>
new Promise((res, rej) => {
const tx = db.transaction(store, "readonly");
const req = tx.objectStore(store).getAll();
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}),
};
}
Mwebclient/src/style.css
@@ -50,6 +50,10 @@ button {
}
}
svg {
flex-shrink: 0;
}
button:has(> svg) {
padding: 0;
background: none;
@@ -59,7 +63,7 @@ button:has(> svg) {
}
html {
min-width: 400px;
min-width: 350px;
min-height: 600px;
background-image: url("data:image/svg+xml,<svg id='patternId' width='100%' height='100%' xmlns='http://www.w3.org/2000/svg'><defs><pattern id='a' patternUnits='userSpaceOnUse' width='100' height='82' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='%23404e4fff'/><path d='M74.4-14.6l-24 41.1h48l-24-41.1zm0 82l-24 41.1h48l-24-41.1z' stroke-width='1' stroke='none' fill='%23a37c5cff'/><path d='M0 0l24 41.1L48 0H0zm-1.6 26.5l-24 41.1h48l-24-41.1zm100 0l-24 41.1h48l-24-41.1z' stroke-width='1' stroke='none' fill='%232c373aff'/><path d='M24 41.1l24 41.1 24-41.1H24z' stroke-width='1' stroke='none' fill='%23726f69ff'/></pattern></defs><rect width='800%' height='800%' transform='translate(0,0)' fill='url(%23a)'/></svg>");
}
@@ -76,7 +80,7 @@ html {
.header {
display: flex;
gap: 0.5rem;
gap: 1.5rem;
justify-content: space-between;
align-items: center;
padding: 1rem;
@@ -105,8 +109,9 @@ html {
flex-grow: 1;
}
.title {
font-size: 1.2rem;
margin-bottom: 1rem;
font-weight: bold;
}
input {
background: none;
border: 1px solid var(--color-c);
padding: 0.25rem;
}
Mwebclient/src/types.ts
@@ -2,7 +2,12 @@ import type { AudioFile, MediaFormat } from "music-server-shared/types";
export interface PlaylistItem extends AudioFile {
id: string;
path: string;
path: string; //TODO: change to basePath to avoid redundancy with name
}
export interface Playlist {
name: string;
items: PlaylistItem[];
}
export interface PlayerState {
Mwebclient/src/utils.ts
@@ -37,9 +37,9 @@ function stringErr(e: unknown) {
return e && typeof e === "object" && "message" in e ? e.message : e;
}
export function toast(text: string, type: "info" | "error" | "sucess", e?: unknown) {
export function toast(text: string, type: "info" | "error" | "success", e?: unknown) {
if (e) console.error(e);
Toastify({
const thisToast = Toastify({
text: e ? `${text}:\n${stringErr(e)}` : text,
duration: 20000,
close: true,
@@ -48,13 +48,15 @@ export function toast(text: string, type: "info" | "error" | "sucess", e?: unkno
display: "flex",
"align-items": "center",
background:
type === "sucess"
type === "success"
? "linear-gradient(135deg,rgb(38, 194, 27),rgb(5, 144, 52))"
: type === "error"
? "linear-gradient(135deg,rgb(255, 24, 24),rgb(220, 0, 0))"
: "linear-gradient(135deg,rgb(50, 60, 255),rgb(34, 37, 209))",
},
}).showToast();
});
thisToast.showToast;
return thisToast;
}
export function toastError() {}