Compare commits
No commits in common. "caa51bea9b0e94461ffd86f56ddcc089853ecd3b" and "8c4694714409d48ff0c10ff0c28dee17964c8249" have entirely different histories.
caa51bea9b
...
8c46947144
100 changed files with 4632 additions and 6094 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -22,6 +22,4 @@ pnpm-debug.log*
|
||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
*.tsbuildinfo
|
|
|
@ -1,71 +1,23 @@
|
||||||
import node from '@astrojs/node'
|
|
||||||
import solid from '@astrojs/solid-js'
|
|
||||||
import { Graphviz } from '@hpcc-js/wasm-graphviz'
|
|
||||||
import { defineConfig } from 'astro/config'
|
import { defineConfig } from 'astro/config'
|
||||||
import { toString } from 'mdast-util-to-string'
|
import solid from '@astrojs/solid-js'
|
||||||
import getReadingTime from 'reading-time'
|
import node from '@astrojs/node'
|
||||||
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>`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'server',
|
output: 'server',
|
||||||
integrations: [
|
integrations: [
|
||||||
solid(),
|
solid(),
|
||||||
UnoCSS({
|
|
||||||
injectReset: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
markdown: {
|
|
||||||
remarkPlugins: [
|
|
||||||
remarkReadingTime,
|
|
||||||
remarkGraphvizSvg,
|
|
||||||
],
|
],
|
||||||
smartypants: false,
|
vite: {
|
||||||
shikiConfig: {
|
esbuild: { jsx: 'automatic' },
|
||||||
themes: {
|
define: {
|
||||||
dark: 'catppuccin-mocha',
|
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString().split('T')[0]),
|
||||||
light: 'catppuccin-latte',
|
},
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
adapter: node({
|
||||||
vite: {
|
mode: 'standalone',
|
||||||
esbuild: { jsx: 'automatic' },
|
}),
|
||||||
define: {
|
server: {
|
||||||
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString().split('T')[0]),
|
host: true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
adapter: node({
|
|
||||||
mode: 'standalone',
|
|
||||||
}),
|
|
||||||
server: {
|
|
||||||
host: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import type { Config } from 'drizzle-kit'
|
import type { Config } from 'drizzle-kit'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
schema: './src/backend/models/index.ts',
|
schema: './src/backend/models/index.ts',
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: '.runtime/data.db',
|
url: '.runtime/data.db',
|
||||||
},
|
},
|
||||||
} satisfies Config
|
} satisfies Config
|
||||||
|
|
|
@ -1,26 +1,32 @@
|
||||||
import antfu from '@antfu/eslint-config'
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
export default antfu({
|
export default antfu({
|
||||||
ignores: [
|
stylistic: {
|
||||||
'public',
|
indent: 4,
|
||||||
'drizzle',
|
},
|
||||||
],
|
typescript: true,
|
||||||
typescript: true,
|
astro: true,
|
||||||
astro: true,
|
solid: true,
|
||||||
solid: true,
|
yaml: false,
|
||||||
yaml: false,
|
rules: {
|
||||||
unocss: true,
|
'curly': ['error', 'multi-line'],
|
||||||
rules: {
|
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||||
'antfu/no-top-level-await': 'off',
|
'n/prefer-global/buffer': 'off',
|
||||||
'curly': ['error', 'multi-line'],
|
'style/quotes': ['error', 'single', { avoidEscape: true }],
|
||||||
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
'test/consistent-test-it': 'off',
|
||||||
'n/prefer-global/buffer': 'off',
|
'test/prefer-lowercase-title': 'off',
|
||||||
'style/quotes': ['error', 'single', { avoidEscape: true }],
|
'import/order': ['error', {
|
||||||
'test/consistent-test-it': 'off',
|
'newlines-between': 'always',
|
||||||
'test/prefer-lowercase-title': 'off',
|
'pathGroups': [
|
||||||
'antfu/if-newline': 'off',
|
{
|
||||||
'style/max-statements-per-line': ['error', { max: 2 }],
|
pattern: '~/**',
|
||||||
'ts/no-redeclare': 'off',
|
group: 'parent',
|
||||||
'node/prefer-global/process': 'off',
|
},
|
||||||
},
|
],
|
||||||
|
}],
|
||||||
|
'antfu/if-newline': 'off',
|
||||||
|
'style/max-statements-per-line': ['error', { max: 2 }],
|
||||||
|
'ts/no-redeclare': 'off',
|
||||||
|
'node/prefer-global/process': 'off',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
103
package.json
103
package.json
|
@ -1,58 +1,49 @@
|
||||||
{
|
{
|
||||||
"name": "tei.su",
|
"name": "tei.su",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903",
|
"packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro check && astro build",
|
"build": "astro check && astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"start:prod": "drizzle-kit migrate && node dist/server/entry.mjs",
|
"start:prod": "drizzle-kit migrate && node dist/server/entry.mjs",
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.4",
|
"@astrojs/check": "^0.9.1",
|
||||||
"@astrojs/node": "^9.0.2",
|
"@astrojs/node": "^8.3.2",
|
||||||
"@astrojs/solid-js": "^5.0.4",
|
"@astrojs/solid-js": "^4.4.0",
|
||||||
"@fuman/fetch": "0.0.10",
|
"@fuman/fetch": "0.0.10",
|
||||||
"@fuman/utils": "0.0.10",
|
"@fuman/utils": "0.0.10",
|
||||||
"@hpcc-js/wasm-graphviz": "^1.7.0",
|
"@mtcute/dispatcher": "^0.17.0",
|
||||||
"@iconify-json/gravity-ui": "^1.2.4",
|
"@mtcute/node": "^0.17.0",
|
||||||
"@mtcute/dispatcher": "^0.17.0",
|
"@tanstack/solid-query": "^5.51.21",
|
||||||
"@mtcute/node": "^0.17.0",
|
"astro": "^4.12.3",
|
||||||
"@tanstack/solid-query": "^5.51.21",
|
"astro-loading-indicator": "^0.5.0",
|
||||||
"@unocss/postcss": "^65.4.3",
|
"better-sqlite3": "^11.1.2",
|
||||||
"@unocss/reset": "^65.4.3",
|
"clsx": "^2.1.1",
|
||||||
"astro": "^5.1.9",
|
"date-fns": "^3.6.0",
|
||||||
"astro-loading-indicator": "0.7.0",
|
"dotenv": "^16.4.5",
|
||||||
"better-sqlite3": "^11.1.2",
|
"drizzle-kit": "^0.23.1",
|
||||||
"clsx": "^2.1.1",
|
"drizzle-orm": "^0.32.1",
|
||||||
"date-fns": "^3.6.0",
|
"parse-duration": "^1.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"rate-limiter-flexible": "^5.0.3",
|
||||||
"drizzle-kit": "^0.23.1",
|
"solid-js": "^1.8.19",
|
||||||
"drizzle-orm": "^0.32.1",
|
"typescript": "^5.5.4",
|
||||||
"mdast-util-to-string": "^4.0.0",
|
"zod": "^3.23.8",
|
||||||
"parse-duration": "^1.1.0",
|
"zod-validation-error": "^3.3.1"
|
||||||
"rate-limiter-flexible": "^5.0.3",
|
},
|
||||||
"reading-time": "^1.5.0",
|
"devDependencies": {
|
||||||
"remark-graphviz-svg": "^0.2.0",
|
"@antfu/eslint-config": "^2.24.0",
|
||||||
"solid-js": "^1.8.19",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"tailwind-merge": "^2.6.0",
|
"@types/node": "^22.0.2",
|
||||||
"typescript": "^5.7.3",
|
"eslint-plugin-astro": "^1.2.3",
|
||||||
"unist-util-visit": "^5.0.0",
|
"eslint-plugin-solid": "0.14",
|
||||||
"unocss": "^65.4.3",
|
"postcss-custom-media": "^10.0.8",
|
||||||
"zod": "^3.23.8",
|
"postcss-import": "^16.1.0",
|
||||||
"zod-validation-error": "^3.3.1"
|
"postcss-mixins": "^10.0.1",
|
||||||
},
|
"postcss-nesting": "^12.1.5"
|
||||||
"devDependencies": {
|
}
|
||||||
"@antfu/eslint-config": "3.16.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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
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(),
|
|
||||||
],
|
|
||||||
}
|
|
|
@ -6,9 +6,9 @@ import { env } from '~/backend/env'
|
||||||
import { shoutboxDp } from './bot/shoutbox'
|
import { shoutboxDp } from './bot/shoutbox'
|
||||||
|
|
||||||
export const tg = new TelegramClient({
|
export const tg = new TelegramClient({
|
||||||
apiId: env.TG_API_ID,
|
apiId: env.TG_API_ID,
|
||||||
apiHash: env.TG_API_HASH,
|
apiHash: env.TG_API_HASH,
|
||||||
storage: '.runtime/bot.session',
|
storage: '.runtime/bot.session',
|
||||||
})
|
})
|
||||||
|
|
||||||
const dp = Dispatcher.for(tg)
|
const dp = Dispatcher.for(tg)
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { tg } from '~/backend/bot'
|
||||||
import { env } from '~/backend/env'
|
import { env } from '~/backend/env'
|
||||||
|
|
||||||
export function telegramNotify(text: InputText, options?: Parameters<TelegramClient['sendText']>[2]): void {
|
export function telegramNotify(text: InputText, options?: Parameters<TelegramClient['sendText']>[2]): void {
|
||||||
tg.sendText(env.TG_CHAT_ID, text, {
|
tg.sendText(env.TG_CHAT_ID, text, {
|
||||||
disableWebPreview: true,
|
disableWebPreview: true,
|
||||||
...options,
|
...options,
|
||||||
}).catch(console.error)
|
}).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import { assertMatches } from '@fuman/utils'
|
|
||||||
import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher'
|
import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher'
|
||||||
import { html } from '@mtcute/node'
|
import { html } from '@mtcute/node'
|
||||||
import parseDuration from 'parse-duration'
|
import parseDuration from 'parse-duration'
|
||||||
|
import { assertMatches } from '@fuman/utils'
|
||||||
|
|
||||||
import { env } from '../env'
|
import { env } from '../env'
|
||||||
import {
|
import {
|
||||||
answerBySerial,
|
answerBySerial,
|
||||||
approveShout,
|
approveShout,
|
||||||
banShouts,
|
banShouts,
|
||||||
declineShout,
|
declineShout,
|
||||||
deleteBySerial,
|
deleteBySerial,
|
||||||
unbanShouts,
|
unbanShouts,
|
||||||
} from '../service/shoutbox'
|
} from '../service/shoutbox'
|
||||||
|
|
||||||
export const ShoutboxAction = new CallbackDataBuilder('shoutbox', 'id', 'action')
|
export const ShoutboxAction = new CallbackDataBuilder('shoutbox', 'id', 'action')
|
||||||
|
@ -18,83 +18,83 @@ export const ShoutboxAction = new CallbackDataBuilder('shoutbox', 'id', 'action'
|
||||||
const dp = Dispatcher.child()
|
const dp = Dispatcher.child()
|
||||||
|
|
||||||
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'approve' }), async (ctx) => {
|
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'approve' }), async (ctx) => {
|
||||||
if (ctx.chat.id !== env.TG_CHAT_ID) return
|
if (ctx.chat.id !== env.TG_CHAT_ID) return
|
||||||
|
|
||||||
const serial = approveShout(ctx.match.id)
|
const serial = approveShout(ctx.match.id)
|
||||||
await ctx.editMessageWith(msg => ({
|
await ctx.editMessageWith(msg => ({
|
||||||
text: html`${msg.textWithEntities}<br><br>✅ Approved! ID: <code>${serial}</code>`,
|
text: html`${msg.textWithEntities}<br><br>✅ Approved! ID: <code>${serial}</code>`,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'decline' }), async (ctx) => {
|
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'decline' }), async (ctx) => {
|
||||||
if (ctx.chat.id !== env.TG_CHAT_ID) return
|
if (ctx.chat.id !== env.TG_CHAT_ID) return
|
||||||
|
|
||||||
declineShout(ctx.match.id)
|
declineShout(ctx.match.id)
|
||||||
|
|
||||||
await ctx.editMessageWith(msg => ({
|
await ctx.editMessageWith(msg => ({
|
||||||
text: html`${msg.textWithEntities}<br><br>❌ Declined!`,
|
text: html`${msg.textWithEntities}<br><br>❌ Declined!`,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_del')), async (ctx) => {
|
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_del')), async (ctx) => {
|
||||||
const serial = Number(ctx.command[1])
|
const serial = Number(ctx.command[1])
|
||||||
if (Number.isNaN(serial)) {
|
if (Number.isNaN(serial)) {
|
||||||
await ctx.answerText('invalid serial')
|
await ctx.answerText('invalid serial')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteBySerial(serial)
|
deleteBySerial(serial)
|
||||||
await ctx.answerText('deleted')
|
await ctx.answerText('deleted')
|
||||||
})
|
})
|
||||||
|
|
||||||
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_ban')), async (ctx) => {
|
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_ban')), async (ctx) => {
|
||||||
const ip = ctx.command[1]
|
const ip = ctx.command[1]
|
||||||
const duration = parseDuration(ctx.command[2])
|
const duration = parseDuration(ctx.command[2])
|
||||||
|
|
||||||
if (!duration) {
|
if (!duration) {
|
||||||
await ctx.answerText('invalid duration')
|
await ctx.answerText('invalid duration')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const until = Date.now() + duration
|
const until = Date.now() + duration
|
||||||
|
|
||||||
banShouts(ip, until)
|
banShouts(ip, until)
|
||||||
await ctx.answerText(`banned ${ip} until ${new Date(until).toISOString()}`)
|
await ctx.answerText(`banned ${ip} until ${new Date(until).toISOString()}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_unban')), async (ctx) => {
|
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_unban')), async (ctx) => {
|
||||||
const ip = ctx.command[1]
|
const ip = ctx.command[1]
|
||||||
|
|
||||||
unbanShouts(ip)
|
unbanShouts(ip)
|
||||||
await ctx.answerText('done')
|
await ctx.answerText('done')
|
||||||
})
|
})
|
||||||
|
|
||||||
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_reply')), async (ctx) => {
|
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_reply')), async (ctx) => {
|
||||||
const serial = Number(ctx.command[1])
|
const serial = Number(ctx.command[1])
|
||||||
if (Number.isNaN(serial)) {
|
if (Number.isNaN(serial)) {
|
||||||
await ctx.answerText('invalid serial')
|
await ctx.answerText('invalid serial')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
answerBySerial(serial, ctx.command[2])
|
answerBySerial(serial, ctx.command[2])
|
||||||
await ctx.answerText('done')
|
await ctx.answerText('done')
|
||||||
})
|
})
|
||||||
|
|
||||||
// eslint-disable-next-line regexp/no-unused-capturing-group
|
// eslint-disable-next-line regexp/no-unused-capturing-group
|
||||||
const APPROVED_REGEX = /Approved! ID: (\d+)/
|
const APPROVED_REGEX = /Approved! ID: (\d+)/
|
||||||
|
|
||||||
dp.onNewMessage(
|
dp.onNewMessage(
|
||||||
filters.replyTo(
|
filters.replyTo(
|
||||||
msg => APPROVED_REGEX.test(msg.text),
|
msg => APPROVED_REGEX.test(msg.text),
|
||||||
),
|
),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const msg = await ctx.getReplyTo()
|
const msg = await ctx.getReplyTo()
|
||||||
|
|
||||||
const serial = assertMatches(msg.text, APPROVED_REGEX)[1]
|
const serial = assertMatches(msg.text, APPROVED_REGEX)[1]
|
||||||
|
|
||||||
answerBySerial(Number(serial), ctx.text)
|
answerBySerial(Number(serial), ctx.text)
|
||||||
await ctx.answerText('reply sent')
|
await ctx.answerText('reply sent')
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export { dp as shoutboxDp }
|
export { dp as shoutboxDp }
|
||||||
|
|
|
@ -1,254 +1,244 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const UserSchema = z.object({
|
const UserSchema = z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
name: z.string().optional().nullable(),
|
name: z.string().optional().nullable(),
|
||||||
username: z.string().optional().nullable(),
|
username: z.string().optional().nullable(),
|
||||||
host: z
|
host: z
|
||||||
.string()
|
.string()
|
||||||
.describe('The local host is represented with `null`.')
|
.describe('The local host is represented with `null`.')
|
||||||
.optional()
|
.optional().nullable(),
|
||||||
.nullable(),
|
avatarUrl: z.string().optional().nullable(),
|
||||||
avatarUrl: z.string().optional().nullable(),
|
avatarBlurhash: z.string().optional().nullable(),
|
||||||
avatarBlurhash: z.string().optional().nullable(),
|
avatarDecorations: z
|
||||||
avatarDecorations: z
|
.array(
|
||||||
.array(
|
z.object({
|
||||||
z.object({
|
id: z.string().optional().nullable(),
|
||||||
id: z.string().optional().nullable(),
|
angle: z.number().optional().nullable(),
|
||||||
angle: z.number().optional().nullable(),
|
flipH: z.boolean().optional().nullable(),
|
||||||
flipH: z.boolean().optional().nullable(),
|
url: z.string().optional().nullable(),
|
||||||
url: z.string().optional().nullable(),
|
offsetX: z.number().optional().nullable(),
|
||||||
offsetX: z.number().optional().nullable(),
|
offsetY: z.number().optional().nullable(),
|
||||||
offsetY: z.number().optional().nullable(),
|
}),
|
||||||
}),
|
)
|
||||||
)
|
.optional().nullable(),
|
||||||
.optional()
|
isAdmin: z.boolean().optional().nullable(),
|
||||||
.nullable(),
|
isModerator: z.boolean().optional().nullable(),
|
||||||
isAdmin: z.boolean().optional().nullable(),
|
isSilenced: z.boolean().optional().nullable(),
|
||||||
isModerator: z.boolean().optional().nullable(),
|
noindex: z.boolean().optional().nullable(),
|
||||||
isSilenced: z.boolean().optional().nullable(),
|
isBot: z.boolean().optional().nullable(),
|
||||||
noindex: z.boolean().optional().nullable(),
|
isCat: z.boolean().optional().nullable(),
|
||||||
isBot: z.boolean().optional().nullable(),
|
speakAsCat: z.boolean().optional().nullable(),
|
||||||
isCat: z.boolean().optional().nullable(),
|
instance: z
|
||||||
speakAsCat: z.boolean().optional().nullable(),
|
.object({
|
||||||
instance: z
|
name: z.string().optional().nullable(),
|
||||||
.object({
|
softwareName: z.string().optional().nullable(),
|
||||||
name: z.string().optional().nullable(),
|
softwareVersion: z.string().optional().nullable(),
|
||||||
softwareName: z.string().optional().nullable(),
|
iconUrl: z.string().optional().nullable(),
|
||||||
softwareVersion: z.string().optional().nullable(),
|
faviconUrl: z.string().optional().nullable(),
|
||||||
iconUrl: z.string().optional().nullable(),
|
themeColor: z.string().optional().nullable(),
|
||||||
faviconUrl: z.string().optional().nullable(),
|
})
|
||||||
themeColor: z.string().optional().nullable(),
|
.optional().nullable(),
|
||||||
})
|
emojis: z.record(z.string()).optional().nullable(),
|
||||||
.optional()
|
onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(),
|
||||||
.nullable(),
|
badgeRoles: z
|
||||||
emojis: z.record(z.string()).optional().nullable(),
|
.array(
|
||||||
onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(),
|
z.object({
|
||||||
badgeRoles: z
|
name: z.string().optional().nullable(),
|
||||||
.array(
|
iconUrl: z.string().optional().nullable(),
|
||||||
z.object({
|
displayOrder: z.number().optional().nullable(),
|
||||||
name: z.string().optional().nullable(),
|
}),
|
||||||
iconUrl: z.string().optional().nullable(),
|
)
|
||||||
displayOrder: z.number().optional().nullable(),
|
.optional().nullable(),
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
})
|
})
|
||||||
export type MkUser = z.infer<typeof UserSchema>
|
export type MkUser = z.infer<typeof UserSchema>
|
||||||
|
|
||||||
const NoteSchema = z.object({
|
const NoteSchema = z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().optional().nullable(),
|
createdAt: z.string().optional().nullable(),
|
||||||
deletedAt: z.string().optional().nullable(),
|
deletedAt: z.string().optional().nullable(),
|
||||||
text: z.string().optional().nullable(),
|
text: z.string().optional().nullable(),
|
||||||
cw: z.string().optional().nullable(),
|
cw: z.string().optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
user: z
|
user: z
|
||||||
.object({})
|
.object({})
|
||||||
.catchall(z.any())
|
.catchall(z.any())
|
||||||
.optional()
|
.optional().nullable(),
|
||||||
.nullable(),
|
replyId: z.string().optional().nullable(),
|
||||||
replyId: z.string().optional().nullable(),
|
renoteId: z.string().optional().nullable(),
|
||||||
renoteId: z.string().optional().nullable(),
|
reply: z
|
||||||
reply: z
|
.object({})
|
||||||
.object({})
|
.catchall(z.any())
|
||||||
.catchall(z.any())
|
.optional().nullable(),
|
||||||
.optional()
|
renote: z
|
||||||
.nullable(),
|
.object({})
|
||||||
renote: z
|
.catchall(z.any())
|
||||||
.object({})
|
.optional().nullable(),
|
||||||
.catchall(z.any())
|
isHidden: z.boolean().optional().nullable(),
|
||||||
.optional()
|
visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(),
|
||||||
.nullable(),
|
mentions: z.array(z.string()).optional().nullable(),
|
||||||
isHidden: z.boolean().optional().nullable(),
|
visibleUserIds: z.array(z.string()).optional().nullable(),
|
||||||
visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(),
|
fileIds: z.array(z.string()).optional().nullable(),
|
||||||
mentions: z.array(z.string()).optional().nullable(),
|
files: z.array(z.object({}).catchall(z.any())).optional().nullable(),
|
||||||
visibleUserIds: z.array(z.string()).optional().nullable(),
|
tags: z.array(z.string()).optional().nullable(),
|
||||||
fileIds: z.array(z.string()).optional().nullable(),
|
poll: z
|
||||||
files: z.array(z.object({}).catchall(z.any())).optional().nullable(),
|
.object({
|
||||||
tags: z.array(z.string()).optional().nullable(),
|
expiresAt: z.string().optional().nullable(),
|
||||||
poll: z
|
multiple: z.boolean().optional().nullable(),
|
||||||
.object({
|
choices: z
|
||||||
expiresAt: z.string().optional().nullable(),
|
.array(
|
||||||
multiple: z.boolean().optional().nullable(),
|
z.object({
|
||||||
choices: z
|
isVoted: z.boolean().optional().nullable(),
|
||||||
.array(
|
text: z.string().optional().nullable(),
|
||||||
z.object({
|
votes: z.number().optional().nullable(),
|
||||||
isVoted: z.boolean().optional().nullable(),
|
}),
|
||||||
text: z.string().optional().nullable(),
|
)
|
||||||
votes: z.number().optional().nullable(),
|
.optional().nullable(),
|
||||||
}),
|
})
|
||||||
)
|
.optional().nullable(),
|
||||||
.optional()
|
emojis: z.record(z.string(), z.any()).optional().nullable(),
|
||||||
.nullable(),
|
channelId: z.string().optional().nullable(),
|
||||||
})
|
channel: z
|
||||||
.optional()
|
.object({
|
||||||
.nullable(),
|
id: z.string().optional().nullable(),
|
||||||
emojis: z.record(z.string(), z.any()).optional().nullable(),
|
name: z.string().optional().nullable(),
|
||||||
channelId: z.string().optional().nullable(),
|
color: z.string().optional().nullable(),
|
||||||
channel: z
|
isSensitive: z.boolean().optional().nullable(),
|
||||||
.object({
|
allowRenoteToExternal: z.boolean().optional().nullable(),
|
||||||
id: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
name: z.string().optional().nullable(),
|
})
|
||||||
color: z.string().optional().nullable(),
|
.optional().nullable(),
|
||||||
isSensitive: z.boolean().optional().nullable(),
|
localOnly: z.boolean().optional().nullable(),
|
||||||
allowRenoteToExternal: z.boolean().optional().nullable(),
|
reactionAcceptance: z.string().optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
reactionEmojis: z.record(z.string(), z.string()).optional().nullable(),
|
||||||
})
|
reactions: z.record(z.string(), z.number()).optional().nullable(),
|
||||||
.optional()
|
renoteCount: z.number().optional().nullable(),
|
||||||
.nullable(),
|
repliesCount: z.number().optional().nullable(),
|
||||||
localOnly: z.boolean().optional().nullable(),
|
uri: z.string().optional().nullable(),
|
||||||
reactionAcceptance: z.string().optional().nullable(),
|
url: z.string().optional().nullable(),
|
||||||
reactionEmojis: z.record(z.string(), z.string()).optional().nullable(),
|
reactionAndUserPairCache: z.array(z.string()).optional().nullable(),
|
||||||
reactions: z.record(z.string(), z.number()).optional().nullable(),
|
clippedCount: z.number().optional().nullable(),
|
||||||
renoteCount: z.number().optional().nullable(),
|
myReaction: z.string().optional().nullable(),
|
||||||
repliesCount: z.number().optional().nullable(),
|
|
||||||
uri: z.string().optional().nullable(),
|
|
||||||
url: z.string().optional().nullable(),
|
|
||||||
reactionAndUserPairCache: z.array(z.string()).optional().nullable(),
|
|
||||||
clippedCount: z.number().optional().nullable(),
|
|
||||||
myReaction: z.string().optional().nullable(),
|
|
||||||
})
|
})
|
||||||
export type MkNote = z.infer<typeof NoteSchema>
|
export type MkNote = z.infer<typeof NoteSchema>
|
||||||
|
|
||||||
const NotificationSchema = z.union([
|
const NotificationSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('note').optional().nullable(),
|
type: z.literal('note').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
note: NoteSchema.optional().nullable(),
|
note: NoteSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('mention').optional().nullable(),
|
type: z.literal('mention').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
note: NoteSchema.optional().nullable(),
|
note: NoteSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('reply').optional().nullable(),
|
type: z.literal('reply').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
note: NoteSchema.optional().nullable(),
|
note: NoteSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('renote').optional().nullable(),
|
type: z.literal('renote').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
note: NoteSchema.optional().nullable(),
|
note: NoteSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('quote').optional().nullable(),
|
type: z.literal('quote').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
note: NoteSchema.optional().nullable(),
|
note: NoteSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('reaction').optional().nullable(),
|
type: z.literal('reaction').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
note: NoteSchema.optional().nullable(),
|
note: NoteSchema.optional().nullable(),
|
||||||
reaction: z.string().optional().nullable(),
|
reaction: z.string().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('pollEnded').optional().nullable(),
|
type: z.literal('pollEnded').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
note: NoteSchema.optional().nullable(),
|
note: NoteSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.union([z.literal('follow'), z.literal('unfollow')]).optional().nullable(),
|
type: z.union([z.literal('follow'), z.literal('unfollow')]).optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('receiveFollowRequest').optional().nullable(),
|
type: z.literal('receiveFollowRequest').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('followRequestAccepted').optional().nullable(),
|
type: z.literal('followRequestAccepted').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('roleAssigned').optional().nullable(),
|
type: z.literal('roleAssigned').optional().nullable(),
|
||||||
role: z.record(z.any()).optional().nullable(),
|
role: z.record(z.any()).optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('achievementEarned').optional().nullable(),
|
type: z.literal('achievementEarned').optional().nullable(),
|
||||||
achievement: z.string().optional().nullable(),
|
achievement: z.string().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('app').optional().nullable(),
|
type: z.literal('app').optional().nullable(),
|
||||||
body: z.string().optional().nullable(),
|
body: z.string().optional().nullable(),
|
||||||
header: z.string().optional().nullable(),
|
header: z.string().optional().nullable(),
|
||||||
icon: z.string().optional().nullable(),
|
icon: z.string().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
createdAt: z.string().datetime().optional().nullable(),
|
createdAt: z.string().datetime().optional().nullable(),
|
||||||
type: z.literal('edited').optional().nullable(),
|
type: z.literal('edited').optional().nullable(),
|
||||||
user: UserSchema.optional().nullable(),
|
user: UserSchema.optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
note: NoteSchema.optional().nullable(),
|
note: NoteSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
] as const)
|
] as const)
|
||||||
|
|
||||||
export const MisskeyWebhookBodySchema = z.object({
|
export const MisskeyWebhookBodySchema = z.object({
|
||||||
server: z.string(),
|
server: z.string(),
|
||||||
hookId: z.string(),
|
hookId: z.string(),
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
eventId: z.string(),
|
eventId: z.string(),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
body: z.object({
|
body: z.object({
|
||||||
notification: NotificationSchema,
|
notification: NotificationSchema,
|
||||||
}).partial(),
|
}).partial(),
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,23 +1,23 @@
|
||||||
|
import 'dotenv/config'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { zodValidateSync } from '~/utils/zod'
|
import { zodValidateSync } from '~/utils/zod'
|
||||||
|
|
||||||
import 'dotenv/config'
|
|
||||||
|
|
||||||
export const env = zodValidateSync(
|
export const env = zodValidateSync(
|
||||||
z.object({
|
z.object({
|
||||||
UMAMI_HOST: z.string().url(),
|
UMAMI_HOST: z.string().url(),
|
||||||
UMAMI_TOKEN: z.string(),
|
UMAMI_TOKEN: z.string(),
|
||||||
UMAMI_SITE_ID: z.string().uuid(),
|
UMAMI_SITE_ID: z.string().uuid(),
|
||||||
TG_API_ID: z.coerce.number(),
|
TG_API_ID: z.coerce.number(),
|
||||||
TG_API_HASH: z.string(),
|
TG_API_HASH: z.string(),
|
||||||
TG_BOT_TOKEN: z.string(),
|
TG_BOT_TOKEN: z.string(),
|
||||||
TG_CHAT_ID: z.coerce.number(),
|
TG_CHAT_ID: z.coerce.number(),
|
||||||
CURRENCY_API_TOKEN: z.string(),
|
CURRENCY_API_TOKEN: z.string(),
|
||||||
FAKE_DEEPL_SECRET: z.string(),
|
FAKE_DEEPL_SECRET: z.string(),
|
||||||
MK_WEBHOOK_SECRET: z.string(),
|
MK_WEBHOOK_SECRET: z.string(),
|
||||||
QBT_WEBHOOK_SECRET: z.string(),
|
QBT_WEBHOOK_SECRET: z.string(),
|
||||||
CSRF_SECRET: z.string(),
|
CSRF_SECRET: z.string(),
|
||||||
}),
|
}),
|
||||||
process.env,
|
process.env,
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,16 +4,16 @@ import { sql } from 'drizzle-orm'
|
||||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
export const shouts = sqliteTable('shouts', {
|
export const shouts = sqliteTable('shouts', {
|
||||||
id: text('id').primaryKey().$defaultFn(randomUUID),
|
id: text('id').primaryKey().$defaultFn(randomUUID),
|
||||||
serial: integer('serial').notNull().default(0),
|
serial: integer('serial').notNull().default(0),
|
||||||
fromIp: text('from_ip'),
|
fromIp: text('from_ip'),
|
||||||
pending: integer('pending', { mode: 'boolean' }).notNull().default(true),
|
pending: integer('pending', { mode: 'boolean' }).notNull().default(true),
|
||||||
text: text('text'),
|
text: text('text'),
|
||||||
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
reply: text('reply'),
|
reply: text('reply'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const shoutsBans = sqliteTable('shouts_bans', {
|
export const shoutsBans = sqliteTable('shouts_bans', {
|
||||||
ip: text('ip').primaryKey(),
|
ip: text('ip').primaryKey(),
|
||||||
expires: integer('expires').notNull(),
|
expires: integer('expires').notNull(),
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AsyncResource } from '@fuman/utils'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { AsyncResource } from '@fuman/utils'
|
||||||
|
|
||||||
import { env } from '../env'
|
import { env } from '../env'
|
||||||
import { ffetch } from '../utils/fetch.ts'
|
import { ffetch } from '../utils/fetch.ts'
|
||||||
|
@ -8,52 +8,52 @@ export const AVAILABLE_CURRENCIES = ['RUB', 'USD', 'EUR']
|
||||||
const TTL = 60 * 60 * 1000 // 1 hour
|
const TTL = 60 * 60 * 1000 // 1 hour
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
meta: z.object({
|
meta: z.object({
|
||||||
last_updated_at: z.string(),
|
last_updated_at: z.string(),
|
||||||
}),
|
}),
|
||||||
data: z.record(z.string(), z.object({
|
data: z.record(z.string(), z.object({
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
value: z.number(),
|
value: z.number(),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
const reloadable = new AsyncResource<z.infer<typeof schema>>({
|
const reloadable = new AsyncResource<z.infer<typeof schema>>({
|
||||||
// expiresIn: () => TTL,
|
// expiresIn: () => TTL,
|
||||||
async fetcher() {
|
async fetcher() {
|
||||||
// https://api.currencyapi.com/v3/latest?apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6¤cies=USD%2CEUR
|
// https://api.currencyapi.com/v3/latest?apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6¤cies=USD%2CEUR
|
||||||
// apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6¤cies=USD%2CEUR
|
// apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6¤cies=USD%2CEUR
|
||||||
const res = await ffetch('https://api.currencyapi.com/v3/latest', {
|
const res = await ffetch('https://api.currencyapi.com/v3/latest', {
|
||||||
query: {
|
query: {
|
||||||
apikey: env.CURRENCY_API_TOKEN,
|
apikey: env.CURRENCY_API_TOKEN,
|
||||||
currencies: AVAILABLE_CURRENCIES.slice(1).join(','),
|
currencies: AVAILABLE_CURRENCIES.slice(1).join(','),
|
||||||
base_currency: AVAILABLE_CURRENCIES[0],
|
base_currency: AVAILABLE_CURRENCIES[0],
|
||||||
},
|
},
|
||||||
}).parsedJson(schema)
|
}).parsedJson(schema)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: res,
|
data: res,
|
||||||
expiresIn: TTL,
|
expiresIn: TTL,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swr: true,
|
swr: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function convertCurrencySync(from: string, to: string, amount: number) {
|
export function convertCurrencySync(from: string, to: string, amount: number) {
|
||||||
if (from === to) return amount
|
if (from === to) return amount
|
||||||
if (!AVAILABLE_CURRENCIES.includes(from)) throw new Error(`Invalid currency: ${from}`)
|
if (!AVAILABLE_CURRENCIES.includes(from)) throw new Error(`Invalid currency: ${from}`)
|
||||||
if (!AVAILABLE_CURRENCIES.includes(to)) throw new Error(`Invalid currency: ${to}`)
|
if (!AVAILABLE_CURRENCIES.includes(to)) throw new Error(`Invalid currency: ${to}`)
|
||||||
|
|
||||||
const data = reloadable.getCached()
|
const data = reloadable.getCached()
|
||||||
if (!data) throw new Error('currencies not available')
|
if (!data) throw new Error('currencies not available')
|
||||||
|
|
||||||
if (from !== AVAILABLE_CURRENCIES[0]) {
|
if (from !== AVAILABLE_CURRENCIES[0]) {
|
||||||
// convert to base currency first
|
// convert to base currency first
|
||||||
amount /= data.data[from].value
|
amount /= data.data[from].value
|
||||||
}
|
}
|
||||||
|
|
||||||
return amount * data.data[to].value
|
return amount * data.data[to].value
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchConvertRates() {
|
export async function fetchConvertRates() {
|
||||||
await reloadable.get()
|
await reloadable.get()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,118 +1,118 @@
|
||||||
import { randomPick } from '~/utils/random'
|
import { randomPick } from '~/utils/random'
|
||||||
|
|
||||||
const USER_AGENTS = [
|
const USER_AGENTS = [
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
|
||||||
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
|
||||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
|
||||||
'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
||||||
'Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
||||||
'Mozilla/5.0 (Linux; Android 10; SM-A102U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 10; SM-A102U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
||||||
'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
||||||
'Mozilla/5.0 (Linux; Android 10; SM-N960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 10; SM-N960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
||||||
'Mozilla/5.0 (Linux; Android 10; LM-Q720) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 10; LM-Q720) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
||||||
'Mozilla/5.0 (Linux; Android 10; LM-X420) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 10; LM-X420) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
||||||
'Mozilla/5.0 (Linux; Android 10; LM-Q710(FGN)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 10; LM-Q710(FGN)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37',
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37',
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37',
|
||||||
'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
|
'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
|
||||||
'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
|
'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
|
||||||
'Mozilla/5.0 (Linux; Android 10; Pixel 3 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
|
'Mozilla/5.0 (Linux; Android 10; Pixel 3 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
|
||||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 EdgiOS/100.1185.50 Mobile/15E148 Safari/605.1.15',
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 EdgiOS/100.1185.50 Mobile/15E148 Safari/605.1.15',
|
||||||
'Mozilla/5.0 (Windows Mobile 10; Android 10.0; Microsoft; Lumia 950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Mobile Safari/537.36 Edge/40.15254.603',
|
'Mozilla/5.0 (Windows Mobile 10; Android 10.0; Microsoft; Lumia 950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Mobile Safari/537.36 Edge/40.15254.603',
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15',
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15',
|
||||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
|
||||||
'Mozilla/5.0 (iPad; CPU OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
|
'Mozilla/5.0 (iPad; CPU OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
|
||||||
'Mozilla/5.0 (iPod touch; CPU iPhone 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
|
'Mozilla/5.0 (iPod touch; CPU iPhone 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
|
||||||
]
|
]
|
||||||
|
|
||||||
const Tk = {
|
const Tk = {
|
||||||
ac(input: string) {
|
ac(input: string) {
|
||||||
const e = new TextEncoder().encode(input)
|
const e = new TextEncoder().encode(input)
|
||||||
let f = 0
|
let f = 0
|
||||||
let a = 0
|
let a = 0
|
||||||
for (f = 0; f < e.length; f++) {
|
for (f = 0; f < e.length; f++) {
|
||||||
a += e[f]
|
a += e[f]
|
||||||
a = Tk.yc(a, '+-a^+6')
|
a = Tk.yc(a, '+-a^+6')
|
||||||
}
|
}
|
||||||
a = Tk.yc(a, '+-3^+b+-f')
|
a = Tk.yc(a, '+-3^+b+-f')
|
||||||
a ^= 0
|
a ^= 0
|
||||||
if (a < 0) { a = (a & 0x7FFFFFFF) + 0x80000000 }
|
if (a < 0) { a = (a & 0x7FFFFFFF) + 0x80000000 }
|
||||||
a %= 1e6
|
a %= 1e6
|
||||||
return `${a}.${a}`
|
return `${a}.${a}`
|
||||||
},
|
},
|
||||||
yc(a: number, b: string) {
|
yc(a: number, b: string) {
|
||||||
for (let c = 0; c < b.length - 2; c += 3) {
|
for (let c = 0; c < b.length - 2; c += 3) {
|
||||||
const d = b[c + 2]
|
const d = b[c + 2]
|
||||||
const number = d >= 'a'
|
const number = d >= 'a'
|
||||||
// @ts-expect-error lol
|
// @ts-expect-error lol
|
||||||
? d - 87
|
? d - 87
|
||||||
: Number.parseInt(d)
|
: Number.parseInt(d)
|
||||||
const number2 = b[c + 1] === '+'
|
const number2 = b[c + 1] === '+'
|
||||||
? a >>> number
|
? a >>> number
|
||||||
: a << number
|
: a << number
|
||||||
a = b[c] === '+'
|
a = b[c] === '+'
|
||||||
? a + number2 & 0xFFFFFFFF
|
? a + number2 & 0xFFFFFFFF
|
||||||
: a ^ number2
|
: a ^ number2
|
||||||
}
|
}
|
||||||
return a
|
return a
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async function translate(text: string, fromLanguage: string, toLanguage: string) {
|
async function translate(text: string, fromLanguage: string, toLanguage: string) {
|
||||||
let json = null
|
let json = null
|
||||||
const response = await fetch('https://translate.googleapis.com/translate_a/single?client=gtx&'
|
const response = await fetch('https://translate.googleapis.com/translate_a/single?client=gtx&'
|
||||||
+ `sl=${encodeURIComponent(fromLanguage)}&tl=${encodeURIComponent(toLanguage)}&dt=t&ie=UTF-8&`
|
+ `sl=${encodeURIComponent(fromLanguage)}&tl=${encodeURIComponent(toLanguage)}&dt=t&ie=UTF-8&`
|
||||||
+ 'oe=UTF-8&otf=1&ssel=0&tsel=0&kc=7&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss'
|
+ 'oe=UTF-8&otf=1&ssel=0&tsel=0&kc=7&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss'
|
||||||
+ `&tk=${Tk.ac(text)}`
|
+ `&tk=${Tk.ac(text)}`
|
||||||
+ '&source=input'
|
+ '&source=input'
|
||||||
+ `&q=${encodeURIComponent(text)}`, {
|
+ `&q=${encodeURIComponent(text)}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': randomPick(USER_AGENTS),
|
'User-Agent': randomPick(USER_AGENTS),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Error while requesting translation')
|
throw new Error('Error while requesting translation')
|
||||||
}
|
}
|
||||||
const content = await response.text()
|
const content = await response.text()
|
||||||
json = JSON.parse(content)
|
json = JSON.parse(content)
|
||||||
|
|
||||||
let sourceLanguage = null
|
let sourceLanguage = null
|
||||||
sourceLanguage = json[2]
|
sourceLanguage = json[2]
|
||||||
let result = ''
|
let result = ''
|
||||||
|
|
||||||
for (let i = 0; i < json[0]?.length; ++i) {
|
for (let i = 0; i < json[0]?.length; ++i) {
|
||||||
const block = json[0][i][0]
|
const block = json[0][i][0]
|
||||||
if (block == null) { continue }
|
if (block == null) { continue }
|
||||||
const blockText = block.toString()
|
const blockText = block.toString()
|
||||||
if (blockText !== 'null') { result += blockText }
|
if (blockText !== 'null') { result += blockText }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sourceLanguage,
|
sourceLanguage,
|
||||||
originalText: text,
|
originalText: text,
|
||||||
translatedText: result,
|
translatedText: result,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function translateChunked(text: string, fromLanguage: string, toLanguage: string) {
|
export async function translateChunked(text: string, fromLanguage: string, toLanguage: string) {
|
||||||
let result = ''
|
let result = ''
|
||||||
const chunks = text.match(/.{1,5000}/gs)!
|
const chunks = text.match(/.{1,5000}/gs)!
|
||||||
const promises = []
|
const promises = []
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; ++i) {
|
for (let i = 0; i < chunks.length; ++i) {
|
||||||
promises.push(translate(chunks[i], fromLanguage, toLanguage))
|
promises.push(translate(chunks[i], fromLanguage, toLanguage))
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(promises)
|
const results = await Promise.all(promises)
|
||||||
for (let i = 0; i < results.length; ++i) {
|
for (let i = 0; i < results.length; ++i) {
|
||||||
result += results[i].translatedText
|
result += results[i].translatedText
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sourceLanguage: results[0].sourceLanguage,
|
sourceLanguage: results[0].sourceLanguage,
|
||||||
originalText: text,
|
originalText: text,
|
||||||
translatedText: result,
|
translatedText: result,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,57 +1,57 @@
|
||||||
import type { LastSeenItem } from './index.ts'
|
|
||||||
import { AsyncResource } from '@fuman/utils'
|
import { AsyncResource } from '@fuman/utils'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { ffetch } from '../../utils/fetch.ts'
|
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 ENDPOINT = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed'
|
||||||
const TTL = 3 * 60 * 60 * 1000 // 3 hours
|
const TTL = 3 * 60 * 60 * 1000 // 3 hours
|
||||||
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
|
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
uri: z.string(),
|
uri: z.string(),
|
||||||
record: z.object({
|
record: z.object({
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const bskyLastSeen = new AsyncResource<LastSeenItem | null>({
|
export const bskyLastSeen = new AsyncResource<LastSeenItem | null>({
|
||||||
async fetcher() {
|
async fetcher() {
|
||||||
const res = await ffetch(ENDPOINT, {
|
const res = await ffetch(ENDPOINT, {
|
||||||
query: {
|
query: {
|
||||||
actor: 'did:web:tei.su',
|
actor: 'did:web:tei.su',
|
||||||
filter: 'posts_and_author_threads',
|
filter: 'posts_and_author_threads',
|
||||||
limit: 1,
|
limit: 1,
|
||||||
},
|
},
|
||||||
}).parsedJson(z.object({
|
}).parsedJson(z.object({
|
||||||
feed: z.array(z.object({
|
feed: z.array(z.object({
|
||||||
post: schema,
|
post: schema,
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const post = res.feed[0].post
|
const post = res.feed[0].post
|
||||||
|
|
||||||
const postId = post.uri.match(/at:\/\/did:web:tei.su\/app\.bsky\.feed\.post\/([a-zA-Z0-9]+)/)
|
const postId = post.uri.match(/at:\/\/did:web:tei.su\/app\.bsky\.feed\.post\/([a-zA-Z0-9]+)/)
|
||||||
if (postId) {
|
if (postId) {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
source: 'bsky',
|
source: 'bsky',
|
||||||
sourceLink: 'https://bsky.app/profile/did:web:tei.su',
|
sourceLink: 'https://bsky.app/profile/did:web:tei.su',
|
||||||
time: new Date(post.record.createdAt).getTime(),
|
time: new Date(post.record.createdAt).getTime(),
|
||||||
text: post.record.text.slice(0, 40) || '[no text]',
|
text: post.record.text.slice(0, 40) || '[no text]',
|
||||||
link: `https://bsky.app/profile/did:web:tei.su/post/${postId[1]}`,
|
link: `https://bsky.app/profile/did:web:tei.su/post/${postId[1]}`,
|
||||||
},
|
},
|
||||||
expiresIn: TTL,
|
expiresIn: TTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
expiresIn: TTL,
|
expiresIn: TTL,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swr: true,
|
swr: true,
|
||||||
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
|
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,80 +1,80 @@
|
||||||
import type { LastSeenItem } from './index.ts'
|
|
||||||
import { AsyncResource } from '@fuman/utils'
|
import { AsyncResource } from '@fuman/utils'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { ffetch } from '../../utils/fetch.ts'
|
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 ENDPOINT = 'https://git.stupid.fish/api/v1/users/teidesu/activities/feeds?only-performed-by=true'
|
||||||
const TTL = 1 * 60 * 60 * 1000 // 1 hour
|
const TTL = 1 * 60 * 60 * 1000 // 1 hour
|
||||||
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
|
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
// create_repo,rename_repo,star_repo,watch_repo,commit_repo,create_issue,create_pull_request,transfer_repo,push_tag,comment_issue,
|
// create_repo,rename_repo,star_repo,watch_repo,commit_repo,create_issue,create_pull_request,transfer_repo,push_tag,comment_issue,
|
||||||
// merge_pull_request,close_issue,reopen_issue,close_pull_request,reopen_pull_request,delete_tag,delete_branch,mirror_sync_push,
|
// merge_pull_request,close_issue,reopen_issue,close_pull_request,reopen_pull_request,delete_tag,delete_branch,mirror_sync_push,
|
||||||
// mirror_sync_create,mirror_sync_delete,approve_pull_request,reject_pull_request,comment_pull,publish_release,pull_review_dismissed,
|
// mirror_sync_create,mirror_sync_delete,approve_pull_request,reject_pull_request,comment_pull,publish_release,pull_review_dismissed,
|
||||||
// pull_request_ready_for_review,auto_merge_pull_request
|
// pull_request_ready_for_review,auto_merge_pull_request
|
||||||
op_type: z.string(),
|
op_type: z.string(),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
repo: z.object({
|
repo: z.object({
|
||||||
full_name: z.string(),
|
full_name: z.string(),
|
||||||
html_url: z.string(),
|
html_url: z.string(),
|
||||||
}),
|
}),
|
||||||
created: z.string(),
|
created: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// {"Commits":[{"Sha1":"2ee71666823761350bd28a0db9ef9140cd3814cb","Message":"docs: fix docs config\n","AuthorEmail":"alina@tei.su","AuthorName":"alina sireneva","CommitterEmail":"alina@tei.su","CommitterName":"alina sireneva","Timestamp":"2025-01-03T22:42:30+03:00"}],"HeadCommit":{"Sha1":"2ee71666823761350bd28a0db9ef9140cd3814cb","Message":"docs: fix docs config\n","AuthorEmail":"alina@tei.su","AuthorName":"alina sireneva","CommitterEmail":"alina@tei.su","CommitterName":"alina sireneva","Timestamp":"2025-01-03T22:42:30+03:00"},"CompareURL":"teidesu/mtcute/compare/db9b083d3595e78240146e8a590fe70049259612...2ee71666823761350bd28a0db9ef9140cd3814cb","Len":1}
|
// {"Commits":[{"Sha1":"2ee71666823761350bd28a0db9ef9140cd3814cb","Message":"docs: fix docs config\n","AuthorEmail":"alina@tei.su","AuthorName":"alina sireneva","CommitterEmail":"alina@tei.su","CommitterName":"alina sireneva","Timestamp":"2025-01-03T22:42:30+03:00"}],"HeadCommit":{"Sha1":"2ee71666823761350bd28a0db9ef9140cd3814cb","Message":"docs: fix docs config\n","AuthorEmail":"alina@tei.su","AuthorName":"alina sireneva","CommitterEmail":"alina@tei.su","CommitterName":"alina sireneva","Timestamp":"2025-01-03T22:42:30+03:00"},"CompareURL":"teidesu/mtcute/compare/db9b083d3595e78240146e8a590fe70049259612...2ee71666823761350bd28a0db9ef9140cd3814cb","Len":1}
|
||||||
const CommitEventSchema = z.object({
|
const CommitEventSchema = z.object({
|
||||||
Commits: z.array(z.object({
|
Commits: z.array(z.object({
|
||||||
Message: z.string(),
|
Message: z.string(),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
function mkItem(item: z.infer<typeof schema>, text: string) {
|
function mkItem(item: z.infer<typeof schema>, text: string) {
|
||||||
return {
|
return {
|
||||||
source: 'forgejo',
|
source: 'forgejo',
|
||||||
sourceLink: 'https://git.stupid.fish/teidesu',
|
sourceLink: 'https://git.stupid.fish/teidesu',
|
||||||
time: new Date(item.created).getTime(),
|
time: new Date(item.created).getTime(),
|
||||||
text: item.repo.full_name,
|
text: item.repo.full_name,
|
||||||
link: item.repo.html_url,
|
link: item.repo.html_url,
|
||||||
suffix: `: ${text}`,
|
suffix: `: ${text}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const forgejoLastSeen = new AsyncResource<LastSeenItem | null>({
|
export const forgejoLastSeen = new AsyncResource<LastSeenItem | null>({
|
||||||
async fetcher() {
|
async fetcher() {
|
||||||
const res = await ffetch(ENDPOINT).parsedJson(z.array(schema))
|
const res = await ffetch(ENDPOINT).parsedJson(z.array(schema))
|
||||||
|
|
||||||
// for simplicity (and lack of proper documentation) we'll just support a few common events and return the first supported one
|
// for simplicity (and lack of proper documentation) we'll just support a few common events and return the first supported one
|
||||||
|
|
||||||
let result: LastSeenItem | null = null
|
let result: LastSeenItem | null = null
|
||||||
|
|
||||||
for (const item of res) {
|
for (const item of res) {
|
||||||
if (item.op_type === 'commit_repo') {
|
if (item.op_type === 'commit_repo') {
|
||||||
const commits = CommitEventSchema.parse(JSON.parse(item.content)).Commits
|
const commits = CommitEventSchema.parse(JSON.parse(item.content)).Commits
|
||||||
|
|
||||||
result = mkItem(
|
result = mkItem(
|
||||||
item,
|
item,
|
||||||
commits.length === 1 && commits[0].Message.length > 0
|
commits.length === 1 && commits[0].Message.length > 0
|
||||||
? `${commits[0].Message.slice(0, 40)}`
|
? `${commits[0].Message.slice(0, 40)}`
|
||||||
: `pushed ${commits.length} commits`,
|
: `pushed ${commits.length} commits`,
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
} else if (item.op_type === 'close_pull_request') {
|
} else if (item.op_type === 'close_pull_request') {
|
||||||
result = mkItem(item, 'closed pull request')
|
result = mkItem(item, 'closed pull request')
|
||||||
break
|
break
|
||||||
} else if (item.op_type === 'merge_pull_request') {
|
} else if (item.op_type === 'merge_pull_request') {
|
||||||
result = mkItem(item, 'merged pull request')
|
result = mkItem(item, 'merged pull request')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: result,
|
data: result,
|
||||||
expiresIn: TTL,
|
expiresIn: TTL,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swr: true,
|
swr: true,
|
||||||
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
|
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,67 +1,67 @@
|
||||||
import type { LastSeenItem } from './index.ts'
|
|
||||||
import { AsyncResource } from '@fuman/utils'
|
import { AsyncResource } from '@fuman/utils'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { ffetch } from '../../utils/fetch.ts'
|
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 ENDPOINT = 'https://api.github.com/users/teidesu/events/public?per_page=1'
|
||||||
const TTL = 1 * 60 * 60 * 1000 // 1 hour
|
const TTL = 1 * 60 * 60 * 1000 // 1 hour
|
||||||
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
|
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
payload: z.any(),
|
payload: z.any(),
|
||||||
repo: z.object({ name: z.string(), url: z.string() }),
|
repo: z.object({ name: z.string(), url: z.string() }),
|
||||||
public: z.boolean(),
|
public: z.boolean(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const githubLastSeen = new AsyncResource<LastSeenItem | null>({
|
export const githubLastSeen = new AsyncResource<LastSeenItem | null>({
|
||||||
async fetcher() {
|
async fetcher() {
|
||||||
const res = await ffetch(ENDPOINT, {
|
const res = await ffetch(ENDPOINT, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'tei.su/1.0',
|
'User-Agent': 'tei.su/1.0',
|
||||||
'X-GitHub-Api-Version': '2022-11-28',
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
},
|
},
|
||||||
}).parsedJson(z.array(schema))
|
}).parsedJson(z.array(schema))
|
||||||
|
|
||||||
const data = res[0]
|
const data = res[0]
|
||||||
|
|
||||||
const eventTextMapper: Record<string, () => string> = {
|
const eventTextMapper: Record<string, () => string> = {
|
||||||
CreateEvent: () => `${data.payload.ref_type} created`,
|
CreateEvent: () => `${data.payload.ref_type} created`,
|
||||||
DeleteEvent: () => `${data.payload.ref_type} deleted`,
|
DeleteEvent: () => `${data.payload.ref_type} deleted`,
|
||||||
ForkEvent: () => 'forked',
|
ForkEvent: () => 'forked',
|
||||||
GollumEvent: () => 'wiki updated',
|
GollumEvent: () => 'wiki updated',
|
||||||
IssueCommentEvent: () => `issue comment ${data.payload.action}`,
|
IssueCommentEvent: () => `issue comment ${data.payload.action}`,
|
||||||
IssuesEvent: () => `issue ${data.payload.action}`,
|
IssuesEvent: () => `issue ${data.payload.action}`,
|
||||||
PublicEvent: () => 'made public',
|
PublicEvent: () => 'made public',
|
||||||
PullRequestEvent: () => `pr ${data.payload.action}`,
|
PullRequestEvent: () => `pr ${data.payload.action}`,
|
||||||
PushEvent: () => `pushed ${data.payload.distinct_size} commits`,
|
PushEvent: () => `pushed ${data.payload.distinct_size} commits`,
|
||||||
ReleaseEvent: () => `release ${data.payload.action}`,
|
ReleaseEvent: () => `release ${data.payload.action}`,
|
||||||
WatchEvent: () => 'starred',
|
WatchEvent: () => 'starred',
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventTextMapper[data.type]) {
|
if (eventTextMapper[data.type]) {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
source: 'github',
|
source: 'github',
|
||||||
sourceLink: 'https://github.com/teidesu',
|
sourceLink: 'https://github.com/teidesu',
|
||||||
time: new Date(data.created_at).getTime(),
|
time: new Date(data.created_at).getTime(),
|
||||||
text: data.repo.name,
|
text: data.repo.name,
|
||||||
suffix: `: ${eventTextMapper[data.type]()}`,
|
suffix: `: ${eventTextMapper[data.type]()}`,
|
||||||
link: `https://github.com/${data.repo.name}`,
|
link: `https://github.com/${data.repo.name}`,
|
||||||
},
|
},
|
||||||
expiresIn: TTL,
|
expiresIn: TTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
expiresIn: TTL,
|
expiresIn: TTL,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swr: true,
|
swr: true,
|
||||||
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
|
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,51 +1,53 @@
|
||||||
|
import { assertMatches } from '@fuman/utils'
|
||||||
|
|
||||||
import { bskyLastSeen } from './bsky.ts'
|
import { bskyLastSeen } from './bsky.ts'
|
||||||
import { forgejoLastSeen } from './forgejo.ts'
|
|
||||||
import { githubLastSeen } from './github'
|
import { githubLastSeen } from './github'
|
||||||
import { lastfm } from './listenbrainz.ts'
|
import { lastfm } from './listenbrainz.ts'
|
||||||
import { shikimoriLastSeen } from './shikimori'
|
import { shikimoriLastSeen } from './shikimori'
|
||||||
|
import { forgejoLastSeen } from './forgejo.ts'
|
||||||
|
|
||||||
export interface LastSeenItem {
|
export interface LastSeenItem {
|
||||||
source: string
|
source: string
|
||||||
sourceLink: string
|
sourceLink: string
|
||||||
time: number
|
time: number
|
||||||
text: string
|
text: string
|
||||||
suffix?: string
|
suffix?: string
|
||||||
link: string
|
link: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLastSeen() {
|
export async function fetchLastSeen() {
|
||||||
const [
|
const [
|
||||||
lastfmData,
|
lastfmData,
|
||||||
bskyData,
|
bskyData,
|
||||||
shikimoriData,
|
shikimoriData,
|
||||||
githubData,
|
githubData,
|
||||||
forgejoData,
|
forgejoData,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
lastfm.get(),
|
lastfm.get(),
|
||||||
bskyLastSeen.get(),
|
bskyLastSeen.get(),
|
||||||
shikimoriLastSeen.get(),
|
shikimoriLastSeen.get(),
|
||||||
githubLastSeen.get(),
|
githubLastSeen.get(),
|
||||||
forgejoLastSeen.get(),
|
forgejoLastSeen.get(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const res: LastSeenItem[] = []
|
const res: LastSeenItem[] = []
|
||||||
|
|
||||||
if (lastfmData) res.push(lastfmData)
|
if (lastfmData) res.push(lastfmData)
|
||||||
if (bskyData) res.push(bskyData)
|
if (bskyData) res.push(bskyData)
|
||||||
if (shikimoriData) res.push(shikimoriData)
|
if (shikimoriData) res.push(shikimoriData)
|
||||||
|
|
||||||
if (githubData && forgejoData) {
|
if (githubData && forgejoData) {
|
||||||
// only push the last one
|
// only push the last one
|
||||||
if (forgejoData.time > githubData.time) {
|
if (forgejoData.time > githubData.time) {
|
||||||
res.push(forgejoData)
|
res.push(forgejoData)
|
||||||
} else {
|
} else {
|
||||||
res.push(githubData)
|
res.push(githubData)
|
||||||
|
}
|
||||||
|
} else if (githubData) {
|
||||||
|
res.push(githubData)
|
||||||
|
} else if (forgejoData) {
|
||||||
|
res.push(forgejoData)
|
||||||
}
|
}
|
||||||
} else if (githubData) {
|
|
||||||
res.push(githubData)
|
|
||||||
} else if (forgejoData) {
|
|
||||||
res.push(forgejoData)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,71 +1,71 @@
|
||||||
import type { LastSeenItem } from './index.ts'
|
import { z } from 'zod'
|
||||||
import { AsyncResource } from '@fuman/utils'
|
import { AsyncResource } from '@fuman/utils'
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import { ffetch } from '../../utils/fetch.ts'
|
import { ffetch } from '../../utils/fetch.ts'
|
||||||
|
|
||||||
|
import type { LastSeenItem } from './index.ts'
|
||||||
|
|
||||||
const LB_TTL = 1000 * 60 * 5 // 5 minutes
|
const LB_TTL = 1000 * 60 * 5 // 5 minutes
|
||||||
const LB_STALE_TTL = 1000 * 60 * 60 // 1 hour
|
const LB_STALE_TTL = 1000 * 60 * 60 // 1 hour
|
||||||
const LB_USERNAME = 'teidumb'
|
const LB_USERNAME = 'teidumb'
|
||||||
|
|
||||||
const LbListen = z.object({
|
const LbListen = z.object({
|
||||||
listened_at: z.number(),
|
listened_at: z.number(),
|
||||||
track_metadata: z.object({
|
track_metadata: z.object({
|
||||||
artist_name: z.string(),
|
artist_name: z.string(),
|
||||||
track_name: z.string(),
|
track_name: z.string(),
|
||||||
additional_info: z.object({
|
additional_info: z.object({
|
||||||
origin_url: z.string().optional(),
|
origin_url: z.string().optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
mbid_mapping: z.object({
|
mbid_mapping: z.object({
|
||||||
recording_mbid: z.string().optional(),
|
recording_mbid: z.string().optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ResponseSchema = z.object({
|
const ResponseSchema = z.object({
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
listens: z.array(LbListen),
|
listens: z.array(LbListen),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const lastfm = new AsyncResource<LastSeenItem | null>({
|
export const lastfm = new AsyncResource<LastSeenItem | null>({
|
||||||
async fetcher({ current }) {
|
async fetcher({ current }) {
|
||||||
const res = await ffetch(`https://api.listenbrainz.org/1/user/${LB_USERNAME}/listens`, {
|
const res = await ffetch(`https://api.listenbrainz.org/1/user/${LB_USERNAME}/listens`, {
|
||||||
query: {
|
query: {
|
||||||
count: 1,
|
count: 1,
|
||||||
min_ts: current ? Math.floor(current.time / 1000) : '',
|
min_ts: current ? Math.floor(current.time / 1000) : '',
|
||||||
},
|
},
|
||||||
}).parsedJson(ResponseSchema)
|
}).parsedJson(ResponseSchema)
|
||||||
|
|
||||||
if (!res.payload.listens.length) {
|
if (!res.payload.listens.length) {
|
||||||
return {
|
return {
|
||||||
data: current,
|
data: current,
|
||||||
expiresIn: 0,
|
expiresIn: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const listen = res.payload.listens[0]
|
const listen = res.payload.listens[0]
|
||||||
|
|
||||||
let url: string | undefined
|
let url: string | undefined
|
||||||
if (listen.track_metadata.mbid_mapping?.recording_mbid) {
|
if (listen.track_metadata.mbid_mapping?.recording_mbid) {
|
||||||
url = `https://musicbrainz.org/recording/${listen.track_metadata.mbid_mapping.recording_mbid}`
|
url = `https://musicbrainz.org/recording/${listen.track_metadata.mbid_mapping.recording_mbid}`
|
||||||
} else if (listen.track_metadata.additional_info?.origin_url) {
|
} else if (listen.track_metadata.additional_info?.origin_url) {
|
||||||
url = listen.track_metadata.additional_info.origin_url
|
url = listen.track_metadata.additional_info.origin_url
|
||||||
} else {
|
} else {
|
||||||
url = 'https://listenbrainz.org/user/teidumb/'
|
url = 'https://listenbrainz.org/user/teidumb/'
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
source: 'listenbrainz',
|
source: 'listenbrainz',
|
||||||
sourceLink: 'https://listenbrainz.org/user/teidumb/',
|
sourceLink: 'https://listenbrainz.org/user/teidumb/',
|
||||||
time: listen.listened_at * 1000,
|
time: listen.listened_at * 1000,
|
||||||
text: `${listen.track_metadata.track_name} – ${listen.track_metadata.artist_name}`,
|
text: `${listen.track_metadata.track_name} – ${listen.track_metadata.artist_name}`,
|
||||||
link: url,
|
link: url,
|
||||||
},
|
},
|
||||||
expiresIn: LB_TTL,
|
expiresIn: LB_TTL,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swr: true,
|
swr: true,
|
||||||
swrValidator: ({ currentExpiresAt }) => Date.now() - currentExpiresAt < LB_STALE_TTL,
|
swrValidator: ({ currentExpiresAt }) => Date.now() - currentExpiresAt < LB_STALE_TTL,
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,63 +1,63 @@
|
||||||
import type { LastSeenItem } from './index.ts'
|
|
||||||
import { AsyncResource } from '@fuman/utils'
|
import { AsyncResource } from '@fuman/utils'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { ffetch } from '../../utils/fetch.ts'
|
import { ffetch } from '../../utils/fetch.ts'
|
||||||
|
|
||||||
|
import type { LastSeenItem } from './index.ts'
|
||||||
|
|
||||||
const ENDPOINT = 'https://shikimori.one/api/users/698215/history?limit=1'
|
const ENDPOINT = 'https://shikimori.one/api/users/698215/history?limit=1'
|
||||||
const TTL = 3 * 60 * 60 * 1000 // 3 hours
|
const TTL = 3 * 60 * 60 * 1000 // 3 hours
|
||||||
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
|
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
target: z.object({
|
target: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const shikimoriLastSeen = new AsyncResource<LastSeenItem | null>({
|
export const shikimoriLastSeen = new AsyncResource<LastSeenItem | null>({
|
||||||
async fetcher() {
|
async fetcher() {
|
||||||
const res = (await ffetch(ENDPOINT).parsedJson(z.array(schema)))[0]
|
const res = (await ffetch(ENDPOINT).parsedJson(z.array(schema)))[0]
|
||||||
|
|
||||||
// thx morr for this fucking awesome api
|
// thx morr for this fucking awesome api
|
||||||
|
|
||||||
const mapper: Record<string, string> = {
|
const mapper: Record<string, string> = {
|
||||||
'Просмотрено': 'completed',
|
'Просмотрено': 'completed',
|
||||||
'Прочитано': 'completed',
|
'Прочитано': 'completed',
|
||||||
'Добавлено в список': 'added',
|
'Добавлено в список': 'added',
|
||||||
'Брошено': 'dropped',
|
'Брошено': 'dropped',
|
||||||
}
|
}
|
||||||
let event = mapper[res.description]
|
let event = mapper[res.description]
|
||||||
|
|
||||||
if (!event && res.description.match(/^Просмотрен.*эпизод(ов)?$/)) {
|
if (!event && res.description.match(/^Просмотрен.*эпизод(ов)?$/)) {
|
||||||
event = 'watched'
|
event = 'watched'
|
||||||
}
|
}
|
||||||
if (!event && res.description.match(/^(Просмотрено|Прочитано) и оценено/)) {
|
if (!event && res.description.match(/^(Просмотрено|Прочитано) и оценено/)) {
|
||||||
event = 'completed'
|
event = 'completed'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
source: 'shiki',
|
source: 'shiki',
|
||||||
sourceLink: 'https://shikimori.one/teidesu',
|
sourceLink: 'https://shikimori.one/teidesu',
|
||||||
time: new Date(res.created_at).getTime(),
|
time: new Date(res.created_at).getTime(),
|
||||||
text: res.target.name,
|
text: res.target.name,
|
||||||
suffix: `: ${event}`,
|
suffix: `: ${event}`,
|
||||||
link: `https://shikimori.one${res.target.url}`,
|
link: `https://shikimori.one${res.target.url}`,
|
||||||
},
|
},
|
||||||
expiresIn: TTL,
|
expiresIn: TTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: null,
|
data: null,
|
||||||
expiresIn: TTL,
|
expiresIn: TTL,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swr: true,
|
swr: true,
|
||||||
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
|
swrValidator: ({ currentFetchedAt }) => Date.now() - currentFetchedAt < STALE_TTL,
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,189 +1,197 @@
|
||||||
import { BotKeyboard, html } from '@mtcute/node'
|
import { BotKeyboard, html } from '@mtcute/node'
|
||||||
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
|
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
import { tg } from '../bot'
|
|
||||||
import { ShoutboxAction } from '../bot/shoutbox.js'
|
import { ShoutboxAction } from '../bot/shoutbox.js'
|
||||||
import { db } from '../db'
|
|
||||||
import { env } from '../env'
|
|
||||||
import { shouts, shoutsBans } from '../models/index.js'
|
import { shouts, shoutsBans } from '../models/index.js'
|
||||||
import { URL_REGEX } from '../utils/url.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
|
const SHOUTS_PER_PAGE = 5
|
||||||
|
|
||||||
const filter = or(
|
const filter = or(
|
||||||
not(shouts.pending),
|
not(shouts.pending),
|
||||||
and(shouts.pending, eq(shouts.fromIp, sql.placeholder('fromIp'))),
|
and(shouts.pending, eq(shouts.fromIp, sql.placeholder('fromIp'))),
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchTotal = db.select({
|
const fetchTotal = db.select({
|
||||||
count: sql<number>`count(1)`,
|
count: sql<number>`count(1)`,
|
||||||
}).from(shouts).where(filter).prepare()
|
}).from(shouts)
|
||||||
|
.where(filter)
|
||||||
|
.prepare()
|
||||||
|
|
||||||
const fetchList = db.select({
|
const fetchList = db.select({
|
||||||
createdAt: shouts.createdAt,
|
createdAt: shouts.createdAt,
|
||||||
text: shouts.text,
|
text: shouts.text,
|
||||||
pending: shouts.pending,
|
pending: shouts.pending,
|
||||||
serial: shouts.serial,
|
serial: shouts.serial,
|
||||||
reply: shouts.reply,
|
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) {
|
export function fetchShouts(page: number, ip: string) {
|
||||||
return {
|
return {
|
||||||
items: fetchList.all({
|
items: fetchList.all({
|
||||||
offset: page * SHOUTS_PER_PAGE,
|
offset: page * SHOUTS_PER_PAGE,
|
||||||
fromIp: ip,
|
fromIp: ip,
|
||||||
}),
|
}),
|
||||||
pageCount: Math.ceil((fetchTotal.get({
|
pageCount: Math.ceil((fetchTotal.get({
|
||||||
fromIp: ip,
|
fromIp: ip,
|
||||||
})?.count ?? 0) / SHOUTS_PER_PAGE),
|
})?.count ?? 0) / SHOUTS_PER_PAGE),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type ShoutsData = ReturnType<typeof fetchShouts>
|
export type ShoutsData = ReturnType<typeof fetchShouts>
|
||||||
|
|
||||||
const fetchNextSerial = db.select({
|
const fetchNextSerial = db.select({
|
||||||
serial: sql<number>`coalesce(max(serial), 0) + 1`,
|
serial: sql<number>`coalesce(max(serial), 0) + 1`,
|
||||||
}).from(shouts).prepare()
|
}).from(shouts)
|
||||||
|
.prepare()
|
||||||
|
|
||||||
export function approveShout(id: string) {
|
export function approveShout(id: string) {
|
||||||
const nextSerial = fetchNextSerial.get({})!.serial
|
const nextSerial = fetchNextSerial.get({})!.serial
|
||||||
|
|
||||||
db.update(shouts)
|
db.update(shouts)
|
||||||
.set({ pending: false, serial: nextSerial })
|
.set({ pending: false, serial: nextSerial })
|
||||||
.where(eq(shouts.id, id))
|
.where(eq(shouts.id, id))
|
||||||
.run()
|
.run()
|
||||||
|
|
||||||
return nextSerial
|
return nextSerial
|
||||||
}
|
}
|
||||||
|
|
||||||
export function declineShout(id: string) {
|
export function declineShout(id: string) {
|
||||||
db.delete(shouts)
|
db.delete(shouts)
|
||||||
.where(eq(shouts.id, id))
|
.where(eq(shouts.id, id))
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBySerial(serial: number) {
|
export function deleteBySerial(serial: number) {
|
||||||
db.delete(shouts)
|
db.delete(shouts)
|
||||||
.where(eq(shouts.serial, serial))
|
.where(eq(shouts.serial, serial))
|
||||||
.run()
|
.run()
|
||||||
// adjust serials
|
// adjust serials
|
||||||
db.update(shouts)
|
db.update(shouts)
|
||||||
.set({ serial: sql<number>`serial - 1` })
|
.set({ serial: sql<number>`serial - 1` })
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(shouts.pending, false),
|
eq(shouts.pending, false),
|
||||||
gt(shouts.serial, sql.placeholder('serial')),
|
gt(shouts.serial, sql.placeholder('serial')),
|
||||||
))
|
))
|
||||||
.run({ serial })
|
.run({ serial })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function answerBySerial(serial: number, reply: string) {
|
export function answerBySerial(serial: number, reply: string) {
|
||||||
db.update(shouts)
|
db.update(shouts)
|
||||||
.set({ reply })
|
.set({ reply })
|
||||||
.where(eq(shouts.serial, serial))
|
.where(eq(shouts.serial, serial))
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function banShouts(ip: string, expires: number) {
|
export function banShouts(ip: string, expires: number) {
|
||||||
db.insert(shoutsBans)
|
db.insert(shoutsBans)
|
||||||
.values({
|
.values({
|
||||||
ip,
|
ip,
|
||||||
expires,
|
expires,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: shoutsBans.ip,
|
target: shoutsBans.ip,
|
||||||
set: { expires },
|
set: { expires },
|
||||||
})
|
})
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unbanShouts(ip: string) {
|
export function unbanShouts(ip: string) {
|
||||||
db.delete(shoutsBans)
|
db.delete(shoutsBans)
|
||||||
.where(eq(shoutsBans.ip, ip))
|
.where(eq(shoutsBans.ip, ip))
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isShoutboxBanned(ip: string): Date | null {
|
export function isShoutboxBanned(ip: string): Date | null {
|
||||||
const ban = db.select()
|
const ban = db.select()
|
||||||
.from(shoutsBans)
|
.from(shoutsBans)
|
||||||
.where(eq(shoutsBans.ip, ip))
|
.where(eq(shoutsBans.ip, ip))
|
||||||
.get()
|
.get()
|
||||||
if (!ban) return null
|
if (!ban) return null
|
||||||
|
|
||||||
const expires = ban.expires
|
const expires = ban.expires
|
||||||
if (Date.now() > expires) return null
|
if (Date.now() > expires) return null
|
||||||
return new Date(ban.expires)
|
return new Date(ban.expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateShout(text: string, isPublic: boolean) {
|
function validateShout(text: string, isPublic: boolean) {
|
||||||
if (text.length < 3) {
|
if (text.length < 3) {
|
||||||
return 'too short, come on'
|
return 'too short, come on'
|
||||||
}
|
|
||||||
|
|
||||||
if (text.length > 300) {
|
|
||||||
return 'please keep it under 300 characters'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPublic) {
|
|
||||||
const lineCount = text.split('\n').length
|
|
||||||
if (lineCount > 5) {
|
|
||||||
return 'too many lines, keep it under 5'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (URL_REGEX.test(text)) {
|
if (text.length > 300) {
|
||||||
return 'no links plz'
|
return 'please keep it under 300 characters'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
if (isPublic) {
|
||||||
|
const lineCount = text.split('\n').length
|
||||||
|
if (lineCount > 5) {
|
||||||
|
return 'too many lines, keep it under 5'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (URL_REGEX.test(text)) {
|
||||||
|
return 'no links plz'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createShout(params: {
|
export async function createShout(params: {
|
||||||
fromIp: string
|
fromIp: string
|
||||||
private: boolean
|
private: boolean
|
||||||
text: string
|
text: string
|
||||||
}): Promise<boolean | string> {
|
}): Promise<boolean | string> {
|
||||||
let { text } = params
|
let { text } = params
|
||||||
|
|
||||||
text = text.trim()
|
text = text.trim()
|
||||||
|
|
||||||
const validateResult = validateShout(text, !params.private)
|
const validateResult = validateShout(text, !params.private)
|
||||||
|
|
||||||
const header = html`${params.private ? 'private message' : 'shout'} from <code>${params.fromIp}</code>`
|
const header = html`${params.private ? 'private message' : 'shout'} from <code>${params.fromIp}</code>`
|
||||||
const subheader = html`<br>via: #api<br><br>`
|
const subheader = html`<br>via: #api<br><br>`
|
||||||
|
|
||||||
if (params.private || validateResult !== true) {
|
if (params.private || validateResult !== true) {
|
||||||
const was = params.private ? '' : ` was auto-declined (${validateResult})`
|
const was = params.private ? '' : ` was auto-declined (${validateResult})`
|
||||||
await tg.sendText(
|
await tg.sendText(
|
||||||
env.TG_CHAT_ID,
|
env.TG_CHAT_ID,
|
||||||
html`
|
html`
|
||||||
${header}${was}:
|
${header}${was}:
|
||||||
${subheader}
|
${subheader}
|
||||||
${text}
|
${text}
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!params.private && validateResult === true) {
|
if (!params.private && validateResult === true) {
|
||||||
const result = await db.insert(shouts)
|
const result = await db.insert(shouts)
|
||||||
.values(params)
|
.values(params)
|
||||||
.returning({ id: shouts.id })
|
.returning({ id: shouts.id })
|
||||||
.execute()
|
.execute()
|
||||||
const id = result[0].id
|
const id = result[0].id
|
||||||
|
|
||||||
await tg.sendText(
|
await tg.sendText(
|
||||||
env.TG_CHAT_ID,
|
env.TG_CHAT_ID,
|
||||||
html`
|
html`
|
||||||
${header}:
|
${header}:
|
||||||
${subheader}
|
${subheader}
|
||||||
${text}
|
${text}
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
replyMarkup: BotKeyboard.inline([[
|
replyMarkup: BotKeyboard.inline([[
|
||||||
BotKeyboard.callback('✅ approve', ShoutboxAction.build({ id, action: 'approve' })),
|
BotKeyboard.callback('✅ approve', ShoutboxAction.build({ id, action: 'approve' })),
|
||||||
BotKeyboard.callback('❌ decline', ShoutboxAction.build({ id, action: 'decline' })),
|
BotKeyboard.callback('❌ decline', ShoutboxAction.build({ id, action: 'decline' })),
|
||||||
]]),
|
]]),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return validateResult
|
return validateResult
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,64 +2,64 @@ import { ffetchAddons, ffetchBase } from '@fuman/fetch'
|
||||||
import { ffetchZodAdapter } from '@fuman/fetch/zod'
|
import { ffetchZodAdapter } from '@fuman/fetch/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { env } from '~/backend/env'
|
|
||||||
import { isBotUserAgent } from '../utils/bot'
|
import { isBotUserAgent } from '../utils/bot'
|
||||||
|
import { env } from '~/backend/env'
|
||||||
|
|
||||||
const ffetch = ffetchBase.extend({
|
const ffetch = ffetchBase.extend({
|
||||||
addons: [
|
addons: [
|
||||||
ffetchAddons.parser(ffetchZodAdapter()),
|
ffetchAddons.parser(ffetchZodAdapter()),
|
||||||
ffetchAddons.timeout(),
|
ffetchAddons.timeout(),
|
||||||
],
|
],
|
||||||
baseUrl: env.UMAMI_HOST,
|
baseUrl: env.UMAMI_HOST,
|
||||||
timeout: 1000,
|
timeout: 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function umamiFetchStats(page: string, startAt: number) {
|
export async function umamiFetchStats(page: string, startAt: number) {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
return Promise.resolve({ visitors: { value: 1337 } })
|
return Promise.resolve({ visitors: { value: 1337 } })
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ffetch(`/api/websites/${env.UMAMI_SITE_ID}/stats`, {
|
return await ffetch(`/api/websites/${env.UMAMI_SITE_ID}/stats`, {
|
||||||
query: {
|
query: {
|
||||||
endAt: Math.floor(Date.now()).toString(),
|
endAt: Math.floor(Date.now()).toString(),
|
||||||
startAt: startAt.toString(),
|
startAt: startAt.toString(),
|
||||||
url: page,
|
url: page,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${env.UMAMI_TOKEN}`,
|
Authorization: `Bearer ${env.UMAMI_TOKEN}`,
|
||||||
},
|
},
|
||||||
}).parsedJson(z.object({
|
}).parsedJson(z.object({
|
||||||
visitors: z.object({
|
visitors: z.object({
|
||||||
value: z.number(),
|
value: z.number(),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function umamiLogThisVisit(request: Request, path?: string, website = env.UMAMI_SITE_ID): void {
|
export function umamiLogThisVisit(request: Request, path?: string, website = env.UMAMI_SITE_ID): void {
|
||||||
if (import.meta.env.DEV) return
|
if (import.meta.env.DEV) return
|
||||||
if (isBotUserAgent(request.headers.get('user-agent') || '')) return
|
if (isBotUserAgent(request.headers.get('user-agent') || '')) return
|
||||||
const language = request.headers.get('accept-language')?.split(';')[0].split(',')[0] || ''
|
const language = request.headers.get('accept-language')?.split(';')[0].split(',')[0] || ''
|
||||||
|
|
||||||
ffetch.post('/api/send', {
|
ffetch.post('/api/send', {
|
||||||
json: {
|
json: {
|
||||||
payload: {
|
payload: {
|
||||||
hostname: request.headers.get('host') || '',
|
hostname: request.headers.get('host') || '',
|
||||||
language,
|
language,
|
||||||
referrer: request.headers.get('referer') || '',
|
referrer: request.headers.get('referer') || '',
|
||||||
screen: '',
|
screen: '',
|
||||||
title: '',
|
title: '',
|
||||||
url: path ?? new URL(request.url).pathname,
|
url: path ?? new URL(request.url).pathname,
|
||||||
website,
|
website,
|
||||||
},
|
},
|
||||||
type: 'event',
|
type: 'event',
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': request.headers.get('user-agent') || '',
|
'User-Agent': request.headers.get('user-agent') || '',
|
||||||
'X-Forwarded-For': request.headers.get('x-forwarded-for')?.[0] || '',
|
'X-Forwarded-For': request.headers.get('x-forwarded-for')?.[0] || '',
|
||||||
},
|
},
|
||||||
}).then(async (r) => {
|
}).then(async (r) => {
|
||||||
if (!r.ok) throw new Error(`failed to log visit: ${r.status} ${await r.text()}`)
|
if (!r.ok) throw new Error(`failed to log visit: ${r.status} ${await r.text()}`)
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.warn(err)
|
console.warn(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,26 +7,26 @@ const WEBRING_URL = 'https://otomir23.me/webring/5/data'
|
||||||
const WEBRING_TTL = 1000 * 60 * 60 * 24 // 24 hours
|
const WEBRING_TTL = 1000 * 60 * 60 * 24 // 24 hours
|
||||||
|
|
||||||
const WebringItem = z.object({
|
const WebringItem = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
})
|
})
|
||||||
export type WebringItem = z.infer<typeof WebringItem>
|
export type WebringItem = z.infer<typeof WebringItem>
|
||||||
|
|
||||||
const WebringData = z.object({
|
const WebringData = z.object({
|
||||||
prev: WebringItem,
|
prev: WebringItem,
|
||||||
next: WebringItem,
|
next: WebringItem,
|
||||||
})
|
})
|
||||||
export type WebringData = z.infer<typeof WebringData>
|
export type WebringData = z.infer<typeof WebringData>
|
||||||
|
|
||||||
export const webring = new AsyncResource<WebringData>({
|
export const webring = new AsyncResource<WebringData>({
|
||||||
fetcher: async () => {
|
fetcher: async () => {
|
||||||
const res = await ffetch(WEBRING_URL).parsedJson(WebringData)
|
const res = await ffetch(WEBRING_URL).parsedJson(WebringData)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: res,
|
data: res,
|
||||||
expiresIn: WEBRING_TTL,
|
expiresIn: WEBRING_TTL,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swr: true,
|
swr: true,
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
export function isBotUserAgent(userAgent: string) {
|
export function isBotUserAgent(userAgent: string) {
|
||||||
return /bot|crawl|slurp|spider|mediapartners|mastodon|akkoma|pleroma|misskey|firefish|sharkey/i.test(userAgent)
|
return /bot|crawl|slurp|spider|mediapartners|mastodon|akkoma|pleroma|misskey|firefish|sharkey/i.test(userAgent)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,42 +1,40 @@
|
||||||
import { createHmac, randomBytes } from 'node:crypto'
|
import { createHmac, randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
import { base64, typed, u8, utf8 } from '@fuman/utils'
|
|
||||||
|
|
||||||
import { env } from '~/backend/env'
|
import { env } from '~/backend/env'
|
||||||
|
|
||||||
const secret = env.CSRF_SECRET
|
const secret = env.CSRF_SECRET
|
||||||
const validity = 300_000
|
const validity = 300_000
|
||||||
|
|
||||||
export function getCsrfToken(ip: string) {
|
export function getCsrfToken(ip: string) {
|
||||||
const data = utf8.encoder.encode(JSON.stringify([Date.now(), ip]))
|
const data = Buffer.from(JSON.stringify([Date.now(), ip]))
|
||||||
const salt = randomBytes(8) as Uint8Array
|
const salt = randomBytes(8)
|
||||||
const sign = createHmac('sha256', secret).update(data).update(salt).digest()
|
const sign = createHmac('sha256', secret).update(data).update(salt).digest()
|
||||||
|
|
||||||
return base64.encode(u8.concat3(
|
return Buffer.concat([
|
||||||
data,
|
data,
|
||||||
salt,
|
salt,
|
||||||
sign.subarray(0, 8),
|
sign.subarray(0, 8),
|
||||||
), true)
|
]).toString('base64url')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyCsrfToken(ip: string, token: string) {
|
export function verifyCsrfToken(ip: string, token: string) {
|
||||||
try {
|
try {
|
||||||
const buf = base64.decode(token, true)
|
const buf = Buffer.from(token, 'base64url')
|
||||||
if (buf.length < 16) return false
|
if (buf.length < 16) return false
|
||||||
|
|
||||||
const saltedData = buf.subarray(0, -8)
|
const saltedData = buf.subarray(0, -8)
|
||||||
const correctSign = createHmac('sha256', secret).update(saltedData).digest()
|
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
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const [issued, correctIp] = JSON.parse(buf.subarray(0, -16).toString())
|
||||||
|
if (issued + validity < Date.now()) return false
|
||||||
|
if (ip !== correctIp) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const [issued, correctIp] = JSON.parse(utf8.decoder.decode(buf.subarray(0, -16)))
|
|
||||||
if (issued + validity < Date.now()) return false
|
|
||||||
if (ip !== correctIp) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { ffetchAddons, ffetchBase } from '@fuman/fetch'
|
||||||
import { ffetchZodAdapter } from '@fuman/fetch/zod'
|
import { ffetchZodAdapter } from '@fuman/fetch/zod'
|
||||||
|
|
||||||
export const ffetch = ffetchBase.extend({
|
export const ffetch = ffetchBase.extend({
|
||||||
addons: [
|
addons: [
|
||||||
ffetchAddons.parser(ffetchZodAdapter()),
|
ffetchAddons.parser(ffetchZodAdapter()),
|
||||||
ffetchAddons.retry(),
|
ffetchAddons.retry(),
|
||||||
],
|
],
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'tei.su/1.0',
|
'User-Agent': 'tei.su/1.0',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { randomPick } from '../../utils/random'
|
import { randomPick } from '../../utils/random'
|
||||||
|
|
||||||
export function obfuscateEmail(email: string) {
|
export function obfuscateEmail(email: string) {
|
||||||
const opener = randomPick(['[', '{', '(', '<', '|'])
|
const opener = randomPick(['[', '{', '(', '<', '|'])
|
||||||
const closer = {
|
const closer = {
|
||||||
'(': ')',
|
'(': ')',
|
||||||
'[': ']',
|
'[': ']',
|
||||||
'{': '}',
|
'{': '}',
|
||||||
'<': '>',
|
'<': '>',
|
||||||
'|': '|',
|
'|': '|',
|
||||||
}[opener]
|
}[opener]
|
||||||
|
|
||||||
return email.replace(/@/g, ` ${opener}at${closer} `).replace(/\./g, ` ${opener}dot${closer} `)
|
return email.replace(/@/g, ` ${opener}at${closer} `).replace(/\./g, ` ${opener}dot${closer} `)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import type { APIContext, AstroGlobal } from 'astro'
|
import type { APIContext, AstroGlobal } from 'astro'
|
||||||
|
|
||||||
export function getRequestIp(ctx: AstroGlobal | APIContext) {
|
export function getRequestIp(ctx: AstroGlobal | APIContext) {
|
||||||
const xForwardedFor = ctx.request.headers.get('x-forwarded-for')
|
const xForwardedFor = ctx.request.headers.get('x-forwarded-for')
|
||||||
if (xForwardedFor) return xForwardedFor.split(',')[0]
|
if (xForwardedFor) return xForwardedFor.split(',')[0]
|
||||||
|
|
||||||
return ctx.clientAddress
|
return ctx.clientAddress
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,28 +1,28 @@
|
||||||
export class HttpResponse {
|
export class HttpResponse {
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
static json(body: unknown, init?: ResponseInit) {
|
static json(body: unknown, init?: ResponseInit) {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...init?.headers,
|
...init?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static error(status: number) {
|
static error(status: number) {
|
||||||
return new Response(null, { status })
|
return new Response(null, { status })
|
||||||
}
|
}
|
||||||
|
|
||||||
static redirect(url: string, init?: ResponseInit) {
|
static redirect(url: string, init?: ResponseInit) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 301,
|
status: 301,
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
Location: url,
|
Location: url,
|
||||||
...init?.headers,
|
...init?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1454,13 +1454,13 @@ ZW
|
||||||
const TLD_LIST = new Set(TLD_LIST_IANA.trim().split('\n'))
|
const TLD_LIST = new Set(TLD_LIST_IANA.trim().split('\n'))
|
||||||
|
|
||||||
for (const tld of TLD_LIST) {
|
for (const tld of TLD_LIST) {
|
||||||
if (tld.startsWith('XN--')) {
|
if (tld.startsWith('XN--')) {
|
||||||
// also add the non-punycode version
|
// also add the non-punycode version
|
||||||
TLD_LIST.add(domainToUnicode(tld))
|
TLD_LIST.add(domainToUnicode(tld))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const URL_REGEX = new RegExp(
|
export const URL_REGEX = new RegExp(
|
||||||
`(https?://)?([a-z0-9-]+\\.)+(${[...TLD_LIST].join('|')})(/[a-z0-9-._~:/?#[\\]@!$&'()*+,;=]*)?`,
|
`(https?://)?([a-z0-9-]+\\.)+(${[...TLD_LIST].join('|')})(/[a-z0-9-._~:/?#[\\]@!$&'()*+,;=]*)?`,
|
||||||
'i',
|
'i',
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 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 { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
|
||||||
|
import { fetchDonatePageData } from './data'
|
||||||
|
|
||||||
umamiLogThisVisit(Astro.request, '/donate')
|
umamiLogThisVisit(Astro.request, '/donate')
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ const data = await fetchDonatePageData(Astro.request)
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout
|
<DefaultLayout
|
||||||
og={{
|
og={{
|
||||||
title: 'teidesu > donate.txt',
|
title: 'teidesu > donate.txt',
|
||||||
description: 'i would be extremely pleased if you sent me some money ❤️',
|
description: 'i would be extremely pleased if you sent me some money ❤️',
|
||||||
image: moneyImg.src,
|
image: moneyImg.src,
|
||||||
|
|
|
@ -1,96 +1,96 @@
|
||||||
/** @jsxImportSource solid-js */
|
/** @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 type { PageData } from './data'
|
||||||
import { createSignal, type JSX, onMount } from 'solid-js'
|
import type { PaymentMethod } from './constants'
|
||||||
|
|
||||||
import { Link } from '../../ui/Link.tsx'
|
|
||||||
import { SectionTitle } from '../../ui/Section.tsx'
|
|
||||||
import { TextTable } from '../../ui/TextTable.tsx'
|
|
||||||
import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
|
import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
|
||||||
|
|
||||||
export function PaymentMethods(props: { data: PageData }) {
|
export function PaymentMethods(props: { data: PageData }) {
|
||||||
const [items, setItems] = createSignal<PaymentMethod[]>(
|
const [items, setItems] = createSignal<PaymentMethod[]>(
|
||||||
// eslint-disable-next-line solid/reactivity
|
// eslint-disable-next-line solid/reactivity
|
||||||
props.data.encryptedData.map(it => ({
|
props.data.encryptedData.map(it => ({
|
||||||
link: undefined,
|
link: undefined,
|
||||||
name: it.name,
|
name: it.name,
|
||||||
text: '[encrypted]',
|
text: '[encrypted]',
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// force client-side
|
// force client-side
|
||||||
const key = deriveKey(navigator.userAgent, location.href, props.data.salt)
|
const key = deriveKey(navigator.userAgent, location.href, props.data.salt)
|
||||||
const keyHash = dumbHash(key)
|
const keyHash = dumbHash(key)
|
||||||
const xor = [0]
|
const xor = [0]
|
||||||
|
|
||||||
const probeDec = xorContinuous(keyHash, props.data.probeEnc, xor)
|
const probeDec = xorContinuous(keyHash, props.data.probeEnc, xor)
|
||||||
if (probeDec !== props.data.probe) {
|
if (probeDec !== props.data.probe) {
|
||||||
console.error(`Probe mismatch (expected: ${props.data.probe}, got: ${probeDec})`)
|
console.error(`Probe mismatch (expected: ${props.data.probe}, got: ${probeDec})`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setItems(props.data.encryptedData.map(it => ({
|
setItems(props.data.encryptedData.map(it => ({
|
||||||
link: it.link ? xorContinuous(keyHash, it.link!, xor) : undefined,
|
link: it.link ? xorContinuous(keyHash, it.link!, xor) : undefined,
|
||||||
name: it.name,
|
name: it.name,
|
||||||
text: xorContinuous(keyHash, it.text, xor),
|
text: xorContinuous(keyHash, it.text, xor),
|
||||||
})))
|
})))
|
||||||
})
|
})
|
||||||
|
|
||||||
const itemsToRender = () => items().map(it => ({
|
const itemsToRender = () => items().map(it => ({
|
||||||
name: it.name,
|
name: it.name,
|
||||||
value: () => it.link
|
value: () => it.link
|
||||||
? <Link href={it.link} target="_blank">{it.text}</Link>
|
? <Link href={it.link} target="_blank">{it.text}</Link>
|
||||||
: it.text,
|
: it.text,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextTable
|
<TextTable
|
||||||
items={itemsToRender()}
|
items={itemsToRender()}
|
||||||
minColumnWidth={12}
|
minColumnWidth={12}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageDonate(props: { methods?: JSX.Element, data: PageData }) {
|
export function PageDonate(props: { methods?: JSX.Element, data: PageData }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section>heya</section>
|
<section>heya</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
i'm not really struggling with money, but if you like what i do and want to support me — i
|
i'm not really struggling with money, but if you like what i do and want to support me — i
|
||||||
would totally appreciate it <3
|
would totally appreciate it <3
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>when donating crypto, please use stablecoins (usdt/dai) or native token</section>
|
<section>when donating crypto, please use stablecoins (usdt/dai) or native token</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle>my payment addresses (in order of preference):</SectionTitle>
|
<SectionTitle>my payment addresses (in order of preference):</SectionTitle>
|
||||||
|
|
||||||
{props.methods}
|
{props.methods}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<noscript>
|
<noscript>
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle>‼️ looks like javascript is disabled.</SectionTitle>
|
<SectionTitle>‼️ looks like javascript is disabled.</SectionTitle>
|
||||||
that is why payment methods above aren't displayed.
|
that is why payment methods above aren't displayed.
|
||||||
<br />
|
<br />
|
||||||
to protect myself from osint and bot attacks, i do a little obfuscation.
|
to protect myself from osint and bot attacks, i do a little obfuscation.
|
||||||
<br />
|
<br />
|
||||||
i promise, there are no trackers here ^_^
|
i promise, there are no trackers here ^_^
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
if you are a weirdo using noscript or lynx or something instead of a browser, you can
|
if you are a weirdo using noscript or lynx or something instead of a browser, you can
|
||||||
dm me for payment details.
|
dm me for payment details.
|
||||||
</section>
|
</section>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
total page views so far:
|
total page views so far:
|
||||||
{' '}
|
{' '}
|
||||||
{props.data.pageViews}
|
{props.data.pageViews}
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import data from './data.json' with { type: 'json' }
|
import data from './data.json' with { type: 'json' }
|
||||||
|
|
||||||
export interface PaymentMethod {
|
export interface PaymentMethod {
|
||||||
link?: string
|
link?: string
|
||||||
name: string
|
name: string
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PAYMENT_METHODS = data as PaymentMethod[]
|
export const PAYMENT_METHODS = data as PaymentMethod[]
|
||||||
|
|
|
@ -1,34 +1,34 @@
|
||||||
const ascii = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
const ascii = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
|
|
||||||
export function dumbHash(str: string) {
|
export function dumbHash(str: string) {
|
||||||
let hash = 0
|
let hash = 0
|
||||||
const len = str.length
|
const len = str.length
|
||||||
for (let s = 0; s < len; s++) {
|
for (let s = 0; s < len; s++) {
|
||||||
hash += str.charCodeAt(s) * (s + 1) * (len - s)
|
hash += str.charCodeAt(s) * (s + 1) * (len - s)
|
||||||
}
|
}
|
||||||
hash >>>= 0
|
hash >>>= 0
|
||||||
|
|
||||||
let res = ''
|
let res = ''
|
||||||
while (hash > 0) {
|
while (hash > 0) {
|
||||||
const q = hash % ascii.length
|
const q = hash % ascii.length
|
||||||
hash = ~~(hash / ascii.length)
|
hash = ~~(hash / ascii.length)
|
||||||
res += ascii[q]
|
res += ascii[q]
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveKey(userAgent: string, href: string, salt: string) {
|
export function deriveKey(userAgent: string, href: string, salt: string) {
|
||||||
return userAgent.trim() + href.replace(/#.*$/, '') + Math.floor(Date.now() / 100000) + salt
|
return userAgent.trim() + href.replace(/#.*$/, '') + Math.floor(Date.now() / 100000) + salt
|
||||||
}
|
}
|
||||||
|
|
||||||
export function xorContinuous(key: string, str: string, posRef: number[]) {
|
export function xorContinuous(key: string, str: string, posRef: number[]) {
|
||||||
let pos = posRef[0]
|
let pos = posRef[0]
|
||||||
let ret = ''
|
let ret = ''
|
||||||
for (let s = 0; s < str.length; s++) {
|
for (let s = 0; s < str.length; s++) {
|
||||||
ret += String.fromCharCode(str.charCodeAt(s) ^ key.charCodeAt(pos))
|
ret += String.fromCharCode(str.charCodeAt(s) ^ key.charCodeAt(pos))
|
||||||
pos = (pos + 1) % key.length
|
pos = (pos + 1) % key.length
|
||||||
}
|
}
|
||||||
posRef[0] = pos
|
posRef[0] = pos
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,42 +1,42 @@
|
||||||
import type { PaymentMethod } from './constants'
|
|
||||||
|
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
import { umamiFetchStats } from '~/backend/service/umami'
|
import { umamiFetchStats } from '~/backend/service/umami'
|
||||||
|
|
||||||
|
import type { PaymentMethod } from './constants'
|
||||||
import { PAYMENT_METHODS } from './constants'
|
import { PAYMENT_METHODS } from './constants'
|
||||||
import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
|
import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
|
||||||
|
|
||||||
export async function fetchDonatePageData(request: Request) {
|
export async function fetchDonatePageData(request: Request) {
|
||||||
const pageViews = await umamiFetchStats('/donate', 1700088965789)
|
const pageViews = await umamiFetchStats('/donate', 1700088965789)
|
||||||
.then(stats => `${stats.visitors.value + 9089}`) // value before umami
|
.then(stats => `${stats.visitors.value + 9089}`) // value before umami
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('Failed to fetch page views: ', err)
|
console.error('Failed to fetch page views: ', err)
|
||||||
return '[error]'
|
return '[error]'
|
||||||
})
|
})
|
||||||
|
|
||||||
const salt = randomBytes(12).toString('base64')
|
const salt = randomBytes(12).toString('base64')
|
||||||
const probe = randomBytes(12).toString('base64')
|
const probe = randomBytes(12).toString('base64')
|
||||||
const url = new URL(request.url, `${import.meta.env.DEV ? 'http' : 'https'}://${request.headers.get('host')}`)
|
const url = new URL(request.url, `${import.meta.env.DEV ? 'http' : 'https'}://${request.headers.get('host')}`)
|
||||||
const key = deriveKey(request.headers.get('user-agent') || '', url.href, salt)
|
const key = deriveKey(request.headers.get('user-agent') || '', url.href, salt)
|
||||||
|
|
||||||
const keyHash = dumbHash(key)
|
const keyHash = dumbHash(key)
|
||||||
const xorPos = [0]
|
const xorPos = [0]
|
||||||
|
|
||||||
const probeEnc = xorContinuous(keyHash, probe, xorPos)
|
const probeEnc = xorContinuous(keyHash, probe, xorPos)
|
||||||
|
|
||||||
const encryptedData: PaymentMethod[] = PAYMENT_METHODS.map(it => ({
|
const encryptedData: PaymentMethod[] = PAYMENT_METHODS.map(it => ({
|
||||||
...it,
|
...it,
|
||||||
link: it.link ? xorContinuous(keyHash, it.link, xorPos) : undefined,
|
link: it.link ? xorContinuous(keyHash, it.link, xorPos) : undefined,
|
||||||
text: xorContinuous(keyHash, it.text, xorPos),
|
text: xorContinuous(keyHash, it.text, xorPos),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encryptedData,
|
encryptedData,
|
||||||
probe,
|
probe,
|
||||||
probeEnc,
|
probeEnc,
|
||||||
salt,
|
salt,
|
||||||
pageViews,
|
pageViews,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PageData = Awaited<ReturnType<typeof fetchDonatePageData>>
|
export type PageData = Awaited<ReturnType<typeof fetchDonatePageData>>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
---
|
---
|
||||||
import { umamiLogThisVisit } from '~/backend/service/umami'
|
|
||||||
import { RandomWord } from '~/components/interactive/RandomWord'
|
|
||||||
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
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 { PARTTIME_VARIANTS } from './constants'
|
||||||
import { fetchMainPageData } from './data'
|
import { fetchMainPageData } from './data'
|
||||||
import { PageMain as PageMainSolid } from './PageMain'
|
|
||||||
import Shoutbox from './Shoutbox/Shoutbox.astro'
|
import Shoutbox from './Shoutbox/Shoutbox.astro'
|
||||||
|
|
||||||
umamiLogThisVisit(Astro.request)
|
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,339 +1,333 @@
|
||||||
/** @jsxImportSource solid-js */
|
/** @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 { For, type JSX, Show } from 'solid-js'
|
||||||
import { Dynamic } from 'solid-js/web'
|
import { Dynamic } from 'solid-js/web'
|
||||||
import axolotl from '~/assets/axolotl.png'
|
import { intlFormatDistance } from 'date-fns'
|
||||||
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 { 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 { randomInt } from '~/utils/random'
|
||||||
import { cn } from '../../../utils/cn.ts'
|
|
||||||
import { Emoji } from '../../ui/Emoji.tsx'
|
import css from './PageMain.module.css'
|
||||||
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 { SUBLINKS, TESTIMONIALS } from './constants'
|
import { SUBLINKS, TESTIMONIALS } from './constants'
|
||||||
|
import type { PageData } from './data'
|
||||||
|
|
||||||
function formatTimeRelative(time: number) {
|
function formatTimeRelative(time: number) {
|
||||||
return intlFormatDistance(
|
return intlFormatDistance(
|
||||||
new Date(time),
|
new Date(time),
|
||||||
new Date(),
|
new Date(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
||||||
return (
|
return (
|
||||||
<Dynamic
|
<Dynamic component={props.first ? 'summary' : 'div'} class={css.lastSeenItem}>
|
||||||
component={props.first ? 'summary' : 'div'}
|
<div class={css.lastSeenLinkWrap}>
|
||||||
class={cn(
|
<div class={css.lastSeenLinkWrapInner}>
|
||||||
'flex flex-row items-center justify-between',
|
<Link
|
||||||
props.first && 'pos-relative list-none cursor-pointer rounded-md hover:bg-control-bg-hover active:select-none [&::-webkit-details-marker]:hidden',
|
class={css.lastSeenLink}
|
||||||
)}
|
href={props.item.link}
|
||||||
>
|
target="_blank"
|
||||||
<div class="max-w-full flex flex-col overflow-hidden sm:flex-row sm:items-center">
|
title={props.item.text}
|
||||||
<div class="max-w-full w-min flex flex-row items-center overflow-hidden">
|
>
|
||||||
<Link
|
{props.item.text}
|
||||||
class="max-w-200px overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-300px"
|
</Link>
|
||||||
href={props.item.link}
|
{props.item.suffix && (
|
||||||
target="_blank"
|
<span class={css.lastSeenSuffix}>
|
||||||
title={props.item.text}
|
{props.item.suffix}
|
||||||
>
|
</span>
|
||||||
{props.item.text}
|
)}
|
||||||
</Link>
|
</div>
|
||||||
{props.item.suffix && (
|
<i class={css.lastSeenSource}>
|
||||||
<span class="whitespace-nowrap text-xs">
|
{'@ '}
|
||||||
{props.item.suffix}
|
<Link href={props.item.sourceLink} target="_blank">
|
||||||
</span>
|
{props.item.source}
|
||||||
)}
|
</Link>
|
||||||
</div>
|
{', '}
|
||||||
<i class="ml-2 whitespace-nowrap text-xs text-text-secondary">
|
{formatTimeRelative(props.item.time)}
|
||||||
{'@ '}
|
</i>
|
||||||
<Link href={props.item.sourceLink} target="_blank">
|
</div>
|
||||||
{props.item.source}
|
<Show when={props.first}>
|
||||||
</Link>
|
<div class={css.lastSeenTrigger} />
|
||||||
{', '}
|
</Show>
|
||||||
{formatTimeRelative(props.item.time)}
|
</Dynamic>
|
||||||
</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)'])" />
|
|
||||||
</Show>
|
|
||||||
</Dynamic>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageMain(props: {
|
export function PageMain(props: {
|
||||||
data: PageData
|
data: PageData
|
||||||
partTimeWords?: JSX.Element
|
partTimeWords?: JSX.Element
|
||||||
shoutbox?: JSX.Element
|
shoutbox?: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
const testimonials = TESTIMONIALS.map((props) => {
|
const testimonials = TESTIMONIALS.map((props) => {
|
||||||
const link = props.href
|
const link = props.href
|
||||||
? (
|
? (
|
||||||
<Link href={props.href} target="_blank">
|
<Link href={props.href} target="_blank">
|
||||||
{props.author}
|
{props.author}
|
||||||
</Link>
|
</Link>
|
||||||
|
)
|
||||||
|
: <i>{props.author}</i>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={css.testimonial}>
|
||||||
|
"
|
||||||
|
{props.text}
|
||||||
|
" -
|
||||||
|
{link}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
: <i>{props.author}</i>
|
})
|
||||||
|
|
||||||
|
/* eslint-disable solid/no-innerhtml */
|
||||||
|
const sublinks = SUBLINKS.map(item => (
|
||||||
|
<div>
|
||||||
|
-
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
data-astro-prefetch={item.noPrefetch ? 'false' : undefined}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
:
|
||||||
|
{' '}
|
||||||
|
<span innerHTML={item.subtitle} />
|
||||||
|
<TextComment
|
||||||
|
class={css.comment}
|
||||||
|
innerHTML={item.comment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
/* eslint-enable solid/no-innerhtml */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="mb-2">
|
<>
|
||||||
"
|
<section>{`h${'i'.repeat(randomInt(2, 5))}~`}</section>
|
||||||
{props.text}
|
|
||||||
" -
|
<section>
|
||||||
{link}
|
i am
|
||||||
</div>
|
{' '}
|
||||||
|
<b>alina</b>
|
||||||
|
{' '}
|
||||||
|
aka
|
||||||
|
{' '}
|
||||||
|
<b>teidesu</b>
|
||||||
|
{' '}
|
||||||
|
🌸
|
||||||
|
<br />
|
||||||
|
full-time js/ts developer, part-time
|
||||||
|
{' '}
|
||||||
|
{props.partTimeWords}
|
||||||
|
{' '}
|
||||||
|
<br />
|
||||||
|
more about me as a dev on my
|
||||||
|
{' '}
|
||||||
|
<Link href="//github.com/teidesu" target="_blank">
|
||||||
|
github page
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionTitle>
|
||||||
|
extremely interesting info (no):
|
||||||
|
</SectionTitle>
|
||||||
|
<TextTable
|
||||||
|
items={[
|
||||||
|
{ name: 'birthday', value: () => 'july 25 (leo ♌)' },
|
||||||
|
{
|
||||||
|
name: 'langs',
|
||||||
|
value: () => (
|
||||||
|
<>
|
||||||
|
<Emoji alt="🇷🇺" src={ruFlag.src} />
|
||||||
|
{' '}
|
||||||
|
native,
|
||||||
|
{' '}
|
||||||
|
<Emoji alt="🇬🇧" src={ukFlag.src} />
|
||||||
|
{' '}
|
||||||
|
c1,
|
||||||
|
{' '}
|
||||||
|
<Emoji alt="javascript" src={jsLogo.src} />
|
||||||
|
{' '}
|
||||||
|
native
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'last seen',
|
||||||
|
value: () => {
|
||||||
|
if (!props.data.lastSeen?.length) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details class={css.lastSeen}>
|
||||||
|
<LastSeenItem first item={props.data.lastSeen[0]} />
|
||||||
|
<For each={props.data.lastSeen.slice(1)}>
|
||||||
|
{it => <LastSeenItem item={it} />}
|
||||||
|
</For>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fav color',
|
||||||
|
value: () => (
|
||||||
|
<>
|
||||||
|
#be15dc
|
||||||
|
{' '}
|
||||||
|
<div class={css.favColor} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fav flower',
|
||||||
|
value: () => (
|
||||||
|
<>
|
||||||
|
cherry blossom
|
||||||
|
{' '}
|
||||||
|
<Emoji alt="🌸" src={cherry.src} />
|
||||||
|
, lilac
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fav animal',
|
||||||
|
value: () => (
|
||||||
|
<>
|
||||||
|
axolotl
|
||||||
|
{' '}
|
||||||
|
<Emoji alt="axolotl" src={axolotl.src} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fav anime',
|
||||||
|
value: () => (
|
||||||
|
<>
|
||||||
|
nichijou (
|
||||||
|
<Link href="//shikimori.one/animes/10165-nichijou" target="_blank">
|
||||||
|
shiki
|
||||||
|
</Link>
|
||||||
|
/
|
||||||
|
<Link href="//anilist.co/anime/10165/Nichijou" target="_blank">
|
||||||
|
anilist
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fav music',
|
||||||
|
value: () => (
|
||||||
|
<>
|
||||||
|
hyperpop, digicore, happy hardcore
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
wrap
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<SectionTitle>
|
||||||
|
contact me (in order of preference):
|
||||||
|
</SectionTitle>
|
||||||
|
<TextTable
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
name: 'telegram',
|
||||||
|
value: () => (
|
||||||
|
<Link href="//t.me/teidumb" target="_blank">
|
||||||
|
@teidumb
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bluesky',
|
||||||
|
value: () => (
|
||||||
|
<Link href="https://bsky.app/profile/did:web:tei.su" target="_blank">
|
||||||
|
@tei.pet
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'matrix',
|
||||||
|
value: () => (
|
||||||
|
<Link href="//matrix.to/#/@teidesu:stupid.fish" target="_blank">
|
||||||
|
@teidesu:stupid.fish
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
value: () => props.data.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phone',
|
||||||
|
value: () => 'secret :p',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'post pigeons',
|
||||||
|
value: () => 'please don\'t',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
wrap
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionTitle>
|
||||||
|
testimonials from THEM:
|
||||||
|
</SectionTitle>
|
||||||
|
|
||||||
|
{testimonials}
|
||||||
|
|
||||||
|
<TextComment class={css.commentInline}>
|
||||||
|
feel free to leave yours :3
|
||||||
|
</TextComment>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{props.shoutbox}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionTitle>
|
||||||
|
top secret sub-pages:
|
||||||
|
</SectionTitle>
|
||||||
|
|
||||||
|
{sublinks}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
total page views so far:
|
||||||
|
{' '}
|
||||||
|
{props.data.pageViews}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Show when={props.data.webring}>
|
||||||
|
<section class={css.webring}>
|
||||||
|
<Link href={props.data.webring!.prev.url}>
|
||||||
|
<
|
||||||
|
{' '}
|
||||||
|
{props.data.webring!.prev.name}
|
||||||
|
</Link>
|
||||||
|
<Link href="https://otomir23.me/webring" target="_blank">
|
||||||
|
rutg webring
|
||||||
|
</Link>
|
||||||
|
<Link href={props.data.webring!.next.url}>
|
||||||
|
{props.data.webring!.next.name}
|
||||||
|
{' '}
|
||||||
|
>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
/* eslint-disable solid/no-innerhtml */
|
|
||||||
const sublinks = SUBLINKS.map(item => (
|
|
||||||
<div>
|
|
||||||
-
|
|
||||||
{' '}
|
|
||||||
<Link
|
|
||||||
href={item.link}
|
|
||||||
target="_blank"
|
|
||||||
data-astro-prefetch={item.noPrefetch ? 'false' : undefined}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
:
|
|
||||||
{' '}
|
|
||||||
<span innerHTML={item.subtitle} />
|
|
||||||
<TextComment
|
|
||||||
class="mb-2 ml-12 text-text-secondary"
|
|
||||||
innerHTML={item.comment}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
/* eslint-enable solid/no-innerhtml */
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section>{`h${'i'.repeat(randomInt(2, 5))}~`}</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
i am
|
|
||||||
{' '}
|
|
||||||
<b>alina</b>
|
|
||||||
{' '}
|
|
||||||
aka
|
|
||||||
{' '}
|
|
||||||
<b>teidesu</b>
|
|
||||||
{' '}
|
|
||||||
🌸
|
|
||||||
<br />
|
|
||||||
full-time js/ts developer, part-time
|
|
||||||
{' '}
|
|
||||||
{props.partTimeWords}
|
|
||||||
{' '}
|
|
||||||
<br />
|
|
||||||
more about me as a dev on my
|
|
||||||
{' '}
|
|
||||||
<Link href="//github.com/teidesu" target="_blank">
|
|
||||||
github page
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<SectionTitle>
|
|
||||||
extremely interesting info (no):
|
|
||||||
</SectionTitle>
|
|
||||||
<TextTable
|
|
||||||
items={[
|
|
||||||
{ name: 'birthday', value: () => 'july 25 (leo ♌)' },
|
|
||||||
{
|
|
||||||
name: 'langs',
|
|
||||||
value: () => (
|
|
||||||
<>
|
|
||||||
<Emoji alt="🇷🇺" src={ruFlag.src} />
|
|
||||||
{' '}
|
|
||||||
native,
|
|
||||||
{' '}
|
|
||||||
<Emoji alt="🇬🇧" src={ukFlag.src} />
|
|
||||||
{' '}
|
|
||||||
c1,
|
|
||||||
{' '}
|
|
||||||
<Emoji alt="javascript" src={jsLogo.src} />
|
|
||||||
{' '}
|
|
||||||
native
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'last seen',
|
|
||||||
value: () => {
|
|
||||||
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)'])">
|
|
||||||
<LastSeenItem first item={props.data.lastSeen[0]} />
|
|
||||||
<For each={props.data.lastSeen.slice(1)}>
|
|
||||||
{it => <LastSeenItem item={it} />}
|
|
||||||
</For>
|
|
||||||
</details>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'fav color',
|
|
||||||
value: () => (
|
|
||||||
<>
|
|
||||||
#be15dc
|
|
||||||
{' '}
|
|
||||||
<div class="mb-0.5 inline-block h-10px w-10px border border-#ccc bg-[#be15dc] align-middle" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'fav flower',
|
|
||||||
value: () => (
|
|
||||||
<>
|
|
||||||
cherry blossom
|
|
||||||
{' '}
|
|
||||||
<Emoji alt="🌸" src={cherry.src} />
|
|
||||||
, lilac
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'fav animal',
|
|
||||||
value: () => (
|
|
||||||
<>
|
|
||||||
axolotl
|
|
||||||
{' '}
|
|
||||||
<Emoji alt="axolotl" src={axolotl.src} />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'fav anime',
|
|
||||||
value: () => (
|
|
||||||
<>
|
|
||||||
nichijou (
|
|
||||||
<Link href="//shikimori.one/animes/10165-nichijou" target="_blank">
|
|
||||||
shiki
|
|
||||||
</Link>
|
|
||||||
/
|
|
||||||
<Link href="//anilist.co/anime/10165/Nichijou" target="_blank">
|
|
||||||
anilist
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'fav music',
|
|
||||||
value: () => (
|
|
||||||
<>
|
|
||||||
hyperpop, digicore, happy hardcore
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
wrap
|
|
||||||
fill
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<SectionTitle>
|
|
||||||
contact me (in order of preference):
|
|
||||||
</SectionTitle>
|
|
||||||
<TextTable
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
name: 'telegram',
|
|
||||||
value: () => (
|
|
||||||
<Link href="//t.me/teidumb" target="_blank">
|
|
||||||
@teidumb
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'bluesky',
|
|
||||||
value: () => (
|
|
||||||
<Link href="https://bsky.app/profile/did:web:tei.su" target="_blank">
|
|
||||||
@tei.pet
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'matrix',
|
|
||||||
value: () => (
|
|
||||||
<Link href="//matrix.to/#/@teidesu:stupid.fish" target="_blank">
|
|
||||||
@teidesu:stupid.fish
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'email',
|
|
||||||
value: () => props.data.email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'phone',
|
|
||||||
value: () => 'secret :p',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'post pigeons',
|
|
||||||
value: () => 'please don\'t',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
wrap
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<SectionTitle>
|
|
||||||
testimonials from THEM:
|
|
||||||
</SectionTitle>
|
|
||||||
|
|
||||||
{testimonials}
|
|
||||||
|
|
||||||
<TextComment class="mb-2 ml-2em text-text-secondary">
|
|
||||||
feel free to leave yours :3
|
|
||||||
</TextComment>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{props.shoutbox}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<SectionTitle>
|
|
||||||
top secret sub-pages:
|
|
||||||
</SectionTitle>
|
|
||||||
|
|
||||||
{sublinks}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
total page views so far:
|
|
||||||
{' '}
|
|
||||||
{props.data.pageViews}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Show when={props.data.webring}>
|
|
||||||
<section class="mt-4 flex items-center justify-between text-xs">
|
|
||||||
<Link href={props.data.webring!.prev.url}>
|
|
||||||
<
|
|
||||||
{' '}
|
|
||||||
{props.data.webring!.prev.name}
|
|
||||||
</Link>
|
|
||||||
<Link href="https://otomir23.me/webring" target="_blank">
|
|
||||||
rutg webring
|
|
||||||
</Link>
|
|
||||||
<Link href={props.data.webring!.next.url}>
|
|
||||||
{props.data.webring!.next.name}
|
|
||||||
{' '}
|
|
||||||
>
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import { fetchShouts } from '~/backend/service/shoutbox'
|
import { fetchShouts } from '~/backend/service/shoutbox'
|
||||||
import { getCsrfToken } from '~/backend/utils/csrf'
|
|
||||||
import { getRequestIp } from '~/backend/utils/request'
|
import { getRequestIp } from '~/backend/utils/request'
|
||||||
|
import { getCsrfToken } from '~/backend/utils/csrf'
|
||||||
|
|
||||||
import { Shoutbox as ShoutboxSolid } from './Shoutbox'
|
import { Shoutbox as ShoutboxSolid } from './Shoutbox'
|
||||||
|
|
||||||
|
@ -17,9 +17,9 @@ const csrf = getCsrfToken(ip)
|
||||||
---
|
---
|
||||||
|
|
||||||
<ShoutboxSolid
|
<ShoutboxSolid
|
||||||
client:idle
|
client:idle
|
||||||
csrf={csrf}
|
csrf={csrf}
|
||||||
shoutError={shoutError}
|
shoutError={shoutError}
|
||||||
initPage={page}
|
initPage={page}
|
||||||
initPageData={data}
|
initPageData={data}
|
||||||
/>
|
/>
|
||||||
|
|
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,211 +1,222 @@
|
||||||
/** @jsxImportSource solid-js */
|
|
||||||
/* eslint-disable no-alert */
|
/* eslint-disable no-alert */
|
||||||
import type { ShoutsData } from '~/backend/service/shoutbox'
|
/** @jsxImportSource solid-js */
|
||||||
import { createQuery, keepPreviousData, QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
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 { format } from 'date-fns/format'
|
||||||
|
|
||||||
import { type ComponentProps, createSignal, onMount, Show } from 'solid-js'
|
import { Button } from '~/components/ui/Button/Button'
|
||||||
import { Button } from '../../../ui/Button.tsx'
|
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 css from './Shoutbox.module.css'
|
||||||
import { SectionTitle } from '../../../ui/Section.tsx'
|
|
||||||
import { TextArea } from '../../../ui/TextArea.tsx'
|
|
||||||
import { TextComment } from '../../../ui/TextComment.tsx'
|
|
||||||
|
|
||||||
async function fetchShouts(page: number): Promise<ShoutsData> {
|
async function fetchShouts(page: number): Promise<ShoutsData> {
|
||||||
return fetch(`/api/shoutbox?page=${page}`).then(r => r.json())
|
return fetch(`/api/shoutbox?page=${page}`).then(r => r.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShoutboxInner(props: {
|
function ShoutboxInner(props: {
|
||||||
initPage: number
|
initPage: number
|
||||||
initPageData: ShoutsData
|
initPageData: ShoutsData
|
||||||
shoutError?: string
|
shoutError?: string
|
||||||
csrf: string
|
csrf: string
|
||||||
}) {
|
}) {
|
||||||
// eslint-disable-next-line solid/reactivity
|
// eslint-disable-next-line solid/reactivity
|
||||||
const [page, setPage] = createSignal(props.initPage)
|
const [page, setPage] = createSignal(props.initPage)
|
||||||
// eslint-disable-next-line solid/reactivity
|
// eslint-disable-next-line solid/reactivity
|
||||||
const [initData, setInitData] = createSignal<ShoutsData | undefined>(props.initPageData)
|
const [initData, setInitData] = createSignal<ShoutsData | undefined>(props.initPageData)
|
||||||
|
|
||||||
const shouts = createQuery(() => ({
|
const shouts = createQuery(() => ({
|
||||||
queryKey: ['shouts', page()],
|
queryKey: ['shouts', page()],
|
||||||
queryFn: () => fetchShouts(page()),
|
queryFn: () => fetchShouts(page()),
|
||||||
cacheTime: 0,
|
cacheTime: 0,
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
initialData: initData,
|
initialData: initData,
|
||||||
}))
|
}))
|
||||||
const [sending, setSending] = createSignal(false)
|
const [sending, setSending] = createSignal(false)
|
||||||
const [jsEnabled, setJsEnabled] = createSignal(false)
|
const [jsEnabled, setJsEnabled] = createSignal(false)
|
||||||
onMount(() => setJsEnabled(true))
|
onMount(() => setJsEnabled(true))
|
||||||
|
|
||||||
const onPageClick = (next: boolean) => (e: MouseEvent) => {
|
const onPageClick = (next: boolean) => (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setInitData(undefined)
|
setInitData(undefined)
|
||||||
|
|
||||||
const newPage = next ? page() + 1 : page() - 1
|
const newPage = next ? page() + 1 : page() - 1
|
||||||
|
|
||||||
const link = e.currentTarget as HTMLAnchorElement
|
const link = e.currentTarget as HTMLAnchorElement
|
||||||
const href = link.href
|
const href = link.href
|
||||||
|
|
||||||
history.replaceState(null, '', href)
|
history.replaceState(null, '', href)
|
||||||
setPage(newPage)
|
setPage(newPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shoutsRender = () => shouts.data?.items.map((props) => {
|
const shoutsRender = () => shouts.data?.items.map((props) => {
|
||||||
const icon = props.pending
|
const icon = props.pending
|
||||||
? <div class="i-gravity-ui-clock size-4" title="awaiting moderation" />
|
? (
|
||||||
: `#${props.serial}`
|
<Icon
|
||||||
|
glyph={GravityClock}
|
||||||
|
size={16}
|
||||||
|
title="awaiting moderation"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: `#${props.serial}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={css.shout}>
|
||||||
|
<div class={css.header}>
|
||||||
|
{icon}
|
||||||
|
<time class={css.time} datetime={props.createdAt}>
|
||||||
|
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<div class={css.text}>
|
||||||
|
{props.text}
|
||||||
|
{props.reply && (
|
||||||
|
<div class={css.reply}>
|
||||||
|
<b>reply: </b>
|
||||||
|
{props.reply}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
let privateCheckbox!: HTMLInputElement
|
||||||
|
let messageInput!: HTMLTextAreaElement
|
||||||
|
|
||||||
|
const onSubmit = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSending(true)
|
||||||
|
setInitData(undefined)
|
||||||
|
|
||||||
|
const isPrivate = privateCheckbox.checked
|
||||||
|
fetch('/api/shoutbox', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
_csrf: props.csrf,
|
||||||
|
message: messageInput.value,
|
||||||
|
private: isPrivate,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
alert(data.error + (data.message ? `: ${data.message}` : ''))
|
||||||
|
} else if (isPrivate) {
|
||||||
|
alert('private message sent')
|
||||||
|
messageInput.value = ''
|
||||||
|
} else {
|
||||||
|
alert('shout sent! it will appear after moderation')
|
||||||
|
shouts.refetch()
|
||||||
|
messageInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = () => {
|
||||||
|
if (props.shoutError) return props.shoutError
|
||||||
|
if (!jsEnabled()) return '⚠️ please enable javascript to use the form.\nim sorry, but there are just too many spammers out there :c'
|
||||||
|
return 'let the void hear you'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<section>
|
||||||
{icon}
|
<SectionTitle>shoutbox!</SectionTitle>
|
||||||
<time class="whitespace-nowrap" datetime={props.createdAt}>
|
<TextComment class={pageCss.comment}>
|
||||||
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
|
disclaimer: shouts
|
||||||
</time>
|
{' '}
|
||||||
</div>
|
<i>are</i>
|
||||||
<div class="whitespace-pre-wrap">
|
{' '}
|
||||||
{props.text}
|
pre-moderated, but they do not reflect my views.
|
||||||
{props.reply && (
|
</TextComment>
|
||||||
<div class="mt-1.5">
|
|
||||||
<b>reply: </b>
|
<div class={css.form}>
|
||||||
{props.reply}
|
<input type="hidden" name="_csrf" value={props.csrf} />
|
||||||
|
<div class={css.formInput}>
|
||||||
|
<TextArea
|
||||||
|
ref={messageInput}
|
||||||
|
disabled={sending() || !jsEnabled()}
|
||||||
|
class={css.textarea}
|
||||||
|
grow
|
||||||
|
maxRows={5}
|
||||||
|
name="message"
|
||||||
|
placeholder={placeholder()}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={sending() || !jsEnabled()}
|
||||||
|
title="submit"
|
||||||
|
>
|
||||||
|
<Icon glyph={GravityMegaphone} size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class={css.formControls}>
|
||||||
|
<Checkbox
|
||||||
|
ref={privateCheckbox}
|
||||||
|
label="make it private"
|
||||||
|
name="private"
|
||||||
|
/>
|
||||||
|
<Show when={shouts.data && shouts.data.pageCount > 1}>
|
||||||
|
<div class={css.pagination}>
|
||||||
|
<Show when={page() > 0}>
|
||||||
|
<a
|
||||||
|
class={css.paginationLink}
|
||||||
|
rel="external"
|
||||||
|
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
|
||||||
|
onClick={onPageClick(false)}
|
||||||
|
data-astro-reload
|
||||||
|
>
|
||||||
|
< prev
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
<span>{page() + 1}</span>
|
||||||
|
<Show when={page() < shouts.data!.pageCount - 1}>
|
||||||
|
<a
|
||||||
|
class={css.paginationLink}
|
||||||
|
rel="external"
|
||||||
|
href={`?shouts_page=${page() + 1}`}
|
||||||
|
onClick={onPageClick(true)}
|
||||||
|
data-astro-reload
|
||||||
|
>
|
||||||
|
next >
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div class={css.shouts}>
|
||||||
</div>
|
{shoutsRender()}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
let privateCheckbox!: HTMLInputElement
|
|
||||||
let messageInput!: HTMLTextAreaElement
|
|
||||||
|
|
||||||
const onSubmit = (e: Event) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSending(true)
|
|
||||||
setInitData(undefined)
|
|
||||||
|
|
||||||
const isPrivate = privateCheckbox.checked
|
|
||||||
fetch('/api/shoutbox', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
_csrf: props.csrf,
|
|
||||||
message: messageInput.value,
|
|
||||||
private: isPrivate,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert(data.error + (data.message ? `: ${data.message}` : ''))
|
|
||||||
} else if (isPrivate) {
|
|
||||||
alert('private message sent')
|
|
||||||
messageInput.value = ''
|
|
||||||
} else {
|
|
||||||
alert('shout sent! it will appear after moderation')
|
|
||||||
shouts.refetch()
|
|
||||||
messageInput.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
setSending(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const placeholder = () => {
|
|
||||||
if (props.shoutError) return props.shoutError
|
|
||||||
if (!jsEnabled()) return '⚠️ please enable javascript to use the form.\nim sorry, but there are just too many spammers out there :c'
|
|
||||||
return 'let the void hear you'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<SectionTitle>shoutbox!</SectionTitle>
|
|
||||||
<TextComment class="mb-2 ml-12 text-text-secondary">
|
|
||||||
disclaimer: shouts
|
|
||||||
{' '}
|
|
||||||
<i>are</i>
|
|
||||||
{' '}
|
|
||||||
pre-moderated, but they do not reflect my views.
|
|
||||||
</TextComment>
|
|
||||||
|
|
||||||
<div class="w-full flex flex-col gap-2">
|
|
||||||
<input type="hidden" name="_csrf" value={props.csrf} />
|
|
||||||
<div class="w-full flex gap-2">
|
|
||||||
<TextArea
|
|
||||||
ref={messageInput}
|
|
||||||
disabled={sending() || !jsEnabled()}
|
|
||||||
class="w-full"
|
|
||||||
grow
|
|
||||||
maxRows={5}
|
|
||||||
name="message"
|
|
||||||
placeholder={placeholder()}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={sending() || !jsEnabled()}
|
|
||||||
title="submit"
|
|
||||||
>
|
|
||||||
<div class="i-gravity-ui-megaphone size-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row justify-between">
|
|
||||||
<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">
|
|
||||||
<Show when={page() > 0}>
|
|
||||||
<a
|
|
||||||
class="text-text-secondary underline underline-offset-2"
|
|
||||||
rel="external"
|
|
||||||
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
|
|
||||||
onClick={onPageClick(false)}
|
|
||||||
data-astro-reload
|
|
||||||
>
|
|
||||||
< prev
|
|
||||||
</a>
|
|
||||||
</Show>
|
|
||||||
<span>{page() + 1}</span>
|
|
||||||
<Show when={page() < shouts.data!.pageCount - 1}>
|
|
||||||
<a
|
|
||||||
class="text-text-secondary underline underline-offset-2"
|
|
||||||
rel="external"
|
|
||||||
href={`?shouts_page=${page() + 1}`}
|
|
||||||
onClick={onPageClick(true)}
|
|
||||||
data-astro-reload
|
|
||||||
>
|
|
||||||
next >
|
|
||||||
</a>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-2">
|
|
||||||
{shoutsRender()}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shoutbox(props: ComponentProps<typeof ShoutboxInner>) {
|
export function Shoutbox(props: ComponentProps<typeof ShoutboxInner>) {
|
||||||
const client = new QueryClient()
|
const client = new QueryClient()
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<ShoutboxInner {...props} />
|
<ShoutboxInner {...props} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,67 +1,67 @@
|
||||||
export const PARTTIME_VARIANTS = [
|
export const PARTTIME_VARIANTS = [
|
||||||
'anime girl',
|
'anime girl',
|
||||||
'puppygirl',
|
'puppygirl',
|
||||||
'human being',
|
'human being',
|
||||||
'shitposter',
|
'shitposter',
|
||||||
'js fanatic',
|
'js fanatic',
|
||||||
'dumbass',
|
'dumbass',
|
||||||
'delulu',
|
'delulu',
|
||||||
'silly goofball',
|
'silly goofball',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const TESTIMONIALS = [
|
export const TESTIMONIALS = [
|
||||||
{ author: 'sanspie', href: 'https://akarpov.ru/about', text: 'lil purry cat(cute!)' },
|
{ author: 'sanspie', href: 'https://akarpov.ru/about', text: 'lil purry cat(cute!)' },
|
||||||
{ author: 'attorelle', text: 'today she asks to dm her "tomato", tomorrow she\'ll ask to sign over apartment to her' },
|
{ author: 'attorelle', text: 'today she asks to dm her "tomato", tomorrow she\'ll ask to sign over apartment to her' },
|
||||||
{ author: 'astrra', href: 'https://astrra.space', text: 'why are you in my walls why are you in my walls why are you in my walls why are you in my walls' },
|
{ author: 'astrra', href: 'https://astrra.space', text: 'why are you in my walls why are you in my walls why are you in my walls why are you in my walls' },
|
||||||
{ author: 'wffl', href: 'https://ihatereality.space', text: 'i knew a girl with the same name in my childhood' },
|
{ author: 'wffl', href: 'https://ihatereality.space', text: 'i knew a girl with the same name in my childhood' },
|
||||||
{ author: 'mo', href: 'https://mo.rijndael.cc', text: 'okay okay, i will write a review for you please don\'t be offended' },
|
{ author: 'mo', href: 'https://mo.rijndael.cc', text: 'okay okay, i will write a review for you please don\'t be offended' },
|
||||||
{ author: 'svpra', text: 'i like to write all sorts of cute phrases on other people\'s websites when nobody asks about it. meow.' },
|
{ author: 'svpra', text: 'i like to write all sorts of cute phrases on other people\'s websites when nobody asks about it. meow.' },
|
||||||
{ author: 'toil', href: 'https://toil.cc', text: 'i like cute anime girls (oops it\'s you) <3' },
|
{ author: 'toil', href: 'https://toil.cc', text: 'i like cute anime girls (oops it\'s you) <3' },
|
||||||
{ author: 'jsopn', href: 'https://jsopn.com', text: 'barks at me in js' },
|
{ author: 'jsopn', href: 'https://jsopn.com', text: 'barks at me in js' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const SUBLINKS = [
|
export const SUBLINKS = [
|
||||||
{
|
{
|
||||||
link: '/nudes',
|
link: '/nudes',
|
||||||
title: 'nudes',
|
title: 'nudes',
|
||||||
subtitle: '( ͡° ͜ʖ ͡°)',
|
subtitle: '( ͡° ͜ʖ ͡°)',
|
||||||
comment: 'a lot of them, actually',
|
comment: 'a lot of them, actually',
|
||||||
noPrefetch: true,
|
noPrefetch: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: '/cheerio/index.html',
|
link: '/cheerio/index.html',
|
||||||
title: 'cheerio',
|
title: 'cheerio',
|
||||||
subtitle: 'cheerio repl for debugging and stuff',
|
subtitle: 'cheerio repl for debugging and stuff',
|
||||||
comment: 'made in 20 minutes, use it all the time, very useful /gen',
|
comment: 'made in 20 minutes, use it all the time, very useful /gen',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: '/gdz',
|
link: '/gdz',
|
||||||
title: 'gdz',
|
title: 'gdz',
|
||||||
subtitle: 'custom frontend for gdz.ru (fetches <i>*everything*</i> for free)',
|
subtitle: 'custom frontend for gdz.ru (fetches <i>*everything*</i> for free)',
|
||||||
comment: 'i made it a long time ago and everything is very bad 💀<br />idk how it still works',
|
comment: 'i made it a long time ago and everything is very bad 💀<br />idk how it still works',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: '/oauth.blank.html',
|
link: '/oauth.blank.html',
|
||||||
title: 'oauth.blank.html',
|
title: 'oauth.blank.html',
|
||||||
subtitle: 'thingy for redirect_uri',
|
subtitle: 'thingy for redirect_uri',
|
||||||
comment: 'i promise it doesn\'t collect your tokens',
|
comment: 'i promise it doesn\'t collect your tokens',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: '/proxifier.html',
|
link: '/proxifier.html',
|
||||||
title: 'proxifier.html',
|
title: 'proxifier.html',
|
||||||
subtitle: 'proxifier keygen',
|
subtitle: 'proxifier keygen',
|
||||||
comment: 'basically a port of some c# implementation bc im lazy',
|
comment: 'basically a port of some c# implementation bc im lazy',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: '/spring.html',
|
link: '/spring.html',
|
||||||
title: 'spring.html',
|
title: 'spring.html',
|
||||||
subtitle: 'no idea',
|
subtitle: 'no idea',
|
||||||
comment: 'spring physics in ui are fun',
|
comment: 'spring physics in ui are fun',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
link: '/test_voice.ogg',
|
link: '/test_voice.ogg',
|
||||||
title: 'test_voice.ogg',
|
title: 'test_voice.ogg',
|
||||||
subtitle: 'фильм земляне 2005 года смотреть всем',
|
subtitle: 'фильм земляне 2005 года смотреть всем',
|
||||||
comment: 'libopus encoded, valid for telegram',
|
comment: 'libopus encoded, valid for telegram',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
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 { 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() {
|
export async function fetchMainPageData() {
|
||||||
const [
|
const [
|
||||||
pageViews,
|
pageViews,
|
||||||
webringData,
|
webringData,
|
||||||
lastSeen,
|
lastSeen,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
umamiFetchStats('/', 1700088965789)
|
umamiFetchStats('/', 1700088965789)
|
||||||
.then(stats => `${stats.visitors.value + 321487}`) // value before umami
|
.then(stats => `${stats.visitors.value + 321487}`) // value before umami
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('Failed to fetch page views: ', err)
|
console.error('Failed to fetch page views: ', err)
|
||||||
return '[error]'
|
return '[error]'
|
||||||
}),
|
}),
|
||||||
webring.get(),
|
webring.get(),
|
||||||
fetchLastSeen(),
|
fetchLastSeen(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: obfuscateEmail('alina@tei.su'),
|
email: obfuscateEmail('alina@tei.su'),
|
||||||
pageViews,
|
pageViews,
|
||||||
shouts: [],
|
shouts: [],
|
||||||
webring: webringData,
|
webring: webringData,
|
||||||
lastSeen,
|
lastSeen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PageData = Awaited<ReturnType<typeof fetchMainPageData>>
|
export type PageData = Awaited<ReturnType<typeof fetchMainPageData>>
|
||||||
|
|
|
@ -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 {
|
.input {
|
||||||
@apply hidden;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.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 {
|
.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 {
|
.input:checked + .label .box::before {
|
||||||
content: '';
|
content: '';
|
||||||
display: 'block';
|
display: 'block';
|
||||||
|
width: 8px;
|
||||||
@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;
|
height: 8px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,27 +5,27 @@ import { splitProps } from 'solid-js'
|
||||||
import css from './Checkbox.module.css'
|
import css from './Checkbox.module.css'
|
||||||
|
|
||||||
export interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
|
export interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
|
||||||
class?: string
|
class?: string
|
||||||
label?: JSX.Element
|
label?: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Checkbox(props: CheckboxProps) {
|
export function Checkbox(props: CheckboxProps) {
|
||||||
const [my, rest] = splitProps(props, ['label', 'class'])
|
const [my, rest] = splitProps(props, ['label', 'class'])
|
||||||
|
|
||||||
const id = `checkbox-${Math.random().toString(36).slice(2)}`
|
const id = `checkbox-${Math.random().toString(36).slice(2)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={my.class}>
|
<div class={my.class}>
|
||||||
<input
|
<input
|
||||||
{...rest}
|
{...rest}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class={css.input}
|
class={css.input}
|
||||||
id={id}
|
id={id}
|
||||||
/>
|
/>
|
||||||
<label class={css.label} for={id} tabIndex={0}>
|
<label class={css.label} for={id} tabIndex={0}>
|
||||||
<div class={css.box} />
|
<div class={css.box} />
|
||||||
{my.label}
|
{my.label}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 LoadingIndicator from 'astro-loading-indicator/component'
|
||||||
import { ClientRouter } from 'astro:transitions'
|
|
||||||
|
|
||||||
import cherry from '~/assets/cherry-blossom_1f338.png'
|
import cherry from '~/assets/cherry-blossom_1f338.png'
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ const finalOg = { ...defaultOgTags, ...og }
|
||||||
))}
|
))}
|
||||||
<link href={icon ?? cherry.src} rel="icon" />
|
<link href={icon ?? cherry.src} rel="icon" />
|
||||||
<title>{title ?? finalOg.title}</title>
|
<title>{title ?? finalOg.title}</title>
|
||||||
<ClientRouter transition:name="slide" />
|
<ViewTransitions transition:name="slide" />
|
||||||
<LoadingIndicator color="var(--text-primary)" />
|
<LoadingIndicator color="var(--text-primary)" />
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
|
@ -81,11 +81,18 @@ const finalOg = { ...defaultOgTags, ...og }
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
*:not(:active, summary):focus {
|
*:focus {
|
||||||
outline: 1px solid var(--text-primary)
|
outline-color: var(--text-primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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>
|
</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 BaseLayout, { type Props } from '../BaseLayout.astro'
|
||||||
|
|
||||||
import Header from './Header.astro'
|
import Header from './Header.astro'
|
||||||
|
@ -7,12 +7,11 @@ import Header from './Header.astro'
|
||||||
|
|
||||||
<BaseLayout {...Astro.props}>
|
<BaseLayout {...Astro.props}>
|
||||||
<slot name="head" slot="head" />
|
<slot name="head" slot="head" />
|
||||||
<div class="min-h-screen flex flex-col items-center">
|
<div class="app">
|
||||||
<div class="min-h-screen w-full flex flex-col gap-6 p-6 md:w-720px">
|
<div class="content">
|
||||||
<Header />
|
<Header />
|
||||||
<slot />
|
<slot />
|
||||||
<div class="flex-1" />
|
<footer class="footer">
|
||||||
<footer class="mx-4 border-t border-text-secondary pt-2 text-center text-2xs text-text-secondary">
|
|
||||||
<div>
|
<div>
|
||||||
<3 teidesu
|
<3 teidesu
|
||||||
{' / '}
|
{' / '}
|
||||||
|
@ -33,3 +32,37 @@ import Header from './Header.astro'
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</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,19 +1,18 @@
|
||||||
---
|
---
|
||||||
import karin from '~/assets/karin.gif'
|
import karin from '~/assets/karin.gif'
|
||||||
import { Link } from '../../components/ui/Link.tsx'
|
import { Link } from '~/components/ui/Link/Link'
|
||||||
|
|
||||||
const PAGES = [
|
const PAGES = [
|
||||||
{ name: 'hewwo', path: '/', match: /^\/$/ },
|
{ name: 'hewwo', path: '/' },
|
||||||
{ name: 'blog', path: '/blog', match: /^\/blog(\/|$)/ },
|
{ name: 'donate', path: ['/donate', '/$'] },
|
||||||
{ name: 'donate', path: '/donate', match: /^\/(donate|\$)\/?$/ },
|
|
||||||
]
|
]
|
||||||
---
|
---
|
||||||
<header class="pos-relative flex items-center justify-center gap-2">
|
<header class="header">
|
||||||
<img
|
<img
|
||||||
aria-hidden="true"
|
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}
|
src={karin.src}
|
||||||
transition:persist
|
transition:persist
|
||||||
/>
|
/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const elements = []
|
const elements = []
|
||||||
|
@ -21,15 +20,20 @@ const PAGES = [
|
||||||
for (const page of PAGES) {
|
for (const page of PAGES) {
|
||||||
if (elements.length > 0) {
|
if (elements.length > 0) {
|
||||||
elements.push(
|
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) {
|
if (isActive) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<a href={page.path} class="font-bold">{page.name}</a>,
|
<span class="active">{page.name}</span>,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const href = Array.isArray(page.path) ? page.path[0] : page.path
|
const href = Array.isArray(page.path) ? page.path[0] : page.path
|
||||||
|
@ -45,3 +49,38 @@ const PAGES = [
|
||||||
return elements
|
return elements
|
||||||
})()}
|
})()}
|
||||||
</header>
|
</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>
|
||||||
|
|
|
@ -4,30 +4,30 @@ import { env } from '~/backend/env'
|
||||||
import { translateChunked } from '~/backend/service/gtrans'
|
import { translateChunked } from '~/backend/service/gtrans'
|
||||||
|
|
||||||
export const POST: APIRoute = async (ctx) => {
|
export const POST: APIRoute = async (ctx) => {
|
||||||
const body = ctx.request.headers.get('content-type') === 'application/json'
|
const body = ctx.request.headers.get('content-type') === 'application/json'
|
||||||
? await ctx.request.json()
|
? await ctx.request.json()
|
||||||
: Object.fromEntries((await ctx.request.formData()).entries())
|
: Object.fromEntries((await ctx.request.formData()).entries())
|
||||||
|
|
||||||
if (body.auth_key !== env.FAKE_DEEPL_SECRET) {
|
if (body.auth_key !== env.FAKE_DEEPL_SECRET) {
|
||||||
return new Response('Unauthorized', { status: 401 })
|
return new Response('Unauthorized', { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body.text) {
|
if (!body.text) {
|
||||||
return new Response('Bad request', { status: 400 })
|
return new Response('Bad request', { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await translateChunked(body.text, 'auto', body.target_lang)
|
const result = await translateChunked(body.text, 'auto', body.target_lang)
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
translations: [
|
translations: [
|
||||||
{
|
{
|
||||||
detected_source_language: result.sourceLanguage,
|
detected_source_language: result.sourceLanguage,
|
||||||
text: result.translatedText,
|
text: result.translatedText,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}), {
|
}), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,11 @@ import type { APIRoute } from 'astro'
|
||||||
import { randomPick } from '~/utils/random'
|
import { randomPick } from '~/utils/random'
|
||||||
|
|
||||||
export const GET: APIRoute = () => new Response(`${randomPick([
|
export const GET: APIRoute = () => new Response(`${randomPick([
|
||||||
'mrrrp meow!',
|
'mrrrp meow!',
|
||||||
'meowwww~',
|
'meowwww~',
|
||||||
'mrrrrrrrrp',
|
'mrrrrrrrrp',
|
||||||
'purrrrrrrrrrrrrr',
|
'purrrrrrrrrrrrrr',
|
||||||
'meow :3',
|
'meow :3',
|
||||||
'miew >_<',
|
'miew >_<',
|
||||||
'try BARKing instead',
|
'try BARKing instead',
|
||||||
])}\n`)
|
])}\n`)
|
||||||
|
|
|
@ -1,89 +1,89 @@
|
||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
import { html } from '@mtcute/node'
|
import { html } from '@mtcute/node'
|
||||||
|
|
||||||
import { telegramNotify } from '~/backend/bot/notify'
|
|
||||||
import { MisskeyWebhookBodySchema, type MkNote, type MkUser } from '~/backend/domain/misskey'
|
import { MisskeyWebhookBodySchema, type MkNote, type MkUser } from '~/backend/domain/misskey'
|
||||||
import { env } from '~/backend/env'
|
import { env } from '~/backend/env'
|
||||||
import { zodValidate } from '~/utils/zod'
|
import { zodValidate } from '~/utils/zod'
|
||||||
|
import { telegramNotify } from '~/backend/bot/notify'
|
||||||
|
|
||||||
function misskeyMentionUser(user: MkUser, server: string): string {
|
function misskeyMentionUser(user: MkUser, server: string): string {
|
||||||
const fullUsername = user.host ? `@${user.username}@${user.host}` : `@${user.username}`
|
const fullUsername = user.host ? `@${user.username}@${user.host}` : `@${user.username}`
|
||||||
|
|
||||||
if (user.name) {
|
if (user.name) {
|
||||||
return `<a href="${server}/${fullUsername}">${user.name}</a>`
|
return `<a href="${server}/${fullUsername}">${user.name}</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<a href="${server}/${fullUsername}">${fullUsername}</a>`
|
return `<a href="${server}/${fullUsername}">${fullUsername}</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function misskeyNoteBrief(note: MkNote): string {
|
function misskeyNoteBrief(note: MkNote): string {
|
||||||
let text = note.text || '<i><no text></i>'
|
let text = note.text || '<i><no text></i>'
|
||||||
|
|
||||||
if (text.length > 100) {
|
if (text.length > 100) {
|
||||||
text = `${text.substring(0, 100)}...`
|
text = `${text.substring(0, 100)}...`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.cw) {
|
if (note.cw) {
|
||||||
text = `CW: ${note.cw}\n\n${text}`
|
text = `CW: ${note.cw}\n\n${text}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
function misskeyNoteLink(note: MkNote, server: string, text: string): string {
|
function misskeyNoteLink(note: MkNote, server: string, text: string): string {
|
||||||
return `<a href="${server}/notes/${note.id}">${text}</a>`
|
return `<a href="${server}/notes/${note.id}">${text}</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async (ctx) => {
|
export const POST: APIRoute = async (ctx) => {
|
||||||
if (ctx.request.headers.get('x-misskey-hook-secret') !== env.MK_WEBHOOK_SECRET) {
|
if (ctx.request.headers.get('x-misskey-hook-secret') !== env.MK_WEBHOOK_SECRET) {
|
||||||
return new Response('Unauthorized', { status: 401 })
|
return new Response('Unauthorized', { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = await zodValidate(MisskeyWebhookBodySchema, await ctx.request.json())
|
const parsed = await zodValidate(MisskeyWebhookBodySchema, await ctx.request.json())
|
||||||
|
|
||||||
|
if (!parsed.body.notification) {
|
||||||
|
return new Response('OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = parsed.body.notification
|
||||||
|
const server = parsed.server
|
||||||
|
|
||||||
|
let text
|
||||||
|
switch (notification.type) {
|
||||||
|
case 'note':
|
||||||
|
case 'mention':
|
||||||
|
case 'reply':
|
||||||
|
case 'renote':
|
||||||
|
case 'quote':
|
||||||
|
if (notification.note) {
|
||||||
|
text = `${misskeyNoteLink(notification.note!, server, `new ${notification.type}`)} from ${misskeyMentionUser(notification.note.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'follow':
|
||||||
|
case 'unfollow':
|
||||||
|
text = `${misskeyMentionUser(notification.user!, server)} ${notification.type}ed you`
|
||||||
|
break
|
||||||
|
case 'receiveFollowRequest':
|
||||||
|
text = `${misskeyMentionUser(notification.user!, server)} sent you a follow request`
|
||||||
|
break
|
||||||
|
case 'followRequestAccepted':
|
||||||
|
text = `${misskeyMentionUser(notification.user!, server)} accepted your follow request`
|
||||||
|
break
|
||||||
|
case 'reaction':
|
||||||
|
if (notification.note) {
|
||||||
|
text = `${misskeyNoteLink(notification.note!, server, 'note')} received ${notification.reaction} reaction from ${misskeyMentionUser(notification.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'edited':
|
||||||
|
if (notification.note) {
|
||||||
|
text = `${misskeyNoteLink(notification.note!, server, 'a note')} was edited by ${misskeyMentionUser(notification.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
telegramNotify(html(text.replace(/\n/g, '<br/>')))
|
||||||
|
}
|
||||||
|
|
||||||
if (!parsed.body.notification) {
|
|
||||||
return new Response('OK')
|
return new Response('OK')
|
||||||
}
|
|
||||||
|
|
||||||
const notification = parsed.body.notification
|
|
||||||
const server = parsed.server
|
|
||||||
|
|
||||||
let text
|
|
||||||
switch (notification.type) {
|
|
||||||
case 'note':
|
|
||||||
case 'mention':
|
|
||||||
case 'reply':
|
|
||||||
case 'renote':
|
|
||||||
case 'quote':
|
|
||||||
if (notification.note) {
|
|
||||||
text = `${misskeyNoteLink(notification.note!, server, `new ${notification.type}`)} from ${misskeyMentionUser(notification.note.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'follow':
|
|
||||||
case 'unfollow':
|
|
||||||
text = `${misskeyMentionUser(notification.user!, server)} ${notification.type}ed you`
|
|
||||||
break
|
|
||||||
case 'receiveFollowRequest':
|
|
||||||
text = `${misskeyMentionUser(notification.user!, server)} sent you a follow request`
|
|
||||||
break
|
|
||||||
case 'followRequestAccepted':
|
|
||||||
text = `${misskeyMentionUser(notification.user!, server)} accepted your follow request`
|
|
||||||
break
|
|
||||||
case 'reaction':
|
|
||||||
if (notification.note) {
|
|
||||||
text = `${misskeyNoteLink(notification.note!, server, 'note')} received ${notification.reaction} reaction from ${misskeyMentionUser(notification.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'edited':
|
|
||||||
if (notification.note) {
|
|
||||||
text = `${misskeyNoteLink(notification.note!, server, 'a note')} was edited by ${misskeyMentionUser(notification.user!, server)}:\n\n${misskeyNoteBrief(notification.note)}`
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text) {
|
|
||||||
telegramNotify(html(text.replace(/\n/g, '<br/>')))
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response('OK')
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
// import { telegramNotify } from '~/backend/bot/notify'
|
// import { telegramNotify } from '~/backend/bot/notify'
|
||||||
|
|
||||||
import type { APIRoute } from 'astro'
|
|
||||||
import { html } from '@mtcute/node'
|
import { html } from '@mtcute/node'
|
||||||
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
import { telegramNotify } from '~/backend/bot/notify'
|
|
||||||
import { env } from '~/backend/env'
|
import { env } from '~/backend/env'
|
||||||
|
import { telegramNotify } from '~/backend/bot/notify'
|
||||||
|
|
||||||
export const POST: APIRoute = async (ctx) => {
|
export const POST: APIRoute = async (ctx) => {
|
||||||
if (new URL(ctx.request.url).searchParams.get('secret') !== env.QBT_WEBHOOK_SECRET) {
|
if (new URL(ctx.request.url).searchParams.get('secret') !== env.QBT_WEBHOOK_SECRET) {
|
||||||
return new Response('Unauthorized', { status: 401 })
|
return new Response('Unauthorized', { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
telegramNotify(html`📥 Torrent finished: ${await ctx.request.text()}`)
|
telegramNotify(html`📥 Torrent finished: ${await ctx.request.text()}`)
|
||||||
|
|
||||||
return new Response('OK')
|
return new Response('OK')
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,85 +1,85 @@
|
||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { fromError } from 'zod-validation-error'
|
import { fromError } from 'zod-validation-error'
|
||||||
|
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||||
|
|
||||||
import { createShout, fetchShouts, isShoutboxBanned } from '~/backend/service/shoutbox'
|
import { createShout, fetchShouts, isShoutboxBanned } from '~/backend/service/shoutbox'
|
||||||
import { verifyCsrfToken } from '~/backend/utils/csrf'
|
|
||||||
import { getRequestIp } from '~/backend/utils/request'
|
import { getRequestIp } from '~/backend/utils/request'
|
||||||
|
import { verifyCsrfToken } from '~/backend/utils/csrf'
|
||||||
import { HttpResponse } from '~/backend/utils/response'
|
import { HttpResponse } from '~/backend/utils/response'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
_csrf: z.string(),
|
_csrf: z.string(),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
private: z.boolean(),
|
private: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const rateLimitPerIp = new RateLimiterMemory({ points: 3, duration: 300 })
|
const rateLimitPerIp = new RateLimiterMemory({ points: 3, duration: 300 })
|
||||||
const rateLimitGlobal = new RateLimiterMemory({ points: 100, duration: 3600 })
|
const rateLimitGlobal = new RateLimiterMemory({ points: 100, duration: 3600 })
|
||||||
|
|
||||||
export const POST: APIRoute = async (ctx) => {
|
export const POST: APIRoute = async (ctx) => {
|
||||||
const body = await schema.safeParseAsync(await ctx.request.json())
|
const body = await schema.safeParseAsync(await ctx.request.json())
|
||||||
if (body.error) {
|
if (body.error) {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
error: fromError(body.error).message,
|
error: fromError(body.error).message,
|
||||||
}, { status: 400 })
|
}, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = getRequestIp(ctx)
|
const ip = getRequestIp(ctx)
|
||||||
|
|
||||||
if (!verifyCsrfToken(ip, body.data._csrf)) {
|
if (!verifyCsrfToken(ip, body.data._csrf)) {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
error: 'csrf token is invalid',
|
error: 'csrf token is invalid',
|
||||||
}, { status: 400 })
|
}, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isShoutboxBanned('GLOBAL')) {
|
if (isShoutboxBanned('GLOBAL')) {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
error: 'shoutbox is temporarily disabled',
|
error: 'shoutbox is temporarily disabled',
|
||||||
}, { status: 400 })
|
}, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const bannedUntil = isShoutboxBanned(ip)
|
const bannedUntil = isShoutboxBanned(ip)
|
||||||
if (bannedUntil) {
|
if (bannedUntil) {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
error: `you were banned until ${bannedUntil}`,
|
error: `you were banned until ${bannedUntil}`,
|
||||||
}, { status: 400 })
|
}, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingLocal = await rateLimitPerIp.get(ip)
|
const remainingLocal = await rateLimitPerIp.get(ip)
|
||||||
const remainingGlobal = await rateLimitGlobal.get('GLOBAL')
|
const remainingGlobal = await rateLimitGlobal.get('GLOBAL')
|
||||||
if (remainingLocal?.remainingPoints === 0) {
|
if (remainingLocal?.remainingPoints === 0) {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
error: 'too many requests',
|
error: 'too many requests',
|
||||||
}, { status: 400 })
|
}, { status: 400 })
|
||||||
}
|
}
|
||||||
if (remainingGlobal?.remainingPoints === 0) {
|
if (remainingGlobal?.remainingPoints === 0) {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
error: `too many requests (globally), please retry after ${Math.ceil(remainingGlobal.msBeforeNext) / 60_000} minutes`,
|
error: `too many requests (globally), please retry after ${Math.ceil(remainingGlobal.msBeforeNext) / 60_000} minutes`,
|
||||||
}, { status: 400 })
|
}, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createShout({
|
const result = await createShout({
|
||||||
fromIp: ip,
|
fromIp: ip,
|
||||||
private: body.data.private,
|
private: body.data.private,
|
||||||
text: body.data.message,
|
text: body.data.message,
|
||||||
})
|
})
|
||||||
|
|
||||||
await rateLimitPerIp.penalty(ip, 1)
|
await rateLimitPerIp.penalty(ip, 1)
|
||||||
await rateLimitGlobal.penalty('GLOBAL', 1)
|
await rateLimitGlobal.penalty('GLOBAL', 1)
|
||||||
|
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
typeof result === 'string' ? { error: result } : { ok: true },
|
typeof result === 'string' ? { error: result } : { ok: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET: APIRoute = async (ctx) => {
|
export const GET: APIRoute = async (ctx) => {
|
||||||
const url = new URL(ctx.request.url)
|
const url = new URL(ctx.request.url)
|
||||||
|
|
||||||
let page = Number(url.searchParams.get('page'))
|
let page = Number(url.searchParams.get('page'))
|
||||||
if (Number.isNaN(page)) page = 0
|
if (Number.isNaN(page)) page = 0
|
||||||
|
|
||||||
const data = fetchShouts(page, getRequestIp(ctx))
|
const data = fetchShouts(page, getRequestIp(ctx))
|
||||||
|
|
||||||
return HttpResponse.json(data)
|
return HttpResponse.json(data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,12 +1,12 @@
|
||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
export const GET: APIRoute = ctx => new Response(
|
export const GET: APIRoute = ctx => new Response(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
Object.fromEntries(new URL(ctx.request.url).searchParams.entries()),
|
Object.fromEntries(new URL(ctx.request.url).searchParams.entries()),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
export const GET: APIRoute = () => new Response(null, {
|
export const GET: APIRoute = () => new Response(null, {
|
||||||
status: 301,
|
status: 301,
|
||||||
headers: {
|
headers: {
|
||||||
Location: 'https://legacy.tei.su/gdz',
|
Location: 'https://legacy.tei.su/gdz',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -18,20 +18,20 @@ const HTML = `
|
||||||
`.trim()
|
`.trim()
|
||||||
|
|
||||||
export const GET: APIRoute = async (ctx) => {
|
export const GET: APIRoute = async (ctx) => {
|
||||||
if (isBotUserAgent(ctx.request.headers.get('user-agent') || '')) {
|
if (isBotUserAgent(ctx.request.headers.get('user-agent') || '')) {
|
||||||
return new Response(HTML, {
|
return new Response(HTML, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html',
|
'Content-Type': 'text/html',
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
telegramNotify(html`someone (ip ${getRequestIp(ctx)}) got rickrolled >:3`)
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
telegramNotify(html`someone (ip ${getRequestIp(ctx)}) got rickrolled >:3`)
|
|
||||||
|
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: {
|
|
||||||
Location: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
|
|
||||||
export const GET: APIRoute = () => new Response(null, {
|
export const GET: APIRoute = () => new Response(null, {
|
||||||
status: 301,
|
status: 301,
|
||||||
headers: {
|
headers: {
|
||||||
Location: 'https://teidesu.github.io/protoflex/repl',
|
Location: 'https://teidesu.github.io/protoflex/repl',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
||||||
import { Link } from '../components/ui/Link.tsx'
|
import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency'
|
||||||
import { SectionTitle } from '../components/ui/Section.tsx'
|
import { Link } from '~/components/ui/Link/Link'
|
||||||
|
|
||||||
let currentCurrency = new URL(Astro.request.url).searchParams.get('currency')
|
let currentCurrency = new URL(Astro.request.url).searchParams.get('currency')
|
||||||
if (!currentCurrency || !AVAILABLE_CURRENCIES.includes(currentCurrency)) {
|
if (!currentCurrency || !AVAILABLE_CURRENCIES.includes(currentCurrency)) {
|
||||||
|
|
|
@ -4,24 +4,24 @@ import { umamiLogThisVisit } from '../backend/service/umami'
|
||||||
|
|
||||||
const EMPTY_GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64')
|
const EMPTY_GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64')
|
||||||
export const GET: APIRoute = async (ctx) => {
|
export const GET: APIRoute = async (ctx) => {
|
||||||
const website = new URL(ctx.request.url).searchParams.get('website')
|
const website = new URL(ctx.request.url).searchParams.get('website')
|
||||||
if (!website) {
|
if (!website) {
|
||||||
return new Response('no website', {
|
return new Response('no website', {
|
||||||
status: 400,
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
umamiLogThisVisit(
|
||||||
|
ctx.request,
|
||||||
|
ctx.request.headers.get('origin') ?? ctx.request.headers.get('referer') ?? undefined,
|
||||||
|
website,
|
||||||
|
)
|
||||||
|
|
||||||
|
return new Response(EMPTY_GIF, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/gif',
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
umamiLogThisVisit(
|
|
||||||
ctx.request,
|
|
||||||
ctx.request.headers.get('origin') ?? ctx.request.headers.get('referer') ?? undefined,
|
|
||||||
website,
|
|
||||||
)
|
|
||||||
|
|
||||||
return new Response(EMPTY_GIF, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'image/gif',
|
|
||||||
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
|
|
||||||
'Pragma': 'no-cache',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { type ClassValue, clsx } from 'clsx'
|
|
||||||
import { twMerge } from 'tailwind-merge'
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(...inputs))
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
export function randomInt(min: number, max: number): number {
|
export function randomInt(min: number, max: number): number {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
}
|
}
|
||||||
|
|
||||||
export function randomPick<T>(arr: T[]): T {
|
export function randomPick<T>(arr: T[]): T {
|
||||||
return arr[Math.floor(Math.random() * arr.length)]
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shuffle<T>(arr: T[]): T[] {
|
export function shuffle<T>(arr: T[]): T[] {
|
||||||
return arr.slice().sort(() => Math.random() - 0.5)
|
return arr.slice().sort(() => Math.random() - 0.5)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,13 @@ import type { z } from 'zod'
|
||||||
import { fromError } from 'zod-validation-error'
|
import { fromError } from 'zod-validation-error'
|
||||||
|
|
||||||
export async function zodValidate<T extends z.ZodTypeAny>(schema: T, data: unknown): Promise<z.TypeOf<T>> {
|
export async function zodValidate<T extends z.ZodTypeAny>(schema: T, data: unknown): Promise<z.TypeOf<T>> {
|
||||||
const res = await schema.safeParseAsync(data)
|
const res = await schema.safeParseAsync(data)
|
||||||
if (res.error) throw fromError(res.error)
|
if (res.error) throw fromError(res.error)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export function zodValidateSync<T extends z.ZodTypeAny>(schema: T, data: unknown): z.TypeOf<T> {
|
export function zodValidateSync<T extends z.ZodTypeAny>(schema: T, data: unknown): z.TypeOf<T> {
|
||||||
const res = schema.safeParse(data)
|
const res = schema.safeParse(data)
|
||||||
if (res.error) throw fromError(res.error)
|
if (res.error) throw fromError(res.error)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"]
|
"~/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [".astro/types.d.ts", "**/*"],
|
"exclude": [
|
||||||
"exclude": [
|
"node_modules",
|
||||||
"node_modules",
|
"public",
|
||||||
"public",
|
"dist"
|
||||||
"dist"
|
]
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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