├── .editorconfig ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── bsky.ts ├── embed-test.html ├── embed.html ├── esbuild.server.mjs ├── esbuild.site.mjs ├── index.html ├── index.ts ├── logo.svg ├── package-lock.json ├── package.json ├── postcss.config.js ├── publish.sh ├── server.ts ├── styles-bundle.css ├── styles-bundle.css.txt ├── styles.css ├── styles.ts ├── tailwind.config.js ├── test.html ├── tsconfig.json └── tutorial.png /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | 7 | [{package.json,.prettierrc}] 8 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | /data/ 4 | site/build/ 5 | server/build/ 6 | build 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | data/ 2 | .github/ 3 | .vscode/ 4 | alasql.js 5 | charts.js 6 | latest-canonical*.json 7 | momentum-cart.json 8 | package-lock.json 9 | site/_templates 10 | docs/ 11 | stores/*.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "bracketSpacing": true, 4 | "printWidth": 150 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "frontend", 11 | "url": "http://localhost:8080", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mario Zechner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # skyview 2 | 3 | A Bluesky thread reader. 4 | -------------------------------------------------------------------------------- /bsky.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { Agent } from "@intrnl/bluesky-client/agent"; 3 | // @ts-ignore 4 | import type { DID } from "@intrnl/bluesky-client/atp-schema"; 5 | import { RichText } from "@atproto/api"; 6 | 7 | const agent = new Agent({ serviceUri: "https://api.bsky.app" }); 8 | 9 | export type BskyAuthor = { 10 | did: string; 11 | avatar?: string; 12 | displayName: string; 13 | handle?: string; 14 | }; 15 | 16 | export type BskyFacet = { 17 | features: { uri?: string; tag?: string }[]; 18 | index: { byteStart: number; byteEnd: number }; 19 | }; 20 | 21 | export type BskyRecord = { 22 | createdAt: string; 23 | text: string; 24 | facets?: BskyFacet[]; 25 | }; 26 | 27 | export type BskyImage = { 28 | thumb: string; 29 | fullsize: string; 30 | alt: string; 31 | aspectRatio?: { 32 | width: number; 33 | height: number; 34 | }; 35 | }; 36 | 37 | export type BskyViewRecord = { 38 | $type: "app.bsky.embed.record#viewRecord"; 39 | uri: string; 40 | cid: string; 41 | author: BskyAuthor; 42 | value?: BskyRecord; 43 | embeds: { 44 | media?: { images: BskyImage[] }; 45 | images?: BskyImage[]; 46 | external?: BskyExternalCard; 47 | record?: BskyViewRecord | BskyViewRecordWithMedia; 48 | }[]; 49 | }; 50 | 51 | export type BskyViewRecordWithMedia = { 52 | $type: "app.bsky.embed.record_with_media#viewRecord"; 53 | record: BskyViewRecord; 54 | }; 55 | 56 | export type BskyExternalCard = { 57 | uri: string; 58 | title: string; 59 | description: string; 60 | thumb?: string; 61 | }; 62 | 63 | export type BskyPost = { 64 | uri: string; 65 | cid: string; 66 | author: BskyAuthor; 67 | record: BskyRecord; 68 | embed?: { 69 | media?: { images: BskyImage[] }; 70 | images?: BskyImage[]; 71 | external?: BskyExternalCard; 72 | record?: BskyViewRecord | BskyViewRecordWithMedia; 73 | }; 74 | likeCount: number; 75 | replyCount: number; 76 | repostCount: number; 77 | }; 78 | 79 | export type BskyThreadPost = { 80 | parent?: BskyThreadPost; 81 | post: BskyPost; 82 | replies: BskyThreadPost[]; 83 | }; 84 | 85 | export type ViewType = "tree" | "embed" | "unroll"; 86 | 87 | export async function collectFullThread(postUri: string) { 88 | const seen = new Set(); 89 | const threadCache = new Map(); 90 | 91 | async function getAndCacheThread(uri: string) { 92 | const response = await agent.rpc.get("app.bsky.feed.getPostThread", { 93 | params: { 94 | uri, 95 | parentHeight: 100, 96 | depth: 100, 97 | }, 98 | }); 99 | 100 | if (!response?.success || !response.data?.thread) { 101 | throw new Error(`Failed to load thread for ${uri}`); 102 | } 103 | 104 | function cacheThreadPosts(thread: BskyThreadPost) { 105 | if (!seen.has(thread.post.uri)) { 106 | seen.add(thread.post.uri); 107 | threadCache.set(thread.post.uri, thread); 108 | thread.replies?.forEach(reply => cacheThreadPosts(reply)); 109 | } 110 | } 111 | 112 | cacheThreadPosts(response.data.thread); 113 | return response.data.thread; 114 | } 115 | 116 | async function findRootPost(startUri: string): Promise { 117 | let currentThread = threadCache.get(startUri) || await getAndCacheThread(startUri); 118 | 119 | while (currentThread.parent) { 120 | const parentUri = currentThread.parent.post.uri; 121 | currentThread = threadCache.get(parentUri) || await getAndCacheThread(parentUri); 122 | } 123 | 124 | return currentThread; 125 | } 126 | 127 | async function processThreadBranch(post: BskyThreadPost): Promise { 128 | if (!post.replies?.length && post.post.replyCount > 0) { 129 | const freshData = await getAndCacheThread(post.post.uri); 130 | if (freshData.replies?.length) { 131 | post.replies = freshData.replies; 132 | } 133 | } 134 | 135 | if (post.replies?.length) { 136 | const fullReplies: BskyThreadPost[] = []; 137 | 138 | for (const reply of post.replies) { 139 | let fullReplyThread: BskyThreadPost; 140 | 141 | if (threadCache.has(reply.post.uri)) { 142 | fullReplyThread = threadCache.get(reply.post.uri)!; 143 | } else { 144 | fullReplyThread = await getAndCacheThread(reply.post.uri); 145 | } 146 | 147 | fullReplies.push(await processThreadBranch(fullReplyThread)); 148 | } 149 | 150 | post.replies = fullReplies.sort((a, b) => 151 | a.post.record.createdAt.localeCompare(b.post.record.createdAt) 152 | ); 153 | } 154 | 155 | return post; 156 | } 157 | 158 | const rootPost = await findRootPost(postUri); 159 | const fullThread = await processThreadBranch(rootPost); 160 | 161 | return { 162 | thread: fullThread, 163 | rootUri: rootPost.post.uri 164 | }; 165 | } 166 | 167 | export async function loadThread(url: string, viewType: ViewType): Promise<{ thread: BskyThreadPost; originalUri: string | undefined } | string> { 168 | try { 169 | const tokens = url.replace("https://", "").split("/"); 170 | const actor = tokens[2]; 171 | let rkey = tokens[4]; 172 | if (!actor || !rkey) { 173 | return "Sorry, couldn't load thread (invalid URL)"; 174 | } 175 | let did: DID; 176 | if (actor.startsWith("did:")) { 177 | did = actor as DID; 178 | } else { 179 | const response = await agent.rpc.get("com.atproto.identity.resolveHandle", { 180 | params: { 181 | handle: actor, 182 | }, 183 | }); 184 | 185 | if (!response.success) { 186 | return "Sorry, couldn't load thread (invalid handle)"; 187 | } 188 | did = response.data.did; 189 | } 190 | 191 | let originalUri = `at://${did}/app.bsky.feed.post/${rkey}`; 192 | let thread = (await collectFullThread(originalUri)).thread; 193 | 194 | if (!thread) { 195 | return "Sorry, couldn't load thread (invalid thread)"; 196 | } 197 | if (viewType == "embed") { 198 | if (thread.post.record.text.includes("@skyview.social") && thread.post.record.text.includes("embed") && thread.parent) { 199 | thread = thread.parent; 200 | } 201 | thread.replies = []; 202 | } 203 | 204 | if (viewType == "unroll") { 205 | const posts: BskyThreadPost[] = []; 206 | posts.push(thread); 207 | while (true) { 208 | const post = posts[posts.length - 1]; 209 | if (post.replies) { 210 | post.replies.sort((a, b) => a.post.record.createdAt.localeCompare(b.post.record.createdAt)); 211 | const next = post.replies.find( 212 | (reply) => 213 | reply.post.author.did == post.post.author.did && 214 | !reply.post.record.text.includes("@skyview.social") && 215 | !reply.post.record.text.includes("unroll") 216 | ); 217 | if (!next) break; 218 | posts.push(next); 219 | } else { 220 | break; 221 | } 222 | } 223 | thread.replies = posts.length > 1 ? posts.slice(1, posts.length) : []; 224 | thread.replies.forEach((reply) => (reply.replies = [])); 225 | if (thread.replies.length > 0) { 226 | const lastPost = thread.replies[thread.replies.length - 1]; 227 | if (lastPost.post.record.text.includes("@skyview.social") && lastPost.post.record.text.includes("unroll")) { 228 | thread.replies.pop(); 229 | } 230 | } 231 | } 232 | 233 | return { thread, originalUri }; 234 | } catch (e) { 235 | return `Sorry, couldn't load thread (exception) ${(e as any).message ? "\n" + (e as any).message : ""}`; 236 | } 237 | } 238 | 239 | function replaceHandles(text: string): string { 240 | const handleRegex = /@([\p{L}_.-]+)/gu; 241 | const replacedText = text.replace(handleRegex, (match, handle) => { 242 | return `@${handle}`; 243 | }); 244 | 245 | return replacedText; 246 | } 247 | 248 | function applyFacets(record: BskyRecord) { 249 | if (!record.facets) { 250 | return record.text; 251 | } 252 | 253 | const rt = new RichText({ 254 | text: record.text, 255 | facets: record.facets as any, 256 | }); 257 | 258 | const text: string[] = []; 259 | 260 | for (const segment of rt.segments()) { 261 | if (segment.isMention()) { 262 | text.push(`${segment.text}`); 263 | } else if (segment.isLink()) { 264 | text.push(`${segment.text}`); 265 | } else if (segment.isTag()) { 266 | text.push(`${segment.text}`); 267 | } else { 268 | text.push(segment.text); 269 | } 270 | } 271 | const result = text.join(""); 272 | return result; 273 | } 274 | 275 | export function processText(record: BskyRecord) { 276 | return replaceHandles(applyFacets(record)).trim().replaceAll("\n", "
"); 277 | } 278 | -------------------------------------------------------------------------------- /embed-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /embed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Skyview 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /esbuild.server.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import esbuild from "esbuild"; 4 | 5 | let watch = process.argv.length >= 3 && process.argv[2] == "--watch"; 6 | 7 | const config = { 8 | entryPoints: { 9 | server: "server.ts", 10 | }, 11 | bundle: true, 12 | sourcemap: true, 13 | platform: "node", 14 | outdir: "build/", 15 | logLevel: "info", 16 | minify: !watch, 17 | }; 18 | 19 | if (!watch) { 20 | console.log("Building site"); 21 | await esbuild.build(config); 22 | } else { 23 | const buildContext = await esbuild.context(config); 24 | buildContext.watch(); 25 | } 26 | -------------------------------------------------------------------------------- /esbuild.site.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import esbuild from "esbuild"; 4 | 5 | let watch = process.argv.length >= 3 && process.argv[2] == "--watch"; 6 | 7 | const config = { 8 | entryPoints: { 9 | index: "index.ts", 10 | }, 11 | bundle: true, 12 | sourcemap: true, 13 | outdir: "build/", 14 | loader: { 15 | ".ttf": "dataurl", 16 | ".woff": "dataurl", 17 | ".woff2": "dataurl", 18 | ".eot": "dataurl", 19 | ".html": "text", 20 | ".svg": "text", 21 | }, 22 | logLevel: "info", 23 | minify: !watch, 24 | }; 25 | 26 | if (!watch) { 27 | console.log("Building site"); 28 | await esbuild.build(config); 29 | } else { 30 | const buildContext = await esbuild.context(config); 31 | buildContext.watch(); 32 | } 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Skyview 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, PropertyValueMap, TemplateResult, css, html, nothing, render, svg } from "lit"; 2 | import { customElement, property, query, state } from "lit/decorators.js"; 3 | import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; 4 | import { globalStyles } from "./styles"; 5 | import { map } from "lit-html/directives/map.js"; 6 | // @ts-ignore 7 | import logoSvg from "./logo.svg"; 8 | 9 | function getTimeDifferenceString(inputDate: string): string { 10 | const currentDate = new Date(); 11 | const inputDateTime = new Date(inputDate); 12 | 13 | const timeDifference = currentDate.getTime() - inputDateTime.getTime(); 14 | const seconds = Math.floor(timeDifference / 1000); 15 | const minutes = Math.floor(seconds / 60); 16 | const hours = Math.floor(minutes / 60); 17 | const days = Math.floor(hours / 24); 18 | const years = Math.floor(days / 365); 19 | 20 | if (years > 0) { 21 | return `${years}y}`; 22 | } else if (days > 0) { 23 | return `${days}d`; 24 | } else if (hours > 0) { 25 | return `${hours}h`; 26 | } else if (minutes > 0) { 27 | return `${minutes}m`; 28 | } else { 29 | return `${seconds}s`; 30 | } 31 | } 32 | 33 | @customElement("radio-button-group") 34 | class RadioButtonGroup extends LitElement { 35 | @property() 36 | selectedValue = "funny"; 37 | @property() 38 | options = ["funny", "serious"]; 39 | @property() 40 | disabled = false; 41 | 42 | static styles = [globalStyles]; 43 | 44 | render() { 45 | return html`
46 | ${this.options.map( 47 | (option) => html` 48 | 59 | ` 60 | )} 61 |
`; 62 | } 63 | 64 | capitalizeFirstLetter(str: string) { 65 | return str.charAt(0).toUpperCase() + str.slice(1); 66 | } 67 | 68 | handleRadioChange(e: Event) { 69 | const selectedValue = (e.target as HTMLInputElement).value; 70 | this.selectedValue = selectedValue; 71 | this.dispatchEvent( 72 | new CustomEvent("change", { 73 | detail: { 74 | value: selectedValue, 75 | }, 76 | }) 77 | ); 78 | } 79 | } 80 | 81 | @customElement("skyview-popup") 82 | class Popup extends LitElement { 83 | static styles = globalStyles; 84 | 85 | @property() 86 | buttonText = "Click me"; 87 | 88 | @property() 89 | show = false; 90 | 91 | protected render(): TemplateResult { 92 | return html`
93 |
(this.show = !this.show)} class="rounded bg-black text-white p-1 text-xs">${this.buttonText}
94 | ${this.show 95 | ? html`
(this.show = !this.show)} class="absolute bg-black text-white p-4 z-[100] rounded border border-gray/50"> 96 | 97 |
` 98 | : nothing} 99 |
`; 100 | } 101 | } 102 | 103 | function dom(template: TemplateResult, container?: HTMLElement | DocumentFragment): HTMLElement[] { 104 | if (container) { 105 | render(template, container); 106 | return []; 107 | } 108 | 109 | const div = document.createElement(`div`); 110 | render(template, div); 111 | const children: Element[] = []; 112 | for (let i = 0; i < div.children.length; i++) { 113 | children.push(div.children[i]); 114 | } 115 | return children as HTMLElement[]; 116 | } 117 | 118 | function renderGallery(images: BskyImage[], expandGallery = true): HTMLElement { 119 | const galleryDom = dom(html` 120 |
121 | ${images.map( 122 | (img, index) => html` 123 |
124 | ${img.alt} 125 | ${img.alt && img.alt.length > 0 126 | ? html` 127 |
${img.alt}
128 |
` 129 | : nothing} 130 |
131 | ` 132 | )} 133 | ${images.length > 1 && !expandGallery 134 | ? html`
{ 138 | imageDoms[0].click(); 139 | (ev.target as HTMLElement).innerText = `Show ${images.length - 1} more images`; 140 | }} 141 | > 142 | Show ${images.length - 1} more images 143 |
` 144 | : nothing} 145 |
146 | `)[0]; 147 | 148 | const imageDoms = galleryDom.querySelectorAll("img"); 149 | const imageClickListener = () => { 150 | imageDoms.forEach((img, index) => { 151 | if (index == 0) return; 152 | img.parentElement!.classList.toggle("hidden"); 153 | }); 154 | if (imageDoms[1].classList.contains("hidden")) { 155 | imageDoms[0].scrollIntoView({ 156 | behavior: "auto", 157 | block: "nearest", 158 | }); 159 | } else { 160 | (galleryDom.querySelector("#toggle") as HTMLElement).remove(); 161 | } 162 | }; 163 | 164 | if (!expandGallery) { 165 | for (let i = 0; i < imageDoms.length; i++) { 166 | imageDoms[i].addEventListener("click", imageClickListener); 167 | } 168 | } 169 | return galleryDom; 170 | } 171 | 172 | function renderCard(card: BskyExternalCard) { 173 | return html` 174 |
175 | ${card.thumb 176 | ? html`
` 177 | : nothing} 178 |
179 | ${card.title ? card.title : card.uri} 180 | ${card.description.split("\n")[0]} 181 | ${new URL(card.uri).host} 182 |
183 |
184 |
`; 185 | } 186 | 187 | const contentLoader = html`
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
`; 200 | 201 | // @ts-ignore 202 | import sunIconSvg from "remixicon/icons/Weather/sun-line.svg"; 203 | // @ts-ignore 204 | import moonIconSvg from "remixicon/icons/Weather/moon-line.svg"; 205 | import { BskyAuthor, BskyExternalCard, BskyImage, BskyRecord, BskyThreadPost, ViewType, loadThread, processText } from "./bsky"; 206 | 207 | function icon(svg: string) { 208 | return html`${unsafeHTML(svg)}`; 209 | } 210 | 211 | @customElement("theme-toggle") 212 | class ThemeToggle extends LitElement { 213 | static style = [globalStyles]; 214 | 215 | @state() 216 | theme = "dark"; 217 | 218 | protected createRenderRoot(): Element | ShadowRoot { 219 | return this; 220 | } 221 | 222 | connectedCallback(): void { 223 | super.connectedCallback(); 224 | this.theme = localStorage.getItem("theme") ?? "dark"; 225 | this.setTheme(this.theme); 226 | } 227 | 228 | setTheme(theme: string) { 229 | localStorage.setItem("theme", theme); 230 | if (theme == "dark") document.documentElement.classList.add("dark"); 231 | else document.documentElement.classList.remove("dark"); 232 | } 233 | 234 | toggleTheme() { 235 | this.theme = this.theme == "dark" ? "light" : "dark"; 236 | this.setTheme(this.theme); 237 | } 238 | 239 | render() { 240 | const moonIcon = icon(moonIconSvg); 241 | const sunIcon = icon(sunIconSvg); 242 | 243 | return html``; 246 | } 247 | } 248 | 249 | @customElement("skyview-app") 250 | class App extends LitElement { 251 | static styles = [globalStyles]; 252 | 253 | @query("#url") 254 | urlElement!: HTMLInputElement; 255 | 256 | @query("#viewTypeElement") 257 | viewTypeElement?: RadioButtonGroup; 258 | 259 | @state() 260 | error?: string; 261 | 262 | url: string | null = null; 263 | 264 | @state() 265 | loading = false; 266 | 267 | @state() 268 | thread?: BskyThreadPost; 269 | originalUri?: string; 270 | 271 | @state() 272 | copiedLink = false; 273 | 274 | @state() 275 | copiedCode = false; 276 | 277 | viewType: ViewType = "tree"; 278 | 279 | @property() 280 | embed = false; 281 | 282 | constructor() { 283 | super(); 284 | this.url = new URL(location.href).searchParams.get("url"); 285 | this.viewType = new URL(location.href).searchParams.get("viewtype") as ViewType; 286 | if (!this.viewType) this.viewType = "tree"; 287 | } 288 | 289 | firstUpdate = true; 290 | protected willUpdate(_changedProperties: PropertyValueMap | Map): void { 291 | if (this.firstUpdate) { 292 | if (this.embed) this.viewType = "embed"; 293 | if (this.url) this.load(); 294 | this.firstUpdate = false; 295 | } 296 | } 297 | 298 | protected createRenderRoot(): Element | ShadowRoot { 299 | return this; 300 | } 301 | 302 | async load() { 303 | if (!this.url) { 304 | this.loading = false; 305 | this.error = "Sorry, couldn't load thread (no URL given)"; 306 | return; 307 | } 308 | this.loading = true; 309 | this.originalUri = undefined; 310 | try { 311 | const result = await loadThread(this.url, this.viewType); 312 | if (typeof result == "string") { 313 | this.error = result; 314 | } else { 315 | this.thread = result.thread; 316 | this.originalUri = result.originalUri; 317 | } 318 | } catch (e) { 319 | this.error = `Sorry, couldn't load thread (exception) ${(e as any).message ? "\n" + (e as any).message : ""}`; 320 | return; 321 | } finally { 322 | this.loading = false; 323 | } 324 | } 325 | 326 | render() { 327 | let content: TemplateResult = html``; 328 | if (this.loading) { 329 | content = html`
${contentLoader}
`; 330 | } else if (this.error) { 331 | content = html`
Error: ${this.error}
`; 332 | } else if (this.thread) { 333 | content = html` ${!this.embed 334 | ? html`
this.copyToClipboard(location.href)}> 335 | ${this.copiedLink ? "Copied link to clipboard" : "Share"} 336 |
337 | 344 | ${this.viewType == "embed" 345 | ? html`
Use this HTML code on your website, blog, etc. to embed the post below.
346 |
349 |
350 | <iframe
351 | src="${location.protocol}//${location.host}/embed.html?url=${this.url}"
352 | style="border: none; outline: none; width: 400px; height: 600px"
353 | ></iframe>
355 | 365 |
` 366 | : nothing} ` 367 | : nothing} 368 | ${this.viewType == "unroll" 369 | ? this.unroll(this.thread) 370 | : this.postPartial(this.thread, this.viewType == "tree" ? this.originalUri : undefined)}`; 371 | } else { 372 | content = html`
373 | View and share BlueSky threads without needing a 374 | BlueSky account. 375 |
376 |
377 | 382 | 383 |
384 |
How it works (BlueSky mobile & web app)
385 |
386 |
387 | Reply anywhere in a BlueSky thread and mention 388 | @skyview.social using one of the 389 | following commands. The Skyview bot will reply to you with a link, which shows the BlueSky thread depending on your command. 390 |
391 |
392 |
393 | @skyview.social tree 394 |
395 |
396 | Shows the entire conversation as a nested tree. Great for seeing all comments in a thread. 397 | Example 403 |
404 |
405 |
406 |
407 | @skyview.social unroll 408 |
409 |
410 | Only shows the top post and replies by the same author. Great for viewing long form content consisting of multiple chained 411 | posts. 412 | Example 418 |
419 |
420 |
421 |
422 | @skyview.social embed 423 |
424 |
425 | Only shows the post to which you reply with a mention of @skyview.social. Also shows HTML you can add to your own website 426 | or blog to embed the post. 427 | Example 433 |
434 |
435 |
436 | 437 |
How it works (this website)
438 |
1. Open a post in the BlueSky app or on the BlueSky website
439 |
2. Click the three dots
440 |
3. Click "Share" and copy the URL
441 |
4. Paste the URL into the text field above and click "View"
442 |
5. Set your preferred viewing type ("tree", "embed", "unroll") and share it by clicking "Share"
443 | 444 |
Privacy on BlueSky (or lack thereof)
445 |
446 |

447 | By design, all your BlueSky posts are available to anyone with an internet connection. They do not even need a BlueSky account 448 | to view all your posts (and images). All they need is your BlueSky user name. This is by design. There is no privacy on 449 | BlueSky. 450 |

451 |

452 | Here's a 453 | post of mine on BlueSky. You'll need an account to view it on the BlueSky website or in the BlueSky app. 456 |

457 |

458 | And here is the 459 | same post, publically available to anyone with an internet connection. Yes, it reads like gibberish, but computer people can take this data and reconstruct all of the post's information. That's 465 | essentially what Skyview does. Without needing a BlueSky account. Here is the 466 | same post viewed via Skyview. 472 |

473 |

474 | Skyview runs directly in your browser. Everything you do happens directly on your device. Skyview does not collect any data 475 | whatsovever. It also does not store any data users are viewing through it. 476 |

477 |

478 |
`; 479 | } 480 | 481 | return html`
482 | ${!this.embed || this.loading 483 | ? html`${unsafeHTML(logoSvg)}Skyview 486 | Entertained? Consider donating to our 🇺🇦 charity 487 | ` 488 | : nothing} 489 |
${content}
490 |
491 | Skyview is lovingly made by 492 | Mario Zechner
493 | Kindly supported by Mediamask, the most amazing template engine for image 494 | generation
495 | Logo by Jan Hax
496 | No data is collected, not even your IP address.
497 | Source code 498 |
499 |
`; 500 | } 501 | 502 | viewPosts() { 503 | if (!this.urlElement) return; 504 | 505 | const newUrl = new URL(location.href); 506 | newUrl.searchParams.set("url", this.urlElement.value); 507 | newUrl.searchParams.set("viewtype", "tree"); 508 | location.href = newUrl.href; 509 | } 510 | 511 | changeView() { 512 | const el = this.viewTypeElement; 513 | if (!el) return; 514 | if (!this.url) return; 515 | 516 | const newUrl = new URL(location.href); 517 | newUrl.searchParams.set("url", this.url!); 518 | newUrl.searchParams.set("viewtype", el.selectedValue); 519 | location.href = newUrl.href; 520 | } 521 | 522 | defaultAvatar = svg``; 523 | 524 | recordPartial(author: BskyAuthor, uri: string, record: BskyRecord, isQuote = false) { 525 | return html` 537 |
${unsafeHTML(processText(record))}
`; 538 | } 539 | 540 | postPartial(post: BskyThreadPost, originalUri?: string): HTMLElement { 541 | let images = post.post.embed?.images ? renderGallery(post.post.embed.images) : undefined; 542 | if (!images) images = post.post.embed?.media?.images ? renderGallery(post.post.embed.media.images) : undefined; 543 | let card = post.post.embed?.external ? renderCard(post.post.embed.external) : undefined; 544 | 545 | let quotedPost = post.post.embed?.record; 546 | if (quotedPost && quotedPost?.$type != "app.bsky.embed.record#viewRecord") quotedPost = quotedPost.record; 547 | const quotedPostAuthor = quotedPost?.author; 548 | const quotedPostUri = quotedPost?.uri; 549 | const quotedPostValue = quotedPost?.value; 550 | let quotedPostImages = quotedPost?.embeds[0]?.images ? renderGallery(quotedPost.embeds[0].images) : undefined; 551 | if (!quotedPostImages) quotedPostImages = quotedPost?.embeds[0]?.media?.images ? renderGallery(quotedPost.embeds[0].media.images) : undefined; 552 | let quotedPostCard = quotedPost?.embeds[0]?.external ? renderCard(quotedPost.embeds[0].external) : undefined; 553 | 554 | const postDom = dom(html`
555 |
556 | ${this.recordPartial(post.post.author, post.post.uri, post.post.record)} ${images ? html`
${images}
` : nothing} 557 | ${quotedPost 558 | ? html`
559 | ${this.recordPartial(quotedPostAuthor!, quotedPostUri!, quotedPostValue!, true)} 560 | ${quotedPostImages ? html`
${quotedPostImages}
` : nothing} 561 | ${quotedPostCard ? quotedPostCard : nothing} 562 |
` 563 | : nothing} 564 | ${card ? card : nothing} 565 |
566 | ${post.replies?.length > 0 567 | ? html`
568 | ${map(post.replies, (reply) => this.postPartial(reply, originalUri))} 569 |
` 570 | : nothing} 571 |
`)[0]; 572 | 573 | return postDom; 574 | } 575 | 576 | unroll(post: BskyThreadPost) { 577 | const openPost = (ev: Event, url: string) => { 578 | let el: HTMLElement | null = ev.target as HTMLElement; 579 | 580 | while (el) { 581 | if (el.tagName == "A") return; 582 | el = el.parentElement; 583 | } 584 | window.open(url, "_blank"); 585 | }; 586 | 587 | const postPartial = (post: BskyThreadPost, isQuote = false) => { 588 | let images = post.post.embed?.images ? renderGallery(post.post.embed.images, true) : undefined; 589 | if (!images) images = post.post.embed?.media?.images ? renderGallery(post.post.embed.media.images, true) : undefined; 590 | let card = post.post.embed?.external ? renderCard(post.post.embed.external) : undefined; 591 | const record = post.post.record; 592 | const uri = post.post.uri; 593 | const author = post.post.author; 594 | 595 | let quotedPost = post.post.embed?.record; 596 | if (quotedPost && quotedPost?.$type != "app.bsky.embed.record#viewRecord") quotedPost = quotedPost.record; 597 | const quotedPostAuthor = quotedPost?.author; 598 | const quotedPostUri = quotedPost?.uri; 599 | const quotedPostValue = quotedPost?.value; 600 | let quotedPostImages = quotedPost?.embeds[0]?.images ? renderGallery(quotedPost.embeds[0].images) : undefined; 601 | if (!quotedPostImages) 602 | quotedPostImages = quotedPost?.embeds[0]?.media?.images ? renderGallery(quotedPost.embeds[0].media.images) : undefined; 603 | let quotedPostCard = quotedPost?.embeds[0]?.external ? renderCard(quotedPost.embeds[0].external) : undefined; 604 | 605 | return html` 606 |
openPost(ev, `https://bsky.app/profile/${author.did}/post/${uri.replace("at://", "").split("/")[2]}`)} 609 | > 610 |
${unsafeHTML(processText(record))}
611 | ${images ? html`
${images}
` : nothing} 612 | ${quotedPost 613 | ? html`
614 | ${this.recordPartial(quotedPostAuthor!, quotedPostUri!, quotedPostValue!, true)} 615 | ${quotedPostImages ? html`
${quotedPostImages}
` : nothing} 616 | ${quotedPostCard ? quotedPostCard : nothing} 617 |
` 618 | : nothing} 619 | ${card ? card : nothing} 620 |
621 | `; 622 | }; 623 | 624 | const author = post.post.author; 625 | const uri = post.post.uri; 626 | 627 | const postDom = dom(html`
628 | 640 |
${postPartial(post)} ${map(post.replies, (reply) => postPartial(reply))}
641 |
`)[0]; 642 | return postDom; 643 | } 644 | 645 | copyToClipboard(text: string, label: "link" | "code" = "link") { 646 | const input = document.createElement("input"); 647 | input.value = text; 648 | 649 | document.body.appendChild(input); 650 | input.select(); 651 | 652 | try { 653 | document.execCommand("copy"); 654 | if (label == "link") this.copiedLink = true; 655 | if (label == "code") this.copiedCode = true; 656 | } catch (err) { 657 | if (label == "link") this.copiedLink = false; 658 | if (label == "code") this.copiedCode = false; 659 | } finally { 660 | document.body.removeChild(input); 661 | } 662 | } 663 | } 664 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 11 | 13 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skyview", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "prepare": "husky install", 7 | "format": "npx prettier --write .", 8 | "clean": "rimraf build", 9 | "build:css": "npx tailwindcss -i styles.css -o styles-bundle.css", 10 | "build:css-txt": "npx tailwindcss -i styles.css -o styles-bundle.css.txt", 11 | "build:site": "node ./esbuild.site.mjs", 12 | "build:server": "node ./esbuild.server.mjs", 13 | "dev": "concurrently \"PORT=3333 node --watch build/server.js\" \"npx live-server . --cors --watch . --no-browser\" \"npm run build:site -- --watch\" \"npm run build:css -- --watch\" \"npm run build:css-txt -- --watch\" \"npm run build:server -- --watch\"", 14 | "build": "npm run clean && npm run build:css && npm run build:css-txt && npm run build:site && npm run build:server", 15 | "server": "PORT=3333 node --max_old_space_size=8192 --trace-warnings build/server.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/badlogic/skyview.git" 20 | }, 21 | "author": "", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/badlogic/skyview/issues" 25 | }, 26 | "homepage": "https://github.com/badlogic/skyview#readme", 27 | "dependencies": { 28 | "@atproto/api": "^0.6.20", 29 | "@intrnl/bluesky-client": "^0.2.5", 30 | "compression": "^1.7.4", 31 | "cors": "^2.8.5", 32 | "easy-bsky-bot-sdk": "^0.1.2", 33 | "express": "^4.18.2", 34 | "husky": "^8.0.3", 35 | "lit": "^2.8.0", 36 | "lit-html": "^2.8.0", 37 | "mediamask-js": "^1.1.10", 38 | "remixicon": "^3.5.0" 39 | }, 40 | "devDependencies": { 41 | "@types/compression": "^1.7.3", 42 | "@types/cors": "^2.8.14", 43 | "@types/express": "^4.17.18", 44 | "autoprefixer": "^10.4.14", 45 | "concurrently": "^8.1.0", 46 | "esbuild": "^0.17.19", 47 | "live-server": "^1.2.2", 48 | "nodemon": "^3.0.1", 49 | "postcss": "^8.4.24", 50 | "prettier": "^2.8.8", 51 | "pretty-quick": "^3.1.3", 52 | "rimraf": "^5.0.5", 53 | "tailwindcss": "^3.3.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | npm run build 4 | rsync -avz --exclude node_modules --exclude .git ./ badlogic@marioslab.io:/home/badlogic/skyview.social/app 5 | cmd="export SKYVIEW_BLUESKY_ACCOUNT=$SKYVIEW_BLUESKY_ACCOUNT && export SKYVIEW_BLUESKY_PASSWORD=$SKYVIEW_BLUESKY_PASSWORD && export SKYVIEW_MEDIAMASK_KEY=$SKYVIEW_MEDIAMASK_KEY && ./reload.sh && docker-compose logs -f" 6 | echo $cmd 7 | ssh -t marioslab.io "cd skyview.social && $cmd" 8 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import compression from "compression"; 2 | import express from "express"; 3 | import * as http from "http"; 4 | import * as fs from "fs"; 5 | import cors from "cors"; 6 | import { BskyBot, Events } from "easy-bsky-bot-sdk"; 7 | import { ViewType, loadThread } from "./bsky"; 8 | import { MediamaskApi, Configuration } from "mediamask-js"; 9 | 10 | const port = process.env.PORT ?? 3333; 11 | const blueskyAccount = process.env.SKYVIEW_BLUESKY_ACCOUNT; 12 | const blueskyKey = process.env.SKYVIEW_BLUESKY_PASSWORD; 13 | const mediaMaskKey = process.env.SKYVIEW_MEDIAMASK_KEY; 14 | 15 | if (!blueskyAccount || !blueskyKey || !mediaMaskKey) { 16 | console.error("Please specify SKYVIEW_BLUESKY_ACCOUNT, SKYVIEW_BLUESKY_PASSWORD, and SKYVIEW_MEDIAMASK_KEY via env vars."); 17 | process.exit(-1); 18 | } 19 | 20 | const mediaMask = new MediamaskApi( 21 | new Configuration({ 22 | accessToken: mediaMaskKey, 23 | }) 24 | ); 25 | 26 | const indexTemplate = fs.readFileSync("index.html").toString(); 27 | if (!indexTemplate) { 28 | console.error("Couldn't read index.html"); 29 | process.exit(-1); 30 | } 31 | const embedTemplate = fs.readFileSync("embed.html").toString(); 32 | if (!embedTemplate) { 33 | console.error("Couldn't read embed.html"); 34 | process.exit(-1); 35 | } 36 | const metaCache = new Map(); 37 | 38 | console.log(`BlueSky account: ${blueskyAccount}`); 39 | console.log(`BlueSky key: ${blueskyKey}`); 40 | console.log(`MediaMask key: ${mediaMaskKey}`); 41 | 42 | (async () => { 43 | BskyBot.setOwner({ handle: blueskyAccount, contact: "badlogicgames@gmail.com" }); 44 | const bot = new BskyBot({ handle: blueskyAccount, replyToNonFollowers: true }); 45 | try { 46 | await bot.login(blueskyKey); 47 | bot.setHandler(Events.MENTION, async (event) => { 48 | const { post } = event; 49 | const text = post.text.toLowerCase(); 50 | const magicWords = ["unroll", "tree", "embed", "oida", "heast", "geh bitte", "es is ned olles schlecht in österreich"]; 51 | if (magicWords.some((word) => text.includes(word))) { 52 | let viewType = "tree"; 53 | if (text.includes("unroll")) viewType = "unroll"; 54 | if (text.includes("embed")) viewType = "embed"; 55 | console.log(`mentioned by @${post.author.handle}: ${post.text}`); 56 | await bot.reply( 57 | `sure, here you go: \nhttps://skyview.social/?url=https://bsky.app/profile/${post.author.did}/post/${ 58 | post.uri.replace("at://", "").split("/")[2] 59 | }&viewtype=${viewType}`, 60 | post 61 | ); 62 | } 63 | }); 64 | bot.startPolling(); 65 | } catch (e) { 66 | console.error("Couldn't log in."); 67 | process.exit(-1); 68 | } 69 | console.log("Bot is running"); 70 | 71 | const app = express(); 72 | app.use(cors()); 73 | app.use(compression()); 74 | 75 | const getMeta = async (url: string, originalUrl: string, viewType: ViewType): Promise => { 76 | let meta = ` 77 | 78 | 79 | 80 | 81 | `; 82 | 83 | if (url) { 84 | const cacheKey = url + "|" + viewType; 85 | console.log("Generating meta for : " + cacheKey); 86 | if (metaCache.has(cacheKey)) { 87 | console.log("Using cached meta for " + cacheKey); 88 | meta = metaCache.get(cacheKey)!; 89 | } else { 90 | try { 91 | const result = await loadThread(url, viewType); 92 | if (typeof result != "string") { 93 | const name = result.thread.post.author.displayName ?? result.thread.post.author.handle; 94 | const title = `A BlueSky ${viewType == "embed" ? "post" : "thread"} by ${name} on Skyview`; 95 | const text = result.thread.post.record.text; 96 | const url = "https://skyview.social" + originalUrl; 97 | const avatar = result.thread.post.author.avatar; 98 | let twitterAvatar: string | undefined = undefined; 99 | try { 100 | twitterAvatar = avatar 101 | ? await mediaMask.createSignedUrl("e5ed7a04-9252-4fa2-92e3-200d7dbfa3a0", { 102 | title: title, 103 | description: text, 104 | image: avatar, 105 | url: url, 106 | }) 107 | : undefined; 108 | } catch (e) { 109 | console.error("Couldn't create twitter card image", (e as any).message); 110 | } 111 | meta = ` 112 | ${title} 113 | 114 | 115 | 116 | 117 | ${avatar ? `` : ""} 118 | 119 | 120 | 121 | 122 | 123 | 124 | ${twitterAvatar ? `` : ""} 125 | `; 126 | console.log("Setting meta cache entry for " + cacheKey); 127 | metaCache.set(cacheKey, meta); 128 | } 129 | } catch (e) { 130 | // no-op, we simply set no meta tags 131 | } 132 | } 133 | } 134 | return meta; 135 | }; 136 | 137 | app.get("/", async (req, res) => { 138 | res.setHeader("Content-Type", "text/html"); 139 | const url = req.query.url as string; 140 | const viewType = (req.query.viewtype as ViewType) ?? "tree"; 141 | res.status(200).send(indexTemplate.replace("", await getMeta(url, req.originalUrl, viewType))); 142 | }); 143 | app.get("/embed.html", async (req, res) => { 144 | res.setHeader("Content-Type", "text/html"); 145 | const url = req.query.url as string; 146 | const viewType = "embed"; 147 | res.status(200).send(embedTemplate.replace("", await getMeta(url, req.originalUrl, viewType))); 148 | }); 149 | app.use(express.static("./")); 150 | 151 | http.createServer(app).listen(port, () => { 152 | console.log(`App listening on port ${port}`); 153 | }); 154 | })(); 155 | -------------------------------------------------------------------------------- /styles-bundle.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: currentColor; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | */ 36 | 37 | html { 38 | line-height: 1.5; 39 | /* 1 */ 40 | -webkit-text-size-adjust: 100%; 41 | /* 2 */ 42 | -moz-tab-size: 4; 43 | /* 3 */ 44 | -o-tab-size: 4; 45 | tab-size: 4; 46 | /* 3 */ 47 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 48 | /* 4 */ 49 | font-feature-settings: normal; 50 | /* 5 */ 51 | font-variation-settings: normal; 52 | /* 6 */ 53 | } 54 | 55 | /* 56 | 1. Remove the margin in all browsers. 57 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 58 | */ 59 | 60 | body { 61 | margin: 0; 62 | /* 1 */ 63 | line-height: inherit; 64 | /* 2 */ 65 | } 66 | 67 | /* 68 | 1. Add the correct height in Firefox. 69 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 70 | 3. Ensure horizontal rules are visible by default. 71 | */ 72 | 73 | hr { 74 | height: 0; 75 | /* 1 */ 76 | color: inherit; 77 | /* 2 */ 78 | border-top-width: 1px; 79 | /* 3 */ 80 | } 81 | 82 | /* 83 | Add the correct text decoration in Chrome, Edge, and Safari. 84 | */ 85 | 86 | abbr:where([title]) { 87 | -webkit-text-decoration: underline dotted; 88 | text-decoration: underline dotted; 89 | } 90 | 91 | /* 92 | Remove the default font size and weight for headings. 93 | */ 94 | 95 | h1, 96 | h2, 97 | h3, 98 | h4, 99 | h5, 100 | h6 { 101 | font-size: inherit; 102 | font-weight: inherit; 103 | } 104 | 105 | /* 106 | Reset links to optimize for opt-in styling instead of opt-out. 107 | */ 108 | 109 | a { 110 | color: inherit; 111 | text-decoration: inherit; 112 | } 113 | 114 | /* 115 | Add the correct font weight in Edge and Safari. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bolder; 121 | } 122 | 123 | /* 124 | 1. Use the user's configured `mono` font family by default. 125 | 2. Correct the odd `em` font sizing in all browsers. 126 | */ 127 | 128 | code, 129 | kbd, 130 | samp, 131 | pre { 132 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 133 | /* 1 */ 134 | font-size: 1em; 135 | /* 2 */ 136 | } 137 | 138 | /* 139 | Add the correct font size in all browsers. 140 | */ 141 | 142 | small { 143 | font-size: 80%; 144 | } 145 | 146 | /* 147 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 148 | */ 149 | 150 | sub, 151 | sup { 152 | font-size: 75%; 153 | line-height: 0; 154 | position: relative; 155 | vertical-align: baseline; 156 | } 157 | 158 | sub { 159 | bottom: -0.25em; 160 | } 161 | 162 | sup { 163 | top: -0.5em; 164 | } 165 | 166 | /* 167 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 168 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 169 | 3. Remove gaps between table borders by default. 170 | */ 171 | 172 | table { 173 | text-indent: 0; 174 | /* 1 */ 175 | border-color: inherit; 176 | /* 2 */ 177 | border-collapse: collapse; 178 | /* 3 */ 179 | } 180 | 181 | /* 182 | 1. Change the font styles in all browsers. 183 | 2. Remove the margin in Firefox and Safari. 184 | 3. Remove default padding in all browsers. 185 | */ 186 | 187 | button, 188 | input, 189 | optgroup, 190 | select, 191 | textarea { 192 | font-family: inherit; 193 | /* 1 */ 194 | font-feature-settings: inherit; 195 | /* 1 */ 196 | font-variation-settings: inherit; 197 | /* 1 */ 198 | font-size: 100%; 199 | /* 1 */ 200 | font-weight: inherit; 201 | /* 1 */ 202 | line-height: inherit; 203 | /* 1 */ 204 | color: inherit; 205 | /* 1 */ 206 | margin: 0; 207 | /* 2 */ 208 | padding: 0; 209 | /* 3 */ 210 | } 211 | 212 | /* 213 | Remove the inheritance of text transform in Edge and Firefox. 214 | */ 215 | 216 | button, 217 | select { 218 | text-transform: none; 219 | } 220 | 221 | /* 222 | 1. Correct the inability to style clickable types in iOS and Safari. 223 | 2. Remove default button styles. 224 | */ 225 | 226 | button, 227 | [type='button'], 228 | [type='reset'], 229 | [type='submit'] { 230 | -webkit-appearance: button; 231 | /* 1 */ 232 | background-color: transparent; 233 | /* 2 */ 234 | background-image: none; 235 | /* 2 */ 236 | } 237 | 238 | /* 239 | Use the modern Firefox focus style for all focusable elements. 240 | */ 241 | 242 | :-moz-focusring { 243 | outline: auto; 244 | } 245 | 246 | /* 247 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 248 | */ 249 | 250 | :-moz-ui-invalid { 251 | box-shadow: none; 252 | } 253 | 254 | /* 255 | Add the correct vertical alignment in Chrome and Firefox. 256 | */ 257 | 258 | progress { 259 | vertical-align: baseline; 260 | } 261 | 262 | /* 263 | Correct the cursor style of increment and decrement buttons in Safari. 264 | */ 265 | 266 | ::-webkit-inner-spin-button, 267 | ::-webkit-outer-spin-button { 268 | height: auto; 269 | } 270 | 271 | /* 272 | 1. Correct the odd appearance in Chrome and Safari. 273 | 2. Correct the outline style in Safari. 274 | */ 275 | 276 | [type='search'] { 277 | -webkit-appearance: textfield; 278 | /* 1 */ 279 | outline-offset: -2px; 280 | /* 2 */ 281 | } 282 | 283 | /* 284 | Remove the inner padding in Chrome and Safari on macOS. 285 | */ 286 | 287 | ::-webkit-search-decoration { 288 | -webkit-appearance: none; 289 | } 290 | 291 | /* 292 | 1. Correct the inability to style clickable types in iOS and Safari. 293 | 2. Change font properties to `inherit` in Safari. 294 | */ 295 | 296 | ::-webkit-file-upload-button { 297 | -webkit-appearance: button; 298 | /* 1 */ 299 | font: inherit; 300 | /* 2 */ 301 | } 302 | 303 | /* 304 | Add the correct display in Chrome and Safari. 305 | */ 306 | 307 | summary { 308 | display: list-item; 309 | } 310 | 311 | /* 312 | Removes the default spacing and border for appropriate elements. 313 | */ 314 | 315 | blockquote, 316 | dl, 317 | dd, 318 | h1, 319 | h2, 320 | h3, 321 | h4, 322 | h5, 323 | h6, 324 | hr, 325 | figure, 326 | p, 327 | pre { 328 | margin: 0; 329 | } 330 | 331 | fieldset { 332 | margin: 0; 333 | padding: 0; 334 | } 335 | 336 | legend { 337 | padding: 0; 338 | } 339 | 340 | ol, 341 | ul, 342 | menu { 343 | list-style: none; 344 | margin: 0; 345 | padding: 0; 346 | } 347 | 348 | /* 349 | Reset default styling for dialogs. 350 | */ 351 | 352 | dialog { 353 | padding: 0; 354 | } 355 | 356 | /* 357 | Prevent resizing textareas horizontally by default. 358 | */ 359 | 360 | textarea { 361 | resize: vertical; 362 | } 363 | 364 | /* 365 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 366 | 2. Set the default placeholder color to the user's configured gray 400 color. 367 | */ 368 | 369 | input::-moz-placeholder, textarea::-moz-placeholder { 370 | opacity: 1; 371 | /* 1 */ 372 | color: #9ca3af; 373 | /* 2 */ 374 | } 375 | 376 | input::placeholder, 377 | textarea::placeholder { 378 | opacity: 1; 379 | /* 1 */ 380 | color: #9ca3af; 381 | /* 2 */ 382 | } 383 | 384 | /* 385 | Set the default cursor for buttons. 386 | */ 387 | 388 | button, 389 | [role="button"] { 390 | cursor: pointer; 391 | } 392 | 393 | /* 394 | Make sure disabled buttons don't get the pointer cursor. 395 | */ 396 | 397 | :disabled { 398 | cursor: default; 399 | } 400 | 401 | /* 402 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 403 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 404 | This can trigger a poorly considered lint error in some tools but is included by design. 405 | */ 406 | 407 | img, 408 | svg, 409 | video, 410 | canvas, 411 | audio, 412 | iframe, 413 | embed, 414 | object { 415 | display: block; 416 | /* 1 */ 417 | vertical-align: middle; 418 | /* 2 */ 419 | } 420 | 421 | /* 422 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 423 | */ 424 | 425 | img, 426 | video { 427 | max-width: 100%; 428 | height: auto; 429 | } 430 | 431 | /* Make elements with the HTML hidden attribute stay hidden by default */ 432 | 433 | [hidden] { 434 | display: none; 435 | } 436 | 437 | html, 438 | body { 439 | height: 100%; 440 | width: 100%; 441 | } 442 | 443 | .hidden { 444 | display: none; 445 | } 446 | 447 | i > svg { 448 | height: 100%; 449 | width: 100%; 450 | } 451 | 452 | *, ::before, ::after { 453 | --tw-border-spacing-x: 0; 454 | --tw-border-spacing-y: 0; 455 | --tw-translate-x: 0; 456 | --tw-translate-y: 0; 457 | --tw-rotate: 0; 458 | --tw-skew-x: 0; 459 | --tw-skew-y: 0; 460 | --tw-scale-x: 1; 461 | --tw-scale-y: 1; 462 | --tw-pan-x: ; 463 | --tw-pan-y: ; 464 | --tw-pinch-zoom: ; 465 | --tw-scroll-snap-strictness: proximity; 466 | --tw-gradient-from-position: ; 467 | --tw-gradient-via-position: ; 468 | --tw-gradient-to-position: ; 469 | --tw-ordinal: ; 470 | --tw-slashed-zero: ; 471 | --tw-numeric-figure: ; 472 | --tw-numeric-spacing: ; 473 | --tw-numeric-fraction: ; 474 | --tw-ring-inset: ; 475 | --tw-ring-offset-width: 0px; 476 | --tw-ring-offset-color: #fff; 477 | --tw-ring-color: rgb(59 130 246 / 0.5); 478 | --tw-ring-offset-shadow: 0 0 #0000; 479 | --tw-ring-shadow: 0 0 #0000; 480 | --tw-shadow: 0 0 #0000; 481 | --tw-shadow-colored: 0 0 #0000; 482 | --tw-blur: ; 483 | --tw-brightness: ; 484 | --tw-contrast: ; 485 | --tw-grayscale: ; 486 | --tw-hue-rotate: ; 487 | --tw-invert: ; 488 | --tw-saturate: ; 489 | --tw-sepia: ; 490 | --tw-drop-shadow: ; 491 | --tw-backdrop-blur: ; 492 | --tw-backdrop-brightness: ; 493 | --tw-backdrop-contrast: ; 494 | --tw-backdrop-grayscale: ; 495 | --tw-backdrop-hue-rotate: ; 496 | --tw-backdrop-invert: ; 497 | --tw-backdrop-opacity: ; 498 | --tw-backdrop-saturate: ; 499 | --tw-backdrop-sepia: ; 500 | } 501 | 502 | ::backdrop { 503 | --tw-border-spacing-x: 0; 504 | --tw-border-spacing-y: 0; 505 | --tw-translate-x: 0; 506 | --tw-translate-y: 0; 507 | --tw-rotate: 0; 508 | --tw-skew-x: 0; 509 | --tw-skew-y: 0; 510 | --tw-scale-x: 1; 511 | --tw-scale-y: 1; 512 | --tw-pan-x: ; 513 | --tw-pan-y: ; 514 | --tw-pinch-zoom: ; 515 | --tw-scroll-snap-strictness: proximity; 516 | --tw-gradient-from-position: ; 517 | --tw-gradient-via-position: ; 518 | --tw-gradient-to-position: ; 519 | --tw-ordinal: ; 520 | --tw-slashed-zero: ; 521 | --tw-numeric-figure: ; 522 | --tw-numeric-spacing: ; 523 | --tw-numeric-fraction: ; 524 | --tw-ring-inset: ; 525 | --tw-ring-offset-width: 0px; 526 | --tw-ring-offset-color: #fff; 527 | --tw-ring-color: rgb(59 130 246 / 0.5); 528 | --tw-ring-offset-shadow: 0 0 #0000; 529 | --tw-ring-shadow: 0 0 #0000; 530 | --tw-shadow: 0 0 #0000; 531 | --tw-shadow-colored: 0 0 #0000; 532 | --tw-blur: ; 533 | --tw-brightness: ; 534 | --tw-contrast: ; 535 | --tw-grayscale: ; 536 | --tw-hue-rotate: ; 537 | --tw-invert: ; 538 | --tw-saturate: ; 539 | --tw-sepia: ; 540 | --tw-drop-shadow: ; 541 | --tw-backdrop-blur: ; 542 | --tw-backdrop-brightness: ; 543 | --tw-backdrop-contrast: ; 544 | --tw-backdrop-grayscale: ; 545 | --tw-backdrop-hue-rotate: ; 546 | --tw-backdrop-invert: ; 547 | --tw-backdrop-opacity: ; 548 | --tw-backdrop-saturate: ; 549 | --tw-backdrop-sepia: ; 550 | } 551 | 552 | .container { 553 | width: 100%; 554 | } 555 | 556 | @media (min-width: 640px) { 557 | .container { 558 | max-width: 640px; 559 | } 560 | } 561 | 562 | @media (min-width: 768px) { 563 | .container { 564 | max-width: 768px; 565 | } 566 | } 567 | 568 | @media (min-width: 1024px) { 569 | .container { 570 | max-width: 1024px; 571 | } 572 | } 573 | 574 | @media (min-width: 1280px) { 575 | .container { 576 | max-width: 1280px; 577 | } 578 | } 579 | 580 | @media (min-width: 1536px) { 581 | .container { 582 | max-width: 1536px; 583 | } 584 | } 585 | 586 | .visible { 587 | visibility: visible; 588 | } 589 | 590 | .invisible { 591 | visibility: hidden; 592 | } 593 | 594 | .collapse { 595 | visibility: collapse; 596 | } 597 | 598 | .static { 599 | position: static; 600 | } 601 | 602 | .fixed { 603 | position: fixed; 604 | } 605 | 606 | .absolute { 607 | position: absolute; 608 | } 609 | 610 | .relative { 611 | position: relative; 612 | } 613 | 614 | .sticky { 615 | position: sticky; 616 | } 617 | 618 | .bottom-1 { 619 | bottom: 0.25rem; 620 | } 621 | 622 | .left-1 { 623 | left: 0.25rem; 624 | } 625 | 626 | .right-0 { 627 | right: 0px; 628 | } 629 | 630 | .top-0 { 631 | top: 0px; 632 | } 633 | 634 | .isolate { 635 | isolation: isolate; 636 | } 637 | 638 | .z-\[100\] { 639 | z-index: 100; 640 | } 641 | 642 | .col-span-1 { 643 | grid-column: span 1 / span 1; 644 | } 645 | 646 | .col-span-2 { 647 | grid-column: span 2 / span 2; 648 | } 649 | 650 | .m-2 { 651 | margin: 0.5rem; 652 | } 653 | 654 | .m-auto { 655 | margin: auto; 656 | } 657 | 658 | .mx-4 { 659 | margin-left: 1rem; 660 | margin-right: 1rem; 661 | } 662 | 663 | .mx-auto { 664 | margin-left: auto; 665 | margin-right: auto; 666 | } 667 | 668 | .my-4 { 669 | margin-top: 1rem; 670 | margin-bottom: 1rem; 671 | } 672 | 673 | .my-8 { 674 | margin-top: 2rem; 675 | margin-bottom: 2rem; 676 | } 677 | 678 | .mb-12 { 679 | margin-bottom: 3rem; 680 | } 681 | 682 | .mb-2 { 683 | margin-bottom: 0.5rem; 684 | } 685 | 686 | .mb-4 { 687 | margin-bottom: 1rem; 688 | } 689 | 690 | .mb-8 { 691 | margin-bottom: 2rem; 692 | } 693 | 694 | .ml-2 { 695 | margin-left: 0.5rem; 696 | } 697 | 698 | .ml-4 { 699 | margin-left: 1rem; 700 | } 701 | 702 | .mt-1 { 703 | margin-top: 0.25rem; 704 | } 705 | 706 | .mt-16 { 707 | margin-top: 4rem; 708 | } 709 | 710 | .mt-2 { 711 | margin-top: 0.5rem; 712 | } 713 | 714 | .mt-4 { 715 | margin-top: 1rem; 716 | } 717 | 718 | .mt-8 { 719 | margin-top: 2rem; 720 | } 721 | 722 | .block { 723 | display: block; 724 | } 725 | 726 | .inline-block { 727 | display: inline-block; 728 | } 729 | 730 | .inline { 731 | display: inline; 732 | } 733 | 734 | .flex { 735 | display: flex; 736 | } 737 | 738 | .table { 739 | display: table; 740 | } 741 | 742 | .grid { 743 | display: grid; 744 | } 745 | 746 | .contents { 747 | display: contents; 748 | } 749 | 750 | .hidden { 751 | display: none; 752 | } 753 | 754 | .\!h-full { 755 | height: 100% !important; 756 | } 757 | 758 | .h-10 { 759 | height: 2.5rem; 760 | } 761 | 762 | .h-2 { 763 | height: 0.5rem; 764 | } 765 | 766 | .h-\[1\.2em\] { 767 | height: 1.2em; 768 | } 769 | 770 | .h-\[2em\] { 771 | height: 2em; 772 | } 773 | 774 | .h-\[32px\] { 775 | height: 32px; 776 | } 777 | 778 | .h-full { 779 | height: 100%; 780 | } 781 | 782 | .\!max-h-full { 783 | max-height: 100% !important; 784 | } 785 | 786 | .max-h-\[70vh\] { 787 | max-height: 70vh; 788 | } 789 | 790 | .\!w-\[240px\] { 791 | width: 240px !important; 792 | } 793 | 794 | .w-10 { 795 | width: 2.5rem; 796 | } 797 | 798 | .w-\[1\.2m\] { 799 | width: 1.2m; 800 | } 801 | 802 | .w-\[2em\] { 803 | width: 2em; 804 | } 805 | 806 | .w-\[32px\] { 807 | width: 32px; 808 | } 809 | 810 | .w-\[350px\] { 811 | width: 350px; 812 | } 813 | 814 | .w-\[80\%\] { 815 | width: 80%; 816 | } 817 | 818 | .w-full { 819 | width: 100%; 820 | } 821 | 822 | .min-w-\[280px\] { 823 | min-width: 280px; 824 | } 825 | 826 | .max-w-\[300px\] { 827 | max-width: 300px; 828 | } 829 | 830 | .max-w-\[728px\] { 831 | max-width: 728px; 832 | } 833 | 834 | .flex-1 { 835 | flex: 1 1 0%; 836 | } 837 | 838 | .flex-none { 839 | flex: none; 840 | } 841 | 842 | .flex-grow { 843 | flex-grow: 1; 844 | } 845 | 846 | .grow { 847 | flex-grow: 1; 848 | } 849 | 850 | .grow-0 { 851 | flex-grow: 0; 852 | } 853 | 854 | .border-collapse { 855 | border-collapse: collapse; 856 | } 857 | 858 | .transform { 859 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 860 | } 861 | 862 | @keyframes pulse { 863 | 50% { 864 | opacity: .5; 865 | } 866 | } 867 | 868 | .animate-pulse { 869 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 870 | } 871 | 872 | .cursor-pointer { 873 | cursor: pointer; 874 | } 875 | 876 | .resize { 877 | resize: both; 878 | } 879 | 880 | .list-disc { 881 | list-style-type: disc; 882 | } 883 | 884 | .grid-cols-3 { 885 | grid-template-columns: repeat(3, minmax(0, 1fr)); 886 | } 887 | 888 | .flex-col { 889 | flex-direction: column; 890 | } 891 | 892 | .items-center { 893 | align-items: center; 894 | } 895 | 896 | .justify-center { 897 | justify-content: center; 898 | } 899 | 900 | .justify-between { 901 | justify-content: space-between; 902 | } 903 | 904 | .gap-2 { 905 | gap: 0.5rem; 906 | } 907 | 908 | .gap-4 { 909 | gap: 1rem; 910 | } 911 | 912 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { 913 | --tw-space-x-reverse: 0; 914 | margin-right: calc(1rem * var(--tw-space-x-reverse)); 915 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); 916 | } 917 | 918 | .space-y-3 > :not([hidden]) ~ :not([hidden]) { 919 | --tw-space-y-reverse: 0; 920 | margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); 921 | margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); 922 | } 923 | 924 | .space-y-6 > :not([hidden]) ~ :not([hidden]) { 925 | --tw-space-y-reverse: 0; 926 | margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); 927 | margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); 928 | } 929 | 930 | .overflow-auto { 931 | overflow: auto; 932 | } 933 | 934 | .overflow-hidden { 935 | overflow: hidden; 936 | } 937 | 938 | .truncate { 939 | overflow: hidden; 940 | text-overflow: ellipsis; 941 | white-space: nowrap; 942 | } 943 | 944 | .text-ellipsis { 945 | text-overflow: ellipsis; 946 | } 947 | 948 | .rounded { 949 | border-radius: 0.25rem; 950 | } 951 | 952 | .rounded-full { 953 | border-radius: 9999px; 954 | } 955 | 956 | .\!rounded-r-none { 957 | border-top-right-radius: 0px !important; 958 | border-bottom-right-radius: 0px !important; 959 | } 960 | 961 | .rounded-l { 962 | border-top-left-radius: 0.25rem; 963 | border-bottom-left-radius: 0.25rem; 964 | } 965 | 966 | .rounded-r { 967 | border-top-right-radius: 0.25rem; 968 | border-bottom-right-radius: 0.25rem; 969 | } 970 | 971 | .border { 972 | border-width: 1px; 973 | } 974 | 975 | .border-b { 976 | border-bottom-width: 1px; 977 | } 978 | 979 | .border-l { 980 | border-left-width: 1px; 981 | } 982 | 983 | .border-r { 984 | border-right-width: 1px; 985 | } 986 | 987 | .border-r-2 { 988 | border-right-width: 2px; 989 | } 990 | 991 | .border-r-4 { 992 | border-right-width: 4px; 993 | } 994 | 995 | .border-t { 996 | border-top-width: 1px; 997 | } 998 | 999 | .border-dotted { 1000 | border-style: dotted; 1001 | } 1002 | 1003 | .border-none { 1004 | border-style: none; 1005 | } 1006 | 1007 | .border-gray { 1008 | --tw-border-opacity: 1; 1009 | border-color: rgb(85 85 85 / var(--tw-border-opacity)); 1010 | } 1011 | 1012 | .border-gray\/30 { 1013 | border-color: rgb(85 85 85 / 0.3); 1014 | } 1015 | 1016 | .border-gray\/50 { 1017 | border-color: rgb(85 85 85 / 0.5); 1018 | } 1019 | 1020 | .border-gray\/75 { 1021 | border-color: rgb(85 85 85 / 0.75); 1022 | } 1023 | 1024 | .border-primary { 1025 | --tw-border-opacity: 1; 1026 | border-color: rgb(204 125 36 / var(--tw-border-opacity)); 1027 | } 1028 | 1029 | .border-white { 1030 | --tw-border-opacity: 1; 1031 | border-color: rgb(255 255 255 / var(--tw-border-opacity)); 1032 | } 1033 | 1034 | .bg-black { 1035 | --tw-bg-opacity: 1; 1036 | background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 1037 | } 1038 | 1039 | .bg-gray { 1040 | --tw-bg-opacity: 1; 1041 | background-color: rgb(85 85 85 / var(--tw-bg-opacity)); 1042 | } 1043 | 1044 | .bg-gray\/30 { 1045 | background-color: rgb(85 85 85 / 0.3); 1046 | } 1047 | 1048 | .bg-gray\/50 { 1049 | background-color: rgb(85 85 85 / 0.5); 1050 | } 1051 | 1052 | .bg-primary { 1053 | --tw-bg-opacity: 1; 1054 | background-color: rgb(204 125 36 / var(--tw-bg-opacity)); 1055 | } 1056 | 1057 | .bg-white { 1058 | --tw-bg-opacity: 1; 1059 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 1060 | } 1061 | 1062 | .bg-none { 1063 | background-image: none; 1064 | } 1065 | 1066 | .fill-primary { 1067 | fill: #cc7d24; 1068 | } 1069 | 1070 | .\!object-cover { 1071 | -o-object-fit: cover !important; 1072 | object-fit: cover !important; 1073 | } 1074 | 1075 | .p-1 { 1076 | padding: 0.25rem; 1077 | } 1078 | 1079 | .p-2 { 1080 | padding: 0.5rem; 1081 | } 1082 | 1083 | .p-4 { 1084 | padding: 1rem; 1085 | } 1086 | 1087 | .px-2 { 1088 | padding-left: 0.5rem; 1089 | padding-right: 0.5rem; 1090 | } 1091 | 1092 | .px-4 { 1093 | padding-left: 1rem; 1094 | padding-right: 1rem; 1095 | } 1096 | 1097 | .py-1 { 1098 | padding-top: 0.25rem; 1099 | padding-bottom: 0.25rem; 1100 | } 1101 | 1102 | .py-2 { 1103 | padding-top: 0.5rem; 1104 | padding-bottom: 0.5rem; 1105 | } 1106 | 1107 | .py-4 { 1108 | padding-top: 1rem; 1109 | padding-bottom: 1rem; 1110 | } 1111 | 1112 | .pb-4 { 1113 | padding-bottom: 1rem; 1114 | } 1115 | 1116 | .pl-2 { 1117 | padding-left: 0.5rem; 1118 | } 1119 | 1120 | .pl-4 { 1121 | padding-left: 1rem; 1122 | } 1123 | 1124 | .pr-2 { 1125 | padding-right: 0.5rem; 1126 | } 1127 | 1128 | .text-center { 1129 | text-align: center; 1130 | } 1131 | 1132 | .align-top { 1133 | vertical-align: top; 1134 | } 1135 | 1136 | .font-mono { 1137 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 1138 | } 1139 | 1140 | .text-2xl { 1141 | font-size: 1.5rem; 1142 | line-height: 2rem; 1143 | } 1144 | 1145 | .text-sm { 1146 | font-size: 0.875rem; 1147 | line-height: 1.25rem; 1148 | } 1149 | 1150 | .text-xl { 1151 | font-size: 1.25rem; 1152 | line-height: 1.75rem; 1153 | } 1154 | 1155 | .text-xs { 1156 | font-size: 0.75rem; 1157 | line-height: 1rem; 1158 | } 1159 | 1160 | .font-bold { 1161 | font-weight: 700; 1162 | } 1163 | 1164 | .uppercase { 1165 | text-transform: uppercase; 1166 | } 1167 | 1168 | .lowercase { 1169 | text-transform: lowercase; 1170 | } 1171 | 1172 | .italic { 1173 | font-style: italic; 1174 | } 1175 | 1176 | .ordinal { 1177 | --tw-ordinal: ordinal; 1178 | font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction); 1179 | } 1180 | 1181 | .text-black { 1182 | --tw-text-opacity: 1; 1183 | color: rgb(0 0 0 / var(--tw-text-opacity)); 1184 | } 1185 | 1186 | .text-blue-500 { 1187 | --tw-text-opacity: 1; 1188 | color: rgb(59 130 246 / var(--tw-text-opacity)); 1189 | } 1190 | 1191 | .text-gray { 1192 | --tw-text-opacity: 1; 1193 | color: rgb(85 85 85 / var(--tw-text-opacity)); 1194 | } 1195 | 1196 | .text-primary { 1197 | --tw-text-opacity: 1; 1198 | color: rgb(204 125 36 / var(--tw-text-opacity)); 1199 | } 1200 | 1201 | .text-primary\/75 { 1202 | color: rgb(204 125 36 / 0.75); 1203 | } 1204 | 1205 | .text-white { 1206 | --tw-text-opacity: 1; 1207 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1208 | } 1209 | 1210 | .underline { 1211 | text-decoration-line: underline; 1212 | } 1213 | 1214 | .overline { 1215 | text-decoration-line: overline; 1216 | } 1217 | 1218 | .antialiased { 1219 | -webkit-font-smoothing: antialiased; 1220 | -moz-osx-font-smoothing: grayscale; 1221 | } 1222 | 1223 | .shadow { 1224 | --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 1225 | --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 1226 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1227 | } 1228 | 1229 | .outline-none { 1230 | outline: 2px solid transparent; 1231 | outline-offset: 2px; 1232 | } 1233 | 1234 | .outline { 1235 | outline-style: solid; 1236 | } 1237 | 1238 | .outline-gray { 1239 | outline-color: #555555; 1240 | } 1241 | 1242 | .ring { 1243 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1244 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1245 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1246 | } 1247 | 1248 | .blur { 1249 | --tw-blur: blur(8px); 1250 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1251 | } 1252 | 1253 | .grayscale { 1254 | --tw-grayscale: grayscale(100%); 1255 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1256 | } 1257 | 1258 | .invert { 1259 | --tw-invert: invert(100%); 1260 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1261 | } 1262 | 1263 | .sepia { 1264 | --tw-sepia: sepia(100%); 1265 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1266 | } 1267 | 1268 | .filter { 1269 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1270 | } 1271 | 1272 | .transition { 1273 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 1274 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 1275 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 1276 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1277 | transition-duration: 150ms; 1278 | } 1279 | 1280 | .hover\:bg-gray\/20:hover { 1281 | background-color: rgb(85 85 85 / 0.2); 1282 | } 1283 | 1284 | :is(.dark .dark\:border-gray) { 1285 | --tw-border-opacity: 1; 1286 | border-color: rgb(85 85 85 / var(--tw-border-opacity)); 1287 | } 1288 | 1289 | :is(.dark .dark\:bg-black) { 1290 | --tw-bg-opacity: 1; 1291 | background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 1292 | } 1293 | 1294 | :is(.dark .dark\:bg-gray) { 1295 | --tw-bg-opacity: 1; 1296 | background-color: rgb(85 85 85 / var(--tw-bg-opacity)); 1297 | } 1298 | 1299 | :is(.dark .dark\:text-white) { 1300 | --tw-text-opacity: 1; 1301 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1302 | } 1303 | -------------------------------------------------------------------------------- /styles-bundle.css.txt: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: currentColor; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | */ 36 | 37 | html { 38 | line-height: 1.5; 39 | /* 1 */ 40 | -webkit-text-size-adjust: 100%; 41 | /* 2 */ 42 | -moz-tab-size: 4; 43 | /* 3 */ 44 | -o-tab-size: 4; 45 | tab-size: 4; 46 | /* 3 */ 47 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 48 | /* 4 */ 49 | font-feature-settings: normal; 50 | /* 5 */ 51 | font-variation-settings: normal; 52 | /* 6 */ 53 | } 54 | 55 | /* 56 | 1. Remove the margin in all browsers. 57 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 58 | */ 59 | 60 | body { 61 | margin: 0; 62 | /* 1 */ 63 | line-height: inherit; 64 | /* 2 */ 65 | } 66 | 67 | /* 68 | 1. Add the correct height in Firefox. 69 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 70 | 3. Ensure horizontal rules are visible by default. 71 | */ 72 | 73 | hr { 74 | height: 0; 75 | /* 1 */ 76 | color: inherit; 77 | /* 2 */ 78 | border-top-width: 1px; 79 | /* 3 */ 80 | } 81 | 82 | /* 83 | Add the correct text decoration in Chrome, Edge, and Safari. 84 | */ 85 | 86 | abbr:where([title]) { 87 | -webkit-text-decoration: underline dotted; 88 | text-decoration: underline dotted; 89 | } 90 | 91 | /* 92 | Remove the default font size and weight for headings. 93 | */ 94 | 95 | h1, 96 | h2, 97 | h3, 98 | h4, 99 | h5, 100 | h6 { 101 | font-size: inherit; 102 | font-weight: inherit; 103 | } 104 | 105 | /* 106 | Reset links to optimize for opt-in styling instead of opt-out. 107 | */ 108 | 109 | a { 110 | color: inherit; 111 | text-decoration: inherit; 112 | } 113 | 114 | /* 115 | Add the correct font weight in Edge and Safari. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bolder; 121 | } 122 | 123 | /* 124 | 1. Use the user's configured `mono` font family by default. 125 | 2. Correct the odd `em` font sizing in all browsers. 126 | */ 127 | 128 | code, 129 | kbd, 130 | samp, 131 | pre { 132 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 133 | /* 1 */ 134 | font-size: 1em; 135 | /* 2 */ 136 | } 137 | 138 | /* 139 | Add the correct font size in all browsers. 140 | */ 141 | 142 | small { 143 | font-size: 80%; 144 | } 145 | 146 | /* 147 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 148 | */ 149 | 150 | sub, 151 | sup { 152 | font-size: 75%; 153 | line-height: 0; 154 | position: relative; 155 | vertical-align: baseline; 156 | } 157 | 158 | sub { 159 | bottom: -0.25em; 160 | } 161 | 162 | sup { 163 | top: -0.5em; 164 | } 165 | 166 | /* 167 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 168 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 169 | 3. Remove gaps between table borders by default. 170 | */ 171 | 172 | table { 173 | text-indent: 0; 174 | /* 1 */ 175 | border-color: inherit; 176 | /* 2 */ 177 | border-collapse: collapse; 178 | /* 3 */ 179 | } 180 | 181 | /* 182 | 1. Change the font styles in all browsers. 183 | 2. Remove the margin in Firefox and Safari. 184 | 3. Remove default padding in all browsers. 185 | */ 186 | 187 | button, 188 | input, 189 | optgroup, 190 | select, 191 | textarea { 192 | font-family: inherit; 193 | /* 1 */ 194 | font-feature-settings: inherit; 195 | /* 1 */ 196 | font-variation-settings: inherit; 197 | /* 1 */ 198 | font-size: 100%; 199 | /* 1 */ 200 | font-weight: inherit; 201 | /* 1 */ 202 | line-height: inherit; 203 | /* 1 */ 204 | color: inherit; 205 | /* 1 */ 206 | margin: 0; 207 | /* 2 */ 208 | padding: 0; 209 | /* 3 */ 210 | } 211 | 212 | /* 213 | Remove the inheritance of text transform in Edge and Firefox. 214 | */ 215 | 216 | button, 217 | select { 218 | text-transform: none; 219 | } 220 | 221 | /* 222 | 1. Correct the inability to style clickable types in iOS and Safari. 223 | 2. Remove default button styles. 224 | */ 225 | 226 | button, 227 | [type='button'], 228 | [type='reset'], 229 | [type='submit'] { 230 | -webkit-appearance: button; 231 | /* 1 */ 232 | background-color: transparent; 233 | /* 2 */ 234 | background-image: none; 235 | /* 2 */ 236 | } 237 | 238 | /* 239 | Use the modern Firefox focus style for all focusable elements. 240 | */ 241 | 242 | :-moz-focusring { 243 | outline: auto; 244 | } 245 | 246 | /* 247 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 248 | */ 249 | 250 | :-moz-ui-invalid { 251 | box-shadow: none; 252 | } 253 | 254 | /* 255 | Add the correct vertical alignment in Chrome and Firefox. 256 | */ 257 | 258 | progress { 259 | vertical-align: baseline; 260 | } 261 | 262 | /* 263 | Correct the cursor style of increment and decrement buttons in Safari. 264 | */ 265 | 266 | ::-webkit-inner-spin-button, 267 | ::-webkit-outer-spin-button { 268 | height: auto; 269 | } 270 | 271 | /* 272 | 1. Correct the odd appearance in Chrome and Safari. 273 | 2. Correct the outline style in Safari. 274 | */ 275 | 276 | [type='search'] { 277 | -webkit-appearance: textfield; 278 | /* 1 */ 279 | outline-offset: -2px; 280 | /* 2 */ 281 | } 282 | 283 | /* 284 | Remove the inner padding in Chrome and Safari on macOS. 285 | */ 286 | 287 | ::-webkit-search-decoration { 288 | -webkit-appearance: none; 289 | } 290 | 291 | /* 292 | 1. Correct the inability to style clickable types in iOS and Safari. 293 | 2. Change font properties to `inherit` in Safari. 294 | */ 295 | 296 | ::-webkit-file-upload-button { 297 | -webkit-appearance: button; 298 | /* 1 */ 299 | font: inherit; 300 | /* 2 */ 301 | } 302 | 303 | /* 304 | Add the correct display in Chrome and Safari. 305 | */ 306 | 307 | summary { 308 | display: list-item; 309 | } 310 | 311 | /* 312 | Removes the default spacing and border for appropriate elements. 313 | */ 314 | 315 | blockquote, 316 | dl, 317 | dd, 318 | h1, 319 | h2, 320 | h3, 321 | h4, 322 | h5, 323 | h6, 324 | hr, 325 | figure, 326 | p, 327 | pre { 328 | margin: 0; 329 | } 330 | 331 | fieldset { 332 | margin: 0; 333 | padding: 0; 334 | } 335 | 336 | legend { 337 | padding: 0; 338 | } 339 | 340 | ol, 341 | ul, 342 | menu { 343 | list-style: none; 344 | margin: 0; 345 | padding: 0; 346 | } 347 | 348 | /* 349 | Reset default styling for dialogs. 350 | */ 351 | 352 | dialog { 353 | padding: 0; 354 | } 355 | 356 | /* 357 | Prevent resizing textareas horizontally by default. 358 | */ 359 | 360 | textarea { 361 | resize: vertical; 362 | } 363 | 364 | /* 365 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 366 | 2. Set the default placeholder color to the user's configured gray 400 color. 367 | */ 368 | 369 | input::-moz-placeholder, textarea::-moz-placeholder { 370 | opacity: 1; 371 | /* 1 */ 372 | color: #9ca3af; 373 | /* 2 */ 374 | } 375 | 376 | input::placeholder, 377 | textarea::placeholder { 378 | opacity: 1; 379 | /* 1 */ 380 | color: #9ca3af; 381 | /* 2 */ 382 | } 383 | 384 | /* 385 | Set the default cursor for buttons. 386 | */ 387 | 388 | button, 389 | [role="button"] { 390 | cursor: pointer; 391 | } 392 | 393 | /* 394 | Make sure disabled buttons don't get the pointer cursor. 395 | */ 396 | 397 | :disabled { 398 | cursor: default; 399 | } 400 | 401 | /* 402 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 403 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 404 | This can trigger a poorly considered lint error in some tools but is included by design. 405 | */ 406 | 407 | img, 408 | svg, 409 | video, 410 | canvas, 411 | audio, 412 | iframe, 413 | embed, 414 | object { 415 | display: block; 416 | /* 1 */ 417 | vertical-align: middle; 418 | /* 2 */ 419 | } 420 | 421 | /* 422 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 423 | */ 424 | 425 | img, 426 | video { 427 | max-width: 100%; 428 | height: auto; 429 | } 430 | 431 | /* Make elements with the HTML hidden attribute stay hidden by default */ 432 | 433 | [hidden] { 434 | display: none; 435 | } 436 | 437 | html, 438 | body { 439 | height: 100%; 440 | width: 100%; 441 | } 442 | 443 | .hidden { 444 | display: none; 445 | } 446 | 447 | i > svg { 448 | height: 100%; 449 | width: 100%; 450 | } 451 | 452 | *, ::before, ::after { 453 | --tw-border-spacing-x: 0; 454 | --tw-border-spacing-y: 0; 455 | --tw-translate-x: 0; 456 | --tw-translate-y: 0; 457 | --tw-rotate: 0; 458 | --tw-skew-x: 0; 459 | --tw-skew-y: 0; 460 | --tw-scale-x: 1; 461 | --tw-scale-y: 1; 462 | --tw-pan-x: ; 463 | --tw-pan-y: ; 464 | --tw-pinch-zoom: ; 465 | --tw-scroll-snap-strictness: proximity; 466 | --tw-gradient-from-position: ; 467 | --tw-gradient-via-position: ; 468 | --tw-gradient-to-position: ; 469 | --tw-ordinal: ; 470 | --tw-slashed-zero: ; 471 | --tw-numeric-figure: ; 472 | --tw-numeric-spacing: ; 473 | --tw-numeric-fraction: ; 474 | --tw-ring-inset: ; 475 | --tw-ring-offset-width: 0px; 476 | --tw-ring-offset-color: #fff; 477 | --tw-ring-color: rgb(59 130 246 / 0.5); 478 | --tw-ring-offset-shadow: 0 0 #0000; 479 | --tw-ring-shadow: 0 0 #0000; 480 | --tw-shadow: 0 0 #0000; 481 | --tw-shadow-colored: 0 0 #0000; 482 | --tw-blur: ; 483 | --tw-brightness: ; 484 | --tw-contrast: ; 485 | --tw-grayscale: ; 486 | --tw-hue-rotate: ; 487 | --tw-invert: ; 488 | --tw-saturate: ; 489 | --tw-sepia: ; 490 | --tw-drop-shadow: ; 491 | --tw-backdrop-blur: ; 492 | --tw-backdrop-brightness: ; 493 | --tw-backdrop-contrast: ; 494 | --tw-backdrop-grayscale: ; 495 | --tw-backdrop-hue-rotate: ; 496 | --tw-backdrop-invert: ; 497 | --tw-backdrop-opacity: ; 498 | --tw-backdrop-saturate: ; 499 | --tw-backdrop-sepia: ; 500 | } 501 | 502 | ::backdrop { 503 | --tw-border-spacing-x: 0; 504 | --tw-border-spacing-y: 0; 505 | --tw-translate-x: 0; 506 | --tw-translate-y: 0; 507 | --tw-rotate: 0; 508 | --tw-skew-x: 0; 509 | --tw-skew-y: 0; 510 | --tw-scale-x: 1; 511 | --tw-scale-y: 1; 512 | --tw-pan-x: ; 513 | --tw-pan-y: ; 514 | --tw-pinch-zoom: ; 515 | --tw-scroll-snap-strictness: proximity; 516 | --tw-gradient-from-position: ; 517 | --tw-gradient-via-position: ; 518 | --tw-gradient-to-position: ; 519 | --tw-ordinal: ; 520 | --tw-slashed-zero: ; 521 | --tw-numeric-figure: ; 522 | --tw-numeric-spacing: ; 523 | --tw-numeric-fraction: ; 524 | --tw-ring-inset: ; 525 | --tw-ring-offset-width: 0px; 526 | --tw-ring-offset-color: #fff; 527 | --tw-ring-color: rgb(59 130 246 / 0.5); 528 | --tw-ring-offset-shadow: 0 0 #0000; 529 | --tw-ring-shadow: 0 0 #0000; 530 | --tw-shadow: 0 0 #0000; 531 | --tw-shadow-colored: 0 0 #0000; 532 | --tw-blur: ; 533 | --tw-brightness: ; 534 | --tw-contrast: ; 535 | --tw-grayscale: ; 536 | --tw-hue-rotate: ; 537 | --tw-invert: ; 538 | --tw-saturate: ; 539 | --tw-sepia: ; 540 | --tw-drop-shadow: ; 541 | --tw-backdrop-blur: ; 542 | --tw-backdrop-brightness: ; 543 | --tw-backdrop-contrast: ; 544 | --tw-backdrop-grayscale: ; 545 | --tw-backdrop-hue-rotate: ; 546 | --tw-backdrop-invert: ; 547 | --tw-backdrop-opacity: ; 548 | --tw-backdrop-saturate: ; 549 | --tw-backdrop-sepia: ; 550 | } 551 | 552 | .container { 553 | width: 100%; 554 | } 555 | 556 | @media (min-width: 640px) { 557 | .container { 558 | max-width: 640px; 559 | } 560 | } 561 | 562 | @media (min-width: 768px) { 563 | .container { 564 | max-width: 768px; 565 | } 566 | } 567 | 568 | @media (min-width: 1024px) { 569 | .container { 570 | max-width: 1024px; 571 | } 572 | } 573 | 574 | @media (min-width: 1280px) { 575 | .container { 576 | max-width: 1280px; 577 | } 578 | } 579 | 580 | @media (min-width: 1536px) { 581 | .container { 582 | max-width: 1536px; 583 | } 584 | } 585 | 586 | .visible { 587 | visibility: visible; 588 | } 589 | 590 | .invisible { 591 | visibility: hidden; 592 | } 593 | 594 | .collapse { 595 | visibility: collapse; 596 | } 597 | 598 | .static { 599 | position: static; 600 | } 601 | 602 | .fixed { 603 | position: fixed; 604 | } 605 | 606 | .absolute { 607 | position: absolute; 608 | } 609 | 610 | .relative { 611 | position: relative; 612 | } 613 | 614 | .sticky { 615 | position: sticky; 616 | } 617 | 618 | .bottom-1 { 619 | bottom: 0.25rem; 620 | } 621 | 622 | .left-1 { 623 | left: 0.25rem; 624 | } 625 | 626 | .right-0 { 627 | right: 0px; 628 | } 629 | 630 | .top-0 { 631 | top: 0px; 632 | } 633 | 634 | .isolate { 635 | isolation: isolate; 636 | } 637 | 638 | .z-\[100\] { 639 | z-index: 100; 640 | } 641 | 642 | .col-span-1 { 643 | grid-column: span 1 / span 1; 644 | } 645 | 646 | .col-span-2 { 647 | grid-column: span 2 / span 2; 648 | } 649 | 650 | .m-2 { 651 | margin: 0.5rem; 652 | } 653 | 654 | .m-auto { 655 | margin: auto; 656 | } 657 | 658 | .mx-4 { 659 | margin-left: 1rem; 660 | margin-right: 1rem; 661 | } 662 | 663 | .mx-auto { 664 | margin-left: auto; 665 | margin-right: auto; 666 | } 667 | 668 | .my-4 { 669 | margin-top: 1rem; 670 | margin-bottom: 1rem; 671 | } 672 | 673 | .my-8 { 674 | margin-top: 2rem; 675 | margin-bottom: 2rem; 676 | } 677 | 678 | .mb-12 { 679 | margin-bottom: 3rem; 680 | } 681 | 682 | .mb-2 { 683 | margin-bottom: 0.5rem; 684 | } 685 | 686 | .mb-4 { 687 | margin-bottom: 1rem; 688 | } 689 | 690 | .mb-8 { 691 | margin-bottom: 2rem; 692 | } 693 | 694 | .ml-2 { 695 | margin-left: 0.5rem; 696 | } 697 | 698 | .ml-4 { 699 | margin-left: 1rem; 700 | } 701 | 702 | .mt-1 { 703 | margin-top: 0.25rem; 704 | } 705 | 706 | .mt-16 { 707 | margin-top: 4rem; 708 | } 709 | 710 | .mt-2 { 711 | margin-top: 0.5rem; 712 | } 713 | 714 | .mt-4 { 715 | margin-top: 1rem; 716 | } 717 | 718 | .mt-8 { 719 | margin-top: 2rem; 720 | } 721 | 722 | .block { 723 | display: block; 724 | } 725 | 726 | .inline-block { 727 | display: inline-block; 728 | } 729 | 730 | .inline { 731 | display: inline; 732 | } 733 | 734 | .flex { 735 | display: flex; 736 | } 737 | 738 | .table { 739 | display: table; 740 | } 741 | 742 | .grid { 743 | display: grid; 744 | } 745 | 746 | .contents { 747 | display: contents; 748 | } 749 | 750 | .hidden { 751 | display: none; 752 | } 753 | 754 | .\!h-full { 755 | height: 100% !important; 756 | } 757 | 758 | .h-10 { 759 | height: 2.5rem; 760 | } 761 | 762 | .h-2 { 763 | height: 0.5rem; 764 | } 765 | 766 | .h-\[1\.2em\] { 767 | height: 1.2em; 768 | } 769 | 770 | .h-\[2em\] { 771 | height: 2em; 772 | } 773 | 774 | .h-\[32px\] { 775 | height: 32px; 776 | } 777 | 778 | .h-full { 779 | height: 100%; 780 | } 781 | 782 | .\!max-h-full { 783 | max-height: 100% !important; 784 | } 785 | 786 | .max-h-\[70vh\] { 787 | max-height: 70vh; 788 | } 789 | 790 | .\!w-\[240px\] { 791 | width: 240px !important; 792 | } 793 | 794 | .w-10 { 795 | width: 2.5rem; 796 | } 797 | 798 | .w-\[1\.2m\] { 799 | width: 1.2m; 800 | } 801 | 802 | .w-\[2em\] { 803 | width: 2em; 804 | } 805 | 806 | .w-\[32px\] { 807 | width: 32px; 808 | } 809 | 810 | .w-\[350px\] { 811 | width: 350px; 812 | } 813 | 814 | .w-\[80\%\] { 815 | width: 80%; 816 | } 817 | 818 | .w-full { 819 | width: 100%; 820 | } 821 | 822 | .min-w-\[280px\] { 823 | min-width: 280px; 824 | } 825 | 826 | .max-w-\[300px\] { 827 | max-width: 300px; 828 | } 829 | 830 | .max-w-\[728px\] { 831 | max-width: 728px; 832 | } 833 | 834 | .flex-1 { 835 | flex: 1 1 0%; 836 | } 837 | 838 | .flex-none { 839 | flex: none; 840 | } 841 | 842 | .flex-grow { 843 | flex-grow: 1; 844 | } 845 | 846 | .grow { 847 | flex-grow: 1; 848 | } 849 | 850 | .grow-0 { 851 | flex-grow: 0; 852 | } 853 | 854 | .border-collapse { 855 | border-collapse: collapse; 856 | } 857 | 858 | .transform { 859 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 860 | } 861 | 862 | @keyframes pulse { 863 | 50% { 864 | opacity: .5; 865 | } 866 | } 867 | 868 | .animate-pulse { 869 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 870 | } 871 | 872 | .cursor-pointer { 873 | cursor: pointer; 874 | } 875 | 876 | .resize { 877 | resize: both; 878 | } 879 | 880 | .list-disc { 881 | list-style-type: disc; 882 | } 883 | 884 | .grid-cols-3 { 885 | grid-template-columns: repeat(3, minmax(0, 1fr)); 886 | } 887 | 888 | .flex-col { 889 | flex-direction: column; 890 | } 891 | 892 | .items-center { 893 | align-items: center; 894 | } 895 | 896 | .justify-center { 897 | justify-content: center; 898 | } 899 | 900 | .justify-between { 901 | justify-content: space-between; 902 | } 903 | 904 | .gap-2 { 905 | gap: 0.5rem; 906 | } 907 | 908 | .gap-4 { 909 | gap: 1rem; 910 | } 911 | 912 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { 913 | --tw-space-x-reverse: 0; 914 | margin-right: calc(1rem * var(--tw-space-x-reverse)); 915 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); 916 | } 917 | 918 | .space-y-3 > :not([hidden]) ~ :not([hidden]) { 919 | --tw-space-y-reverse: 0; 920 | margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); 921 | margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); 922 | } 923 | 924 | .space-y-6 > :not([hidden]) ~ :not([hidden]) { 925 | --tw-space-y-reverse: 0; 926 | margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); 927 | margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); 928 | } 929 | 930 | .overflow-auto { 931 | overflow: auto; 932 | } 933 | 934 | .overflow-hidden { 935 | overflow: hidden; 936 | } 937 | 938 | .truncate { 939 | overflow: hidden; 940 | text-overflow: ellipsis; 941 | white-space: nowrap; 942 | } 943 | 944 | .text-ellipsis { 945 | text-overflow: ellipsis; 946 | } 947 | 948 | .rounded { 949 | border-radius: 0.25rem; 950 | } 951 | 952 | .rounded-full { 953 | border-radius: 9999px; 954 | } 955 | 956 | .\!rounded-r-none { 957 | border-top-right-radius: 0px !important; 958 | border-bottom-right-radius: 0px !important; 959 | } 960 | 961 | .rounded-l { 962 | border-top-left-radius: 0.25rem; 963 | border-bottom-left-radius: 0.25rem; 964 | } 965 | 966 | .rounded-r { 967 | border-top-right-radius: 0.25rem; 968 | border-bottom-right-radius: 0.25rem; 969 | } 970 | 971 | .border { 972 | border-width: 1px; 973 | } 974 | 975 | .border-b { 976 | border-bottom-width: 1px; 977 | } 978 | 979 | .border-l { 980 | border-left-width: 1px; 981 | } 982 | 983 | .border-r { 984 | border-right-width: 1px; 985 | } 986 | 987 | .border-r-2 { 988 | border-right-width: 2px; 989 | } 990 | 991 | .border-r-4 { 992 | border-right-width: 4px; 993 | } 994 | 995 | .border-t { 996 | border-top-width: 1px; 997 | } 998 | 999 | .border-dotted { 1000 | border-style: dotted; 1001 | } 1002 | 1003 | .border-none { 1004 | border-style: none; 1005 | } 1006 | 1007 | .border-gray { 1008 | --tw-border-opacity: 1; 1009 | border-color: rgb(85 85 85 / var(--tw-border-opacity)); 1010 | } 1011 | 1012 | .border-gray\/30 { 1013 | border-color: rgb(85 85 85 / 0.3); 1014 | } 1015 | 1016 | .border-gray\/50 { 1017 | border-color: rgb(85 85 85 / 0.5); 1018 | } 1019 | 1020 | .border-gray\/75 { 1021 | border-color: rgb(85 85 85 / 0.75); 1022 | } 1023 | 1024 | .border-primary { 1025 | --tw-border-opacity: 1; 1026 | border-color: rgb(204 125 36 / var(--tw-border-opacity)); 1027 | } 1028 | 1029 | .border-white { 1030 | --tw-border-opacity: 1; 1031 | border-color: rgb(255 255 255 / var(--tw-border-opacity)); 1032 | } 1033 | 1034 | .bg-black { 1035 | --tw-bg-opacity: 1; 1036 | background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 1037 | } 1038 | 1039 | .bg-gray { 1040 | --tw-bg-opacity: 1; 1041 | background-color: rgb(85 85 85 / var(--tw-bg-opacity)); 1042 | } 1043 | 1044 | .bg-gray\/30 { 1045 | background-color: rgb(85 85 85 / 0.3); 1046 | } 1047 | 1048 | .bg-gray\/50 { 1049 | background-color: rgb(85 85 85 / 0.5); 1050 | } 1051 | 1052 | .bg-primary { 1053 | --tw-bg-opacity: 1; 1054 | background-color: rgb(204 125 36 / var(--tw-bg-opacity)); 1055 | } 1056 | 1057 | .bg-white { 1058 | --tw-bg-opacity: 1; 1059 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 1060 | } 1061 | 1062 | .bg-none { 1063 | background-image: none; 1064 | } 1065 | 1066 | .fill-primary { 1067 | fill: #cc7d24; 1068 | } 1069 | 1070 | .\!object-cover { 1071 | -o-object-fit: cover !important; 1072 | object-fit: cover !important; 1073 | } 1074 | 1075 | .p-1 { 1076 | padding: 0.25rem; 1077 | } 1078 | 1079 | .p-2 { 1080 | padding: 0.5rem; 1081 | } 1082 | 1083 | .p-4 { 1084 | padding: 1rem; 1085 | } 1086 | 1087 | .px-2 { 1088 | padding-left: 0.5rem; 1089 | padding-right: 0.5rem; 1090 | } 1091 | 1092 | .px-4 { 1093 | padding-left: 1rem; 1094 | padding-right: 1rem; 1095 | } 1096 | 1097 | .py-1 { 1098 | padding-top: 0.25rem; 1099 | padding-bottom: 0.25rem; 1100 | } 1101 | 1102 | .py-2 { 1103 | padding-top: 0.5rem; 1104 | padding-bottom: 0.5rem; 1105 | } 1106 | 1107 | .py-4 { 1108 | padding-top: 1rem; 1109 | padding-bottom: 1rem; 1110 | } 1111 | 1112 | .pb-4 { 1113 | padding-bottom: 1rem; 1114 | } 1115 | 1116 | .pl-2 { 1117 | padding-left: 0.5rem; 1118 | } 1119 | 1120 | .pl-4 { 1121 | padding-left: 1rem; 1122 | } 1123 | 1124 | .pr-2 { 1125 | padding-right: 0.5rem; 1126 | } 1127 | 1128 | .text-center { 1129 | text-align: center; 1130 | } 1131 | 1132 | .align-top { 1133 | vertical-align: top; 1134 | } 1135 | 1136 | .font-mono { 1137 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 1138 | } 1139 | 1140 | .text-2xl { 1141 | font-size: 1.5rem; 1142 | line-height: 2rem; 1143 | } 1144 | 1145 | .text-sm { 1146 | font-size: 0.875rem; 1147 | line-height: 1.25rem; 1148 | } 1149 | 1150 | .text-xl { 1151 | font-size: 1.25rem; 1152 | line-height: 1.75rem; 1153 | } 1154 | 1155 | .text-xs { 1156 | font-size: 0.75rem; 1157 | line-height: 1rem; 1158 | } 1159 | 1160 | .font-bold { 1161 | font-weight: 700; 1162 | } 1163 | 1164 | .uppercase { 1165 | text-transform: uppercase; 1166 | } 1167 | 1168 | .lowercase { 1169 | text-transform: lowercase; 1170 | } 1171 | 1172 | .italic { 1173 | font-style: italic; 1174 | } 1175 | 1176 | .ordinal { 1177 | --tw-ordinal: ordinal; 1178 | font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction); 1179 | } 1180 | 1181 | .text-black { 1182 | --tw-text-opacity: 1; 1183 | color: rgb(0 0 0 / var(--tw-text-opacity)); 1184 | } 1185 | 1186 | .text-blue-500 { 1187 | --tw-text-opacity: 1; 1188 | color: rgb(59 130 246 / var(--tw-text-opacity)); 1189 | } 1190 | 1191 | .text-gray { 1192 | --tw-text-opacity: 1; 1193 | color: rgb(85 85 85 / var(--tw-text-opacity)); 1194 | } 1195 | 1196 | .text-primary { 1197 | --tw-text-opacity: 1; 1198 | color: rgb(204 125 36 / var(--tw-text-opacity)); 1199 | } 1200 | 1201 | .text-primary\/75 { 1202 | color: rgb(204 125 36 / 0.75); 1203 | } 1204 | 1205 | .text-white { 1206 | --tw-text-opacity: 1; 1207 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1208 | } 1209 | 1210 | .underline { 1211 | text-decoration-line: underline; 1212 | } 1213 | 1214 | .overline { 1215 | text-decoration-line: overline; 1216 | } 1217 | 1218 | .antialiased { 1219 | -webkit-font-smoothing: antialiased; 1220 | -moz-osx-font-smoothing: grayscale; 1221 | } 1222 | 1223 | .shadow { 1224 | --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 1225 | --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 1226 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1227 | } 1228 | 1229 | .outline-none { 1230 | outline: 2px solid transparent; 1231 | outline-offset: 2px; 1232 | } 1233 | 1234 | .outline { 1235 | outline-style: solid; 1236 | } 1237 | 1238 | .outline-gray { 1239 | outline-color: #555555; 1240 | } 1241 | 1242 | .ring { 1243 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1244 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1245 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1246 | } 1247 | 1248 | .blur { 1249 | --tw-blur: blur(8px); 1250 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1251 | } 1252 | 1253 | .grayscale { 1254 | --tw-grayscale: grayscale(100%); 1255 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1256 | } 1257 | 1258 | .invert { 1259 | --tw-invert: invert(100%); 1260 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1261 | } 1262 | 1263 | .sepia { 1264 | --tw-sepia: sepia(100%); 1265 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1266 | } 1267 | 1268 | .filter { 1269 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1270 | } 1271 | 1272 | .transition { 1273 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 1274 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 1275 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 1276 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1277 | transition-duration: 150ms; 1278 | } 1279 | 1280 | .hover\:bg-gray\/20:hover { 1281 | background-color: rgb(85 85 85 / 0.2); 1282 | } 1283 | 1284 | :is(.dark .dark\:border-gray) { 1285 | --tw-border-opacity: 1; 1286 | border-color: rgb(85 85 85 / var(--tw-border-opacity)); 1287 | } 1288 | 1289 | :is(.dark .dark\:bg-black) { 1290 | --tw-bg-opacity: 1; 1291 | background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 1292 | } 1293 | 1294 | :is(.dark .dark\:bg-gray) { 1295 | --tw-bg-opacity: 1; 1296 | background-color: rgb(85 85 85 / var(--tw-bg-opacity)); 1297 | } 1298 | 1299 | :is(.dark .dark\:text-white) { 1300 | --tw-text-opacity: 1; 1301 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1302 | } 1303 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | html, 7 | body { 8 | @apply h-full w-full; 9 | } 10 | 11 | .hidden { 12 | display: none; 13 | } 14 | i > svg { 15 | @apply w-full h-full; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /styles.ts: -------------------------------------------------------------------------------- 1 | import { unsafeCSS } from "lit"; 2 | 3 | import "./styles-bundle.css"; 4 | // @ts-ignore 5 | import globalCssTxt from "./styles-bundle.css.txt"; 6 | 7 | export const globalStyles = [unsafeCSS(globalCssTxt)]; 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["**/*.{html,ts,css}"], 4 | darkMode: "class", 5 | theme: { 6 | extend: { 7 | colors: { 8 | primary: "#cc7d24", 9 | gray: "#555555", 10 | black: "#000000", 11 | }, 12 | }, 13 | }, 14 | plugins: [], 15 | }; 16 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 |

There should be a BlueSky embed below

10 | 14 |

There should be a BlueSky embed above

15 | 16 | 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "CommonJS", 5 | "strict": true, 6 | "lib": ["es2021", "dom"], 7 | "esModuleInterop": true, 8 | "experimentalDecorators": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tutorial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/badlogic/skyview/2a8001a28da587eeaa3cf939d44b7dbfdf2daf26/tutorial.png --------------------------------------------------------------------------------