feat: make guestbook use the bsky yay
This commit is contained in:
parent
3d5d808c9a
commit
6c105a6052
src
@ -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="{" 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="}" 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}
|
||||
><< 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 >></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="{" 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="}" 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="{" 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="}" punct/>
|
||||
<br>
|
||||
<br>
|
||||
{#each data.feedPosts as note, index}
|
||||
|
Loading…
x
Reference in New Issue
Block a user