learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add a toast notification system

+190 -6
+7 -3
web/src/components/DeckEditor.tsx
··· 1 1 import { createSignal, Show } from "solid-js"; 2 2 import { api } from "../lib/api"; 3 3 import type { Visibility } from "../lib/store"; 4 + import { toast } from "../lib/toast"; 4 5 5 6 export function DeckEditor() { 6 7 const [title, setTitle] = createSignal(""); ··· 20 21 21 22 const payload = { title: title(), description: description(), tags: [], visibility }; 22 23 23 - await api.post("/decks", payload); 24 - // TODO: Navigate or show success 25 - alert("Deck created!"); 24 + try { 25 + await api.post("/decks", payload); 26 + toast.success("Deck created!"); 27 + } catch { 28 + toast.error("Failed to create deck"); 29 + } 26 30 }; 27 31 28 32 return (
+3 -3
web/src/components/layout/AppLayout.tsx
··· 1 1 import type { Component, JSX } from "solid-js"; 2 + import { Toaster } from "../ui/Toast"; 2 3 import { Header } from "./Header"; 3 4 4 - interface AppLayoutProps { 5 - children?: JSX.Element; 6 - } 5 + type AppLayoutProps = { children?: JSX.Element }; 7 6 8 7 export const AppLayout: Component<AppLayoutProps> = (props) => { 9 8 return ( 10 9 <div class="min-h-screen bg-black text-gray-100 font-sans selection:bg-blue-500/30"> 10 + <Toaster /> 11 11 <Header /> 12 12 <main class="container mx-auto px-4 py-8 md:px-6 lg:px-8 max-w-7xl">{props.children}</main> 13 13 </div>
+98
web/src/components/ui/Toast.tsx
··· 1 + import { For, Match, Switch } from "solid-js"; 2 + import type { Component } from "solid-js"; 3 + import { toast, toasts, type ToastType } from "../../lib/toast"; 4 + 5 + const borderColors: Record<ToastType, string> = { 6 + success: "border-l-4 border-green-500", 7 + error: "border-l-4 border-red-500", 8 + warning: "border-l-4 border-yellow-500", 9 + info: "border-l-4 border-blue-500", 10 + }; 11 + 12 + const InfoIcon: Component = () => ( 13 + <svg 14 + xmlns="http://www.w3.org/2000/svg" 15 + class="h-6 w-6 text-blue-500" 16 + fill="none" 17 + viewBox="0 0 24 24" 18 + stroke="currentColor"> 19 + <path 20 + stroke-linecap="round" 21 + stroke-linejoin="round" 22 + stroke-width="2" 23 + d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> 24 + </svg> 25 + ); 26 + 27 + export const Toaster: Component = () => { 28 + return ( 29 + <div class="fixed top-4 right-4 z-50 flex flex-col gap-2 w-full max-w-sm"> 30 + <For each={toasts()}> 31 + {(t) => ( 32 + <div 33 + class={`relative flex items-center gap-3 p-4 bg-gray-900 shadow-lg text-white transition-all transform animate-in slide-in-from-right-full duration-300 ${ 34 + borderColors[t.type] 35 + }`} 36 + role="alert"> 37 + <div class="flex-shrink-0"> 38 + <Switch fallback={<InfoIcon />}> 39 + <Match when={t.type === "success"}> 40 + <svg 41 + xmlns="http://www.w3.org/2000/svg" 42 + class="h-6 w-6 text-green-400" 43 + fill="none" 44 + viewBox="0 0 24 24" 45 + stroke="currentColor"> 46 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> 47 + </svg> 48 + </Match> 49 + <Match when={t.type === "error"}> 50 + <svg 51 + xmlns="http://www.w3.org/2000/svg" 52 + class="h-6 w-6 text-red-500" 53 + fill="none" 54 + viewBox="0 0 24 24" 55 + stroke="currentColor"> 56 + <path 57 + stroke-linecap="round" 58 + stroke-linejoin="round" 59 + stroke-width="2" 60 + d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> 61 + </svg> 62 + </Match> 63 + <Match when={t.type === "warning"}> 64 + <svg 65 + xmlns="http://www.w3.org/2000/svg" 66 + class="h-6 w-6 text-yellow-500" 67 + fill="none" 68 + viewBox="0 0 24 24" 69 + stroke="currentColor"> 70 + <path 71 + stroke-linecap="round" 72 + stroke-linejoin="round" 73 + stroke-width="2" 74 + d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> 75 + </svg> 76 + </Match> 77 + </Switch> 78 + </div> 79 + <div class="flex-1 text-sm">{t.message}</div> 80 + <button 81 + onClick={() => toast.remove(t.id)} 82 + class="flex-shrink-0 text-gray-400 hover:text-white focus:outline-none" 83 + aria-label="Close"> 84 + <svg 85 + xmlns="http://www.w3.org/2000/svg" 86 + class="h-4 w-4" 87 + fill="none" 88 + viewBox="0 0 24 24" 89 + stroke="currentColor"> 90 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 91 + </svg> 92 + </button> 93 + </div> 94 + )} 95 + </For> 96 + </div> 97 + ); 98 + };
+45
web/src/lib/toast.test.ts
··· 1 + import { createRoot } from "solid-js"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { toast, toasts } from "./toast"; 4 + 5 + describe("Toast Store", () => { 6 + beforeEach(() => { 7 + const currentToasts = toasts(); 8 + currentToasts.forEach(t => toast.remove(t.id)); 9 + }); 10 + 11 + it("should add a toast", () => { 12 + createRoot(() => { 13 + toast.success("Success message"); 14 + const currentToasts = toasts(); 15 + expect(currentToasts.length).toBe(1); 16 + expect(currentToasts[0].message).toBe("Success message"); 17 + expect(currentToasts[0].type).toBe("success"); 18 + }); 19 + }); 20 + 21 + it("should remove a toast", () => { 22 + createRoot(() => { 23 + toast.error("Error message"); 24 + let currentToasts = toasts(); 25 + expect(currentToasts.length).toBe(1); 26 + const id = currentToasts[0].id; 27 + 28 + toast.remove(id); 29 + currentToasts = toasts(); 30 + expect(currentToasts.length).toBe(0); 31 + }); 32 + }); 33 + 34 + it("should auto-dismiss toast", () => { 35 + vi.useFakeTimers(); 36 + createRoot(() => { 37 + toast.info("Info message", 1000); 38 + expect(toasts().length).toBe(1); 39 + 40 + vi.advanceTimersByTime(1000); 41 + expect(toasts().length).toBe(0); 42 + }); 43 + vi.useRealTimers(); 44 + }); 45 + });
+37
web/src/lib/toast.ts
··· 1 + import { createSignal } from "solid-js"; 2 + 3 + export type ToastType = "success" | "error" | "info" | "warning"; 4 + 5 + export interface ToastData { 6 + id: string; 7 + type: ToastType; 8 + message: string; 9 + duration?: number; 10 + } 11 + 12 + const [toasts, setToasts] = createSignal<ToastData[]>([]); 13 + 14 + const addToast = (type: ToastType, message: string, duration = 5000) => { 15 + const id = Math.random().toString(36).substring(2, 9); 16 + setToasts((prev) => [...prev, { id, type, message, duration }]); 17 + 18 + if (duration > 0) { 19 + setTimeout(() => { 20 + removeToast(id); 21 + }, duration); 22 + } 23 + }; 24 + 25 + const removeToast = (id: string) => { 26 + setToasts((prev) => prev.filter((t) => t.id !== id)); 27 + }; 28 + 29 + export const toast = { 30 + success: (message: string, duration?: number) => addToast("success", message, duration), 31 + error: (message: string, duration?: number) => addToast("error", message, duration), 32 + info: (message: string, duration?: number) => addToast("info", message, duration), 33 + warning: (message: string, duration?: number) => addToast("warning", message, duration), 34 + remove: removeToast, 35 + }; 36 + 37 + export { toasts };