1import { Handle } from "@atcute/lexicons";
2import { Meta, MetaProvider } from "@solidjs/meta";
3import { A, RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
4import { createEffect, createSignal, ErrorBoundary, onMount, Show, Suspense } from "solid-js";
5import { AccountManager } from "./components/account.jsx";
6import { RecordEditor } from "./components/create.jsx";
7import { DropdownMenu, MenuProvider, NavMenu } from "./components/dropdown.jsx";
8import { agent } from "./components/login.jsx";
9import { NavBar } from "./components/navbar.jsx";
10import { Search, SearchButton, showSearch } from "./components/search.jsx";
11import { themeEvent, ThemeSelection } from "./components/theme.jsx";
12import { resolveHandle } from "./utils/api.js";
13
14export const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 1;
15
16export const [notif, setNotif] = createSignal<{
17 show: boolean;
18 icon?: string;
19 text?: string;
20}>({ show: false });
21
22const headers: Record<string, string> = {
23 "did:plc:ia76kvnndjutgedggx2ibrem": "bunny.jpg",
24 "did:plc:oisofpd7lj26yvgiivf3lxsi": "puppy.jpg",
25 "did:plc:vwzwgnygau7ed7b7wt5ux7y2": "water.webp",
26 "did:plc:uu5axsmbm2or2dngy4gwchec": "city.webp",
27 "did:plc:aokggmp5jzj4nc5jifhiplqc": "bridge.jpg",
28 "did:plc:bnqkww7bjxaacajzvu5gswdf": "forest.jpg",
29 "did:plc:p2cp5gopk7mgjegy6wadk3ep": "aurora.jpg",
30 "did:plc:ucaezectmpny7l42baeyooxi": "almaty.webp",
31};
32
33const Layout = (props: RouteSectionProps<unknown>) => {
34 const location = useLocation();
35 const navigate = useNavigate();
36 let timeout: number;
37
38 if (location.search.includes("hrt=true")) localStorage.setItem("hrt", "true");
39 else if (location.search.includes("hrt=false")) localStorage.setItem("hrt", "false");
40 if (location.search.includes("sailor=true")) localStorage.setItem("sailor", "true");
41 else if (location.search.includes("sailor=false")) localStorage.setItem("sailor", "false");
42
43 createEffect(async () => {
44 if (props.params.repo && !props.params.repo.startsWith("did:")) {
45 const did = await resolveHandle(props.params.repo as Handle);
46 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 }
55 });
56
57 onMount(() => {
58 window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", themeEvent);
59
60 if (localStorage.getItem("sailor") === "true") {
61 const style = document.createElement("style");
62 style.textContent = `
63 html, * {
64 cursor: url(/cursor.cur), pointer;
65 }
66
67 .star {
68 position: fixed;
69 pointer-events: none;
70 z-index: 9999;
71 font-size: 20px;
72 animation: sparkle 0.8s ease-out forwards;
73 }
74
75 @keyframes sparkle {
76 0% {
77 opacity: 1;
78 transform: translate(0, 0) rotate(var(--ttheta1)) scale(1);
79 }
80 100% {
81 opacity: 0;
82 transform: translate(var(--tx), var(--ty)) rotate(var(--ttheta2)) scale(0);
83 }
84 }
85 `;
86 document.head.appendChild(style);
87
88 let lastTime = 0;
89 const throttleDelay = 30;
90
91 document.addEventListener("mousemove", (e) => {
92 const now = Date.now();
93 if (now - lastTime < throttleDelay) return;
94 lastTime = now;
95
96 const star = document.createElement("div");
97 star.className = "star";
98 star.textContent = "✨";
99 star.style.left = e.clientX + "px";
100 star.style.top = e.clientY + "px";
101
102 const tx = (Math.random() - 0.5) * 50;
103 const ty = (Math.random() - 0.5) * 50;
104 const ttheta1 = Math.random() * 360;
105 const ttheta2 = ttheta1 + (Math.random() - 0.5) * 540;
106 star.style.setProperty("--tx", tx + "px");
107 star.style.setProperty("--ty", ty + "px");
108 star.style.setProperty("--ttheta1", ttheta1 + "deg");
109 star.style.setProperty("--ttheta2", ttheta2 + "deg");
110
111 document.body.appendChild(star);
112
113 setTimeout(() => star.remove(), 800);
114 });
115 }
116 });
117
118 return (
119 <div
120 id="main"
121 class="mx-auto mb-8 flex max-w-lg flex-col items-center p-4 text-neutral-900 dark:text-neutral-200"
122 >
123 <MetaProvider>
124 <Show when={location.pathname !== "/"}>
125 <Meta name="robots" content="noindex, nofollow" />
126 </Show>
127 </MetaProvider>
128 <header
129 class={`dark:shadow-dark-700 dark:bg-dark-300 mb-3 flex w-full items-center justify-between rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 bg-size-[95%] bg-right bg-no-repeat p-3 shadow-xs [--header-bg:#fafafa] dark:border-neutral-700 dark:[--header-bg:#2d2d2d] ${localStorage.getItem("hrt") === "true" ? "bg-[linear-gradient(to_left,transparent_10%,var(--header-bg)_85%),linear-gradient(to_bottom,#5BCEFA90_0%,#5BCEFA90_20%,#F5A9B890_20%,#F5A9B890_40%,#FFFFFF90_40%,#FFFFFF90_60%,#F5A9B890_60%,#F5A9B890_80%,#5BCEFA90_80%,#5BCEFA90_100%)]" : ""}`}
130 style={{
131 "background-image":
132 props.params.repo in headers ?
133 `linear-gradient(to left, transparent 10%, var(--header-bg) 85%), url(/headers/${headers[props.params.repo]})`
134 : undefined,
135 }}
136 >
137 <A
138 href="/"
139 style='font-feature-settings: "cv05"'
140 class="flex items-center gap-1 text-xl font-semibold"
141 >
142 <span class="iconify tabler--binary-tree-filled text-[#76c4e5]"></span>
143 <span>PDSls</span>
144 </A>
145 <div class="dark:bg-dark-300/60 relative flex items-center gap-1 rounded-lg bg-neutral-50/60">
146 <Show when={location.pathname !== "/"}>
147 <SearchButton />
148 </Show>
149 <Show when={agent()}>
150 <RecordEditor create={true} />
151 </Show>
152 <AccountManager />
153 <MenuProvider>
154 <DropdownMenu
155 icon="lucide--menu text-xl"
156 buttonClass="rounded-lg p-1"
157 menuClass="top-10 p-3 text-sm"
158 >
159 <NavMenu href="/jetstream" label="Jetstream" />
160 <NavMenu href="/firehose" label="Firehose" />
161 <NavMenu href="/settings" label="Settings" />
162 <NavMenu
163 href="https://bsky.app/profile/did:plc:6q5daed5gutiyerimlrnojnz"
164 label="Bluesky"
165 newTab
166 external
167 />
168 <NavMenu
169 href="https://tangled.org/@pdsls.dev/pdsls/"
170 label="Source"
171 newTab
172 external
173 />
174 <ThemeSelection />
175 </DropdownMenu>
176 </MenuProvider>
177 </div>
178 </header>
179 <div class="flex w-full flex-col items-center gap-3 text-pretty">
180 <Show when={showSearch() || location.pathname === "/"}>
181 <Search />
182 </Show>
183 <Show when={props.params.pds}>
184 <NavBar params={props.params} />
185 </Show>
186 <Show keyed when={location.pathname}>
187 <ErrorBoundary
188 fallback={(err) => <div class="mt-3 wrap-break-word">Error: {err.message}</div>}
189 >
190 <Suspense
191 fallback={
192 <span class="iconify lucide--loader-circle mt-3 animate-spin text-xl"></span>
193 }
194 >
195 {props.children}
196 </Suspense>
197 </ErrorBoundary>
198 </Show>
199 </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>
209 </div>
210 );
211};
212
213export { Layout };