atmosphere explorer

new notification system

handle.invalid 0040a4a7 c67f0065

verified
+181 -42
+11 -3
src/components/create.tsx
··· 6 6 import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 7 7 import { Editor, editorView } from "../components/editor.jsx"; 8 8 import { agent } from "../components/login.jsx"; 9 - import { setNotif } from "../layout.jsx"; 10 9 import { sessions } from "./account.jsx"; 11 10 import { Button } from "./button.jsx"; 12 11 import { Modal } from "./modal.jsx"; 12 + import { addNotification, removeNotification } from "./notification.jsx"; 13 13 import { TextInput } from "./text-input.jsx"; 14 14 import Tooltip from "./tooltip.jsx"; 15 15 ··· 93 93 return; 94 94 } 95 95 setOpenDialog(false); 96 - setNotif({ show: true, icon: "lucide--file-check", text: "Record created" }); 96 + const id = addNotification({ 97 + message: "Record created", 98 + type: "success", 99 + }); 100 + setTimeout(() => removeNotification(id), 3000); 97 101 navigate(`/${res.data.uri}`); 98 102 }; 99 103 ··· 143 147 } 144 148 } 145 149 setOpenDialog(false); 146 - setNotif({ show: true, icon: "lucide--file-check", text: "Record edited" }); 150 + const id = addNotification({ 151 + message: "Record edited", 152 + type: "success", 153 + }); 154 + setTimeout(() => removeNotification(id), 3000); 147 155 props.refetch(); 148 156 } catch (err: any) { 149 157 setNotice(err.message);
+5 -5
src/components/json.tsx
··· 1 1 import { isCid, isDid, isNsid, Nsid } from "@atcute/lexicons/syntax"; 2 2 import { A, useNavigate, useParams } from "@solidjs/router"; 3 3 import { createEffect, createSignal, ErrorBoundary, For, on, Show } from "solid-js"; 4 - import { setNotif } from "../layout"; 5 4 import { resolveLexiconAuthority } from "../utils/api"; 6 5 import { ATURI_RE } from "../utils/types/at-uri"; 7 6 import { hideMedia } from "../views/settings"; 8 7 import { pds } from "./navbar"; 8 + import { addNotification, removeNotification } from "./notification"; 9 9 import VideoPlayer from "./video-player"; 10 10 11 11 interface AtBlob { ··· 43 43 navigate(`/at://${authority}/com.atproto.lexicon.schema/${nsid}${hash}`); 44 44 } catch (err) { 45 45 console.error("Failed to resolve lexicon authority:", err); 46 - setNotif({ 47 - show: true, 48 - icon: "lucide--circle-alert", 49 - text: "Could not resolve schema", 46 + const id = addNotification({ 47 + message: "Could not resolve schema", 48 + type: "error", 50 49 }); 50 + setTimeout(() => removeNotification(id), 5000); 51 51 } 52 52 }; 53 53
+80
src/components/notification.tsx
··· 1 + import { createSignal, For, Show } from "solid-js"; 2 + 3 + export type Notification = { 4 + id: string; 5 + message: string; 6 + progress?: number; 7 + total?: number; 8 + type?: "info" | "success" | "error"; 9 + }; 10 + 11 + const [notifications, setNotifications] = createSignal<Notification[]>([]); 12 + 13 + export const addNotification = (notification: Omit<Notification, "id">) => { 14 + const id = `notification-${Date.now()}-${Math.random()}`; 15 + setNotifications([...notifications(), { ...notification, id }]); 16 + return id; 17 + }; 18 + 19 + export const updateNotification = (id: string, updates: Partial<Notification>) => { 20 + setNotifications(notifications().map((n) => (n.id === id ? { ...n, ...updates } : n))); 21 + }; 22 + 23 + export const removeNotification = (id: string) => { 24 + setNotifications(notifications().filter((n) => n.id !== id)); 25 + }; 26 + 27 + export const NotificationContainer = () => { 28 + return ( 29 + <div class="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-2"> 30 + <For each={notifications()}> 31 + {(notification) => ( 32 + <div 33 + class="dark:bg-dark-300 dark:shadow-dark-700 pointer-events-auto flex min-w-64 flex-col gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-3 shadow-md dark:border-neutral-700" 34 + classList={{ 35 + "border-blue-500 dark:border-blue-400": notification.type === "info", 36 + "border-green-500 dark:border-green-400": notification.type === "success", 37 + "border-red-500 dark:border-red-400": notification.type === "error", 38 + }} 39 + onClick={() => removeNotification(notification.id)} 40 + > 41 + <div class="flex items-center gap-2 text-sm"> 42 + <Show when={notification.progress !== undefined}> 43 + <span class="iconify lucide--download" /> 44 + </Show> 45 + <Show when={notification.type === "success"}> 46 + <span class="iconify lucide--check-circle text-green-600 dark:text-green-400" /> 47 + </Show> 48 + <Show when={notification.type === "error"}> 49 + <span class="iconify lucide--x-circle text-red-500 dark:text-red-400" /> 50 + </Show> 51 + <span>{notification.message}</span> 52 + </div> 53 + <Show when={notification.progress !== undefined}> 54 + <div class="flex flex-col gap-1"> 55 + <Show 56 + when={notification.total !== undefined && notification.total > 0} 57 + fallback={ 58 + <div class="text-xs text-neutral-600 dark:text-neutral-400"> 59 + {notification.progress} MB 60 + </div> 61 + } 62 + > 63 + <div class="h-2 w-full overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700"> 64 + <div 65 + class="h-full rounded-full bg-blue-500 transition-all dark:bg-blue-400" 66 + style={{ width: `${notification.progress}%` }} 67 + /> 68 + </div> 69 + <div class="text-xs text-neutral-600 dark:text-neutral-400"> 70 + {notification.progress}% 71 + </div> 72 + </Show> 73 + </div> 74 + </Show> 75 + </div> 76 + )} 77 + </For> 78 + </div> 79 + ); 80 + };
+3 -24
src/layout.tsx
··· 1 1 import { Handle } from "@atcute/lexicons"; 2 2 import { Meta, MetaProvider } from "@solidjs/meta"; 3 3 import { A, RouteSectionProps, useLocation, useNavigate } from "@solidjs/router"; 4 - import { createEffect, createSignal, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; 4 + import { createEffect, ErrorBoundary, onMount, Show, Suspense } from "solid-js"; 5 5 import { AccountManager } from "./components/account.jsx"; 6 6 import { RecordEditor } from "./components/create.jsx"; 7 7 import { DropdownMenu, MenuProvider, NavMenu } from "./components/dropdown.jsx"; 8 8 import { agent } from "./components/login.jsx"; 9 9 import { NavBar } from "./components/navbar.jsx"; 10 + import { NotificationContainer } from "./components/notification.jsx"; 10 11 import { Search, SearchButton, showSearch } from "./components/search.jsx"; 11 12 import { themeEvent, ThemeSelection } from "./components/theme.jsx"; 12 13 import { resolveHandle } from "./utils/api.js"; 13 14 14 15 export const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 1; 15 - 16 - export const [notif, setNotif] = createSignal<{ 17 - show: boolean; 18 - icon?: string; 19 - text?: string; 20 - }>({ show: false }); 21 16 22 17 const headers: Record<string, string> = { 23 18 "did:plc:ia76kvnndjutgedggx2ibrem": "bunny.jpg", ··· 33 28 const Layout = (props: RouteSectionProps<unknown>) => { 34 29 const location = useLocation(); 35 30 const navigate = useNavigate(); 36 - let timeout: number; 37 31 38 32 if (location.search.includes("hrt=true")) localStorage.setItem("hrt", "true"); 39 33 else if (location.search.includes("hrt=false")) localStorage.setItem("hrt", "false"); ··· 44 38 if (props.params.repo && !props.params.repo.startsWith("did:")) { 45 39 const did = await resolveHandle(props.params.repo as Handle); 46 40 navigate(location.pathname.replace(props.params.repo, did)); 47 - } 48 - }); 49 - 50 - createEffect(() => { 51 - if (notif().show) { 52 - clearTimeout(timeout); 53 - timeout = setTimeout(() => setNotif({ show: false }), 3000); 54 41 } 55 42 }); 56 43 ··· 197 184 </ErrorBoundary> 198 185 </Show> 199 186 </div> 200 - <Show when={notif().show}> 201 - <button 202 - class="dark:shadow-dark-700 dark:bg-dark-100 fixed bottom-10 z-50 flex items-center rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md dark:border-neutral-700" 203 - onClick={() => setNotif({ show: false })} 204 - > 205 - <span class={`iconify ${notif().icon} mr-1`}></span> 206 - {notif().text} 207 - </button> 208 - </Show> 187 + <NotificationContainer /> 209 188 </div> 210 189 ); 211 190 };
+6 -2
src/utils/copy.ts
··· 1 - import { setNotif } from "../layout"; 1 + import { addNotification, removeNotification } from "../components/notification"; 2 2 3 3 export const addToClipboard = (text: string) => { 4 4 navigator.clipboard.writeText(text); 5 - setNotif({ show: true, icon: "lucide--clipboard-check", text: "Copied to clipboard" }); 5 + const id = addNotification({ 6 + message: "Copied to clipboard", 7 + type: "success", 8 + }); 9 + setTimeout(() => removeNotification(id), 3000); 6 10 };
+5 -5
src/views/collection.tsx
··· 9 9 import { JSONType, JSONValue } from "../components/json.jsx"; 10 10 import { agent } from "../components/login.jsx"; 11 11 import { Modal } from "../components/modal.jsx"; 12 + import { addNotification, removeNotification } from "../components/notification.jsx"; 12 13 import { StickyOverlay } from "../components/sticky.jsx"; 13 14 import { TextInput } from "../components/text-input.jsx"; 14 15 import Tooltip from "../components/tooltip.jsx"; 15 - import { setNotif } from "../layout.jsx"; 16 16 import { resolvePDS } from "../utils/api.js"; 17 17 import { localDateFromTimestamp } from "../utils/date.js"; 18 18 ··· 149 149 }, 150 150 }); 151 151 } 152 - setNotif({ 153 - show: true, 154 - icon: "lucide--trash-2", 155 - text: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`, 152 + const id = addNotification({ 153 + message: `${recsToDel.length} records ${recreate() ? "recreated" : "deleted"}`, 154 + type: "success", 156 155 }); 156 + setTimeout(() => removeNotification(id), 3000); 157 157 setBatchDelete(false); 158 158 setRecords([]); 159 159 setCursor(undefined);
+6 -2
src/views/record.tsx
··· 15 15 import { agent } from "../components/login.jsx"; 16 16 import { Modal } from "../components/modal.jsx"; 17 17 import { pds } from "../components/navbar.jsx"; 18 + import { addNotification, removeNotification } from "../components/notification.jsx"; 18 19 import Tooltip from "../components/tooltip.jsx"; 19 - import { setNotif } from "../layout.jsx"; 20 20 import { resolveLexiconAuthority, resolveLexiconSchema, resolvePDS } from "../utils/api.js"; 21 21 import { AtUri, uriTemplates } from "../utils/templates.js"; 22 22 import { lexicons } from "../utils/types/lexicons.js"; ··· 134 134 rkey: params.rkey, 135 135 }, 136 136 }); 137 - setNotif({ show: true, icon: "lucide--trash-2", text: "Record deleted" }); 137 + const id = addNotification({ 138 + message: "Record deleted", 139 + type: "success", 140 + }); 141 + setTimeout(() => removeNotification(id), 3000); 138 142 navigate(`/at://${params.repo}/${params.collection}`); 139 143 }; 140 144
+65 -1
src/views/repo.tsx
··· 22 22 NavMenu, 23 23 } from "../components/dropdown.jsx"; 24 24 import { setPDS } from "../components/navbar.jsx"; 25 + import { 26 + addNotification, 27 + removeNotification, 28 + updateNotification, 29 + } from "../components/notification.jsx"; 25 30 import { TextInput } from "../components/text-input.jsx"; 26 31 import Tooltip from "../components/tooltip.jsx"; 27 32 import { ··· 155 160 }; 156 161 157 162 const downloadRepo = async () => { 163 + let notificationId: string | null = null; 164 + 158 165 try { 159 166 setDownloading(true); 167 + notificationId = addNotification({ 168 + message: "Downloading repository...", 169 + progress: 0, 170 + total: 0, 171 + type: "info", 172 + }); 173 + 160 174 const response = await fetch(`${pds}/xrpc/com.atproto.sync.getRepo?did=${did}`); 161 175 if (!response.ok) { 162 176 throw new Error(`HTTP error status: ${response.status}`); 163 177 } 164 178 165 - const blob = await response.blob(); 179 + const contentLength = response.headers.get("content-length"); 180 + const total = contentLength ? parseInt(contentLength, 10) : 0; 181 + let loaded = 0; 182 + 183 + const reader = response.body?.getReader(); 184 + const chunks: Uint8Array[] = []; 185 + 186 + if (reader) { 187 + while (true) { 188 + const { done, value } = await reader.read(); 189 + if (done) break; 190 + 191 + chunks.push(value); 192 + loaded += value.length; 193 + 194 + if (total > 0) { 195 + const progress = Math.round((loaded / total) * 100); 196 + updateNotification(notificationId, { 197 + progress, 198 + total, 199 + }); 200 + } else { 201 + const progressMB = Math.round((loaded / (1024 * 1024)) * 10) / 10; 202 + updateNotification(notificationId, { 203 + progress: progressMB, 204 + total: 0, 205 + }); 206 + } 207 + } 208 + } 209 + 210 + const blob = new Blob(chunks); 166 211 const url = window.URL.createObjectURL(blob); 167 212 const a = document.createElement("a"); 168 213 a.href = url; ··· 172 217 173 218 window.URL.revokeObjectURL(url); 174 219 document.body.removeChild(a); 220 + 221 + updateNotification(notificationId, { 222 + message: "Repository downloaded successfully", 223 + type: "success", 224 + progress: undefined, 225 + }); 226 + setTimeout(() => { 227 + if (notificationId) removeNotification(notificationId); 228 + }, 3000); 175 229 } catch (error) { 176 230 console.error("Download failed:", error); 231 + if (notificationId) { 232 + updateNotification(notificationId, { 233 + message: "Download failed", 234 + type: "error", 235 + progress: undefined, 236 + }); 237 + setTimeout(() => { 238 + if (notificationId) removeNotification(notificationId); 239 + }, 5000); 240 + } 177 241 } 178 242 setDownloading(false); 179 243 };