feat: make guestbook use the bsky yay

This commit is contained in:
dusk 2025-02-07 06:53:42 +03:00
parent 3d5d808c9a
commit 6c105a6052
Signed by: dusk
SSH Key Fingerprint: SHA256:Abmvag+juovVufZTxyWY8KcVgrznxvBjQpJesv071Aw
9 changed files with 160 additions and 401 deletions

@ -29,6 +29,7 @@
export let note: NoteData;
export let isHighlighted = false;
export let onlyContent = false;
export let showOutgoing = true;
const renderDate = (timestamp: number) => {
return (new Date(timestamp)).toLocaleString("en-GB", {
@ -59,8 +60,10 @@
{#if !onlyContent}<Token v={renderDate(note.published)} small={!isHighlighted}/> {/if}<Token v={note.content} str/>
{#if note.hasMedia}<Token v="-contains media-" keywd small/>{/if}
{#if note.hasQuote}<Token v="-contains quote-" keywd small/>{/if}
{#if showOutgoing}
{#each note.outgoingLinks ?? [] as {name, link}}
{@const color = outgoingLinkColors[name]}
<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>
{/each}
{/if}
</div>

@ -13,5 +13,9 @@ if (UPDATE_LAST_JOB_NAME in scheduledJobs) {
console.log(`starting ${UPDATE_LAST_JOB_NAME} job...`);
scheduleJob(UPDATE_LAST_JOB_NAME, "*/1 * * * *", async () => {
console.log(`running ${UPDATE_LAST_JOB_NAME} job...`)
await Promise.all([steamUpdateNowPlaying(), lastFmUpdateNowPlaying(), updateLastPosts()])
try {
await Promise.all([steamUpdateNowPlaying(), lastFmUpdateNowPlaying(), updateLastPosts()])
} catch (err) {
console.log(`error while running ${UPDATE_LAST_JOB_NAME} job: ${err}`)
}
}).invoke() // invoke once immediately

@ -14,30 +14,30 @@ export const getBskyClient = async () => {
}
const loginToBsky = async () => {
const bot = new Bot({ service: "https://bsky.social" })
await bot.login({ identifier: 'gaze.systems', password: env.BSKY_PASSWORD ?? "" })
const bot = new Bot({ service: "https://gaze.systems" })
await bot.login({ identifier: 'guestbook.gaze.systems', password: env.BSKY_PASSWORD ?? "" })
return bot
}
export const getUserPosts = async (did: string, includeReposts: boolean = false, count: number = 10) => {
export const getUserPosts = async (did: string, count: number = 10, cursor: string | null = null) => {
const client = await getBskyClient()
let feedCursor = undefined;
let feedCursor: string | null | undefined = cursor;
let posts: Post[] = []
// fetch requested amount of posts
while (posts.length < count || feedCursor === undefined) {
while (posts.length < count - 1 && (typeof feedCursor === "string" || feedCursor === null)) {
let feedData = await client.getUserPosts(
did, { limit: count, filter: 'posts_no_replies', cursor: feedCursor }
did, { limit: count, filter: 'posts_no_replies', cursor: feedCursor === null ? undefined : feedCursor }
)
posts.push(...feedData.posts.filter((post) => !includeReposts && post.author.did === did))
posts.push(...feedData.posts.filter((post) => post.author.did === did))
feedCursor = feedData.cursor
}
return posts
return { posts, cursor: feedCursor === null ? undefined : feedCursor }
}
const lastPosts = writable<Post[]>([])
export const updateLastPosts = async () => {
const posts = await getUserPosts("did:plc:dfl62fgb7wtjj3fcbb72naae", false, 13)
const { posts } = await getUserPosts("did:plc:dfl62fgb7wtjj3fcbb72naae", 13)
lastPosts.set(posts)
}

@ -1,221 +0,0 @@
import { dev } from "$app/environment";
import { env } from "$env/dynamic/private";
import { PUBLIC_BASE_URL } from "$env/static/public";
import type { Cookies } from "@sveltejs/kit";
import base64url from "base64url";
export const callbackUrl = `${PUBLIC_BASE_URL}/guestbook/`
interface TokenResponse {
accessToken: string,
tokenType: string,
scope: string,
}
class OauthConfig {
clientId: string;
clientSecret: string;
authUrl: URL;
tokenUrl: URL;
joinScopes: (scopes: string[]) => string = (scopes) => scopes.join("+");
getAuthParams: (params: Record<string, string>, config: OauthConfig) => Record<string, string> = (params) => { return params };
getTokenParams: (params: Record<string, string>, config: OauthConfig) => Record<string, string> = (params) => { return params };
extractTokenResponse: (tokenResp: any) => any = (tokenResp) => {
return {
accessToken: tokenResp.access_token,
tokenType: tokenResp.token_type,
scope: tokenResp.scope,
}
};
tokenReqHeaders: Record<string, string> = {};
constructor(clientId: string, clientSecret: string, authUrl: URL | string, tokenUrl: URL | string) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.authUrl = typeof authUrl === 'string' ? new URL(authUrl) : authUrl
this.tokenUrl = typeof tokenUrl === 'string' ? new URL(tokenUrl) : tokenUrl
}
withJoinScopes(f: typeof this.joinScopes) {
this.joinScopes = f
return this
}
withGetAuthParams(f: typeof this.getAuthParams) {
this.getAuthParams = f
return this
}
withGetTokenParams(f: typeof this.getTokenParams) {
this.getTokenParams = f
return this
}
withExtractTokenResponse(f: typeof this.extractTokenResponse) {
this.extractTokenResponse = f
return this
}
withTokenRequestHeaders(f: typeof this.tokenReqHeaders) {
this.tokenReqHeaders = f
return this
}
}
const genericOauthClient = (oauthConfig: OauthConfig) => {
return {
getAuthUrl: (state: string, scopes: string[] = []) => {
const redirect_uri = callbackUrl
const scope = oauthConfig.joinScopes(scopes)
const baseParams = {
client_id: oauthConfig.clientId,
redirect_uri,
scope,
state,
}
const params = oauthConfig.getAuthParams(baseParams, oauthConfig)
const urlParams = new URLSearchParams(params)
const urlRaw = `${oauthConfig.authUrl}?${urlParams.toString()}`
return new URL(urlRaw)
},
getToken: async (code: string): Promise<TokenResponse> => {
const api = oauthConfig.tokenUrl
const baseParams = {
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
redirect_uri: callbackUrl,
code,
}
const body = new URLSearchParams(oauthConfig.getTokenParams(baseParams, oauthConfig))
const resp = await fetch(api, { method: 'POST', body, headers: oauthConfig.tokenReqHeaders })
if (resp.status !== 200) {
throw new Error("woopsies, couldnt get oauth token")
}
const tokenResp: any = await resp.json()
return oauthConfig.extractTokenResponse(tokenResp)
}
}
}
export const discord = {
name: 'discord',
...genericOauthClient(
new OauthConfig(
env.DISCORD_CLIENT_ID,
env.DISCORD_CLIENT_SECRET,
'https://discord.com/oauth2/authorize',
'https://discord.com/api/oauth2/token',
)
.withGetAuthParams((params) => { return { ...params, response_type: 'code', prompt: 'none' } })
.withGetTokenParams((params) => { return { ...params, grant_type: 'authorization_code' } })
),
identifyToken: async (tokenResp: TokenResponse): Promise<string> => {
const api = `https://discord.com/api/users/@me`
const resp = await fetch(api, {
headers: {
'Authorization': `${tokenResp.tokenType} ${tokenResp.accessToken}`
}
})
if (resp.status !== 200) {
throw new Error("woopsies, couldnt validate access token")
}
const body = await resp.json()
return body.username
}
}
export const github = {
name: 'github',
...genericOauthClient(
new OauthConfig(
env.GITHUB_CLIENT_ID,
env.GITHUB_CLIENT_SECRET,
'https://github.com/login/oauth/authorize',
'https://github.com/login/oauth/access_token',
)
.withJoinScopes((s) => { return s.join(" ") })
.withTokenRequestHeaders({ 'Accept': 'application/json' })
),
identifyToken: async (tokenResp: TokenResponse): Promise<string> => {
const api = `https://api.github.com/user`
const resp = await fetch(api, {
headers: {
'Authorization': `${tokenResp.tokenType} ${tokenResp.accessToken}`
}
})
if (resp.status !== 200) {
throw new Error("woopsies, couldnt validate access token")
}
const body = await resp.json()
return body.login
}
}
export const indielogin = {
name: 'indielogin',
...genericOauthClient(
new OauthConfig(
PUBLIC_BASE_URL,
'',
'https://indielogin.com/auth',
'https://indielogin.com/auth',
)
.withTokenRequestHeaders({ 'Accept': 'application/json' })
.withExtractTokenResponse((rawResp) => {return {me: rawResp.me}})
),
identifyToken: async (tokenResp: any): Promise<string> => {
let me: string = tokenResp.me
me = me.replace('https://', '').replace('http://', '')
return me
}
}
export const generateState = () => {
const randomValues = new Uint8Array(32)
crypto.getRandomValues(randomValues)
return base64url(Buffer.from(randomValues))
}
export const createAuthUrl = (authCb: (state: string) => URL, cookies: Cookies) => {
const state = generateState()
const url = authCb(state)
cookies.set("state", state, {
secure: !dev,
path: "/guestbook/",
httpOnly: true,
maxAge: 60 * 10,
})
return url
}
export const extractCode = (url: URL, cookies: Cookies) => {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const storedState = cookies.get("state");
if (code === null || state === null) {
return null
}
if (state !== storedState) {
throw new Error("Invalid OAuth request");
}
return code
}
export const getAuthClient = (name: string) => {
return clientsMap[name]
}
const clients = {
discord, github, indielogin
}
const clientsMap: Record<string, any> = clients
export default {
callbackUrl,
createAuthUrl,
extractCode,
getAuthClient,
...clients
}

@ -1,4 +1,5 @@
import type { Cookies } from '@sveltejs/kit'
import { hash } from 'crypto'
export const scopeCookies = (cookies: Cookies, path: string) => {
return {
@ -12,4 +13,16 @@ export const scopeCookies = (cookies: Cookies, path: string) => {
cookies.delete(key, { ...props, path })
}
}
}
const cipherChars = ['#', '%', '+', '=', '//']
export const fancyText = (input: string) => {
const hashed = hash("sha256", input, "hex")
let result = ""
let idx = 0
while (idx < hashed.length) {
result += cipherChars[hashed.charCodeAt(idx) % cipherChars.length]
idx += 1
}
return result
}

@ -41,6 +41,12 @@ export const addLastVisitor = (request: Request, cookies: Cookies) => {
return visitors
}
export const getVisitorId = (cookies: Cookies) => {
const scopedCookies = scopeCookies(cookies, '/')
// parse the last visit timestamp from cookies if it exists
return scopedCookies.get('visitorId')
}
// 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)
const _addLastVisitor = (visitors: Map<string, Visitor>, request: Request, cookies: Cookies) => {
const currentTime = Date.now()

@ -1,68 +1,62 @@
import { env } from '$env/dynamic/private'
import { redirect, type Cookies, type RequestEvent } from '@sveltejs/kit'
import auth from '$lib/guestbookAuth'
import { scopeCookies as _scopeCookies } from '$lib';
import { scopeCookies as _scopeCookies, fancyText } from '$lib';
import { RetryAfterRateLimiter } from 'sveltekit-rate-limiter/server';
import { PUBLIC_BASE_URL } from '$env/static/public';
import { getBskyClient, getUserPosts } from '$lib/bluesky.js';
import { getVisitorId } from '$lib/visits';
import { nanoid } from 'nanoid';
import { noteFromBskyPost, type NoteData } from '../../components/note.svelte';
import { get, writable } from 'svelte/store';
export const prerender = false;
const callbackUrl = `${PUBLIC_BASE_URL}/guestbook/`
const createPostRatelimiter = new RetryAfterRateLimiter({
IP: [10, 'd'],
IPUA: [5, 'h'],
})
interface Entry {
author: string,
content: string,
timestamp: number,
}
const scopeCookies = (cookies: Cookies) => {
return _scopeCookies(cookies, '/guestbook')
}
const postAction = (client: any, scopes: string[]) => {
return async (event: RequestEvent) => {
const postTokens = writable<Set<string>>(new Set());
export const actions = {
post: async (event: RequestEvent) => {
const { request, cookies } = event
const scopedCookies = scopeCookies(cookies)
scopedCookies.set("postAuth", client.name)
const rateStatus = await createPostRatelimiter.check(event)
if (rateStatus.limited) {
scopedCookies.set("sendError", `you are being ratelimited sowwy :c, try again after ${rateStatus.retryAfter} seconds`)
redirect(303, auth.callbackUrl)
redirect(303, callbackUrl)
}
const form = await request.formData()
const content = form.get("content")?.toString().substring(0, 512)
const anon = !(form.get("anon") === null)
const content = form.get("content")?.toString().substring(0, 300)
if (content === undefined) {
scopedCookies.set("sendError", "content field is missing")
redirect(303, auth.callbackUrl)
redirect(303, callbackUrl)
}
// save form content in a cookie
const params = new URLSearchParams({ content, anon: anon ? "1" : "" })
scopedCookies.set("postData", params.toString())
// get auth url to redirect user to
const authUrl = auth.createAuthUrl((state) => client.getAuthUrl(state, scopes), cookies)
redirect(303, authUrl)
scopedCookies.set("postData", content)
// create a token we will use to validate
const token = nanoid()
postTokens.update((set) => set.add(token))
scopedCookies.set("postAuth", token)
redirect(303, callbackUrl)
}
}
export const actions = {
post_indielogin: postAction(auth.indielogin, []),
post_discord: postAction(auth.discord, ["identify"]),
post_github: postAction(auth.github, []),
}
export async function load({ url, fetch, cookies }) {
export async function load({ url, cookies }) {
const scopedCookies = scopeCookies(cookies)
var data = {
entries: [] as [number, Entry][],
page: parseInt(url.searchParams.get('page') || "1"),
hasNext: false,
entries: [] as NoteData[],
sendError: scopedCookies.get("sendError") || "",
getError: "",
sendRatelimited: scopedCookies.get('sendRatelimited') || "",
getRatelimited: false,
fillText: fancyText(getVisitorId(cookies) ?? nanoid()),
}
const rawPostData = scopedCookies.get("postData") || null
const postAuth = scopedCookies.get("postAuth") || null
@ -70,77 +64,37 @@ export async function load({ url, fetch, cookies }) {
// delete the postData cookie after we got it cause we dont need it anymore
scopedCookies.delete("postData")
scopedCookies.delete("postAuth")
// check if we are landing from an auth from a post action
let code: string | null = null
// try to get the code, fails if invalid oauth request
// get and validate token
if (!get(postTokens).has(postAuth)) {
scopedCookies.set("sendError", "invalid post token! this is either a bug or you should stop doing silly stuff")
redirect(303, callbackUrl)
}
// post entry
try {
code = auth.extractCode(url, cookies)
// return error if content was not set or if empty
const content = rawPostData.substring(0, 300).trim()
if (content.length === 0) {
scopedCookies.set("sendError", `content field was empty`)
redirect(303, callbackUrl)
}
// post to guestbook account
await (await getBskyClient()).post({text: content, threadgate: { allowMentioned: false, allowFollowing: false }});
} catch (err: any) {
data.sendError = err.toString()
}
// if we do have a code, then make the access token request
const authClient = auth.getAuthClient(postAuth)
if (authClient !== null && code !== null) {
// get and validate access token, also get username
let author: string
try {
const tokenResp = await authClient.getToken(code)
author = await authClient.identifyToken(tokenResp)
} catch(err: any) {
scopedCookies.set("sendError", `oauth failed: ${err.toString()}`)
redirect(303, auth.callbackUrl)
}
let respRaw: Response
try {
const postData = new URLSearchParams(rawPostData)
const anon = (postData.get('anon') ?? "1").length > 0
// set author to the identified value we got if not anonymous
postData.set('author', anon ? "[REDACTED]" : author)
// return error if content was not set or if empty
const content = postData.get('content')
if (content === null || content.trim().length === 0) {
scopedCookies.set("sendError", `content field was empty`)
redirect(303, auth.callbackUrl)
}
// set content, make sure to trim it
postData.set('content', content.substring(0, 512).trim())
respRaw = await fetch(env.GUESTBOOK_BASE_URL, { method: 'POST', body: postData })
} catch (err: any) {
scopedCookies.set("sendError", `${err.toString()} (is guestbook server running?)`)
redirect(303, auth.callbackUrl)
}
if (respRaw.status === 429) {
scopedCookies.set("sendRatelimited", "true")
}
redirect(303, auth.callbackUrl)
scopedCookies.set("sendError", err.toString())
redirect(303, callbackUrl)
}
redirect(303, callbackUrl)
}
// delete the cookies after we get em since we dont really need these more than once
scopedCookies.delete("sendError")
scopedCookies.delete("sendRatelimited")
// handle cases where the page query might be a string so we just return back page 1 instead
data.page = isNaN(data.page) ? 1 : data.page
data.page = Math.max(data.page, 1)
let respRaw: Response
// actually get posts
try {
const count = 5
const offset = (data.page - 1) * count
respRaw = await fetch(`${env.GUESTBOOK_BASE_URL}?offset=${offset}&count=${count}`)
const { posts } = await getUserPosts("did:web:guestbook.gaze.systems", 16)
data.entries = posts.map(noteFromBskyPost)
} catch (err: any) {
data.getError = `${err.toString()} (is guestbook server running?)`
return data
}
data.getRatelimited = respRaw.status === 429
if (!data.getRatelimited) {
let body: any
try {
body = await respRaw.json()
} catch (err: any) {
data.getError = `invalid body? (${err.toString()})`
return data
}
data.entries = body.entries
data.hasNext = body.hasNext
data.getError = err.toString()
}
return data
}

@ -1,10 +1,9 @@
<script lang="ts">
import Tooltip from '../../components/tooltip.svelte';
import Window from '../../components/window.svelte';
import Note from '../../components/note.svelte';
import Token from '../../components/token.svelte';
import Window from '../../components/window.svelte';
export let data;
$: hasPreviousPage = data.page > 1;
$: hasNextPage = data.hasNext;
function resetEntriesAnimation() {
var el = document.getElementById('guestbookentries');
@ -16,47 +15,35 @@ import Window from '../../components/window.svelte';
</script>
<div class="flex flex-col-reverse md:flex-row gap-2 md:gap-4">
<Window title="guestbook" style="mx-auto" iconUri="/icons/guestbook.png">
<div class="flex flex-col gap-4 2xl:w-[60ch] leading-6">
<p>
hia, here is the guestbook if you wanna post anything :)
</p>
<p>
just fill the post in and click on your preferred auth method to post
</p>
<p>rules: be a good human bean pretty please (and don't be shy!!!)</p>
<Window title="guestbook" style="ml-auto" iconUri="/icons/guestbook.png">
<div class="flex flex-col gap-1 max-w-[50ch] leading-6">
<div class="prose prose-ralsei leading-6 entry p-2">
<p>hia, here is the guestbook if you wanna post anything :)</p>
<p>be a good human bean pretty please (and don't be shy!!!)</p>
<p class="text-sm italic">(to see all the entries, look <a href="https://bsky.app/profile/guestbook.gaze.systems">here</a>)</p>
</div>
<form method="post">
<div class="entry entryflex">
<div class="flex flex-row">
<p class="place-self-start grow text-2xl font-monospace">###</p>
<p class="justify-end self-center text-sm font-monospace">...</p>
</div>
<textarea
class="text-lg ml-0.5 bg-inherit resize-none text-shadow-white placeholder-shown:[text-shadow:none] [field-sizing:content]"
name="content"
placeholder="say meow!"
maxlength="512"
maxlength="300"
required
/>
<div class="flex flex-row gap-2 items-center justify-center">
<input type="checkbox" id="anon" name="anon" checked/>
<label for="anon" class="text-sm font-monospace grow text-shadow-white">post anonymously</label>
<p class="text-sm font-monospace">--- posted by you</p>
</div>
</div>
<div class="entry flex flex-wrap gap-1.5 p-1 items-baseline">
<p class="text-xl ms-2">auth via:</p>
{#each ['discord', 'github'] as platform}
<Tooltip x="" y="translate-y-[70%]" targetY="" targetX="">
<svelte:fragment slot="tooltipContent">post with {platform}</svelte:fragment>
<input
type="submit"
value={platform}
formaction="?/post_{platform}"
class="text-lg text-ralsei-green-light leading-none hover:underline motion-safe:hover:animate-squiggle w-fit py-1 px-0.5"
/>
</Tooltip>
{/each}
<div class="flex flex-row gap-1 mt-1">
<input
type="submit"
value="click to post"
formaction="?/post"
class="entry text-ralsei-green-light leading-none hover:underline motion-safe:hover:animate-squiggle p-1 z-50"
/>
<div class="marquee-wrapper entry text-ralsei-white/50">
<div class="marquee font-monospace">
<p class="text-shadow-none">{data.fillText}</p><p class="text-shadow-none">{data.fillText}</p>
</div>
</div>
</div>
{#if data.sendRatelimited}
<p class="text-error">you are ratelimited, try again in 30 seconds</p>
@ -70,7 +57,7 @@ import Window from '../../components/window.svelte';
</form>
</div>
</Window>
<Window id='guestbookentries' style="mx-auto" title="entries" iconUri="/icons/entries.png">
<Window id='guestbookentries' style="mr-auto" title="entries" iconUri="/icons/entries.png" removePadding>
<div class="flex flex-col gap-2 md:gap-4 2xl:w-[60ch]">
{#if data.getRatelimited}
<p class="text-error">
@ -82,43 +69,28 @@ import Window from '../../components/window.svelte';
<p>{data.getError}</p>
</details>
{:else}
{#each data.entries as [entry_id, entry] (entry_id)}
{@const date = new Date(entry.timestamp * 1e3).toLocaleString()}
<div class="entry entryflex">
<div class="flex flex-row">
<p class="place-self-start grow text-2xl font-monospace">
#{entry_id}
</p>
<p class="justify-end self-center text-sm font-monospace">{date}</p>
</div>
<p class="text-lg text-wrap overflow-hidden text-ellipsis ml-0.5 max-w-[56ch]">
{entry.content}
</p>
<p
class="max-w-[45ch] place-self-end text-sm font-monospace overflow-hidden text-ellipsis text-nowrap"
title={entry.author}
>
--- posted by {entry.author}
</p>
</div>
{:else}
<p>looks like there are no entries :(</p>
<div
class="
prose prose-ralsei
prose-pre:rounded-none prose-pre:!m-0 prose-pre:!p-2
prose-pre:!bg-ralsei-black prose-code:!bg-ralsei-black
"
>
<pre class="language-bash"><code class="language-bash"><nobr>
<Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="source" funct/> <Token v="scripts/log.nu" />
<br>
<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/>
<br>
<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/>
<br>
<br>
{#each data.entries as note, index}
<Note showOutgoing={false} {note}/>
{#if index < data.entries.length - 1}
<div class="mt-3"/>
{/if}
{/each}
{/if}
{#if hasPreviousPage || hasNextPage}
<div class="flex flex-row w-full justify-center items-center font-monospace">
{#if hasPreviousPage}
<a href="/guestbook/?page={data.entries.length > 0 ? data.page - 1 : 1}"
on:click={resetEntriesAnimation}
>&lt;&lt; previous</a
>
{/if}
{#if hasNextPage && hasPreviousPage}
<div class="w-1/12" />
{/if}
{#if hasNextPage}
<a href="/guestbook/?page={data.page + 1}" on:click={resetEntriesAnimation}>next &gt;&gt;</a>
{/if}
</nobr></code></pre>
</div>
{/if}
</div>
@ -130,6 +102,32 @@ import Window from '../../components/window.svelte';
@apply bg-ralsei-green-dark/70 border-ralsei-green-light/30 border-x-[3px] border-y-4;
}
.entryflex {
@apply flex flex-col gap-3 py-2 px-3;
@apply flex flex-col p-1;
}
.marquee-wrapper {
max-width: 100%;
overflow: hidden;
}
.marquee {
white-space: nowrap;
overflow: hidden;
display: inline-block;
animation: marquee 10s linear infinite;
}
.marquee p {
transform: translateY(15%);
display: inline-block;
}
@keyframes marquee {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(-50%, 0, 0);
}
}
</style>

@ -15,9 +15,11 @@
"
>
<pre class="language-bash"><code class="language-bash"><nobr>
<Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="source" funct/> <Token v="scripts/log.nu" />
<br>
<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/>
<Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="source" funct/> <Token v="scripts/log.nu" />
<br>
<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/>
<br>
<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/>
<br>
<br>
{#each data.feedPosts as note, index}