atproto explorer pdsls.dev
atproto tool

new notification system

juli.ee 0040a4a7 c67f0065

verified
+11 -3
src/components/create.tsx
···
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";
···
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}`);
};
···
}
}
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);
+5 -5
src/components/json.tsx
···
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 {
···
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);
}
};
+80
src/components/notification.tsx
···
+
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<Notification[]>([]);
+
+
export const addNotification = (notification: Omit<Notification, "id">) => {
+
const id = `notification-${Date.now()}-${Math.random()}`;
+
setNotifications([...notifications(), { ...notification, id }]);
+
return id;
+
};
+
+
export const updateNotification = (id: string, updates: Partial<Notification>) => {
+
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 (
+
<div class="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-2">
+
<For each={notifications()}>
+
{(notification) => (
+
<div
+
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"
+
classList={{
+
"border-blue-500 dark:border-blue-400": notification.type === "info",
+
"border-green-500 dark:border-green-400": notification.type === "success",
+
"border-red-500 dark:border-red-400": notification.type === "error",
+
}}
+
onClick={() => removeNotification(notification.id)}
+
>
+
<div class="flex items-center gap-2 text-sm">
+
<Show when={notification.progress !== undefined}>
+
<span class="iconify lucide--download" />
+
</Show>
+
<Show when={notification.type === "success"}>
+
<span class="iconify lucide--check-circle text-green-600 dark:text-green-400" />
+
</Show>
+
<Show when={notification.type === "error"}>
+
<span class="iconify lucide--x-circle text-red-500 dark:text-red-400" />
+
</Show>
+
<span>{notification.message}</span>
+
</div>
+
<Show when={notification.progress !== undefined}>
+
<div class="flex flex-col gap-1">
+
<Show
+
when={notification.total !== undefined && notification.total > 0}
+
fallback={
+
<div class="text-xs text-neutral-600 dark:text-neutral-400">
+
{notification.progress} MB
+
</div>
+
}
+
>
+
<div class="h-2 w-full overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700">
+
<div
+
class="h-full rounded-full bg-blue-500 transition-all dark:bg-blue-400"
+
style={{ width: `${notification.progress}%` }}
+
/>
+
</div>
+
<div class="text-xs text-neutral-600 dark:text-neutral-400">
+
{notification.progress}%
+
</div>
+
</Show>
+
</div>
+
</Show>
+
</div>
+
)}
+
</For>
+
</div>
+
);
+
};
+3 -24
src/layout.tsx
···
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<string, string> = {
"did:plc:ia76kvnndjutgedggx2ibrem": "bunny.jpg",
···
const Layout = (props: RouteSectionProps<unknown>) => {
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");
···
if (props.params.repo && !props.params.repo.startsWith("did:")) {
const did = await resolveHandle(props.params.repo as Handle);
navigate(location.pathname.replace(props.params.repo, did));
-
}
-
});
-
-
createEffect(() => {
-
if (notif().show) {
-
clearTimeout(timeout);
-
timeout = setTimeout(() => setNotif({ show: false }), 3000);
}
});
···
</ErrorBoundary>
</Show>
</div>
-
<Show when={notif().show}>
-
<button
-
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"
-
onClick={() => setNotif({ show: false })}
-
>
-
<span class={`iconify ${notif().icon} mr-1`}></span>
-
{notif().text}
-
</button>
-
</Show>
+
<NotificationContainer />
</div>
);
};
+6 -2
src/utils/copy.ts
···
-
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);
};
+5 -5
src/views/collection.tsx
···
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";
···
},
});
}
-
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);
+6 -2
src/views/record.tsx
···
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";
···
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}`);
};
+65 -1
src/views/repo.tsx
···
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 {
···
};
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;
···
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);
};