diff --git a/components/bookmark.js b/components/bookmark.js
index 71a6e33d..2951a4ac 100644
--- a/components/bookmark.js
+++ b/components/bookmark.js
@@ -1,8 +1,10 @@
import { useMutation } from '@apollo/client'
import { gql } from 'graphql-tag'
import Dropdown from 'react-bootstrap/Dropdown'
+import { useToast } from './toast'
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
+ const toaster = useToast()
const [bookmarkItem] = useMutation(
gql`
mutation bookmarkItem($id: ID!) {
@@ -22,7 +24,15 @@ export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {
)
return (
bookmarkItem({ variables: { id } })}
+ onClick={async () => {
+ try {
+ await bookmarkItem({ variables: { id } })
+ toaster.success(meBookmark ? 'bookmark removed' : 'bookmark added')
+ } catch (err) {
+ console.error(err)
+ toaster.danger(meBookmark ? 'failed to remove bookmark' : 'failed to bookmark')
+ }
+ }}
>
{meBookmark ? 'remove bookmark' : 'bookmark'}
diff --git a/components/comment-edit.js b/components/comment-edit.js
index 1fac5c97..0f6e1077 100644
--- a/components/comment-edit.js
+++ b/components/comment-edit.js
@@ -51,7 +51,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
required
/>
-
+
{
return (
{
const { error } = await deleteItem({ variables: { id: itemId } })
if (error) {
@@ -59,8 +61,9 @@ export default function Delete ({ itemId, children, onDelete }) {
)
}
-function DeleteConfirm ({ onConfirm }) {
+function DeleteConfirm ({ onConfirm, type }) {
const [error, setError] = useState()
+ const toaster = useToast()
return (
<>
@@ -71,6 +74,7 @@ function DeleteConfirm ({ onConfirm }) {
variant='danger' onClick={async () => {
try {
await onConfirm()
+ toaster.success(`deleted ${type.toLowerCase()}`)
} catch (e) {
setError(e.message || e)
}
diff --git a/components/dont-link-this.js b/components/dont-link-this.js
index dc3b10d7..c5e4b538 100644
--- a/components/dont-link-this.js
+++ b/components/dont-link-this.js
@@ -2,8 +2,10 @@ import { gql, useMutation } from '@apollo/client'
import Dropdown from 'react-bootstrap/Dropdown'
import FundError from './fund-error'
import { useShowModal } from './modal'
+import { useToast } from './toast'
export default function DontLikeThisDropdownItem ({ id }) {
+ const toaster = useToast()
const showModal = useShowModal()
const [dontLikeThis] = useMutation(
@@ -32,11 +34,15 @@ export default function DontLikeThisDropdownItem ({ id }) {
variables: { id },
optimisticResponse: { dontLikeThis: true }
})
+ toaster.success('item flagged')
} catch (error) {
+ console.error(error)
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
return
})
+ } else {
+ toaster.danger('failed to flag item')
}
}
}}
diff --git a/components/form.js b/components/form.js
index 767847c2..c2f27a6e 100644
--- a/components/form.js
+++ b/components/form.js
@@ -1,7 +1,6 @@
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import BootstrapForm from 'react-bootstrap/Form'
-import Alert from 'react-bootstrap/Alert'
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import copy from 'clipboard-copy'
@@ -19,6 +18,7 @@ import CloseIcon from '../svgs/close-line.svg'
import { useLazyQuery } from '@apollo/client'
import { USER_SEARCH } from '../fragments/users'
import TextareaAutosize from 'react-textarea-autosize'
+import { useToast } from './toast'
export function SubmitButton ({
children, variant, value, onClick, disabled, ...props
@@ -472,7 +472,12 @@ const StorageKeyPrefixContext = createContext()
export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, ...props
}) {
- const [error, setError] = useState(initialError)
+ const toaster = useToast()
+ useEffect(() => {
+ if (initialError) {
+ toaster.danger(initialError.message || initialError.toString?.())
+ }
+ }, [])
return (
- onSubmit && onSubmit(values, ...args).then((options) => {
- if (!storageKeyPrefix || options?.keepLocalStorage) return
- Object.keys(values).forEach(v => {
- window.localStorage.removeItem(storageKeyPrefix + '-' + v)
- if (Array.isArray(values[v])) {
- values[v].forEach(
- (iv, i) => {
- Object.keys(iv).forEach(k => {
- window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`)
+ onSubmit={async (values, ...args) => {
+ try {
+ if (onSubmit) {
+ const options = await onSubmit(values, ...args)
+ if (!storageKeyPrefix || options?.keepLocalStorage) return
+ Object.keys(values).forEach(v => {
+ window.localStorage.removeItem(storageKeyPrefix + '-' + v)
+ if (Array.isArray(values[v])) {
+ values[v].forEach(
+ (iv, i) => {
+ Object.keys(iv).forEach(k => {
+ window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`)
+ })
+ window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)
})
- window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)
- })
- }
+ }
+ })
}
- )
- }).catch(e => setError(e.message || e))}
+ } catch (err) {
+ console.log(err)
+ toaster.danger(err.message || err.toString?.())
+ }
+ }}
>
- {error && setError(undefined)} dismissible>{error}}
{children}
diff --git a/components/item-info.js b/components/item-info.js
index 7b2ac246..04e56c39 100644
--- a/components/item-info.js
+++ b/components/item-info.js
@@ -140,7 +140,7 @@ export default function ItemInfo ({
{me && !item.meSats && !item.position && !item.meDontLike &&
!item.mine && !item.deletedAt && }
{item.mine && !item.position && !item.deletedAt &&
- }
+ }
{extraInfo}
diff --git a/components/share.js b/components/share.js
index 6b7e7827..ab32a431 100644
--- a/components/share.js
+++ b/components/share.js
@@ -2,9 +2,11 @@ import Dropdown from 'react-bootstrap/Dropdown'
import ShareIcon from '../svgs/share-fill.svg'
import copy from 'clipboard-copy'
import { useMe } from './me'
+import { useToast } from './toast'
export default function Share ({ item }) {
const me = useMe()
+ const toaster = useToast()
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
return typeof window !== 'undefined' && navigator?.share
@@ -13,16 +15,17 @@ export default function Share ({ item }) {
{
- if (navigator.share) {
- navigator.share({
+ onClick={async () => {
+ try {
+ await navigator.share({
title: item.title || '',
text: '',
url
- }).then(() => console.log('Successful share'))
- .catch((error) => console.log('Error sharing', error))
- } else {
- console.log('no navigator.share')
+ })
+ toaster.success('link shared')
+ } catch (err) {
+ console.error(err)
+ toaster.danger('failed to share link')
}
}}
/>
@@ -36,7 +39,13 @@ export default function Share ({ item }) {
{
- copy(url)
+ try {
+ await copy(url)
+ toaster.success('link copied')
+ } catch (err) {
+ console.error(err)
+ toaster.danger('failed to copy link')
+ }
}}
>
copy link
@@ -47,19 +56,25 @@ export default function Share ({ item }) {
export function CopyLinkDropdownItem ({ item }) {
const me = useMe()
+ const toaster = useToast()
const url = `https://stacker.news/items/${item.id}${me ? `/r/${me.name}` : ''}`
return (
{
- if (navigator.share) {
- navigator.share({
- title: item.title || '',
- text: '',
- url
- }).then(() => console.log('Successful share'))
- .catch((error) => console.log('Error sharing', error))
- } else {
- copy(url)
+ try {
+ if (navigator.share) {
+ await navigator.share({
+ title: item.title || '',
+ text: '',
+ url
+ })
+ } else {
+ await copy(url)
+ }
+ toaster.success('link copied')
+ } catch (err) {
+ console.error(err)
+ toaster.danger('failed to copy link')
}
}}
>
diff --git a/components/subscribe.js b/components/subscribe.js
index bbded811..32023c85 100644
--- a/components/subscribe.js
+++ b/components/subscribe.js
@@ -1,8 +1,10 @@
import { useMutation } from '@apollo/client'
import { gql } from 'graphql-tag'
import Dropdown from 'react-bootstrap/Dropdown'
+import { useToast } from './toast'
export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) {
+ const toaster = useToast()
const [subscribeItem] = useMutation(
gql`
mutation subscribeItem($id: ID!) {
@@ -22,7 +24,15 @@ export default function SubscribeDropdownItem ({ item: { id, meSubscription } })
)
return (
subscribeItem({ variables: { id } })}
+ onClick={async () => {
+ try {
+ await subscribeItem({ variables: { id } })
+ toaster.success(meSubscription ? 'unsubscribed' : 'subscribed')
+ } catch (err) {
+ console.error(err)
+ toaster.danger(meSubscription ? 'failed to unsubscribe' : 'failed to subscribe')
+ }
+ }}
>
{meSubscription ? 'remove subscription' : 'subscribe'}
diff --git a/components/toast.js b/components/toast.js
new file mode 100644
index 00000000..24c31816
--- /dev/null
+++ b/components/toast.js
@@ -0,0 +1,81 @@
+import { useRouter } from 'next/router'
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import Button from 'react-bootstrap/Button'
+import Toast from 'react-bootstrap/Toast'
+import ToastBody from 'react-bootstrap/ToastBody'
+import ToastContainer from 'react-bootstrap/ToastContainer'
+import styles from './toast.module.css'
+
+const ToastContext = createContext(() => {})
+
+export const ToastProvider = ({ children }) => {
+ const router = useRouter()
+ const [toasts, setToasts] = useState([])
+ const toastId = useRef(0)
+ const dispatchToast = useCallback((toastConfig) => {
+ toastConfig = {
+ ...toastConfig,
+ id: toastId.current++
+ }
+ setToasts(toasts => [...toasts, toastConfig])
+ }, [])
+ const toaster = useMemo(() => ({
+ success: body => {
+ dispatchToast({
+ body,
+ variant: 'success',
+ autohide: true,
+ delay: 5000
+ })
+ },
+ danger: body => {
+ dispatchToast({
+ body,
+ variant: 'danger',
+ autohide: false
+ })
+ }
+ }), [dispatchToast])
+ const removeToast = useCallback(id => {
+ setToasts(toasts => toasts.filter(toast => toast.id !== id))
+ }, [])
+
+ // Clear all toasts on page navigation
+ useEffect(() => {
+ const handleRouteChangeStart = () => setToasts([])
+ router.events.on('routeChangeStart', handleRouteChangeStart)
+
+ return () => {
+ router.events.off('routeChangeStart', handleRouteChangeStart)
+ }
+ }, [router])
+
+ return (
+
+
+ {toasts.map(toast => (
+ removeToast(toast.id)}
+ >
+
+
+
{toast.body}
+
+
+
+
+ ))}
+
+ {children}
+
+ )
+}
+
+export const useToast = () => useContext(ToastContext)
diff --git a/components/toast.module.css b/components/toast.module.css
new file mode 100644
index 00000000..fab1e1f6
--- /dev/null
+++ b/components/toast.module.css
@@ -0,0 +1,26 @@
+.toast {
+ width: auto;
+ border: 1px solid var(--bs-success-border-subtle);
+ color: #fff;
+}
+
+.toastClose {
+color: #fff;
+ font-family: "lightning";
+ font-size: 150%;
+ line-height: 1rem;
+ margin-bottom: -0.25rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+}
+
+.toastClose:hover {
+ opacity: 0.7;
+}
+
+@media screen and (min-width: 400px) {
+ .toast {
+ width: var(--bs-toast-max-width);
+ }
+}
diff --git a/pages/_app.js b/pages/_app.js
index 4efedb03..c2a7a609 100644
--- a/pages/_app.js
+++ b/pages/_app.js
@@ -10,6 +10,7 @@ import { useEffect } from 'react'
import { ShowModalProvider } from '../components/modal'
import ErrorBoundary from '../components/error-boundary'
import { LightningProvider } from '../components/lightning'
+import { ToastProvider } from '../components/toast'
import { ServiceWorkerProvider } from '../components/serviceworker'
import { SSR } from '../lib/constants'
import NProgress from 'nprogress'
@@ -90,11 +91,13 @@ function MyApp ({ Component, pageProps: { ...props } }) {
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/pages/settings.js b/pages/settings.js
index 63320410..543b0306 100644
--- a/pages/settings.js
+++ b/pages/settings.js
@@ -22,6 +22,7 @@ import PageLoading from '../components/page-loading'
import { useShowModal } from '../components/modal'
import { authErrorMessage } from '../components/login'
import { NostrAuth } from '../components/nostr-auth'
+import { useToast } from '../components/toast'
export const getServerSideProps = getGetServerSideProps(SETTINGS)
@@ -30,7 +31,7 @@ function bech32encode (hexString) {
}
export default function Settings ({ ssrData }) {
- const [success, setSuccess] = useState()
+ const toaster = useToast()
const [setSettings] = useMutation(SET_SETTINGS, {
update (cache, { data: { setSettings } }) {
cache.modify({
@@ -89,18 +90,22 @@ export default function Settings ({ ssrData }) {
const nostrRelaysFiltered = nostrRelays?.filter(word => word.trim().length > 0)
- await setSettings({
- variables: {
- tipDefault: Number(tipDefault),
- nostrPubkey,
- nostrRelays: nostrRelaysFiltered,
- ...values
- }
- })
- setSuccess('settings saved')
+ try {
+ await setSettings({
+ variables: {
+ tipDefault: Number(tipDefault),
+ nostrPubkey,
+ nostrRelays: nostrRelaysFiltered,
+ ...values
+ }
+ })
+ toaster.success('saved settings')
+ } catch (err) {
+ console.error(err)
+ toaster.danger('failed to save settings')
+ }
}}
>
- {success && setSuccess(undefined)} dismissible>{success}}
@@ -344,9 +350,15 @@ function UnlinkObstacle ({ onClose, type, unlinkAuth }) {
}}
schema={lastAuthRemovalSchema}
onSubmit={async () => {
- await unlinkAuth({ variables: { authType: type } })
- router.push('/settings')
- onClose()
+ try {
+ await unlinkAuth({ variables: { authType: type } })
+ router.push('/settings')
+ onClose()
+ toaster.success('unlinked auth method')
+ } catch (err) {
+ console.error(err)
+ toaster.danger('failed to unlink auth method')
+ }
}}
>
())
} else {
- await unlinkAuth({ variables: { authType: type } })
+ try {
+ await unlinkAuth({ variables: { authType: type } })
+ toaster.success('unlinked auth method')
+ } catch (err) {
+ console.error(err)
+ toaster.danger('failed to unlink auth method')
+ }
}
}
diff --git a/styles/globals.scss b/styles/globals.scss
index c9909684..d3a72fea 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -90,6 +90,7 @@ $popover-body-padding-y: .5rem;
$popover-max-width: 320px !default;
$popover-border-color: var(--theme-borderColor);
$grid-gutter-width: 2rem;
+$toast-spacing: .5rem;
:root, [data-bs-theme=light] {
--theme-navLink: rgba(0, 0, 0, 0.55);
@@ -153,6 +154,10 @@ $grid-gutter-width: 2rem;
height: 2px !important;
}
+.toast-body {
+ padding: 0.75rem 1.25rem !important;
+}
+
#nprogress .peg {
box-shadow: 0 0 10px var(--bs-primary), 0 0 5px var(--bs-primary) !important;
}