Compare commits
No commits in common. "caa51bea9b0e94461ffd86f56ddcc089853ecd3b" and "8c4694714409d48ff0c10ff0c28dee17964c8249" have entirely different histories.
caa51bea9b
...
8c46947144
100 changed files with 4632 additions and 6094 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -23,5 +23,3 @@ pnpm-debug.log*
|
|||
# jetbrains setting folder
|
||||
.idea/
|
||||
.vscode
|
||||
|
||||
*.tsbuildinfo
|
|
@ -1,61 +1,13 @@
|
|||
import node from '@astrojs/node'
|
||||
import solid from '@astrojs/solid-js'
|
||||
import { Graphviz } from '@hpcc-js/wasm-graphviz'
|
||||
import { defineConfig } from 'astro/config'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import getReadingTime from 'reading-time'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import UnoCSS from 'unocss/astro'
|
||||
|
||||
function remarkReadingTime() {
|
||||
return function (tree, { data }) {
|
||||
const textOnPage = toString(tree)
|
||||
const readingTime = getReadingTime(textOnPage)
|
||||
data.astro.frontmatter.minutesRead = readingTime.text
|
||||
}
|
||||
}
|
||||
|
||||
function remarkGraphvizSvg() {
|
||||
return async function (tree) {
|
||||
const graphviz = await Graphviz.load()
|
||||
|
||||
const instances = []
|
||||
visit(tree, { type: 'code', lang: 'dot' }, (node, index, parent) => {
|
||||
instances.push([node.value, index, parent])
|
||||
})
|
||||
|
||||
for (const [dot, index, parent] of instances) {
|
||||
const svg = graphviz.dot(dot, 'svg')
|
||||
parent.children.splice(index, 1, {
|
||||
type: 'html',
|
||||
value: `<div class="graphviz-svg">${svg}</div>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
import solid from '@astrojs/solid-js'
|
||||
import node from '@astrojs/node'
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
integrations: [
|
||||
solid(),
|
||||
UnoCSS({
|
||||
injectReset: true,
|
||||
}),
|
||||
],
|
||||
markdown: {
|
||||
remarkPlugins: [
|
||||
remarkReadingTime,
|
||||
remarkGraphvizSvg,
|
||||
],
|
||||
smartypants: false,
|
||||
shikiConfig: {
|
||||
themes: {
|
||||
dark: 'catppuccin-mocha',
|
||||
light: 'catppuccin-latte',
|
||||
},
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
esbuild: { jsx: 'automatic' },
|
||||
define: {
|
||||
|
|
|
@ -1,23 +1,29 @@
|
|||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu({
|
||||
ignores: [
|
||||
'public',
|
||||
'drizzle',
|
||||
],
|
||||
stylistic: {
|
||||
indent: 4,
|
||||
},
|
||||
typescript: true,
|
||||
astro: true,
|
||||
solid: true,
|
||||
yaml: false,
|
||||
unocss: true,
|
||||
rules: {
|
||||
'antfu/no-top-level-await': 'off',
|
||||
'curly': ['error', 'multi-line'],
|
||||
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||
'n/prefer-global/buffer': 'off',
|
||||
'style/quotes': ['error', 'single', { avoidEscape: true }],
|
||||
'test/consistent-test-it': 'off',
|
||||
'test/prefer-lowercase-title': 'off',
|
||||
'import/order': ['error', {
|
||||
'newlines-between': 'always',
|
||||
'pathGroups': [
|
||||
{
|
||||
pattern: '~/**',
|
||||
group: 'parent',
|
||||
},
|
||||
],
|
||||
}],
|
||||
'antfu/if-newline': 'off',
|
||||
'style/max-statements-per-line': ['error', { max: 2 }],
|
||||
'ts/no-redeclare': 'off',
|
||||
|
|
33
package.json
33
package.json
|
@ -12,47 +12,38 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/node": "^9.0.2",
|
||||
"@astrojs/solid-js": "^5.0.4",
|
||||
"@astrojs/check": "^0.9.1",
|
||||
"@astrojs/node": "^8.3.2",
|
||||
"@astrojs/solid-js": "^4.4.0",
|
||||
"@fuman/fetch": "0.0.10",
|
||||
"@fuman/utils": "0.0.10",
|
||||
"@hpcc-js/wasm-graphviz": "^1.7.0",
|
||||
"@iconify-json/gravity-ui": "^1.2.4",
|
||||
"@mtcute/dispatcher": "^0.17.0",
|
||||
"@mtcute/node": "^0.17.0",
|
||||
"@tanstack/solid-query": "^5.51.21",
|
||||
"@unocss/postcss": "^65.4.3",
|
||||
"@unocss/reset": "^65.4.3",
|
||||
"astro": "^5.1.9",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro": "^4.12.3",
|
||||
"astro-loading-indicator": "^0.5.0",
|
||||
"better-sqlite3": "^11.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-kit": "^0.23.1",
|
||||
"drizzle-orm": "^0.32.1",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"parse-duration": "^1.1.0",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"reading-time": "^1.5.0",
|
||||
"remark-graphviz-svg": "^0.2.0",
|
||||
"solid-js": "^1.8.19",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"unocss": "^65.4.3",
|
||||
"typescript": "^5.5.4",
|
||||
"zod": "^3.23.8",
|
||||
"zod-validation-error": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "3.16.0",
|
||||
"@antfu/eslint-config": "^2.24.0",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^22.0.2",
|
||||
"@unocss/eslint-plugin": "^65.4.3",
|
||||
"eslint": "9.19.0",
|
||||
"eslint-plugin-astro": "^1.2.3",
|
||||
"eslint-plugin-solid": "0.14.5",
|
||||
"postcss-nesting": "13.0.1"
|
||||
"eslint-plugin-solid": "0.14",
|
||||
"postcss-custom-media": "^10.0.8",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-mixins": "^10.0.1",
|
||||
"postcss-nesting": "^12.1.5"
|
||||
}
|
||||
}
|
||||
|
|
4789
pnpm-lock.yaml
4789
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
8
postcss.config.js
Normal file
8
postcss.config.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'postcss-mixins': {},
|
||||
'postcss-custom-media': {},
|
||||
'postcss-nesting': {},
|
||||
},
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
import UnoCSS from '@unocss/postcss'
|
||||
import nesting from 'postcss-nesting'
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
UnoCSS(),
|
||||
nesting(),
|
||||
],
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { assertMatches } from '@fuman/utils'
|
||||
import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher'
|
||||
import { html } from '@mtcute/node'
|
||||
import parseDuration from 'parse-duration'
|
||||
import { assertMatches } from '@fuman/utils'
|
||||
|
||||
import { env } from '../env'
|
||||
import {
|
||||
|
|
|
@ -7,8 +7,7 @@ const UserSchema = z.object({
|
|||
host: z
|
||||
.string()
|
||||
.describe('The local host is represented with `null`.')
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
avatarUrl: z.string().optional().nullable(),
|
||||
avatarBlurhash: z.string().optional().nullable(),
|
||||
avatarDecorations: z
|
||||
|
@ -22,8 +21,7 @@ const UserSchema = z.object({
|
|||
offsetY: z.number().optional().nullable(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
isAdmin: z.boolean().optional().nullable(),
|
||||
isModerator: z.boolean().optional().nullable(),
|
||||
isSilenced: z.boolean().optional().nullable(),
|
||||
|
@ -40,8 +38,7 @@ const UserSchema = z.object({
|
|||
faviconUrl: z.string().optional().nullable(),
|
||||
themeColor: z.string().optional().nullable(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
emojis: z.record(z.string()).optional().nullable(),
|
||||
onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(),
|
||||
badgeRoles: z
|
||||
|
@ -52,8 +49,7 @@ const UserSchema = z.object({
|
|||
displayOrder: z.number().optional().nullable(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
})
|
||||
export type MkUser = z.infer<typeof UserSchema>
|
||||
|
||||
|
@ -67,20 +63,17 @@ const NoteSchema = z.object({
|
|||
user: z
|
||||
.object({})
|
||||
.catchall(z.any())
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
replyId: z.string().optional().nullable(),
|
||||
renoteId: z.string().optional().nullable(),
|
||||
reply: z
|
||||
.object({})
|
||||
.catchall(z.any())
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
renote: z
|
||||
.object({})
|
||||
.catchall(z.any())
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
isHidden: z.boolean().optional().nullable(),
|
||||
visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(),
|
||||
mentions: z.array(z.string()).optional().nullable(),
|
||||
|
@ -100,11 +93,9 @@ const NoteSchema = z.object({
|
|||
votes: z.number().optional().nullable(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
emojis: z.record(z.string(), z.any()).optional().nullable(),
|
||||
channelId: z.string().optional().nullable(),
|
||||
channel: z
|
||||
|
@ -116,8 +107,7 @@ const NoteSchema = z.object({
|
|||
allowRenoteToExternal: z.boolean().optional().nullable(),
|
||||
userId: z.string().optional().nullable(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
.optional().nullable(),
|
||||
localOnly: z.boolean().optional().nullable(),
|
||||
reactionAcceptance: z.string().optional().nullable(),
|
||||
reactionEmojis: z.record(z.string(), z.string()).optional().nullable(),
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'dotenv/config'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { zodValidateSync } from '~/utils/zod'
|
||||
|
||||
import 'dotenv/config'
|
||||
|
||||
export const env = zodValidateSync(
|
||||
z.object({
|
||||
UMAMI_HOST: z.string().url(),
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { AsyncResource } from '@fuman/utils'
|
||||
import { z } from 'zod'
|
||||
import { AsyncResource } from '@fuman/utils'
|
||||
|
||||
import { env } from '../env'
|
||||
import { ffetch } from '../utils/fetch.ts'
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { LastSeenItem } from './index.ts'
|
||||
import { AsyncResource } from '@fuman/utils'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ffetch } from '../../utils/fetch.ts'
|
||||
|
||||
import type { LastSeenItem } from './index.ts'
|
||||
|
||||
const ENDPOINT = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed'
|
||||
const TTL = 3 * 60 * 60 * 1000 // 3 hours
|
||||
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { LastSeenItem } from './index.ts'
|
||||
import { AsyncResource } from '@fuman/utils'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ffetch } from '../../utils/fetch.ts'
|
||||
|
||||
import type { LastSeenItem } from './index.ts'
|
||||
|
||||
const ENDPOINT = 'https://git.stupid.fish/api/v1/users/teidesu/activities/feeds?only-performed-by=true'
|
||||
const TTL = 1 * 60 * 60 * 1000 // 1 hour
|
||||
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { LastSeenItem } from './index.ts'
|
||||
import { AsyncResource } from '@fuman/utils'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ffetch } from '../../utils/fetch.ts'
|
||||
|
||||
import type { LastSeenItem } from './index.ts'
|
||||
|
||||
const ENDPOINT = 'https://api.github.com/users/teidesu/events/public?per_page=1'
|
||||
const TTL = 1 * 60 * 60 * 1000 // 1 hour
|
||||
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { assertMatches } from '@fuman/utils'
|
||||
|
||||
import { bskyLastSeen } from './bsky.ts'
|
||||
import { forgejoLastSeen } from './forgejo.ts'
|
||||
import { githubLastSeen } from './github'
|
||||
import { lastfm } from './listenbrainz.ts'
|
||||
import { shikimoriLastSeen } from './shikimori'
|
||||
import { forgejoLastSeen } from './forgejo.ts'
|
||||
|
||||
export interface LastSeenItem {
|
||||
source: string
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { LastSeenItem } from './index.ts'
|
||||
import { z } from 'zod'
|
||||
import { AsyncResource } from '@fuman/utils'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ffetch } from '../../utils/fetch.ts'
|
||||
|
||||
import type { LastSeenItem } from './index.ts'
|
||||
|
||||
const LB_TTL = 1000 * 60 * 5 // 5 minutes
|
||||
const LB_STALE_TTL = 1000 * 60 * 60 // 1 hour
|
||||
const LB_USERNAME = 'teidumb'
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { LastSeenItem } from './index.ts'
|
||||
import { AsyncResource } from '@fuman/utils'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ffetch } from '../../utils/fetch.ts'
|
||||
|
||||
import type { LastSeenItem } from './index.ts'
|
||||
|
||||
const ENDPOINT = 'https://shikimori.one/api/users/698215/history?limit=1'
|
||||
const TTL = 3 * 60 * 60 * 1000 // 3 hours
|
||||
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { BotKeyboard, html } from '@mtcute/node'
|
||||
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
|
||||
|
||||
import { tg } from '../bot'
|
||||
import { ShoutboxAction } from '../bot/shoutbox.js'
|
||||
import { db } from '../db'
|
||||
import { env } from '../env'
|
||||
import { shouts, shoutsBans } from '../models/index.js'
|
||||
import { URL_REGEX } from '../utils/url.js'
|
||||
import { db } from '../db'
|
||||
import { env } from '../env'
|
||||
import { tg } from '../bot'
|
||||
|
||||
const SHOUTS_PER_PAGE = 5
|
||||
|
||||
|
@ -17,7 +17,9 @@ const filter = or(
|
|||
|
||||
const fetchTotal = db.select({
|
||||
count: sql<number>`count(1)`,
|
||||
}).from(shouts).where(filter).prepare()
|
||||
}).from(shouts)
|
||||
.where(filter)
|
||||
.prepare()
|
||||
|
||||
const fetchList = db.select({
|
||||
createdAt: shouts.createdAt,
|
||||
|
@ -25,7 +27,12 @@ const fetchList = db.select({
|
|||
pending: shouts.pending,
|
||||
serial: shouts.serial,
|
||||
reply: shouts.reply,
|
||||
}).from(shouts).where(filter).limit(SHOUTS_PER_PAGE).orderBy(desc(shouts.createdAt)).offset(sql.placeholder('offset')).prepare()
|
||||
}).from(shouts)
|
||||
.where(filter)
|
||||
.limit(SHOUTS_PER_PAGE)
|
||||
.orderBy(desc(shouts.createdAt))
|
||||
.offset(sql.placeholder('offset'))
|
||||
.prepare()
|
||||
|
||||
export function fetchShouts(page: number, ip: string) {
|
||||
return {
|
||||
|
@ -42,7 +49,8 @@ export type ShoutsData = ReturnType<typeof fetchShouts>
|
|||
|
||||
const fetchNextSerial = db.select({
|
||||
serial: sql<number>`coalesce(max(serial), 0) + 1`,
|
||||
}).from(shouts).prepare()
|
||||
}).from(shouts)
|
||||
.prepare()
|
||||
|
||||
export function approveShout(id: string) {
|
||||
const nextSerial = fetchNextSerial.get({})!.serial
|
||||
|
|
|
@ -2,8 +2,8 @@ import { ffetchAddons, ffetchBase } from '@fuman/fetch'
|
|||
import { ffetchZodAdapter } from '@fuman/fetch/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '~/backend/env'
|
||||
import { isBotUserAgent } from '../utils/bot'
|
||||
import { env } from '~/backend/env'
|
||||
|
||||
const ffetch = ffetchBase.extend({
|
||||
addons: [
|
||||
|
|
|
@ -1,37 +1,35 @@
|
|||
import { createHmac, randomBytes } from 'node:crypto'
|
||||
|
||||
import { base64, typed, u8, utf8 } from '@fuman/utils'
|
||||
|
||||
import { env } from '~/backend/env'
|
||||
|
||||
const secret = env.CSRF_SECRET
|
||||
const validity = 300_000
|
||||
|
||||
export function getCsrfToken(ip: string) {
|
||||
const data = utf8.encoder.encode(JSON.stringify([Date.now(), ip]))
|
||||
const salt = randomBytes(8) as Uint8Array
|
||||
const data = Buffer.from(JSON.stringify([Date.now(), ip]))
|
||||
const salt = randomBytes(8)
|
||||
const sign = createHmac('sha256', secret).update(data).update(salt).digest()
|
||||
|
||||
return base64.encode(u8.concat3(
|
||||
return Buffer.concat([
|
||||
data,
|
||||
salt,
|
||||
sign.subarray(0, 8),
|
||||
), true)
|
||||
]).toString('base64url')
|
||||
}
|
||||
|
||||
export function verifyCsrfToken(ip: string, token: string) {
|
||||
try {
|
||||
const buf = base64.decode(token, true)
|
||||
const buf = Buffer.from(token, 'base64url')
|
||||
if (buf.length < 16) return false
|
||||
|
||||
const saltedData = buf.subarray(0, -8)
|
||||
const correctSign = createHmac('sha256', secret).update(saltedData).digest()
|
||||
|
||||
if (!typed.equal(new Uint8Array(correctSign.subarray(0, 8)), buf.subarray(-8))) {
|
||||
if (Buffer.compare(correctSign.subarray(0, 8), buf.subarray(-8)) !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [issued, correctIp] = JSON.parse(utf8.decoder.decode(buf.subarray(0, -16)))
|
||||
const [issued, correctIp] = JSON.parse(buf.subarray(0, -16).toString())
|
||||
if (issued + validity < Date.now()) return false
|
||||
if (ip !== correctIp) return false
|
||||
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import { createSignal } from 'solid-js'
|
||||
|
||||
import { shuffle } from '~/utils/random'
|
||||
|
||||
export interface RandomWordProps {
|
||||
choices: JSX.Element[]
|
||||
}
|
||||
|
||||
export function RandomWord(props: RandomWordProps) {
|
||||
const [choice, setChoice] = createSignal<JSX.Element>()
|
||||
let order: JSX.Element[] = []
|
||||
|
||||
function pickNew() {
|
||||
if (order.length === 0) {
|
||||
order = shuffle(props.choices)
|
||||
}
|
||||
|
||||
setChoice(order.pop())
|
||||
}
|
||||
|
||||
function onClick(evt: MouseEvent) {
|
||||
evt.preventDefault()
|
||||
pickNew()
|
||||
}
|
||||
|
||||
pickNew()
|
||||
|
||||
return (
|
||||
<div
|
||||
class="pos-relative inline-block cursor-pointer underline underline-dotted transition-200 active:select-none"
|
||||
onClick={onClick}
|
||||
>
|
||||
{choice()}
|
||||
</div>
|
||||
)
|
||||
}
|
12
src/components/interactive/RandomWord/RandomWord.module.css
Normal file
12
src/components/interactive/RandomWord/RandomWord.module.css
Normal file
|
@ -0,0 +1,12 @@
|
|||
.choice {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
text-decoration: underline dotted;
|
||||
transition: opacity 200ms, transform 200ms;
|
||||
|
||||
/* prevent text selection on 2/3-ple click */
|
||||
&:active {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
41
src/components/interactive/RandomWord/RandomWord.tsx
Normal file
41
src/components/interactive/RandomWord/RandomWord.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import { createSignal } from 'solid-js'
|
||||
|
||||
import { shuffle } from '~/utils/random'
|
||||
|
||||
import css from './RandomWord.module.css'
|
||||
|
||||
export interface RandomWordProps {
|
||||
choices: JSX.Element[]
|
||||
}
|
||||
|
||||
export function RandomWord(props: RandomWordProps) {
|
||||
const [choice, setChoice] = createSignal<JSX.Element>()
|
||||
let order: JSX.Element[] = []
|
||||
|
||||
function pickNew() {
|
||||
if (order.length === 0) {
|
||||
order = shuffle(props.choices)
|
||||
}
|
||||
|
||||
setChoice(order.pop())
|
||||
}
|
||||
|
||||
function onClick(evt: MouseEvent) {
|
||||
evt.preventDefault()
|
||||
pickNew()
|
||||
}
|
||||
|
||||
pickNew()
|
||||
|
||||
return (
|
||||
<div
|
||||
class={css.choice}
|
||||
onClick={onClick}
|
||||
>
|
||||
{choice()}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
import moneyImg from '~/assets/money.jpg'
|
||||
import { umamiLogThisVisit } from '~/backend/service/umami'
|
||||
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
||||
import { umamiLogThisVisit } from '~/backend/service/umami'
|
||||
import moneyImg from '~/assets/money.jpg'
|
||||
|
||||
import { fetchDonatePageData } from './data'
|
||||
import { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
|
||||
import { fetchDonatePageData } from './data'
|
||||
|
||||
umamiLogThisVisit(Astro.request, '/donate')
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { PaymentMethod } from './constants'
|
||||
import { type JSX, createSignal, onMount } from 'solid-js'
|
||||
|
||||
import { Link } from '~/components/ui/Link/Link'
|
||||
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
|
||||
import { TextTable } from '~/components/ui/TextTable/TextTable'
|
||||
|
||||
import type { PageData } from './data'
|
||||
import { createSignal, type JSX, onMount } from 'solid-js'
|
||||
|
||||
import { Link } from '../../ui/Link.tsx'
|
||||
import { SectionTitle } from '../../ui/Section.tsx'
|
||||
import { TextTable } from '../../ui/TextTable.tsx'
|
||||
import type { PaymentMethod } from './constants'
|
||||
import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
|
||||
|
||||
export function PaymentMethods(props: { data: PageData }) {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import type { PaymentMethod } from './constants'
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
import { umamiFetchStats } from '~/backend/service/umami'
|
||||
|
||||
import type { PaymentMethod } from './constants'
|
||||
import { PAYMENT_METHODS } from './constants'
|
||||
import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
|
||||
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
import { umamiLogThisVisit } from '~/backend/service/umami'
|
||||
import { RandomWord } from '~/components/interactive/RandomWord'
|
||||
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
||||
import { RandomWord } from '~/components/interactive/RandomWord/RandomWord'
|
||||
import { umamiLogThisVisit } from '~/backend/service/umami'
|
||||
|
||||
import { PageMain as PageMainSolid } from './PageMain'
|
||||
import { PARTTIME_VARIANTS } from './constants'
|
||||
import { fetchMainPageData } from './data'
|
||||
import { PageMain as PageMainSolid } from './PageMain'
|
||||
import Shoutbox from './Shoutbox/Shoutbox.astro'
|
||||
|
||||
umamiLogThisVisit(Astro.request)
|
||||
|
|
146
src/components/pages/PageMain/PageMain.module.css
Normal file
146
src/components/pages/PageMain/PageMain.module.css
Normal file
|
@ -0,0 +1,146 @@
|
|||
@import url('../../shared.css');
|
||||
|
||||
.comment {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-left: 48px;
|
||||
}
|
||||
|
||||
.commentInline {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.testimonial {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.favColor {
|
||||
background: #be15dc;
|
||||
border: 1px solid #ccc;
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
margin-bottom: 2px;
|
||||
vertical-align: middle;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.webring {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
@mixin font-xs;
|
||||
}
|
||||
|
||||
.lastSeen summary {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
/* prevent text selection on 2/3-ple click */
|
||||
&:active {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeenItem {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.lastSeenItem + .lastSeenItem {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.lastSeen[open] {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lastSeenTrigger::before {
|
||||
content: '(click to expand)';
|
||||
@mixin font-xs;
|
||||
margin-left: 1em;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
@media (--tablet) {
|
||||
content: '(expand)';
|
||||
}
|
||||
|
||||
@media (--mobile) {
|
||||
content: '<';
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeen[open] .lastSeenTrigger::before {
|
||||
content: '(click to collapse)';
|
||||
|
||||
@media (--tablet) {
|
||||
content: '(collapse)';
|
||||
}
|
||||
|
||||
@media (--mobile) {
|
||||
content: 'v';
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeenLinkWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
|
||||
@media (--tablet) {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeenLinkWrapInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.lastSeenLink {
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@media (--tablet) {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.lastSeenSuffix {
|
||||
@mixin font-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lastSeenSource {
|
||||
@mixin font-xs;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (--tablet) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
|
@ -1,24 +1,24 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { PageData } from './data'
|
||||
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
|
||||
import { intlFormatDistance } from 'date-fns'
|
||||
|
||||
import { For, type JSX, Show } from 'solid-js'
|
||||
import { Dynamic } from 'solid-js/web'
|
||||
import axolotl from '~/assets/axolotl.png'
|
||||
import cherry from '~/assets/cherry-blossom_1f338.png'
|
||||
import ruFlag from '~/assets/flag-russia_1f1f7-1f1fa.png'
|
||||
import ukFlag from '~/assets/flag-united-kingdom_1f1ec-1f1e7.png'
|
||||
import jsLogo from '~/assets/javascript.png'
|
||||
import { intlFormatDistance } from 'date-fns'
|
||||
|
||||
import { Emoji } from '~/components/ui/Emoji/Emoji'
|
||||
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
|
||||
import { Link } from '~/components/ui/Link/Link'
|
||||
import { TextComment } from '~/components/ui/TextComment/TextComment'
|
||||
import { TextTable } from '~/components/ui/TextTable/TextTable'
|
||||
import jsLogo from '~/assets/javascript.png'
|
||||
import ukFlag from '~/assets/flag-united-kingdom_1f1ec-1f1e7.png'
|
||||
import ruFlag from '~/assets/flag-russia_1f1f7-1f1fa.png'
|
||||
import cherry from '~/assets/cherry-blossom_1f338.png'
|
||||
import axolotl from '~/assets/axolotl.png'
|
||||
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
|
||||
import { randomInt } from '~/utils/random'
|
||||
import { cn } from '../../../utils/cn.ts'
|
||||
import { Emoji } from '../../ui/Emoji.tsx'
|
||||
import { Link } from '../../ui/Link.tsx'
|
||||
import { SectionTitle } from '../../ui/Section.tsx'
|
||||
import { TextComment } from '../../ui/TextComment.tsx'
|
||||
import { TextTable } from '../../ui/TextTable.tsx'
|
||||
|
||||
import css from './PageMain.module.css'
|
||||
import { SUBLINKS, TESTIMONIALS } from './constants'
|
||||
import type { PageData } from './data'
|
||||
|
||||
function formatTimeRelative(time: number) {
|
||||
return intlFormatDistance(
|
||||
|
@ -29,17 +29,11 @@ function formatTimeRelative(time: number) {
|
|||
|
||||
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
||||
return (
|
||||
<Dynamic
|
||||
component={props.first ? 'summary' : 'div'}
|
||||
class={cn(
|
||||
'flex flex-row items-center justify-between',
|
||||
props.first && 'pos-relative list-none cursor-pointer rounded-md hover:bg-control-bg-hover active:select-none [&::-webkit-details-marker]:hidden',
|
||||
)}
|
||||
>
|
||||
<div class="max-w-full flex flex-col overflow-hidden sm:flex-row sm:items-center">
|
||||
<div class="max-w-full w-min flex flex-row items-center overflow-hidden">
|
||||
<Dynamic component={props.first ? 'summary' : 'div'} class={css.lastSeenItem}>
|
||||
<div class={css.lastSeenLinkWrap}>
|
||||
<div class={css.lastSeenLinkWrapInner}>
|
||||
<Link
|
||||
class="max-w-200px overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-300px"
|
||||
class={css.lastSeenLink}
|
||||
href={props.item.link}
|
||||
target="_blank"
|
||||
title={props.item.text}
|
||||
|
@ -47,12 +41,12 @@ function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
|||
{props.item.text}
|
||||
</Link>
|
||||
{props.item.suffix && (
|
||||
<span class="whitespace-nowrap text-xs">
|
||||
<span class={css.lastSeenSuffix}>
|
||||
{props.item.suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<i class="ml-2 whitespace-nowrap text-xs text-text-secondary">
|
||||
<i class={css.lastSeenSource}>
|
||||
{'@ '}
|
||||
<Link href={props.item.sourceLink} target="_blank">
|
||||
{props.item.source}
|
||||
|
@ -62,7 +56,7 @@ function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
|||
</i>
|
||||
</div>
|
||||
<Show when={props.first}>
|
||||
<div data-expand-label class="before:(ml-1em whitespace-nowrap text-xs text-text-secondary content-['<'] md:content-['(click_to_expand)'] sm:content-['(expand)'])" />
|
||||
<div class={css.lastSeenTrigger} />
|
||||
</Show>
|
||||
</Dynamic>
|
||||
)
|
||||
|
@ -83,7 +77,7 @@ export function PageMain(props: {
|
|||
: <i>{props.author}</i>
|
||||
|
||||
return (
|
||||
<div class="mb-2">
|
||||
<div class={css.testimonial}>
|
||||
"
|
||||
{props.text}
|
||||
" -
|
||||
|
@ -108,7 +102,7 @@ export function PageMain(props: {
|
|||
{' '}
|
||||
<span innerHTML={item.subtitle} />
|
||||
<TextComment
|
||||
class="mb-2 ml-12 text-text-secondary"
|
||||
class={css.comment}
|
||||
innerHTML={item.comment}
|
||||
/>
|
||||
</div>
|
||||
|
@ -173,7 +167,7 @@ export function PageMain(props: {
|
|||
if (!props.data.lastSeen?.length) return
|
||||
|
||||
return (
|
||||
<details class="open:mb-1 [&[open]_[data-expand-label]]:before:(content-['v'] md:content-['(click_to_collapse)'] sm:content-['(collapse)'])">
|
||||
<details class={css.lastSeen}>
|
||||
<LastSeenItem first item={props.data.lastSeen[0]} />
|
||||
<For each={props.data.lastSeen.slice(1)}>
|
||||
{it => <LastSeenItem item={it} />}
|
||||
|
@ -188,7 +182,7 @@ export function PageMain(props: {
|
|||
<>
|
||||
#be15dc
|
||||
{' '}
|
||||
<div class="mb-0.5 inline-block h-10px w-10px border border-#ccc bg-[#be15dc] align-middle" />
|
||||
<div class={css.favColor} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
@ -296,7 +290,7 @@ export function PageMain(props: {
|
|||
|
||||
{testimonials}
|
||||
|
||||
<TextComment class="mb-2 ml-2em text-text-secondary">
|
||||
<TextComment class={css.commentInline}>
|
||||
feel free to leave yours :3
|
||||
</TextComment>
|
||||
</section>
|
||||
|
@ -318,7 +312,7 @@ export function PageMain(props: {
|
|||
</section>
|
||||
|
||||
<Show when={props.data.webring}>
|
||||
<section class="mt-4 flex items-center justify-between text-xs">
|
||||
<section class={css.webring}>
|
||||
<Link href={props.data.webring!.prev.url}>
|
||||
<
|
||||
{' '}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import { fetchShouts } from '~/backend/service/shoutbox'
|
||||
import { getCsrfToken } from '~/backend/utils/csrf'
|
||||
import { getRequestIp } from '~/backend/utils/request'
|
||||
import { getCsrfToken } from '~/backend/utils/csrf'
|
||||
|
||||
import { Shoutbox as ShoutboxSolid } from './Shoutbox'
|
||||
|
||||
|
|
75
src/components/pages/PageMain/Shoutbox/Shoutbox.module.css
Normal file
75
src/components/pages/PageMain/Shoutbox/Shoutbox.module.css
Normal file
|
@ -0,0 +1,75 @@
|
|||
@import '../../../../components/shared.css';
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.formInput {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shouts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.shout {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--control-outline);
|
||||
background: var(--control-bg);
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
|
||||
@media (--mobile) {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.formControls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.paginationLink {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reply {
|
||||
margin-top: 6px;
|
||||
}
|
|
@ -1,16 +1,21 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
/* eslint-disable no-alert */
|
||||
import type { ShoutsData } from '~/backend/service/shoutbox'
|
||||
import { createQuery, keepPreviousData, QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
||||
/** @jsxImportSource solid-js */
|
||||
import { type ComponentProps, Show, createSignal, onMount } from 'solid-js'
|
||||
import { QueryClient, QueryClientProvider, createQuery, keepPreviousData } from '@tanstack/solid-query'
|
||||
import { format } from 'date-fns/format'
|
||||
|
||||
import { type ComponentProps, createSignal, onMount, Show } from 'solid-js'
|
||||
import { Button } from '../../../ui/Button.tsx'
|
||||
import { Button } from '~/components/ui/Button/Button'
|
||||
import { Checkbox } from '~/components/ui/Checkbox/Checkbox'
|
||||
import { GravityClock } from '~/components/ui/Icons/glyphs/GravityClock'
|
||||
import { GravityMegaphone } from '~/components/ui/Icons/glyphs/GravityMegaphone'
|
||||
import { Icon } from '~/components/ui/Icons/Icon'
|
||||
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
|
||||
import { TextArea } from '~/components/ui/TextArea/TextArea'
|
||||
import { TextComment } from '~/components/ui/TextComment/TextComment'
|
||||
import type { ShoutsData } from '~/backend/service/shoutbox'
|
||||
import pageCss from '../PageMain.module.css'
|
||||
|
||||
import { Checkbox } from '../../../ui/Checkbox/Checkbox.tsx'
|
||||
import { SectionTitle } from '../../../ui/Section.tsx'
|
||||
import { TextArea } from '../../../ui/TextArea.tsx'
|
||||
import { TextComment } from '../../../ui/TextComment.tsx'
|
||||
import css from './Shoutbox.module.css'
|
||||
|
||||
async function fetchShouts(page: number): Promise<ShoutsData> {
|
||||
return fetch(`/api/shoutbox?page=${page}`).then(r => r.json())
|
||||
|
@ -56,21 +61,27 @@ function ShoutboxInner(props: {
|
|||
|
||||
const shoutsRender = () => shouts.data?.items.map((props) => {
|
||||
const icon = props.pending
|
||||
? <div class="i-gravity-ui-clock size-4" title="awaiting moderation" />
|
||||
? (
|
||||
<Icon
|
||||
glyph={GravityClock}
|
||||
size={16}
|
||||
title="awaiting moderation"
|
||||
/>
|
||||
)
|
||||
: `#${props.serial}`
|
||||
|
||||
return (
|
||||
<div class="w-fit w-full flex flex-col gap-2 border border-control-outline rounded-md bg-control-bg p-2 md:w-min md:flex-row">
|
||||
<div class="flex flex-row gap-2 text-text-secondary">
|
||||
<div class={css.shout}>
|
||||
<div class={css.header}>
|
||||
{icon}
|
||||
<time class="whitespace-nowrap" datetime={props.createdAt}>
|
||||
<time class={css.time} datetime={props.createdAt}>
|
||||
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
|
||||
</time>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap">
|
||||
<div class={css.text}>
|
||||
{props.text}
|
||||
{props.reply && (
|
||||
<div class="mt-1.5">
|
||||
<div class={css.reply}>
|
||||
<b>reply: </b>
|
||||
{props.reply}
|
||||
</div>
|
||||
|
@ -127,7 +138,7 @@ function ShoutboxInner(props: {
|
|||
|
||||
<section>
|
||||
<SectionTitle>shoutbox!</SectionTitle>
|
||||
<TextComment class="mb-2 ml-12 text-text-secondary">
|
||||
<TextComment class={pageCss.comment}>
|
||||
disclaimer: shouts
|
||||
{' '}
|
||||
<i>are</i>
|
||||
|
@ -135,13 +146,13 @@ function ShoutboxInner(props: {
|
|||
pre-moderated, but they do not reflect my views.
|
||||
</TextComment>
|
||||
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class={css.form}>
|
||||
<input type="hidden" name="_csrf" value={props.csrf} />
|
||||
<div class="w-full flex gap-2">
|
||||
<div class={css.formInput}>
|
||||
<TextArea
|
||||
ref={messageInput}
|
||||
disabled={sending() || !jsEnabled()}
|
||||
class="w-full"
|
||||
class={css.textarea}
|
||||
grow
|
||||
maxRows={5}
|
||||
name="message"
|
||||
|
@ -155,20 +166,20 @@ function ShoutboxInner(props: {
|
|||
disabled={sending() || !jsEnabled()}
|
||||
title="submit"
|
||||
>
|
||||
<div class="i-gravity-ui-megaphone size-5" />
|
||||
<Icon glyph={GravityMegaphone} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class={css.formControls}>
|
||||
<Checkbox
|
||||
ref={privateCheckbox}
|
||||
label="make it private"
|
||||
name="private"
|
||||
/>
|
||||
<Show when={shouts.data && shouts.data.pageCount > 1}>
|
||||
<div class="flex gap-2 text-text-secondary">
|
||||
<div class={css.pagination}>
|
||||
<Show when={page() > 0}>
|
||||
<a
|
||||
class="text-text-secondary underline underline-offset-2"
|
||||
class={css.paginationLink}
|
||||
rel="external"
|
||||
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
|
||||
onClick={onPageClick(false)}
|
||||
|
@ -180,7 +191,7 @@ function ShoutboxInner(props: {
|
|||
<span>{page() + 1}</span>
|
||||
<Show when={page() < shouts.data!.pageCount - 1}>
|
||||
<a
|
||||
class="text-text-secondary underline underline-offset-2"
|
||||
class={css.paginationLink}
|
||||
rel="external"
|
||||
href={`?shouts_page=${page() + 1}`}
|
||||
onClick={onPageClick(true)}
|
||||
|
@ -194,7 +205,7 @@ function ShoutboxInner(props: {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<div class={css.shouts}>
|
||||
{shoutsRender()}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { fetchLastSeen } from '~/backend/service/last-seen'
|
||||
import { umamiFetchStats } from '~/backend/service/umami'
|
||||
import { webring } from '~/backend/service/webring'
|
||||
import { obfuscateEmail } from '~/backend/utils/obfuscate-email'
|
||||
import { webring } from '~/backend/service/webring'
|
||||
import { umamiFetchStats } from '~/backend/service/umami'
|
||||
import { fetchLastSeen } from '~/backend/service/last-seen'
|
||||
|
||||
export async function fetchMainPageData() {
|
||||
const [
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import { splitProps } from 'solid-js'
|
||||
|
||||
import { cn } from '../../utils/cn.ts'
|
||||
|
||||
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
square?: boolean
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const [my, rest] = splitProps(props, ['square', 'class'])
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
class={cn(
|
||||
'block px-2 py-3 border border-control-outline color-text-primary bg-control-bg hover:bg-control-bg-hover rounded-md active:scale-95 transition-all cursor-pointer',
|
||||
my.square && 'p-3 h-min',
|
||||
my.class,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
22
src/components/ui/Button/Button.module.css
Normal file
22
src/components/ui/Button/Button.module.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--control-outline);
|
||||
background: var(--control-bg);
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, transform 0.2s;
|
||||
transition-timing-function: ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.square {
|
||||
height: min-content;
|
||||
padding: 12px;
|
||||
}
|
25
src/components/ui/Button/Button.tsx
Normal file
25
src/components/ui/Button/Button.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import { splitProps } from 'solid-js'
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import css from './Button.module.css'
|
||||
|
||||
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
square?: boolean
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const [my, rest] = splitProps(props, ['square', 'class'])
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
class={clsx(
|
||||
css.button,
|
||||
my.square && css.square,
|
||||
my.class,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,18 +1,41 @@
|
|||
.input {
|
||||
@apply hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply flex items-center gap-2 cursor-pointer select-none;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
@apply bg-control-bg border-control-outline hover:bg-control-bg-hover pos-relative h-4 w-4 border rounded-md transition-all;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background: var(--control-bg);
|
||||
border: 1px solid var(--control-outline);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.input:checked + .label .box::before {
|
||||
content: '';
|
||||
display: 'block';
|
||||
|
||||
@apply bg-text-primary absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-2 h-2 rounded-sm;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js'
|
||||
|
||||
import { cn } from '../../utils/cn.ts'
|
||||
|
||||
export function Emoji(props: JSX.HTMLElementTags['img']) {
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
class={cn(
|
||||
'inline-block h-1em w-1em object-contain overflow-hidden align-middle',
|
||||
props.class,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
8
src/components/ui/Emoji/Emoji.module.css
Normal file
8
src/components/ui/Emoji/Emoji.module.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
.emoji {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
width: 1em;
|
||||
}
|
14
src/components/ui/Emoji/Emoji.tsx
Normal file
14
src/components/ui/Emoji/Emoji.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import css from './Emoji.module.css'
|
||||
|
||||
export function Emoji(props: JSX.HTMLElementTags['img']) {
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
class={clsx(css.emoji, props.class)}
|
||||
/>
|
||||
)
|
||||
}
|
3
src/components/ui/Icons/Icon.module.css
Normal file
3
src/components/ui/Icons/Icon.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.wrap {
|
||||
display: inline-flex;
|
||||
}
|
27
src/components/ui/Icons/Icon.tsx
Normal file
27
src/components/ui/Icons/Icon.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { Component, JSX } from 'solid-js'
|
||||
import { splitProps } from 'solid-js'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import css from './Icon.module.css'
|
||||
|
||||
export interface IconProps extends JSX.HTMLAttributes<HTMLSpanElement> {
|
||||
glyph: Component
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
const [my, rest] = splitProps(props, ['glyph', 'size', 'class'])
|
||||
|
||||
return (
|
||||
<span
|
||||
{...rest}
|
||||
class={clsx(css.wrap, my.class)}
|
||||
style={{
|
||||
'font-size': `${my.size ?? 24}px`,
|
||||
}}
|
||||
>
|
||||
{my.glyph({})}
|
||||
</span>
|
||||
)
|
||||
}
|
23
src/components/ui/Icons/glyphs/COPYING
Normal file
23
src/components/ui/Icons/glyphs/COPYING
Normal file
|
@ -0,0 +1,23 @@
|
|||
Files matching Gravity*.tsx are licensed under:
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022 YANDEX LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
13
src/components/ui/Icons/glyphs/GravityClock.tsx
Normal file
13
src/components/ui/Icons/glyphs/GravityClock.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
export function GravityClock() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M13.5 8a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0M8.75 4.5a.75.75 0 0 0-1.5 0V8a.75.75 0 0 0 .3.6l2 1.5a.75.75 0 1 0 .9-1.2l-1.7-1.275z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
13
src/components/ui/Icons/glyphs/GravityMegaphone.tsx
Normal file
13
src/components/ui/Icons/glyphs/GravityMegaphone.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
export function GravityMegaphone() {
|
||||
return (
|
||||
<svg height="1em" viewBox="0 0 16 16" width="1em" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M11.113 11.615c.374.814.713.885.887.885c.174 0 .513-.071.887-.885c.377-.816.613-2.077.613-3.615c0-1.538-.236-2.799-.613-3.615c-.374-.814-.713-.885-.887-.885c-.174 0-.513.071-.887.885C10.736 5.2 10.5 6.462 10.5 8c0 1.538.236 2.799.613 3.615M9 8c0 1.469.197 2.815.59 3.857L2.902 9.31a1.402 1.402 0 0 1 0-2.62l6.686-2.548C9.196 5.185 9 6.532 9 8m3 6c2 0 3-2.686 3-6s-1-6-3-6c-.661 0-1.317.12-1.934.356L2.369 5.288a2.902 2.902 0 0 0 0 5.424l.827.315a2.5 2.5 0 1 0 4.67 1.78l2.2.837A5.433 5.433 0 0 0 12 14m-5.537-1.729L4.6 11.563a1 1 0 1 0 1.862.71Z"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import { cn } from '../../utils/cn.ts'
|
||||
|
||||
export function Link(props: JSX.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
class={cn(
|
||||
'color-text-accent underline underline-offset-2 hover:no-underline cursor-pointer active:text-text-secondary',
|
||||
props.class,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
)
|
||||
}
|
3
src/components/ui/Link/Link.module.css
Normal file
3
src/components/ui/Link/Link.module.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.link {
|
||||
color: var(--text-accent);
|
||||
}
|
16
src/components/ui/Link/Link.tsx
Normal file
16
src/components/ui/Link/Link.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import clsx from 'clsx'
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
import css from './Link.module.css'
|
||||
|
||||
export function Link(props: JSX.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
class={clsx(css.link, props.class)}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
)
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js'
|
||||
import { cn } from '../../utils/cn.ts'
|
||||
|
||||
export function SectionTitle(props: {
|
||||
class?: string
|
||||
children: JSX.Element
|
||||
}) {
|
||||
return (
|
||||
<h3 class={cn('m-0 mb-1 text-md font-bold', props.class)}>
|
||||
{props.children}
|
||||
</h3>
|
||||
)
|
||||
}
|
37
src/components/ui/SectionTitle/SectionTitle.module.css
Normal file
37
src/components/ui/SectionTitle/SectionTitle.module.css
Normal file
|
@ -0,0 +1,37 @@
|
|||
@import '../../shared.css';
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5em;
|
||||
overflow: hidden;
|
||||
width: 900px;
|
||||
padding: 24px;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
width: 720px;
|
||||
}
|
||||
@media (--tablet) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--text-secondary);
|
||||
padding-top: 8px;
|
||||
margin-inline: 16;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
@mixin font-2xs;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-weight: bold;
|
||||
margin: 0 0 4px 0;
|
||||
@mixin font-md;
|
||||
}
|
12
src/components/ui/SectionTitle/SectionTitle.tsx
Normal file
12
src/components/ui/SectionTitle/SectionTitle.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js'
|
||||
|
||||
import css from './SectionTitle.module.css'
|
||||
|
||||
export function SectionTitle(props: { children: JSX.Element }) {
|
||||
return (
|
||||
<h3 class={css.sectionTitle}>
|
||||
{props.children}
|
||||
</h3>
|
||||
)
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import { splitProps } from 'solid-js'
|
||||
|
||||
import { cn } from '../../utils/cn.ts'
|
||||
|
||||
export interface TextAreaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
grow?: boolean
|
||||
maxRows?: number
|
||||
}
|
||||
|
||||
function calculateLinesByScrollHeight(args: {
|
||||
height: number
|
||||
lineHeight: number
|
||||
paddingBottom: number
|
||||
paddingTop: number
|
||||
}) {
|
||||
const { height, lineHeight } = args
|
||||
const paddingTop = Number.isNaN(args.paddingTop) ? 0 : args.paddingTop
|
||||
const paddingBottom = Number.isNaN(args.paddingBottom) ? 0 : args.paddingBottom
|
||||
|
||||
return (height - paddingTop - paddingBottom) / lineHeight
|
||||
}
|
||||
|
||||
export function TextArea(props: TextAreaProps) {
|
||||
const [my, rest] = splitProps(props, ['grow', 'class', 'maxRows'])
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
// @ts-expect-error lol
|
||||
props.onInput?.(e)
|
||||
if (!my.grow) return
|
||||
|
||||
const control = e.target as HTMLTextAreaElement
|
||||
|
||||
// based on https://github.com/gravity-ui/uikit/blob/main/src/components/controls/TextArea/TextAreaControl.tsx
|
||||
const controlStyles = getComputedStyle(control)
|
||||
const lineHeight = Number.parseInt(controlStyles.getPropertyValue('line-height'), 10)
|
||||
const paddingTop = Number.parseInt(controlStyles.getPropertyValue('padding-top'), 10)
|
||||
const paddingBottom = Number.parseInt(controlStyles.getPropertyValue('padding-bottom'), 10)
|
||||
const innerValue = control.value
|
||||
const linesWithCarriageReturn = (innerValue?.match(/\n/g) || []).length + 1
|
||||
const linesByScrollHeight = calculateLinesByScrollHeight({
|
||||
height: control.scrollHeight,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
lineHeight,
|
||||
})
|
||||
|
||||
control.style.height = 'auto'
|
||||
|
||||
const maxRows = my.maxRows
|
||||
|
||||
if (maxRows && maxRows < Math.max(linesByScrollHeight, linesWithCarriageReturn)) {
|
||||
control.style.height = `${maxRows * lineHeight + 2 * paddingTop + 2}px`
|
||||
} else if (linesWithCarriageReturn > 1 || linesByScrollHeight > 1) {
|
||||
control.style.height = `${control.scrollHeight + 2}px`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...rest}
|
||||
class={cn(
|
||||
'outline-none border border-control-outline bg-control-bg rounded-md text-text-primary min-h-4em resize-none p-2 transition-all text-sm placeholder-text-secondary cursor-pointer hover:bg-control-bg-hover-alt disabled:cursor-not-allowed disabled:bg-control-bg-disabled disabled:border-text-disabled disabled:placeholder-text-disabled focus:(border-text-primary bg-control-bg-active outline outline-text-primary hover:cursor-text outline-offset-0)',
|
||||
my.class,
|
||||
)}
|
||||
onInput={onInput}
|
||||
/>
|
||||
)
|
||||
}
|
39
src/components/ui/TextArea/TextArea.module.css
Normal file
39
src/components/ui/TextArea/TextArea.module.css
Normal file
|
@ -0,0 +1,39 @@
|
|||
@import '../../shared.css';
|
||||
|
||||
.box {
|
||||
border: 1px solid var(--control-outline);
|
||||
background: var(--control-bg);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
min-height: 4em;
|
||||
resize: none;
|
||||
padding: 8px;
|
||||
transition: background 0.2s;
|
||||
@mixin font-sm;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&:hover:not(:focus):not([disabled]) {
|
||||
cursor: pointer;
|
||||
background: var(--bg-hover-alt);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border: 1px solid var(--text-primary);
|
||||
outline: 1px solid var(--text-primary);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: not-allowed;
|
||||
background: var(--control-bg-disabled);
|
||||
border-color: var(--text-disabled);
|
||||
}
|
||||
|
||||
&[disabled]::placeholder {
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
}
|
68
src/components/ui/TextArea/TextArea.tsx
Normal file
68
src/components/ui/TextArea/TextArea.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import { splitProps } from 'solid-js'
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import css from './TextArea.module.css'
|
||||
|
||||
export interface TextAreaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
grow?: boolean
|
||||
maxRows?: number
|
||||
}
|
||||
|
||||
function calculateLinesByScrollHeight(args: {
|
||||
height: number
|
||||
lineHeight: number
|
||||
paddingBottom: number
|
||||
paddingTop: number
|
||||
}) {
|
||||
const { height, lineHeight } = args
|
||||
const paddingTop = Number.isNaN(args.paddingTop) ? 0 : args.paddingTop
|
||||
const paddingBottom = Number.isNaN(args.paddingBottom) ? 0 : args.paddingBottom
|
||||
|
||||
return (height - paddingTop - paddingBottom) / lineHeight
|
||||
}
|
||||
|
||||
export function TextArea(props: TextAreaProps) {
|
||||
const [my, rest] = splitProps(props, ['grow', 'class', 'maxRows'])
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
// @ts-expect-error lol
|
||||
props.onInput?.(e)
|
||||
if (!my.grow) return
|
||||
|
||||
const control = e.target as HTMLTextAreaElement
|
||||
|
||||
// based on https://github.com/gravity-ui/uikit/blob/main/src/components/controls/TextArea/TextAreaControl.tsx
|
||||
const controlStyles = getComputedStyle(control)
|
||||
const lineHeight = Number.parseInt(controlStyles.getPropertyValue('line-height'), 10)
|
||||
const paddingTop = Number.parseInt(controlStyles.getPropertyValue('padding-top'), 10)
|
||||
const paddingBottom = Number.parseInt(controlStyles.getPropertyValue('padding-bottom'), 10)
|
||||
const innerValue = control.value
|
||||
const linesWithCarriageReturn = (innerValue?.match(/\n/g) || []).length + 1
|
||||
const linesByScrollHeight = calculateLinesByScrollHeight({
|
||||
height: control.scrollHeight,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
lineHeight,
|
||||
})
|
||||
|
||||
control.style.height = 'auto'
|
||||
|
||||
const maxRows = my.maxRows
|
||||
|
||||
if (maxRows && maxRows < Math.max(linesByScrollHeight, linesWithCarriageReturn)) {
|
||||
control.style.height = `${maxRows * lineHeight + 2 * paddingTop + 2}px`
|
||||
} else if (linesWithCarriageReturn > 1 || linesByScrollHeight > 1) {
|
||||
control.style.height = `${control.scrollHeight + 2}px`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...rest}
|
||||
class={clsx(css.box, my.class)}
|
||||
onInput={onInput}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
import { cn } from '../../utils/cn.ts'
|
||||
|
||||
export function TextComment(props: JSX.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
class={cn(
|
||||
'text-text-secondary pos-relative before:content-dblslash before:pos-absolute before:-left-2em',
|
||||
props.class,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
10
src/components/ui/TextComment/TextComment.module.css
Normal file
10
src/components/ui/TextComment/TextComment.module.css
Normal file
|
@ -0,0 +1,10 @@
|
|||
.comment {
|
||||
color: var(--text-secondary);
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '// ';
|
||||
position: absolute;
|
||||
left: -2em;
|
||||
}
|
||||
}
|
14
src/components/ui/TextComment/TextComment.tsx
Normal file
14
src/components/ui/TextComment/TextComment.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js/jsx-runtime'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import css from './TextComment.module.css'
|
||||
|
||||
export function TextComment(props: JSX.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
class={clsx(css.comment, props.class)}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js'
|
||||
|
||||
import { cn } from '../../utils/cn.ts'
|
||||
|
||||
export interface TextTableProps {
|
||||
items: {
|
||||
name: string
|
||||
value: () => JSX.Element | false | null | undefined
|
||||
}[]
|
||||
minColumnWidth?: number
|
||||
wrap?: boolean
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
export function TextTable(props: TextTableProps) {
|
||||
const rows = () => props.items.map((item) => {
|
||||
const value = item.value()
|
||||
if (!value) return null
|
||||
return (
|
||||
<>
|
||||
<div class="whitespace-nowrap p-0 pr-2em align-text-top">
|
||||
{item.name}
|
||||
</div>
|
||||
<div class="overflow-hidden p-0">
|
||||
{item.value()}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
'grid grid-cols-[1fr_2fr] sm:grid-cols-[1fr_4fr] overflow-hidden border-spacing-0 line-height-18px',
|
||||
props.wrap ? 'whitespace-pre-wrap' : 'text-ellipsis whitespace-pre',
|
||||
props.fill && 'w-full',
|
||||
)}
|
||||
>
|
||||
{rows()}
|
||||
</div>
|
||||
)
|
||||
}
|
43
src/components/ui/TextTable/TextTable.module.css
Normal file
43
src/components/ui/TextTable/TextTable.module.css
Normal file
|
@ -0,0 +1,43 @@
|
|||
.table {
|
||||
border-spacing: 0;
|
||||
line-height: 18px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 4fr;
|
||||
overflow: hidden;
|
||||
|
||||
@media (--tablet) {
|
||||
grid-template-columns: 1fr 2fr;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
padding-right: 2em;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.value {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.normal {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.normal .value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.fill {
|
||||
width: 100%;
|
||||
}
|
40
src/components/ui/TextTable/TextTable.tsx
Normal file
40
src/components/ui/TextTable/TextTable.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
/** @jsxImportSource solid-js */
|
||||
import type { JSX } from 'solid-js'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import css from './TextTable.module.css'
|
||||
|
||||
export interface TextTableProps {
|
||||
items: {
|
||||
name: string
|
||||
value: () => JSX.Element | false | null | undefined
|
||||
}[]
|
||||
minColumnWidth?: number
|
||||
wrap?: boolean
|
||||
fill?: boolean
|
||||
}
|
||||
|
||||
export function TextTable(props: TextTableProps) {
|
||||
const rows = () => props.items.map((item) => {
|
||||
const value = item.value()
|
||||
if (!value) return null
|
||||
return (
|
||||
<>
|
||||
<div class={css.name}>{item.name}</div>
|
||||
<div class={css.value}>{item.value()}</div>
|
||||
</>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
class={clsx(
|
||||
css.table,
|
||||
props.wrap ? css.wrap : css.normal,
|
||||
props.fill && css.fill,
|
||||
)}
|
||||
>
|
||||
{rows()}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import { glob } from 'astro/loaders'
|
||||
import { type CollectionEntry, defineCollection, z } from 'astro:content'
|
||||
|
||||
const blog = defineCollection({
|
||||
loader: glob({
|
||||
base: './src/content/posts',
|
||||
pattern: [
|
||||
'*.md',
|
||||
...(import.meta.env.PROD ? ['!sample.md'] : []),
|
||||
],
|
||||
}),
|
||||
schema: () => z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.coerce.date(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const collections = { blog }
|
||||
|
||||
export function sortPostsByDate(a: CollectionEntry<'blog'>, b: CollectionEntry<'blog'>) {
|
||||
return a.data.date.valueOf() - b.data.date.valueOf()
|
||||
}
|
||||
|
||||
export function sortPostsByDateReverse(a: CollectionEntry<'blog'>, b: CollectionEntry<'blog'>) {
|
||||
return b.data.date.valueOf() - a.data.date.valueOf()
|
||||
}
|
|
@ -1,317 +0,0 @@
|
|||
---
|
||||
date: '2025-01-25'
|
||||
title: 'how i built mtcute repl'
|
||||
description: 'a tale about browser inconsistencies, threading in javascript and insane workarounds'
|
||||
---
|
||||
|
||||
hey so uhh quick intro
|
||||
|
||||
for the past like two years i've been working on [mtcute](https://github.com/mtcute/mtcute), an mtproto client library in typescript. in case you didn't know, mtproto **FUCKING SUCKS**, but that's for another post.
|
||||
|
||||
and i've always wanted to build an online in-browser interactive tool that would allow me to poke around with telegram api, with minimum friction and maximum convenience. there are quite a few use-cases for this, and im not really going to go into detail here, but trust me, it's a pretty cool idea.
|
||||
|
||||
and so after a few weeks of work, i made a thing: [play.mtcute.dev](https://play.mtcute.dev)!<br/>
|
||||
and while building it, i encountered *quite a few* issues that i want to share
|
||||
|
||||
## mtcute is a library
|
||||
|
||||
and as such, we need to somehow make it available to the user's code.
|
||||
|
||||
something like [`https://esm.sh`](https://esm.sh) would probably work, but i *really* don't trust such services. and hosting something like this myself would defeat the entire point of it being fully in-browser.
|
||||
|
||||
so i decided to go with a different approach.
|
||||
|
||||
instead of using a cdn, i download the library directly from `registry.npmjs.org` (along with all its dependencies), untar and save them to indexeddb. and then... well, we need to somehow run it.
|
||||
|
||||
> one might ask how is npm better than esm.sh in terms of security, and to that i have no definite answer,
|
||||
> but like, if npm stars serving malicious code half the internet will be screwed anyway
|
||||
|
||||
initially i wanted to simply go with something like `esbuild-wasm` with user's code as an entrypoint, bundle everything into a single file and then just run that file as a web worker.
|
||||
|
||||
except esbuild-wasm weighs about **11mb** 🥴
|
||||
|
||||
not even considering the bundling performance overhead, that's a lot of wasted bandwidth. surely there's a better way?
|
||||
|
||||
### import maps
|
||||
|
||||
a good friend of mine (s/o [@kamillaova](https://github.com/kamillaova)) reminded me about import maps.
|
||||
|
||||
in case you haven't heard of them, it's a [recent addition](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) to the web platform,
|
||||
allowing you to map esm imports to *somewhere*.
|
||||
|
||||
a simple `esm.sh` sourcemap for mtcute would look something like this:
|
||||
|
||||
```html
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@mtcute/web": "https://esm.sh/@mtcute/web",
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
the issues were immediately obvious, however:
|
||||
1. the library must be available by url
|
||||
2. dynamic import maps are [not invented yet](https://github.com/WICG/import-maps/issues/92),
|
||||
meaning we can't add an import map at runtime
|
||||
3. import maps are [not supported by web workers](https://github.com/WICG/import-maps/issues/2)
|
||||
|
||||
but still, import maps would allow us to avoid bundling *at all*!
|
||||
|
||||
and the issues above *could probably be worked around*.<br/>
|
||||
so i still decided to give it a try.
|
||||
|
||||
### importing by url
|
||||
|
||||
to make the library available by url without any external backend, we can just serve it from a service worker.
|
||||
|
||||
service workers allow intercepting all requests on our origin, and they also have full access
|
||||
to indexeddb (where we store the downloaded code).<br/>
|
||||
so serving the library from a service worker is as simple as:
|
||||
|
||||
```js
|
||||
globalThis.addEventListener('fetch', (event) => {
|
||||
if (event.request.url.startsWith('/sw/runtime/')) {
|
||||
event.respondWith(serveFromIdb(event.request.url))
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
and then we can just use that url in the import map:
|
||||
|
||||
```html
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@mtcute/web": "/sw/runtime/@mtcute/web/index.js",
|
||||
...
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
> note: since mtcute and all its deps use npm-s `package.json`, when generating the import map we
|
||||
> need to keep in mind the respective `exports` fields (and also `module/main/browser` god damnit 🤮)
|
||||
|
||||
### dynamic import maps
|
||||
|
||||
the remaining issues are very much linked together – we need to provide import maps at runtime.
|
||||
|
||||
since we can't use a web worker, let's use an iframe instead!
|
||||
and since we can't add import maps at runtime, we can just generate the entire html page on the fly:
|
||||
|
||||
```js
|
||||
const html = `
|
||||
<html>
|
||||
<script type="importmap">${generateImportMap()}</script>
|
||||
...some more html idk...
|
||||
</html>
|
||||
`
|
||||
|
||||
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.src = url
|
||||
document.body.appendChild(iframe)
|
||||
```
|
||||
|
||||
except... **[BAM](https://crbug.com/880768)!**
|
||||
|
||||
due to this chrome bug, our generated iframe won't be able to load the libraries
|
||||
from our service worker, because it's not considered the *same origin*
|
||||
|
||||
no biggie, let's just serve the html directly from our service worker:
|
||||
|
||||
```js
|
||||
globalThis.addEventListener('fetch', (event) => {
|
||||
if (event.request.url === '/sw/runtime/_iframe.html') {
|
||||
event.respondWith(generateIframeHtml())
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
and at that point, everything *seemed* to work??
|
||||
|
||||
## threading in browsers
|
||||
|
||||
as you probably know, javascript at its core is single-threaded. this is useful in most cases, however can be quite annoying in some others. especially for this particular one – a repl.
|
||||
|
||||
in case a user ends up writing a computation-heavy task (or just accidentally do a `while (true) {}`),
|
||||
the entire page will **freeze and potentially crash**. and it is something i specifically don't want in our case.
|
||||
|
||||
an obvious solution – just run the user-provided code in a web worker..?
|
||||
|
||||
except we can't (see above).
|
||||
|
||||
<p class="thought">
|
||||
but wait. we are already running the user's code in an iframe! <br/>
|
||||
isn't it already a separate thread?
|
||||
</p>
|
||||
|
||||
<span class="shout">NO</span><br/>
|
||||
|
||||
i used to think that it would run in a separate thread too, honestly.
|
||||
|
||||
but when i actually tried to run a `while (true) {}` **in an iframe**, it froze the entire page, not just the iframe.
|
||||
|
||||
the same issue can be seen in many in-browser playgrounds out there, like [solid.js one](https://playground.solidjs.com/),
|
||||
[vue.js one](https://play.vuejs.org), and probably more...
|
||||
|
||||
### but why?
|
||||
|
||||
> this section is very much a *probably*, i didn't do a lot of research on this, but this sounds reasonable enough
|
||||
|
||||
afaiu, the fact that it's run on the same thread is primarily due to the `contentWindow` api.
|
||||
|
||||
see, when the iframe is same-origin, we can actually access its DOM from the parent window:
|
||||
|
||||
```js
|
||||
const iframe = document.querySelector('iframe')
|
||||
iframe.contentWindow.document.body.innerHTML = 'meow'
|
||||
```
|
||||
|
||||
and because of that, browsers have to share the same javascript runtime thread between the iframe and the parent window.
|
||||
|
||||
for cross-origin iframes, however, the only way of communication is via `postMessage` (similar to web workers!),
|
||||
and as such the browser can create a separate thread for it.
|
||||
|
||||
### what even is a cross-origin iframe?
|
||||
|
||||
i found [this post](https://webperf.tips/tip/iframe-multi-process/) that explains the issue in detail,
|
||||
as well as providing *some pointers* on how to work around it.
|
||||
|
||||
tldr – this is very much implementation-specific. but usually, the iframe is considered a *cross-origin*
|
||||
if the `etld+1` of the parent and the child are different.
|
||||
|
||||
> etld is the [effective top-level domain](https://developer.mozilla.org/en-US/docs/Glossary/eTLD).<br/>
|
||||
> (dont worry, i haven't heard this term before either)
|
||||
>
|
||||
> example.com and very.example.com have the same etld+1 (`example.com`), but example.com and example.co.uk don't.
|
||||
|
||||
one way we can force the browser to isolate the iframe is using the `sandbox` attribute:
|
||||
|
||||
```html
|
||||
<iframe sandbox="allow-scripts"></iframe>
|
||||
```
|
||||
|
||||
but... well, it's no longer cross-origin :D
|
||||
|
||||
and that's an issue!<br />
|
||||
because we can no longer access our service worker from the iframe and load the libraries from it.
|
||||
|
||||
## what do we do?
|
||||
|
||||
so basically to make things work the way i intended, i would need some kind of `sandbox` attribute that would
|
||||
isolate the javascript runtime thread (and disable the `contentWindow` api that i dont even use anyway),
|
||||
while the frame is still considered same-origin.
|
||||
|
||||
and browsers don't have anything like that!! :<
|
||||
|
||||
at this point i basically had two options:
|
||||
- give up on trying to separate the worker into a separate thread
|
||||
- make an actual **cross-origin** iframe where most of the work would happen,
|
||||
and our "main" window would just be a frontend talking to it via `postMessage`
|
||||
|
||||
### the great separation
|
||||
|
||||
i went with the latter because i really wanted to make my repl resilient to broken user code.
|
||||
|
||||
> <span class="big">important!</span>
|
||||
>
|
||||
> having two origins might be a security concern! what if a bad actor embeds my "worker" iframe in their website
|
||||
> and steals everything?
|
||||
>
|
||||
> i had to be extra careful to verify the origin of every embedded message, to make sure it's our "frontend" talking
|
||||
> to our "worker" iframe, and not some malicious website.
|
||||
|
||||
at this point i already had like 90% of the project finished, so refactoring everything to two-origin
|
||||
architecture took some effort, but it was definitely worth it.
|
||||
|
||||
i had to move the following to the "worker" iframe:
|
||||
- authorization and session management
|
||||
- library downloading
|
||||
- the service worker, along with iframe html generation
|
||||
|
||||
and the overall architecture ended up looking something like this:
|
||||
|
||||
<!-- i hate graphviz -->
|
||||
```dot
|
||||
digraph G {
|
||||
subgraph cluster_worker {
|
||||
label = "worker origin";
|
||||
labeljust = "r";
|
||||
sw[label="service worker",shape=rect,style=dashed]
|
||||
worker[label = "worker iframe",shape=rect]
|
||||
runner[label = "runner iframe",shape=cds]
|
||||
idb[label = "indexeddb",shape=cylinder]
|
||||
spacer[label = "",shape=rect,style=invis,fixedsize=true,width=2]
|
||||
|
||||
worker -> sw [label=" talks"]
|
||||
sw -> runner [label=" serves"]
|
||||
sw -> idb [label="stores libs",constraint=false]
|
||||
runner -> idb [label="stores sessions",constraint=false]
|
||||
}
|
||||
|
||||
frontend
|
||||
|
||||
frontend -> worker [dir="both",label="embeds + talks",constraint=false]
|
||||
frontend -> runner [dir="both",label=" embeds + talks ",constraint=false]
|
||||
}
|
||||
```
|
||||
|
||||
- **frontend** is the actual app the user interacts with. just a normal frontend app, but instead of some rest api, it uses `postMessage` to talk to...
|
||||
- **worker iframe** – which implements most of the business logic, as well as manages the service worker and storage
|
||||
- **service worker** is used to serve the library code, as well as the...
|
||||
- **runner iframe** – which is the "sandbox" in which the user's code is actually run
|
||||
|
||||
phew. that's some enterprise-grade backend architecture, right in your browser!<br/>
|
||||
i really hope that one day browsers will make this kind of stuff easier to implement 🙏
|
||||
|
||||
### cross-origin is not the silver bullet, actually
|
||||
|
||||
at this point everything was working fine... in chrome :D
|
||||
|
||||
as soon as i opened the page in firefox, i was greeted with an incredibly helpful "The operation is insecure"
|
||||
|
||||
after some digging, it turned out that firefox has something called [state partitioning](https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning)
|
||||
|
||||
tldr: normally browsers always keyed websites' data by their origin. but trackers can (and do! ~~*cant have shit in this economy*~~) abuse this to track users across websites.
|
||||
|
||||
a simple example of that would be:
|
||||
|
||||
```js
|
||||
// https://tracking.tei.su/get-user-id.html
|
||||
globalThis.addEventListener('message', (event) => {
|
||||
if (!localStorage.userId) localStorage.userId = crypto.randomUUID()
|
||||
event.source.postMessage(localStorage.userId)
|
||||
})
|
||||
|
||||
// which can then be used to track users across websites by simply doing:
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.src = 'https://tracking.tei.su/get-user-id.html'
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
iframe.addEventListener('message', (event) => {
|
||||
console.log('you are %s!', event.data)
|
||||
})
|
||||
```
|
||||
|
||||
to avoid this, firefox keys the data by a combination of the iframe's origin and the top window's origin.
|
||||
this way, the above code would return different results for different websites.
|
||||
|
||||
<p class="thought">
|
||||
this... doesn't really sound like an issue for our case though?
|
||||
</p>
|
||||
|
||||
ikr?? we barely store anything outside of the worker's origin, and only access our worker from a single origin.
|
||||
|
||||
but for **WHATEVER REASON** (likely due to some bug in firefox) our runner iframe *seemed* to have a separate service worker from the worker iframe. and a separate indexeddb. and only in some cases. 🥴
|
||||
|
||||
some stuff did seemingly get fixed by simply updating firefox to the latest version, and some other stuff i had to refactorfrom the worker iframe to the runner iframe. ugh, so annoying.
|
||||
|
||||
...
|
||||
|
||||
i have no idea how people even write outros so uhh<br/>
|
||||
thanks for reading this rambling of a post i guess?
|
||||
|
||||
ok bye
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
date: '1970-01-01'
|
||||
title: 'test post'
|
||||
description: 'sample post to test markdown rendering'
|
||||
---
|
||||
|
||||
## sub title
|
||||
### sub sub title
|
||||
#### sub sub sub title
|
||||
##### sub sub sub sub title
|
||||
###### sub sub sub sub sub title
|
||||
|
||||
> test quote
|
||||
|
||||
some text **bold** and _italic_ and `code` and also ~~strikethrough~~ as well as <u>underline</u>
|
||||
|
||||
- list item
|
||||
- list item
|
||||
- list item
|
||||
- sub list item
|
||||
- sub list item
|
||||
|
||||
1. list item
|
||||
2. list item
|
||||
3. list item
|
||||
1. sub list item
|
||||
2. sub list item
|
||||
|
||||
| table header | table header |
|
||||
| ------------ | ------------ |
|
||||
| table cell | table cell |
|
||||
| table cell | table cell |
|
||||
|
||||
```js
|
||||
const a = 1
|
||||
```
|
||||
|
||||
a [link](https://github.com/teidesu/tei.su)
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import { ViewTransitions } from 'astro:transitions'
|
||||
import LoadingIndicator from 'astro-loading-indicator/component'
|
||||
import { ClientRouter } from 'astro:transitions'
|
||||
|
||||
import cherry from '~/assets/cherry-blossom_1f338.png'
|
||||
|
||||
|
@ -34,7 +34,7 @@ const finalOg = { ...defaultOgTags, ...og }
|
|||
))}
|
||||
<link href={icon ?? cherry.src} rel="icon" />
|
||||
<title>{title ?? finalOg.title}</title>
|
||||
<ClientRouter transition:name="slide" />
|
||||
<ViewTransitions transition:name="slide" />
|
||||
<LoadingIndicator color="var(--text-primary)" />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
|
@ -81,11 +81,18 @@ const finalOg = { ...defaultOgTags, ...og }
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*:not(:active, summary):focus {
|
||||
outline: 1px solid var(--text-primary)
|
||||
*:focus {
|
||||
outline-color: var(--text-primary)
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-sm m-0 p-0 overflow-x-hidden overflow-y-auto bg-bg font-mono text-text-primary min-h-screen;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family-monospace);
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@mixin font-sm;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { Link } from '../../components/ui/Link.tsx'
|
||||
import { Link } from '../../components/ui/Link/Link'
|
||||
import BaseLayout, { type Props } from '../BaseLayout.astro'
|
||||
|
||||
import Header from './Header.astro'
|
||||
|
@ -7,12 +7,11 @@ import Header from './Header.astro'
|
|||
|
||||
<BaseLayout {...Astro.props}>
|
||||
<slot name="head" slot="head" />
|
||||
<div class="min-h-screen flex flex-col items-center">
|
||||
<div class="min-h-screen w-full flex flex-col gap-6 p-6 md:w-720px">
|
||||
<div class="app">
|
||||
<div class="content">
|
||||
<Header />
|
||||
<slot />
|
||||
<div class="flex-1" />
|
||||
<footer class="mx-4 border-t border-text-secondary pt-2 text-center text-2xs text-text-secondary">
|
||||
<footer class="footer">
|
||||
<div>
|
||||
<3 teidesu
|
||||
{' / '}
|
||||
|
@ -33,3 +32,37 @@ import Header from './Header.astro'
|
|||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
@import '../../components/shared.css';
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5em;
|
||||
overflow: hidden;
|
||||
width: 900px;
|
||||
padding: 24px;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
width: 720px;
|
||||
}
|
||||
@media (--tablet) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--text-secondary);
|
||||
padding-top: 8px;
|
||||
margin-inline: 16;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
@mixin font-2xs;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
---
|
||||
import karin from '~/assets/karin.gif'
|
||||
import { Link } from '../../components/ui/Link.tsx'
|
||||
import { Link } from '~/components/ui/Link/Link'
|
||||
|
||||
const PAGES = [
|
||||
{ name: 'hewwo', path: '/', match: /^\/$/ },
|
||||
{ name: 'blog', path: '/blog', match: /^\/blog(\/|$)/ },
|
||||
{ name: 'donate', path: '/donate', match: /^\/(donate|\$)\/?$/ },
|
||||
{ name: 'hewwo', path: '/' },
|
||||
{ name: 'donate', path: ['/donate', '/$'] },
|
||||
]
|
||||
---
|
||||
<header class="pos-relative flex items-center justify-center gap-2">
|
||||
<header class="header">
|
||||
<img
|
||||
aria-hidden="true"
|
||||
class="pos-absolute right-0 top-0 h-64px w-64px motion-reduce:hidden @dark:filter-brightness-90"
|
||||
class="gif"
|
||||
src={karin.src}
|
||||
transition:persist
|
||||
/>
|
||||
|
@ -21,15 +20,20 @@ const PAGES = [
|
|||
for (const page of PAGES) {
|
||||
if (elements.length > 0) {
|
||||
elements.push(
|
||||
<span class="select-none text-text-secondary"> / </span>,
|
||||
<span class="delimiter"> / </span>,
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = page.match.test(Astro.url.pathname)
|
||||
let isActive
|
||||
if (Array.isArray(page.path)) {
|
||||
isActive = page.path.includes(Astro.url.pathname)
|
||||
} else {
|
||||
isActive = Astro.url.pathname === page.path
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
elements.push(
|
||||
<a href={page.path} class="font-bold">{page.name}</a>,
|
||||
<span class="active">{page.name}</span>,
|
||||
)
|
||||
} else {
|
||||
const href = Array.isArray(page.path) ? page.path[0] : page.path
|
||||
|
@ -45,3 +49,38 @@ const PAGES = [
|
|||
return elements
|
||||
})()}
|
||||
</header>
|
||||
<style>
|
||||
.header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gif {
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.delimiter {
|
||||
color: var(--text-secondary);
|
||||
user-select: none
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { APIRoute } from 'astro'
|
||||
import { html } from '@mtcute/node'
|
||||
|
||||
import { telegramNotify } from '~/backend/bot/notify'
|
||||
import { MisskeyWebhookBodySchema, type MkNote, type MkUser } from '~/backend/domain/misskey'
|
||||
import { env } from '~/backend/env'
|
||||
import { zodValidate } from '~/utils/zod'
|
||||
import { telegramNotify } from '~/backend/bot/notify'
|
||||
|
||||
function misskeyMentionUser(user: MkUser, server: string): string {
|
||||
const fullUsername = user.host ? `@${user.username}@${user.host}` : `@${user.username}`
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
// import { telegramNotify } from '~/backend/bot/notify'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
import { html } from '@mtcute/node'
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
import { telegramNotify } from '~/backend/bot/notify'
|
||||
import { env } from '~/backend/env'
|
||||
import { telegramNotify } from '~/backend/bot/notify'
|
||||
|
||||
export const POST: APIRoute = async (ctx) => {
|
||||
if (new URL(ctx.request.url).searchParams.get('secret') !== env.QBT_WEBHOOK_SECRET) {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import type { APIRoute } from 'astro'
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
import { z } from 'zod'
|
||||
import { fromError } from 'zod-validation-error'
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
|
||||
import { createShout, fetchShouts, isShoutboxBanned } from '~/backend/service/shoutbox'
|
||||
import { verifyCsrfToken } from '~/backend/utils/csrf'
|
||||
import { getRequestIp } from '~/backend/utils/request'
|
||||
import { verifyCsrfToken } from '~/backend/utils/csrf'
|
||||
import { HttpResponse } from '~/backend/utils/response'
|
||||
|
||||
const schema = z.object({
|
||||
|
|
|
@ -1,176 +0,0 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content'
|
||||
|
||||
import DefaultLayout from '../../layouts/DefaultLayout/DefaultLayout.astro'
|
||||
|
||||
export const prerender = true
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog')
|
||||
return posts.map(post => ({
|
||||
params: { slug: post.id },
|
||||
props: { post },
|
||||
}))
|
||||
}
|
||||
|
||||
const { post } = Astro.props
|
||||
const { Content, remarkPluginFrontmatter } = await render(post);
|
||||
---
|
||||
|
||||
<DefaultLayout
|
||||
title={post.data.title}
|
||||
og={{
|
||||
title: post.data.title,
|
||||
description: post.data.description,
|
||||
date: post.data.date.toISOString(),
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1 border-b border-text-secondary pb-2">
|
||||
<div class="text-2xl font-bold">{post.data.title}</div>
|
||||
<p class="text-xs text-text-secondary">
|
||||
{post.data.date.toLocaleString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).toLowerCase()}
|
||||
//
|
||||
{remarkPluginFrontmatter.minutesRead}
|
||||
</p>
|
||||
</div>
|
||||
<div data-md-content>
|
||||
<Content />
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el) => {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.setAttribute('data-anchor-link', '')
|
||||
anchor.setAttribute('href', `#${el.id}`)
|
||||
anchor.textContent = '#'
|
||||
el.appendChild(anchor)
|
||||
})
|
||||
</script>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
|
||||
<style is:global>
|
||||
[data-md-content] {
|
||||
--text-content: var(--text-primary);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* our default primary text color is not suitable for large bodies of text */
|
||||
--text-content: #bea7b0;
|
||||
|
||||
.astro-code,
|
||||
.astro-code span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--control-bg) !important;
|
||||
/* Optional, if you also want font styles */
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
}
|
||||
|
||||
color: var(--text-content);
|
||||
|
||||
h1 { @apply text-4xl font-bold mb-4; }
|
||||
h2 { @apply text-3xl font-bold mb-3 mt-3; }
|
||||
h3 { @apply text-2xl font-bold mb-2 mt-2; }
|
||||
h4 { @apply text-xl font-bold mb-2; }
|
||||
h5 { @apply text-lg font-bold mb-2; }
|
||||
h6 { @apply text-md font-bold mb-2; }
|
||||
|
||||
.big { @apply text-2xl font-bold; }
|
||||
.shout { @apply text-4xl font-bold; }
|
||||
|
||||
.thought {
|
||||
&::before {
|
||||
content: '💭';
|
||||
@apply text-4xl font-bold;
|
||||
}
|
||||
|
||||
@apply p-4 mb-4 bg-control-bg-hover rounded-lg flex flex-row gap-4 items-center;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply relative text-text-primary cursor-pointer -ml-1em pl-1em;
|
||||
|
||||
[data-anchor-link] {
|
||||
@apply hidden absolute left-0 text-text-secondary no-underline hover:underline;
|
||||
}
|
||||
|
||||
&:hover [data-anchor-link] {
|
||||
@apply inline;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
@apply text-sm mb-5;
|
||||
line-height: 1.5;
|
||||
}
|
||||
ul { @apply list-disc list-outside mb-4 ml-4; }
|
||||
ol { @apply mb-4 list-decimal list-outside ml-2em; }
|
||||
li {
|
||||
@apply text-sm mt-1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
@apply text-sm bg-control-bg-hover px-1 rounded-md;
|
||||
color: var(--text-content);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-l-4 border-text-secondary pl-4 mb-4 py-2;
|
||||
p { @apply text-sm mb-0; }
|
||||
p + p { @apply mt-4; }
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply bg-control-bg-hover text-text-secondary p-2 rounded-md mb-4;
|
||||
|
||||
code { @apply bg-transparent; }
|
||||
}
|
||||
|
||||
table {
|
||||
@apply border-collapse border-solid border-text-secondary border-spacing-0 mb-2;
|
||||
|
||||
th, td {
|
||||
@apply border border-text-secondary p-1;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply font-bold text-left;
|
||||
}
|
||||
td {
|
||||
@apply text-right;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-text-accent underline hover:no-underline;
|
||||
}
|
||||
|
||||
.graphviz-svg {
|
||||
@apply w-full flex justify-center mb-4;
|
||||
|
||||
svg {
|
||||
@apply border border-text-secondary rounded-md
|
||||
}
|
||||
|
||||
.graph {
|
||||
text {
|
||||
@apply font-mono;
|
||||
fill: var(--text-content);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
path, ellipse, polygon { @apply stroke-text-secondary; }
|
||||
|
||||
> polygon {
|
||||
@apply fill-transparent stroke-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
import { parallelMap } from '@fuman/utils'
|
||||
|
||||
import { getCollection, render } from 'astro:content'
|
||||
import { Link } from '../../components/ui/Link.tsx'
|
||||
import { sortPostsByDateReverse } from '../../content.config.ts'
|
||||
import DefaultLayout from '../../layouts/DefaultLayout/DefaultLayout.astro'
|
||||
|
||||
export const prerender = true
|
||||
|
||||
const posts = (await getCollection('blog')).sort(sortPostsByDateReverse)
|
||||
const postsRendered = await parallelMap(posts, async post => ({
|
||||
post,
|
||||
rendered: await render(post),
|
||||
}))
|
||||
---
|
||||
|
||||
<DefaultLayout title="alina's silly little blog">
|
||||
{postsRendered.map(({ post, rendered }) => (
|
||||
<article class="flex flex-col">
|
||||
<Link href={`/blog/${post.id}`} class="mb-1 w-max text-lg font-bold">
|
||||
{post.data.title}
|
||||
</Link>
|
||||
<p class="mb-2 text-xs text-text-secondary">
|
||||
<time datetime={post.data.date.toISOString()}>
|
||||
{post.data.date.toLocaleString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).toLowerCase()}
|
||||
</time>
|
||||
//
|
||||
{rendered.remarkPluginFrontmatter.minutesRead}
|
||||
</p>
|
||||
<p class="text-sm text-text-primary">
|
||||
{post.data.description}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</DefaultLayout>
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency'
|
||||
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
|
||||
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
||||
import { Link } from '../components/ui/Link.tsx'
|
||||
import { SectionTitle } from '../components/ui/Section.tsx'
|
||||
import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency'
|
||||
import { Link } from '~/components/ui/Link/Link'
|
||||
|
||||
let currentCurrency = new URL(Astro.request.url).searchParams.get('currency')
|
||||
if (!currentCurrency || !AVAILABLE_CURRENCIES.includes(currentCurrency)) {
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(...inputs))
|
||||
}
|
|
@ -6,7 +6,6 @@
|
|||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"public",
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
import { defineConfig, presetIcons, presetUno, transformerVariantGroup } from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetUno(),
|
||||
presetIcons(),
|
||||
],
|
||||
transformers: [
|
||||
transformerVariantGroup(),
|
||||
],
|
||||
shortcuts: {
|
||||
'content-dblslash': [
|
||||
{ content: '"//"' },
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
colors: {
|
||||
'bg': 'var(--bg)',
|
||||
'text-accent': 'var(--text-accent)',
|
||||
'text-primary': 'var(--text-primary)',
|
||||
'text-secondary': 'var(--text-secondary)',
|
||||
'text-disabled': 'var(--text-disabled)',
|
||||
'control-bg': 'var(--control-bg)',
|
||||
'control-bg-hover': 'var(--control-bg-hover)',
|
||||
'control-bg-hover-alt': 'var(--control-bg-hover-alt)',
|
||||
'control-bg-active': 'var(--control-bg-active)',
|
||||
'control-bg-disabled': 'var(--control-bg-disabled)',
|
||||
'control-outline': 'var(--control-outline)',
|
||||
},
|
||||
fontSize: {
|
||||
'2xs': ['10px', '12px'],
|
||||
'xs': ['12px', '14px'],
|
||||
'sm': ['14px', '16px'],
|
||||
'md': ['16px', '20px'],
|
||||
},
|
||||
borderRadius: {
|
||||
md: '4px',
|
||||
},
|
||||
},
|
||||
})
|
Loading…
Reference in a new issue