data endpoint for entity 90008 (aka. a website)
0
fork

Configure Feed

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

feat: make guestbook use the bsky yay

dusk 6c105a60 3d5d808c

+160 -401
+3
src/components/note.svelte
··· 29 29 export let note: NoteData; 30 30 export let isHighlighted = false; 31 31 export let onlyContent = false; 32 + export let showOutgoing = true; 32 33 33 34 const renderDate = (timestamp: number) => { 34 35 return (new Date(timestamp)).toLocaleString("en-GB", { ··· 59 60 {#if !onlyContent}<Token v={renderDate(note.published)} small={!isHighlighted}/> {/if}<Token v={note.content} str/> 60 61 {#if note.hasMedia}<Token v="-contains media-" keywd small/>{/if} 61 62 {#if note.hasQuote}<Token v="-contains quote-" keywd small/>{/if} 63 + {#if showOutgoing} 62 64 {#each note.outgoingLinks ?? [] as {name, link}} 63 65 {@const color = outgoingLinkColors[name]} 64 66 <span class="text-sm"><Token v="(" punct/><a class="hover:motion-safe:animate-squiggle hover:underline" style="color: {color};{getTextShadowStyle(color)}" href={getOutgoingLink(name, link)}>{name}</a><Token v=")" punct/></span> 65 67 {/each} 68 + {/if} 66 69 </div>
+5 -1
src/hooks.server.ts
··· 13 13 console.log(`starting ${UPDATE_LAST_JOB_NAME} job...`); 14 14 scheduleJob(UPDATE_LAST_JOB_NAME, "*/1 * * * *", async () => { 15 15 console.log(`running ${UPDATE_LAST_JOB_NAME} job...`) 16 - await Promise.all([steamUpdateNowPlaying(), lastFmUpdateNowPlaying(), updateLastPosts()]) 16 + try { 17 + await Promise.all([steamUpdateNowPlaying(), lastFmUpdateNowPlaying(), updateLastPosts()]) 18 + } catch (err) { 19 + console.log(`error while running ${UPDATE_LAST_JOB_NAME} job: ${err}`) 20 + } 17 21 }).invoke() // invoke once immediately
+9 -9
src/lib/bluesky.ts
··· 14 14 } 15 15 16 16 const loginToBsky = async () => { 17 - const bot = new Bot({ service: "https://bsky.social" }) 18 - await bot.login({ identifier: 'gaze.systems', password: env.BSKY_PASSWORD ?? "" }) 17 + const bot = new Bot({ service: "https://gaze.systems" }) 18 + await bot.login({ identifier: 'guestbook.gaze.systems', password: env.BSKY_PASSWORD ?? "" }) 19 19 return bot 20 20 } 21 21 22 - export const getUserPosts = async (did: string, includeReposts: boolean = false, count: number = 10) => { 22 + export const getUserPosts = async (did: string, count: number = 10, cursor: string | null = null) => { 23 23 const client = await getBskyClient() 24 - let feedCursor = undefined; 24 + let feedCursor: string | null | undefined = cursor; 25 25 let posts: Post[] = [] 26 26 // fetch requested amount of posts 27 - while (posts.length < count || feedCursor === undefined) { 27 + while (posts.length < count - 1 && (typeof feedCursor === "string" || feedCursor === null)) { 28 28 let feedData = await client.getUserPosts( 29 - did, { limit: count, filter: 'posts_no_replies', cursor: feedCursor } 29 + did, { limit: count, filter: 'posts_no_replies', cursor: feedCursor === null ? undefined : feedCursor } 30 30 ) 31 - posts.push(...feedData.posts.filter((post) => !includeReposts && post.author.did === did)) 31 + posts.push(...feedData.posts.filter((post) => post.author.did === did)) 32 32 feedCursor = feedData.cursor 33 33 } 34 - return posts 34 + return { posts, cursor: feedCursor === null ? undefined : feedCursor } 35 35 } 36 36 37 37 const lastPosts = writable<Post[]>([]) 38 38 39 39 export const updateLastPosts = async () => { 40 - const posts = await getUserPosts("did:plc:dfl62fgb7wtjj3fcbb72naae", false, 13) 40 + const { posts } = await getUserPosts("did:plc:dfl62fgb7wtjj3fcbb72naae", 13) 41 41 lastPosts.set(posts) 42 42 } 43 43
-221
src/lib/guestbookAuth.ts
··· 1 - import { dev } from "$app/environment"; 2 - import { env } from "$env/dynamic/private"; 3 - import { PUBLIC_BASE_URL } from "$env/static/public"; 4 - import type { Cookies } from "@sveltejs/kit"; 5 - import base64url from "base64url"; 6 - 7 - export const callbackUrl = `${PUBLIC_BASE_URL}/guestbook/` 8 - 9 - interface TokenResponse { 10 - accessToken: string, 11 - tokenType: string, 12 - scope: string, 13 - } 14 - 15 - class OauthConfig { 16 - clientId: string; 17 - clientSecret: string; 18 - 19 - authUrl: URL; 20 - tokenUrl: URL; 21 - 22 - joinScopes: (scopes: string[]) => string = (scopes) => scopes.join("+"); 23 - getAuthParams: (params: Record<string, string>, config: OauthConfig) => Record<string, string> = (params) => { return params }; 24 - getTokenParams: (params: Record<string, string>, config: OauthConfig) => Record<string, string> = (params) => { return params }; 25 - extractTokenResponse: (tokenResp: any) => any = (tokenResp) => { 26 - return { 27 - accessToken: tokenResp.access_token, 28 - tokenType: tokenResp.token_type, 29 - scope: tokenResp.scope, 30 - } 31 - }; 32 - 33 - tokenReqHeaders: Record<string, string> = {}; 34 - 35 - constructor(clientId: string, clientSecret: string, authUrl: URL | string, tokenUrl: URL | string) { 36 - this.clientId = clientId; 37 - this.clientSecret = clientSecret; 38 - this.authUrl = typeof authUrl === 'string' ? new URL(authUrl) : authUrl 39 - this.tokenUrl = typeof tokenUrl === 'string' ? new URL(tokenUrl) : tokenUrl 40 - } 41 - 42 - withJoinScopes(f: typeof this.joinScopes) { 43 - this.joinScopes = f 44 - return this 45 - } 46 - withGetAuthParams(f: typeof this.getAuthParams) { 47 - this.getAuthParams = f 48 - return this 49 - } 50 - withGetTokenParams(f: typeof this.getTokenParams) { 51 - this.getTokenParams = f 52 - return this 53 - } 54 - withExtractTokenResponse(f: typeof this.extractTokenResponse) { 55 - this.extractTokenResponse = f 56 - return this 57 - } 58 - withTokenRequestHeaders(f: typeof this.tokenReqHeaders) { 59 - this.tokenReqHeaders = f 60 - return this 61 - } 62 - } 63 - 64 - const genericOauthClient = (oauthConfig: OauthConfig) => { 65 - return { 66 - getAuthUrl: (state: string, scopes: string[] = []) => { 67 - const redirect_uri = callbackUrl 68 - const scope = oauthConfig.joinScopes(scopes) 69 - const baseParams = { 70 - client_id: oauthConfig.clientId, 71 - redirect_uri, 72 - scope, 73 - state, 74 - } 75 - const params = oauthConfig.getAuthParams(baseParams, oauthConfig) 76 - const urlParams = new URLSearchParams(params) 77 - const urlRaw = `${oauthConfig.authUrl}?${urlParams.toString()}` 78 - return new URL(urlRaw) 79 - }, 80 - getToken: async (code: string): Promise<TokenResponse> => { 81 - const api = oauthConfig.tokenUrl 82 - const baseParams = { 83 - client_id: oauthConfig.clientId, 84 - client_secret: oauthConfig.clientSecret, 85 - redirect_uri: callbackUrl, 86 - code, 87 - } 88 - const body = new URLSearchParams(oauthConfig.getTokenParams(baseParams, oauthConfig)) 89 - const resp = await fetch(api, { method: 'POST', body, headers: oauthConfig.tokenReqHeaders }) 90 - if (resp.status !== 200) { 91 - throw new Error("woopsies, couldnt get oauth token") 92 - } 93 - const tokenResp: any = await resp.json() 94 - return oauthConfig.extractTokenResponse(tokenResp) 95 - } 96 - } 97 - } 98 - 99 - export const discord = { 100 - name: 'discord', 101 - ...genericOauthClient( 102 - new OauthConfig( 103 - env.DISCORD_CLIENT_ID, 104 - env.DISCORD_CLIENT_SECRET, 105 - 'https://discord.com/oauth2/authorize', 106 - 'https://discord.com/api/oauth2/token', 107 - ) 108 - .withGetAuthParams((params) => { return { ...params, response_type: 'code', prompt: 'none' } }) 109 - .withGetTokenParams((params) => { return { ...params, grant_type: 'authorization_code' } }) 110 - ), 111 - identifyToken: async (tokenResp: TokenResponse): Promise<string> => { 112 - const api = `https://discord.com/api/users/@me` 113 - const resp = await fetch(api, { 114 - headers: { 115 - 'Authorization': `${tokenResp.tokenType} ${tokenResp.accessToken}` 116 - } 117 - }) 118 - if (resp.status !== 200) { 119 - throw new Error("woopsies, couldnt validate access token") 120 - } 121 - const body = await resp.json() 122 - return body.username 123 - } 124 - } 125 - 126 - export const github = { 127 - name: 'github', 128 - ...genericOauthClient( 129 - new OauthConfig( 130 - env.GITHUB_CLIENT_ID, 131 - env.GITHUB_CLIENT_SECRET, 132 - 'https://github.com/login/oauth/authorize', 133 - 'https://github.com/login/oauth/access_token', 134 - ) 135 - .withJoinScopes((s) => { return s.join(" ") }) 136 - .withTokenRequestHeaders({ 'Accept': 'application/json' }) 137 - ), 138 - identifyToken: async (tokenResp: TokenResponse): Promise<string> => { 139 - const api = `https://api.github.com/user` 140 - const resp = await fetch(api, { 141 - headers: { 142 - 'Authorization': `${tokenResp.tokenType} ${tokenResp.accessToken}` 143 - } 144 - }) 145 - if (resp.status !== 200) { 146 - throw new Error("woopsies, couldnt validate access token") 147 - } 148 - const body = await resp.json() 149 - return body.login 150 - } 151 - } 152 - 153 - export const indielogin = { 154 - name: 'indielogin', 155 - ...genericOauthClient( 156 - new OauthConfig( 157 - PUBLIC_BASE_URL, 158 - '', 159 - 'https://indielogin.com/auth', 160 - 'https://indielogin.com/auth', 161 - ) 162 - .withTokenRequestHeaders({ 'Accept': 'application/json' }) 163 - .withExtractTokenResponse((rawResp) => {return {me: rawResp.me}}) 164 - ), 165 - identifyToken: async (tokenResp: any): Promise<string> => { 166 - let me: string = tokenResp.me 167 - me = me.replace('https://', '').replace('http://', '') 168 - return me 169 - } 170 - } 171 - 172 - export const generateState = () => { 173 - const randomValues = new Uint8Array(32) 174 - crypto.getRandomValues(randomValues) 175 - return base64url(Buffer.from(randomValues)) 176 - } 177 - 178 - export const createAuthUrl = (authCb: (state: string) => URL, cookies: Cookies) => { 179 - const state = generateState() 180 - const url = authCb(state) 181 - cookies.set("state", state, { 182 - secure: !dev, 183 - path: "/guestbook/", 184 - httpOnly: true, 185 - maxAge: 60 * 10, 186 - }) 187 - return url 188 - } 189 - 190 - export const extractCode = (url: URL, cookies: Cookies) => { 191 - const code = url.searchParams.get("code"); 192 - const state = url.searchParams.get("state"); 193 - 194 - const storedState = cookies.get("state"); 195 - 196 - if (code === null || state === null) { 197 - return null 198 - } 199 - if (state !== storedState) { 200 - throw new Error("Invalid OAuth request"); 201 - } 202 - 203 - return code 204 - } 205 - 206 - export const getAuthClient = (name: string) => { 207 - return clientsMap[name] 208 - } 209 - 210 - const clients = { 211 - discord, github, indielogin 212 - } 213 - const clientsMap: Record<string, any> = clients 214 - 215 - export default { 216 - callbackUrl, 217 - createAuthUrl, 218 - extractCode, 219 - getAuthClient, 220 - ...clients 221 - }
+13
src/lib/index.ts
··· 1 1 import type { Cookies } from '@sveltejs/kit' 2 + import { hash } from 'crypto' 2 3 3 4 export const scopeCookies = (cookies: Cookies, path: string) => { 4 5 return { ··· 12 13 cookies.delete(key, { ...props, path }) 13 14 } 14 15 } 16 + } 17 + 18 + const cipherChars = ['#', '%', '+', '=', '//'] 19 + export const fancyText = (input: string) => { 20 + const hashed = hash("sha256", input, "hex") 21 + let result = "" 22 + let idx = 0 23 + while (idx < hashed.length) { 24 + result += cipherChars[hashed.charCodeAt(idx) % cipherChars.length] 25 + idx += 1 26 + } 27 + return result 15 28 }
+6
src/lib/visits.ts
··· 41 41 return visitors 42 42 } 43 43 44 + export const getVisitorId = (cookies: Cookies) => { 45 + const scopedCookies = scopeCookies(cookies, '/') 46 + // parse the last visit timestamp from cookies if it exists 47 + return scopedCookies.get('visitorId') 48 + } 49 + 44 50 // why not use this for incrementVisitCount? cuz i wanna have separate visit counts (one per hour and one per day, per hour being recent visitors) 45 51 const _addLastVisitor = (visitors: Map<string, Visitor>, request: Request, cookies: Cookies) => { 46 52 const currentTime = Date.now()
+47 -93
src/routes/guestbook/+page.server.ts
··· 1 - import { env } from '$env/dynamic/private' 2 1 import { redirect, type Cookies, type RequestEvent } from '@sveltejs/kit' 3 - import auth from '$lib/guestbookAuth' 4 - import { scopeCookies as _scopeCookies } from '$lib'; 2 + import { scopeCookies as _scopeCookies, fancyText } from '$lib'; 5 3 import { RetryAfterRateLimiter } from 'sveltekit-rate-limiter/server'; 4 + import { PUBLIC_BASE_URL } from '$env/static/public'; 5 + import { getBskyClient, getUserPosts } from '$lib/bluesky.js'; 6 + import { getVisitorId } from '$lib/visits'; 7 + import { nanoid } from 'nanoid'; 8 + import { noteFromBskyPost, type NoteData } from '../../components/note.svelte'; 9 + import { get, writable } from 'svelte/store'; 6 10 7 11 export const prerender = false; 12 + 13 + const callbackUrl = `${PUBLIC_BASE_URL}/guestbook/` 8 14 9 15 const createPostRatelimiter = new RetryAfterRateLimiter({ 10 16 IP: [10, 'd'], 11 17 IPUA: [5, 'h'], 12 18 }) 13 - 14 - interface Entry { 15 - author: string, 16 - content: string, 17 - timestamp: number, 18 - } 19 19 20 20 const scopeCookies = (cookies: Cookies) => { 21 21 return _scopeCookies(cookies, '/guestbook') 22 22 } 23 23 24 - const postAction = (client: any, scopes: string[]) => { 25 - return async (event: RequestEvent) => { 24 + const postTokens = writable<Set<string>>(new Set()); 25 + 26 + export const actions = { 27 + post: async (event: RequestEvent) => { 26 28 const { request, cookies } = event 27 29 const scopedCookies = scopeCookies(cookies) 28 - scopedCookies.set("postAuth", client.name) 29 30 const rateStatus = await createPostRatelimiter.check(event) 30 31 if (rateStatus.limited) { 31 32 scopedCookies.set("sendError", `you are being ratelimited sowwy :c, try again after ${rateStatus.retryAfter} seconds`) 32 - redirect(303, auth.callbackUrl) 33 + redirect(303, callbackUrl) 33 34 } 34 35 const form = await request.formData() 35 - const content = form.get("content")?.toString().substring(0, 512) 36 - const anon = !(form.get("anon") === null) 36 + const content = form.get("content")?.toString().substring(0, 300) 37 37 if (content === undefined) { 38 38 scopedCookies.set("sendError", "content field is missing") 39 - redirect(303, auth.callbackUrl) 39 + redirect(303, callbackUrl) 40 40 } 41 41 // save form content in a cookie 42 - const params = new URLSearchParams({ content, anon: anon ? "1" : "" }) 43 - scopedCookies.set("postData", params.toString()) 44 - // get auth url to redirect user to 45 - const authUrl = auth.createAuthUrl((state) => client.getAuthUrl(state, scopes), cookies) 46 - redirect(303, authUrl) 42 + scopedCookies.set("postData", content) 43 + // create a token we will use to validate 44 + const token = nanoid() 45 + postTokens.update((set) => set.add(token)) 46 + scopedCookies.set("postAuth", token) 47 + redirect(303, callbackUrl) 47 48 } 48 49 } 49 50 50 - export const actions = { 51 - post_indielogin: postAction(auth.indielogin, []), 52 - post_discord: postAction(auth.discord, ["identify"]), 53 - post_github: postAction(auth.github, []), 54 - } 55 - 56 - export async function load({ url, fetch, cookies }) { 51 + export async function load({ url, cookies }) { 57 52 const scopedCookies = scopeCookies(cookies) 58 53 var data = { 59 - entries: [] as [number, Entry][], 60 - page: parseInt(url.searchParams.get('page') || "1"), 61 - hasNext: false, 54 + entries: [] as NoteData[], 62 55 sendError: scopedCookies.get("sendError") || "", 63 56 getError: "", 64 57 sendRatelimited: scopedCookies.get('sendRatelimited') || "", 65 58 getRatelimited: false, 59 + fillText: fancyText(getVisitorId(cookies) ?? nanoid()), 66 60 } 67 61 const rawPostData = scopedCookies.get("postData") || null 68 62 const postAuth = scopedCookies.get("postAuth") || null ··· 70 64 // delete the postData cookie after we got it cause we dont need it anymore 71 65 scopedCookies.delete("postData") 72 66 scopedCookies.delete("postAuth") 73 - // check if we are landing from an auth from a post action 74 - let code: string | null = null 75 - // try to get the code, fails if invalid oauth request 76 - try { 77 - code = auth.extractCode(url, cookies) 78 - } catch (err: any) { 79 - data.sendError = err.toString() 67 + // get and validate token 68 + if (!get(postTokens).has(postAuth)) { 69 + scopedCookies.set("sendError", "invalid post token! this is either a bug or you should stop doing silly stuff") 70 + redirect(303, callbackUrl) 80 71 } 81 - // if we do have a code, then make the access token request 82 - const authClient = auth.getAuthClient(postAuth) 83 - if (authClient !== null && code !== null) { 84 - // get and validate access token, also get username 85 - let author: string 86 - try { 87 - const tokenResp = await authClient.getToken(code) 88 - author = await authClient.identifyToken(tokenResp) 89 - } catch(err: any) { 90 - scopedCookies.set("sendError", `oauth failed: ${err.toString()}`) 91 - redirect(303, auth.callbackUrl) 92 - } 93 - let respRaw: Response 94 - try { 95 - const postData = new URLSearchParams(rawPostData) 96 - const anon = (postData.get('anon') ?? "1").length > 0 97 - // set author to the identified value we got if not anonymous 98 - postData.set('author', anon ? "[REDACTED]" : author) 99 - // return error if content was not set or if empty 100 - const content = postData.get('content') 101 - if (content === null || content.trim().length === 0) { 102 - scopedCookies.set("sendError", `content field was empty`) 103 - redirect(303, auth.callbackUrl) 104 - } 105 - // set content, make sure to trim it 106 - postData.set('content', content.substring(0, 512).trim()) 107 - respRaw = await fetch(env.GUESTBOOK_BASE_URL, { method: 'POST', body: postData }) 108 - } catch (err: any) { 109 - scopedCookies.set("sendError", `${err.toString()} (is guestbook server running?)`) 110 - redirect(303, auth.callbackUrl) 111 - } 112 - if (respRaw.status === 429) { 113 - scopedCookies.set("sendRatelimited", "true") 72 + // post entry 73 + try { 74 + // return error if content was not set or if empty 75 + const content = rawPostData.substring(0, 300).trim() 76 + if (content.length === 0) { 77 + scopedCookies.set("sendError", `content field was empty`) 78 + redirect(303, callbackUrl) 114 79 } 115 - redirect(303, auth.callbackUrl) 80 + // post to guestbook account 81 + await (await getBskyClient()).post({text: content, threadgate: { allowMentioned: false, allowFollowing: false }}); 82 + } catch (err: any) { 83 + scopedCookies.set("sendError", err.toString()) 84 + redirect(303, callbackUrl) 116 85 } 86 + redirect(303, callbackUrl) 117 87 } 118 88 // delete the cookies after we get em since we dont really need these more than once 119 89 scopedCookies.delete("sendError") 120 90 scopedCookies.delete("sendRatelimited") 121 - // handle cases where the page query might be a string so we just return back page 1 instead 122 - data.page = isNaN(data.page) ? 1 : data.page 123 - data.page = Math.max(data.page, 1) 124 - let respRaw: Response 91 + // actually get posts 125 92 try { 126 - const count = 5 127 - const offset = (data.page - 1) * count 128 - respRaw = await fetch(`${env.GUESTBOOK_BASE_URL}?offset=${offset}&count=${count}`) 93 + const { posts } = await getUserPosts("did:web:guestbook.gaze.systems", 16) 94 + data.entries = posts.map(noteFromBskyPost) 129 95 } catch (err: any) { 130 - data.getError = `${err.toString()} (is guestbook server running?)` 131 - return data 96 + data.getError = err.toString() 132 97 } 133 - data.getRatelimited = respRaw.status === 429 134 - if (!data.getRatelimited) { 135 - let body: any 136 - try { 137 - body = await respRaw.json() 138 - } catch (err: any) { 139 - data.getError = `invalid body? (${err.toString()})` 140 - return data 141 - } 142 - data.entries = body.entries 143 - data.hasNext = body.hasNext 144 - } 98 + 145 99 return data 146 100 }
+72 -74
src/routes/guestbook/+page.svelte
··· 1 1 <script lang="ts"> 2 - import Tooltip from '../../components/tooltip.svelte'; 3 - import Window from '../../components/window.svelte'; 2 + import Note from '../../components/note.svelte'; 3 + import Token from '../../components/token.svelte'; 4 + import Window from '../../components/window.svelte'; 4 5 5 6 export let data; 6 - $: hasPreviousPage = data.page > 1; 7 - $: hasNextPage = data.hasNext; 8 7 9 8 function resetEntriesAnimation() { 10 9 var el = document.getElementById('guestbookentries'); ··· 16 15 </script> 17 16 18 17 <div class="flex flex-col-reverse md:flex-row gap-2 md:gap-4"> 19 - <Window title="guestbook" style="mx-auto" iconUri="/icons/guestbook.png"> 20 - <div class="flex flex-col gap-4 2xl:w-[60ch] leading-6"> 21 - <p> 22 - hia, here is the guestbook if you wanna post anything :) 23 - </p> 24 - <p> 25 - just fill the post in and click on your preferred auth method to post 26 - </p> 27 - <p>rules: be a good human bean pretty please (and don't be shy!!!)</p> 18 + <Window title="guestbook" style="ml-auto" iconUri="/icons/guestbook.png"> 19 + <div class="flex flex-col gap-1 max-w-[50ch] leading-6"> 20 + <div class="prose prose-ralsei leading-6 entry p-2"> 21 + <p>hia, here is the guestbook if you wanna post anything :)</p> 22 + <p>be a good human bean pretty please (and don't be shy!!!)</p> 23 + <p class="text-sm italic">(to see all the entries, look <a href="https://bsky.app/profile/guestbook.gaze.systems">here</a>)</p> 24 + </div> 28 25 <form method="post"> 29 26 <div class="entry entryflex"> 30 - <div class="flex flex-row"> 31 - <p class="place-self-start grow text-2xl font-monospace">###</p> 32 - <p class="justify-end self-center text-sm font-monospace">...</p> 33 - </div> 34 27 <textarea 35 28 class="text-lg ml-0.5 bg-inherit resize-none text-shadow-white placeholder-shown:[text-shadow:none] [field-sizing:content]" 36 29 name="content" 37 30 placeholder="say meow!" 38 - maxlength="512" 31 + maxlength="300" 39 32 required 40 33 /> 41 - <div class="flex flex-row gap-2 items-center justify-center"> 42 - <input type="checkbox" id="anon" name="anon" checked/> 43 - <label for="anon" class="text-sm font-monospace grow text-shadow-white">post anonymously</label> 44 - <p class="text-sm font-monospace">--- posted by you</p> 34 + </div> 35 + <div class="flex flex-row gap-1 mt-1"> 36 + <input 37 + type="submit" 38 + value="click to post" 39 + formaction="?/post" 40 + class="entry text-ralsei-green-light leading-none hover:underline motion-safe:hover:animate-squiggle p-1 z-50" 41 + /> 42 + <div class="marquee-wrapper entry text-ralsei-white/50"> 43 + <div class="marquee font-monospace"> 44 + <p class="text-shadow-none">{data.fillText}</p><p class="text-shadow-none">{data.fillText}</p> 45 + </div> 45 46 </div> 46 47 </div> 47 - <div class="entry flex flex-wrap gap-1.5 p-1 items-baseline"> 48 - <p class="text-xl ms-2">auth via:</p> 49 - {#each ['discord', 'github'] as platform} 50 - <Tooltip x="" y="translate-y-[70%]" targetY="" targetX=""> 51 - <svelte:fragment slot="tooltipContent">post with {platform}</svelte:fragment> 52 - <input 53 - type="submit" 54 - value={platform} 55 - formaction="?/post_{platform}" 56 - class="text-lg text-ralsei-green-light leading-none hover:underline motion-safe:hover:animate-squiggle w-fit py-1 px-0.5" 57 - /> 58 - </Tooltip> 59 - {/each} 60 - </div> 61 48 {#if data.sendRatelimited} 62 49 <p class="text-error">you are ratelimited, try again in 30 seconds</p> 63 50 {/if} ··· 70 57 </form> 71 58 </div> 72 59 </Window> 73 - <Window id='guestbookentries' style="mx-auto" title="entries" iconUri="/icons/entries.png"> 60 + <Window id='guestbookentries' style="mr-auto" title="entries" iconUri="/icons/entries.png" removePadding> 74 61 <div class="flex flex-col gap-2 md:gap-4 2xl:w-[60ch]"> 75 62 {#if data.getRatelimited} 76 63 <p class="text-error"> ··· 82 69 <p>{data.getError}</p> 83 70 </details> 84 71 {:else} 85 - {#each data.entries as [entry_id, entry] (entry_id)} 86 - {@const date = new Date(entry.timestamp * 1e3).toLocaleString()} 87 - <div class="entry entryflex"> 88 - <div class="flex flex-row"> 89 - <p class="place-self-start grow text-2xl font-monospace"> 90 - #{entry_id} 91 - </p> 92 - <p class="justify-end self-center text-sm font-monospace">{date}</p> 93 - </div> 94 - <p class="text-lg text-wrap overflow-hidden text-ellipsis ml-0.5 max-w-[56ch]"> 95 - {entry.content} 96 - </p> 97 - <p 98 - class="max-w-[45ch] place-self-end text-sm font-monospace overflow-hidden text-ellipsis text-nowrap" 99 - title={entry.author} 100 - > 101 - --- posted by {entry.author} 102 - </p> 103 - </div> 104 - {:else} 105 - <p>looks like there are no entries :(</p> 72 + <div 73 + class=" 74 + prose prose-ralsei 75 + prose-pre:rounded-none prose-pre:!m-0 prose-pre:!p-2 76 + prose-pre:!bg-ralsei-black prose-code:!bg-ralsei-black 77 + " 78 + > 79 + <pre class="language-bash"><code class="language-bash"><nobr> 80 + <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="source" funct/> <Token v="scripts/log.nu" /> 81 + <br> 82 + <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="let" funct/> <Token v="entries"/> <Token v="=" punct/> <Token v="(" punct/><Token v="ls" funct/> <Token v="guestbook" /> <Token v="|" punct/> <Token v="reverse" funct/> <Token v="|" punct/> <Token v="take" funct/> <Token v="16"/><Token v=")" punct/> 83 + <br> 84 + <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="$entries" /> <Token v="|" punct/> <Token v="each" funct/> <Token v="&#123;" punct/><Token v="|" punct/><Token v="file"/><Token v="|" punct/> <Token v="render" funct/> <Token v="(" punct/><Token v="open" funct/> <Token v="$file.name" /><Token v=")" punct/><Token v="&#125;" punct/> 85 + <br> 86 + <br> 87 + {#each data.entries as note, index} 88 + <Note showOutgoing={false} {note}/> 89 + {#if index < data.entries.length - 1} 90 + <div class="mt-3"/> 91 + {/if} 106 92 {/each} 107 - {/if} 108 - {#if hasPreviousPage || hasNextPage} 109 - <div class="flex flex-row w-full justify-center items-center font-monospace"> 110 - {#if hasPreviousPage} 111 - <a href="/guestbook/?page={data.entries.length > 0 ? data.page - 1 : 1}" 112 - on:click={resetEntriesAnimation} 113 - >&lt;&lt; previous</a 114 - > 115 - {/if} 116 - {#if hasNextPage && hasPreviousPage} 117 - <div class="w-1/12" /> 118 - {/if} 119 - {#if hasNextPage} 120 - <a href="/guestbook/?page={data.page + 1}" on:click={resetEntriesAnimation}>next &gt;&gt;</a> 121 - {/if} 93 + </nobr></code></pre> 122 94 </div> 123 95 {/if} 124 96 </div> ··· 130 102 @apply bg-ralsei-green-dark/70 border-ralsei-green-light/30 border-x-[3px] border-y-4; 131 103 } 132 104 .entryflex { 133 - @apply flex flex-col gap-3 py-2 px-3; 105 + @apply flex flex-col p-1; 106 + } 107 + 108 + .marquee-wrapper { 109 + max-width: 100%; 110 + overflow: hidden; 111 + } 112 + 113 + .marquee { 114 + white-space: nowrap; 115 + overflow: hidden; 116 + display: inline-block; 117 + animation: marquee 10s linear infinite; 118 + } 119 + 120 + .marquee p { 121 + transform: translateY(15%); 122 + display: inline-block; 123 + } 124 + 125 + @keyframes marquee { 126 + 0% { 127 + transform: translate3d(0, 0, 0); 128 + } 129 + 100% { 130 + transform: translate3d(-50%, 0, 0); 131 + } 134 132 } 135 133 </style>
+5 -3
src/routes/log/+page.svelte
··· 15 15 " 16 16 > 17 17 <pre class="language-bash"><code class="language-bash"><nobr> 18 - <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="source" funct/> <Token v="scripts/log.nu" /> 19 - <br> 20 - <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="ls" funct/> <Token v="log" /> <Token v="|" punct/> <Token v="each" funct/> <Token v="&#123;" punct/><Token v="|" punct/><Token v="file"/><Token v="|" punct/> <Token v="render" funct/> <Token v="(" punct/><Token v="open" funct/> <Token v="$file.name" /><Token v=")" punct/><Token v="&#125;" punct/> 18 + <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="source" funct/> <Token v="scripts/log.nu" /> 19 + <br> 20 + <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="let" funct/> <Token v="entries"/> <Token v="=" punct/> <Token v="(" punct/><Token v="ls" funct/> <Token v="logs" /> <Token v="|" punct/> <Token v="reverse" funct/> <Token v="|" punct/> <Token v="take" funct/> <Token v="13"/><Token v=")" punct/> 21 + <br> 22 + <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="$entries" /> <Token v="|" punct/> <Token v="each" funct/> <Token v="&#123;" punct/><Token v="|" punct/><Token v="file"/><Token v="|" punct/> <Token v="render" funct/> <Token v="(" punct/><Token v="open" funct/> <Token v="$file.name" /><Token v=")" punct/><Token v="&#125;" punct/> 21 23 <br> 22 24 <br> 23 25 {#each data.feedPosts as note, index}