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