diff --git a/src/components/create.tsx b/src/components/create.tsx index 1ce3cef..d445606 100644 --- a/src/components/create.tsx +++ b/src/components/create.tsx @@ -6,10 +6,10 @@ import { useNavigate, useParams } from "@solidjs/router"; import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; import { Editor, editorView } from "../components/editor.jsx"; import { agent } from "../components/login.jsx"; -import { setNotif } from "../layout.jsx"; import { sessions } from "./account.jsx"; import { Button } from "./button.jsx"; import { Modal } from "./modal.jsx"; +import { addNotification, removeNotification } from "./notification.jsx"; import { TextInput } from "./text-input.jsx"; import Tooltip from "./tooltip.jsx"; @@ -93,7 +93,11 @@ export const RecordEditor = (props: { create: boolean; record?: any; refetch?: a return; } setOpenDialog(false); - setNotif({ show: true, icon: "lucide--file-check", text: "Record created" }); + const id = addNotification({ + message: "Record created", + type: "success", + }); + setTimeout(() => removeNotification(id), 3000); navigate(`/${res.data.uri}`); }; @@ -143,7 +147,11 @@ export const RecordEditor = (props: { create: boolean; record?: any; refetch?: a } } setOpenDialog(false); - setNotif({ show: true, icon: "lucide--file-check", text: "Record edited" }); + const id = addNotification({ + message: "Record edited", + type: "success", + }); + setTimeout(() => removeNotification(id), 3000); props.refetch(); } catch (err: any) { setNotice(err.message); diff --git a/src/components/json.tsx b/src/components/json.tsx index c9f5a73..af1fa24 100644 --- a/src/components/json.tsx +++ b/src/components/json.tsx @@ -1,11 +1,11 @@ import { isCid, isDid, isNsid, Nsid } from "@atcute/lexicons/syntax"; import { A, useNavigate, useParams } from "@solidjs/router"; import { createEffect, createSignal, ErrorBoundary, For, on, Show } from "solid-js"; -import { setNotif } from "../layout"; import { resolveLexiconAuthority } from "../utils/api"; import { ATURI_RE } from "../utils/types/at-uri"; import { hideMedia } from "../views/settings"; import { pds } from "./navbar"; +import { addNotification, removeNotification } from "./notification"; import VideoPlayer from "./video-player"; interface AtBlob { @@ -43,11 +43,11 @@ const JSONString = (props: { navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); } catch (err) { console.error("Failed to resolve lexicon authority:", err); - setNotif({ - show: true, - icon: "lucide--circle-alert", - text: "Could not resolve schema", + const id = addNotification({ + message: "Could not resolve schema", + type: "error", }); + setTimeout(() => removeNotification(id), 5000); } }; diff --git a/src/components/notification.tsx b/src/components/notification.tsx new file mode 100644 index 0000000..b741a24 --- /dev/null +++ b/src/components/notification.tsx @@ -0,0 +1,80 @@ +import { createSignal, For, Show } from "solid-js"; + +export type Notification = { + id: string; + message: string; + progress?: number; + total?: number; + type?: "info" | "success" | "error"; +}; + +const [notifications, setNotifications] = createSignal([]); + +export const addNotification = (notification: Omit) => { + const id = `notification-${Date.now()}-${Math.random()}`; + setNotifications([...notifications(), { ...notification, id }]); + return id; +}; + +export const updateNotification = (id: string, updates: Partial) => { + setNotifications(notifications().map((n) => (n.id === id ? { ...n, ...updates } : n))); +}; + +export const removeNotification = (id: string) => { + setNotifications(notifications().filter((n) => n.id !== id)); +}; + +export const NotificationContainer = () => { + return ( +
+ + {(notification) => ( +
removeNotification(notification.id)} + > +
+ + + + + + + + + + {notification.message} +
+ +
+ 0} + fallback={ +
+ {notification.progress} MB +
+ } + > +
+
+
+
+ {notification.progress}% +
+ +
+
+
+ )} + +
+ ); +}; diff --git a/src/layout.tsx b/src/layout.tsx index 2354f07..f030023 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -1,24 +1,19 @@ import { Handle } from "@atcute/lexicons"; import { Meta, MetaProvider } from "@solidjs/meta"; import { A, RouteSectionProps, useLocation, useNavigate } from "@solidjs/router"; -import { createEffect, createSignal, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; +import { createEffect, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; import { AccountManager } from "./components/account.jsx"; import { RecordEditor } from "./components/create.jsx"; import { DropdownMenu, MenuProvider, NavMenu } from "./components/dropdown.jsx"; import { agent } from "./components/login.jsx"; import { NavBar } from "./components/navbar.jsx"; +import { NotificationContainer } from "./components/notification.jsx"; import { Search, SearchButton, showSearch } from "./components/search.jsx"; import { themeEvent, ThemeSelection } from "./components/theme.jsx"; import { resolveHandle } from "./utils/api.js"; export const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 1; -export const [notif, setNotif] = createSignal<{ - show: boolean; - icon?: string; - text?: string; -}>({ show: false }); - const headers: Record = { "did:plc:ia76kvnndjutgedggx2ibrem": "bunny.jpg", "did:plc:oisofpd7lj26yvgiivf3lxsi": "puppy.jpg", @@ -33,7 +28,6 @@ const headers: Record = { const Layout = (props: RouteSectionProps) => { const location = useLocation(); const navigate = useNavigate(); - let timeout: number; if (location.search.includes("hrt=true")) localStorage.setItem("hrt", "true"); else if (location.search.includes("hrt=false")) localStorage.setItem("hrt", "false"); @@ -47,13 +41,6 @@ const Layout = (props: RouteSectionProps) => { } }); - createEffect(() => { - if (notif().show) { - clearTimeout(timeout); - timeout = setTimeout(() => setNotif({ show: false }), 3000); - } - }); - onMount(() => { window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", themeEvent); @@ -197,15 +184,7 @@ const Layout = (props: RouteSectionProps) => {
- - - + ); }; diff --git a/src/utils/copy.ts b/src/utils/copy.ts index fbdd78e..624e3af 100644 --- a/src/utils/copy.ts +++ b/src/utils/copy.ts @@ -1,6 +1,10 @@ -import { setNotif } from "../layout"; +import { addNotification, removeNotification } from "../components/notification"; export const addToClipboard = (text: string) => { navigator.clipboard.writeText(text); - setNotif({ show: true, icon: "lucide--clipboard-check", text: "Copied to clipboard" }); + const id = addNotification({ + message: "Copied to clipboard", + type: "success", + }); + setTimeout(() => removeNotification(id), 3000); }; diff --git a/src/views/collection.tsx b/src/views/collection.tsx index 60e9c4d..f33a26e 100644 --- a/src/views/collection.tsx +++ b/src/views/collection.tsx @@ -9,10 +9,10 @@ import { Button } from "../components/button.jsx"; import { JSONType, JSONValue } from "../components/json.jsx"; import { agent } from "../components/login.jsx"; import { Modal } from "../components/modal.jsx"; +import { addNotification, removeNotification } from "../components/notification.jsx"; import { StickyOverlay } from "../components/sticky.jsx"; import { TextInput } from "../components/text-input.jsx"; import Tooltip from "../components/tooltip.jsx"; -import { setNotif } from "../layout.jsx"; import { resolvePDS } from "../utils/api.js"; import { localDateFromTimestamp } from "../utils/date.js"; @@ -149,11 +149,11 @@ const CollectionView = () => { }, }); } - setNotif({ - show: true, - icon: "lucide--trash-2", - text: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`, + const id = addNotification({ + message: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`, + type: "success", }); + setTimeout(() => removeNotification(id), 3000); setBatchDelete(false); setRecords([]); setCursor(undefined); diff --git a/src/views/record.tsx b/src/views/record.tsx index 88bc609..70a46ad 100644 --- a/src/views/record.tsx +++ b/src/views/record.tsx @@ -15,8 +15,8 @@ import { LexiconSchemaView } from "../components/lexicon-schema.jsx"; import { agent } from "../components/login.jsx"; import { Modal } from "../components/modal.jsx"; import { pds } from "../components/navbar.jsx"; +import { addNotification, removeNotification } from "../components/notification.jsx"; import Tooltip from "../components/tooltip.jsx"; -import { setNotif } from "../layout.jsx"; import { resolveLexiconAuthority, resolveLexiconSchema, resolvePDS } from "../utils/api.js"; import { AtUri, uriTemplates } from "../utils/templates.js"; import { lexicons } from "../utils/types/lexicons.js"; @@ -134,7 +134,11 @@ export const RecordView = () => { rkey: params.rkey, }, }); - setNotif({ show: true, icon: "lucide--trash-2", text: "Record deleted" }); + const id = addNotification({ + message: "Record deleted", + type: "success", + }); + setTimeout(() => removeNotification(id), 3000); navigate(`/at://${params.repo}/${params.collection}`); }; diff --git a/src/views/repo.tsx b/src/views/repo.tsx index 83a23f9..44d517d 100644 --- a/src/views/repo.tsx +++ b/src/views/repo.tsx @@ -22,6 +22,11 @@ import { NavMenu, } from "../components/dropdown.jsx"; import { setPDS } from "../components/navbar.jsx"; +import { + addNotification, + removeNotification, + updateNotification, +} from "../components/notification.jsx"; import { TextInput } from "../components/text-input.jsx"; import Tooltip from "../components/tooltip.jsx"; import { @@ -155,14 +160,54 @@ export const RepoView = () => { }; const downloadRepo = async () => { + let notificationId: string | null = null; + try { setDownloading(true); + notificationId = addNotification({ + message: "Downloading repository...", + progress: 0, + total: 0, + type: "info", + }); + const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`); if (!response.ok) { throw new Error(`HTTP error status: ${response.status}`); } - const blob = await response.blob(); + const contentLength = response.headers.get("content-length"); + const total = contentLength ? parseInt(contentLength, 10) : 0; + let loaded = 0; + + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + loaded += value.length; + + if (total > 0) { + const progress = Math.round((loaded / total) * 100); + updateNotification(notificationId, { + progress, + total, + }); + } else { + const progressMB = Math.round((loaded / (1024 * 1024)) * 10) / 10; + updateNotification(notificationId, { + progress: progressMB, + total: 0, + }); + } + } + } + + const blob = new Blob(chunks); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -172,8 +217,27 @@ export const RepoView = () => { window.URL.revokeObjectURL(url); document.body.removeChild(a); + + updateNotification(notificationId, { + message: "Repository downloaded successfully", + type: "success", + progress: undefined, + }); + setTimeout(() => { + if (notificationId) removeNotification(notificationId); + }, 3000); } catch (error) { console.error("Download failed:", error); + if (notificationId) { + updateNotification(notificationId, { + message: "Download failed", + type: "error", + progress: undefined, + }); + setTimeout(() => { + if (notificationId) removeNotification(notificationId); + }, 5000); + } } setDownloading(false); };