refactor: unocss
This commit is contained in:
parent
040298b3ef
commit
434a78eb90
94 changed files with 3895 additions and 3805 deletions
|
@ -1,12 +1,16 @@
|
||||||
import { defineConfig } from 'astro/config'
|
|
||||||
import solid from '@astrojs/solid-js'
|
|
||||||
import node from '@astrojs/node'
|
import node from '@astrojs/node'
|
||||||
|
import solid from '@astrojs/solid-js'
|
||||||
|
import { defineConfig } from 'astro/config'
|
||||||
|
import UnoCSS from 'unocss/astro'
|
||||||
|
|
||||||
// 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,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
vite: {
|
vite: {
|
||||||
esbuild: { jsx: 'automatic' },
|
esbuild: { jsx: 'automatic' },
|
||||||
|
|
|
@ -1,29 +1,23 @@
|
||||||
import antfu from '@antfu/eslint-config'
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
export default antfu({
|
export default antfu({
|
||||||
stylistic: {
|
ignores: [
|
||||||
indent: 4,
|
'public',
|
||||||
},
|
'drizzle',
|
||||||
|
],
|
||||||
typescript: true,
|
typescript: true,
|
||||||
astro: true,
|
astro: true,
|
||||||
solid: true,
|
solid: true,
|
||||||
yaml: false,
|
yaml: false,
|
||||||
|
unocss: true,
|
||||||
rules: {
|
rules: {
|
||||||
|
'antfu/no-top-level-await': 'off',
|
||||||
'curly': ['error', 'multi-line'],
|
'curly': ['error', 'multi-line'],
|
||||||
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
|
||||||
'n/prefer-global/buffer': 'off',
|
'n/prefer-global/buffer': 'off',
|
||||||
'style/quotes': ['error', 'single', { avoidEscape: true }],
|
'style/quotes': ['error', 'single', { avoidEscape: true }],
|
||||||
'test/consistent-test-it': 'off',
|
'test/consistent-test-it': 'off',
|
||||||
'test/prefer-lowercase-title': 'off',
|
'test/prefer-lowercase-title': 'off',
|
||||||
'import/order': ['error', {
|
|
||||||
'newlines-between': 'always',
|
|
||||||
'pathGroups': [
|
|
||||||
{
|
|
||||||
pattern: '~/**',
|
|
||||||
group: 'parent',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
'antfu/if-newline': 'off',
|
'antfu/if-newline': 'off',
|
||||||
'style/max-statements-per-line': ['error', { max: 2 }],
|
'style/max-statements-per-line': ['error', { max: 2 }],
|
||||||
'ts/no-redeclare': 'off',
|
'ts/no-redeclare': 'off',
|
||||||
|
|
17
package.json
17
package.json
|
@ -17,11 +17,14 @@
|
||||||
"@astrojs/solid-js": "^5.0.4",
|
"@astrojs/solid-js": "^5.0.4",
|
||||||
"@fuman/fetch": "0.0.10",
|
"@fuman/fetch": "0.0.10",
|
||||||
"@fuman/utils": "0.0.10",
|
"@fuman/utils": "0.0.10",
|
||||||
|
"@iconify-json/gravity-ui": "^1.2.4",
|
||||||
"@mtcute/dispatcher": "^0.17.0",
|
"@mtcute/dispatcher": "^0.17.0",
|
||||||
"@mtcute/node": "^0.17.0",
|
"@mtcute/node": "^0.17.0",
|
||||||
"@tanstack/solid-query": "^5.51.21",
|
"@tanstack/solid-query": "^5.51.21",
|
||||||
|
"@unocss/postcss": "^65.4.3",
|
||||||
|
"@unocss/reset": "^65.4.3",
|
||||||
"astro": "^5.1.9",
|
"astro": "^5.1.9",
|
||||||
"astro-loading-indicator": "^0.5.0",
|
"astro-loading-indicator": "0.7.0",
|
||||||
"better-sqlite3": "^11.1.2",
|
"better-sqlite3": "^11.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
@ -31,19 +34,19 @@
|
||||||
"parse-duration": "^1.1.0",
|
"parse-duration": "^1.1.0",
|
||||||
"rate-limiter-flexible": "^5.0.3",
|
"rate-limiter-flexible": "^5.0.3",
|
||||||
"solid-js": "^1.8.19",
|
"solid-js": "^1.8.19",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
"unocss": "^65.4.3",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-validation-error": "^3.3.1"
|
"zod-validation-error": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^2.24.0",
|
"@antfu/eslint-config": "3.16.0",
|
||||||
"@types/better-sqlite3": "^7.6.11",
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
"@types/node": "^22.0.2",
|
"@types/node": "^22.0.2",
|
||||||
|
"@unocss/eslint-plugin": "^65.4.3",
|
||||||
|
"eslint": "9.19.0",
|
||||||
"eslint-plugin-astro": "^1.2.3",
|
"eslint-plugin-astro": "^1.2.3",
|
||||||
"eslint-plugin-solid": "0.14",
|
"eslint-plugin-solid": "0.14.5"
|
||||||
"postcss-custom-media": "^10.0.8",
|
|
||||||
"postcss-import": "^16.1.0",
|
|
||||||
"postcss-mixins": "^10.0.1",
|
|
||||||
"postcss-nesting": "^12.1.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2476
pnpm-lock.yaml
2476
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -1,8 +0,0 @@
|
||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
'postcss-import': {},
|
|
||||||
'postcss-mixins': {},
|
|
||||||
'postcss-custom-media': {},
|
|
||||||
'postcss-nesting': {},
|
|
||||||
},
|
|
||||||
}
|
|
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import UnoCSS from '@unocss/postcss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [
|
||||||
|
UnoCSS(),
|
||||||
|
],
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
|
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 {
|
||||||
|
|
|
@ -7,7 +7,8 @@ const UserSchema = z.object({
|
||||||
host: z
|
host: z
|
||||||
.string()
|
.string()
|
||||||
.describe('The local host is represented with `null`.')
|
.describe('The local host is represented with `null`.')
|
||||||
.optional().nullable(),
|
.optional()
|
||||||
|
.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
|
||||||
|
@ -21,7 +22,8 @@ const UserSchema = z.object({
|
||||||
offsetY: z.number().optional().nullable(),
|
offsetY: z.number().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional().nullable(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
isAdmin: z.boolean().optional().nullable(),
|
isAdmin: z.boolean().optional().nullable(),
|
||||||
isModerator: z.boolean().optional().nullable(),
|
isModerator: z.boolean().optional().nullable(),
|
||||||
isSilenced: z.boolean().optional().nullable(),
|
isSilenced: z.boolean().optional().nullable(),
|
||||||
|
@ -38,7 +40,8 @@ const UserSchema = z.object({
|
||||||
faviconUrl: z.string().optional().nullable(),
|
faviconUrl: z.string().optional().nullable(),
|
||||||
themeColor: z.string().optional().nullable(),
|
themeColor: z.string().optional().nullable(),
|
||||||
})
|
})
|
||||||
.optional().nullable(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
emojis: z.record(z.string()).optional().nullable(),
|
emojis: z.record(z.string()).optional().nullable(),
|
||||||
onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(),
|
onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(),
|
||||||
badgeRoles: z
|
badgeRoles: z
|
||||||
|
@ -49,7 +52,8 @@ const UserSchema = z.object({
|
||||||
displayOrder: z.number().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>
|
||||||
|
|
||||||
|
@ -63,17 +67,20 @@ const NoteSchema = z.object({
|
||||||
user: z
|
user: z
|
||||||
.object({})
|
.object({})
|
||||||
.catchall(z.any())
|
.catchall(z.any())
|
||||||
.optional().nullable(),
|
.optional()
|
||||||
|
.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()
|
||||||
|
.nullable(),
|
||||||
renote: z
|
renote: z
|
||||||
.object({})
|
.object({})
|
||||||
.catchall(z.any())
|
.catchall(z.any())
|
||||||
.optional().nullable(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
isHidden: z.boolean().optional().nullable(),
|
isHidden: z.boolean().optional().nullable(),
|
||||||
visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(),
|
visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(),
|
||||||
mentions: z.array(z.string()).optional().nullable(),
|
mentions: z.array(z.string()).optional().nullable(),
|
||||||
|
@ -93,9 +100,11 @@ const NoteSchema = z.object({
|
||||||
votes: z.number().optional().nullable(),
|
votes: z.number().optional().nullable(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional().nullable(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
})
|
})
|
||||||
.optional().nullable(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
emojis: z.record(z.string(), z.any()).optional().nullable(),
|
emojis: z.record(z.string(), z.any()).optional().nullable(),
|
||||||
channelId: z.string().optional().nullable(),
|
channelId: z.string().optional().nullable(),
|
||||||
channel: z
|
channel: z
|
||||||
|
@ -107,7 +116,8 @@ const NoteSchema = z.object({
|
||||||
allowRenoteToExternal: z.boolean().optional().nullable(),
|
allowRenoteToExternal: z.boolean().optional().nullable(),
|
||||||
userId: z.string().optional().nullable(),
|
userId: z.string().optional().nullable(),
|
||||||
})
|
})
|
||||||
.optional().nullable(),
|
.optional()
|
||||||
|
.nullable(),
|
||||||
localOnly: z.boolean().optional().nullable(),
|
localOnly: z.boolean().optional().nullable(),
|
||||||
reactionAcceptance: z.string().optional().nullable(),
|
reactionAcceptance: z.string().optional().nullable(),
|
||||||
reactionEmojis: z.record(z.string(), z.string()).optional().nullable(),
|
reactionEmojis: z.record(z.string(), z.string()).optional().nullable(),
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'dotenv/config'
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { 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(),
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { z } from 'zod'
|
|
||||||
import { AsyncResource } from '@fuman/utils'
|
import { AsyncResource } from '@fuman/utils'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { env } from '../env'
|
import { env } from '../env'
|
||||||
import { ffetch } from '../utils/fetch.ts'
|
import { ffetch } from '../utils/fetch.ts'
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
|
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
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
|
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
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
|
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
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
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
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { z } from 'zod'
|
import type { LastSeenItem } from './index.ts'
|
||||||
import { AsyncResource } from '@fuman/utils'
|
import { AsyncResource } from '@fuman/utils'
|
||||||
|
|
||||||
import { ffetch } from '../../utils/fetch.ts'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import type { LastSeenItem } from './index.ts'
|
import { ffetch } from '../../utils/fetch.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
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
|
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
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
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 { shouts, shoutsBans } from '../models/index.js'
|
|
||||||
import { URL_REGEX } from '../utils/url.js'
|
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { env } from '../env'
|
import { env } from '../env'
|
||||||
import { tg } from '../bot'
|
import { shouts, shoutsBans } from '../models/index.js'
|
||||||
|
import { URL_REGEX } from '../utils/url.js'
|
||||||
|
|
||||||
const SHOUTS_PER_PAGE = 5
|
const SHOUTS_PER_PAGE = 5
|
||||||
|
|
||||||
|
@ -17,9 +17,7 @@ const filter = or(
|
||||||
|
|
||||||
const fetchTotal = db.select({
|
const fetchTotal = db.select({
|
||||||
count: sql<number>`count(1)`,
|
count: sql<number>`count(1)`,
|
||||||
}).from(shouts)
|
}).from(shouts).where(filter).prepare()
|
||||||
.where(filter)
|
|
||||||
.prepare()
|
|
||||||
|
|
||||||
const fetchList = db.select({
|
const fetchList = db.select({
|
||||||
createdAt: shouts.createdAt,
|
createdAt: shouts.createdAt,
|
||||||
|
@ -27,12 +25,7 @@ const fetchList = db.select({
|
||||||
pending: shouts.pending,
|
pending: shouts.pending,
|
||||||
serial: shouts.serial,
|
serial: shouts.serial,
|
||||||
reply: shouts.reply,
|
reply: shouts.reply,
|
||||||
}).from(shouts)
|
}).from(shouts).where(filter).limit(SHOUTS_PER_PAGE).orderBy(desc(shouts.createdAt)).offset(sql.placeholder('offset')).prepare()
|
||||||
.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 {
|
||||||
|
@ -49,8 +42,7 @@ 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)
|
}).from(shouts).prepare()
|
||||||
.prepare()
|
|
||||||
|
|
||||||
export function approveShout(id: string) {
|
export function approveShout(id: string) {
|
||||||
const nextSerial = fetchNextSerial.get({})!.serial
|
const nextSerial = fetchNextSerial.get({})!.serial
|
||||||
|
|
|
@ -2,8 +2,8 @@ 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 { isBotUserAgent } from '../utils/bot'
|
|
||||||
import { env } from '~/backend/env'
|
import { env } from '~/backend/env'
|
||||||
|
import { isBotUserAgent } from '../utils/bot'
|
||||||
|
|
||||||
const ffetch = ffetchBase.extend({
|
const ffetch = ffetchBase.extend({
|
||||||
addons: [
|
addons: [
|
||||||
|
|
|
@ -27,11 +27,11 @@ export function verifyCsrfToken(ip: string, token: string) {
|
||||||
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(correctSign.subarray(0, 8) as Uint8Array, buf.subarray(-8))) {
|
if (!typed.equal(new Uint8Array(correctSign.subarray(0, 8)), buf.subarray(-8))) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const [issued, correctIp] = JSON.parse(buf.subarray(0, -16).toString())
|
const [issued, correctIp] = JSON.parse(utf8.decoder.decode(buf.subarray(0, -16)))
|
||||||
if (issued + validity < Date.now()) return false
|
if (issued + validity < Date.now()) return false
|
||||||
if (ip !== correctIp) return false
|
if (ip !== correctIp) return false
|
||||||
|
|
||||||
|
|
39
src/components/interactive/RandomWord.tsx
Normal file
39
src/components/interactive/RandomWord.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
/** @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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,12 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
/** @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 DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
|
||||||
import { umamiLogThisVisit } from '~/backend/service/umami'
|
|
||||||
import moneyImg from '~/assets/money.jpg'
|
import moneyImg from '~/assets/money.jpg'
|
||||||
|
import { umamiLogThisVisit } from '~/backend/service/umami'
|
||||||
|
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
||||||
|
|
||||||
import { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
|
|
||||||
import { fetchDonatePageData } from './data'
|
import { fetchDonatePageData } from './data'
|
||||||
|
import { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
|
||||||
|
|
||||||
umamiLogThisVisit(Astro.request, '/donate')
|
umamiLogThisVisit(Astro.request, '/donate')
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
/** @jsxImportSource solid-js */
|
/** @jsxImportSource solid-js */
|
||||||
import { type JSX, createSignal, onMount } from 'solid-js'
|
import type { PaymentMethod } from './constants'
|
||||||
|
|
||||||
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 type { PaymentMethod } from './constants'
|
import { createSignal, type JSX, onMount } from 'solid-js'
|
||||||
|
|
||||||
|
import { Link } from '../../ui/Link.tsx'
|
||||||
|
import { SectionTitle } from '../../ui/Section.tsx'
|
||||||
|
import { TextTable } from '../../ui/TextTable.tsx'
|
||||||
import { 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 }) {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
---
|
---
|
||||||
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
|
||||||
import { RandomWord } from '~/components/interactive/RandomWord/RandomWord'
|
|
||||||
import { umamiLogThisVisit } from '~/backend/service/umami'
|
import { umamiLogThisVisit } from '~/backend/service/umami'
|
||||||
|
import { RandomWord } from '~/components/interactive/RandomWord'
|
||||||
|
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
@ -1,146 +0,0 @@
|
||||||
@import url('../../shared.css');
|
|
||||||
|
|
||||||
.comment {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
margin-left: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.commentInline {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
margin-left: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.testimonial {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favColor {
|
|
||||||
background: #be15dc;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
display: inline-block;
|
|
||||||
height: 10px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
vertical-align: middle;
|
|
||||||
width: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.webring {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 16px;
|
|
||||||
@mixin font-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeen summary {
|
|
||||||
position: relative;
|
|
||||||
list-style: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--control-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* prevent text selection on 2/3-ple click */
|
|
||||||
&:active {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeenItem {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeenItem + .lastSeenItem {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeen[open] {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeenTrigger::before {
|
|
||||||
content: '(click to expand)';
|
|
||||||
@mixin font-xs;
|
|
||||||
margin-left: 1em;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
@media (--tablet) {
|
|
||||||
content: '(expand)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (--mobile) {
|
|
||||||
content: '<';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeen[open] .lastSeenTrigger::before {
|
|
||||||
content: '(click to collapse)';
|
|
||||||
|
|
||||||
@media (--tablet) {
|
|
||||||
content: '(collapse)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (--mobile) {
|
|
||||||
content: 'v';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeenLinkWrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
@media (--tablet) {
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeenLinkWrapInner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: min-content;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeenLink {
|
|
||||||
max-width: 200px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
@media (--tablet) {
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeenSuffix {
|
|
||||||
@mixin font-xs;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lastSeenSource {
|
|
||||||
@mixin font-xs;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-left: 8px;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
@media (--tablet) {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +1,24 @@
|
||||||
/** @jsxImportSource solid-js */
|
/** @jsxImportSource solid-js */
|
||||||
import { For, type JSX, Show } from 'solid-js'
|
import type { PageData } from './data'
|
||||||
import { Dynamic } from 'solid-js/web'
|
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
|
||||||
import { intlFormatDistance } from 'date-fns'
|
import { intlFormatDistance } from 'date-fns'
|
||||||
|
|
||||||
import { Emoji } from '~/components/ui/Emoji/Emoji'
|
import { For, type JSX, Show } from 'solid-js'
|
||||||
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
|
import { Dynamic } from 'solid-js/web'
|
||||||
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 axolotl from '~/assets/axolotl.png'
|
||||||
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
|
import cherry from '~/assets/cherry-blossom_1f338.png'
|
||||||
import { randomInt } from '~/utils/random'
|
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 css from './PageMain.module.css'
|
import { randomInt } from '~/utils/random'
|
||||||
|
import { cn } from '../../../utils/cn.ts'
|
||||||
|
import { Emoji } from '../../ui/Emoji.tsx'
|
||||||
|
import { Link } from '../../ui/Link.tsx'
|
||||||
|
import { SectionTitle } from '../../ui/Section.tsx'
|
||||||
|
import { TextComment } from '../../ui/TextComment.tsx'
|
||||||
|
import { TextTable } from '../../ui/TextTable.tsx'
|
||||||
import { 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(
|
||||||
|
@ -29,11 +29,17 @@ function formatTimeRelative(time: number) {
|
||||||
|
|
||||||
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
||||||
return (
|
return (
|
||||||
<Dynamic component={props.first ? 'summary' : 'div'} class={css.lastSeenItem}>
|
<Dynamic
|
||||||
<div class={css.lastSeenLinkWrap}>
|
component={props.first ? 'summary' : 'div'}
|
||||||
<div class={css.lastSeenLinkWrapInner}>
|
class={cn(
|
||||||
|
'flex flex-row items-center justify-between',
|
||||||
|
props.first && 'pos-relative list-none cursor-pointer rounded-md hover:bg-control-bg-hover active:select-none [&::-webkit-details-marker]:hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="max-w-full flex items-center overflow-hidden">
|
||||||
|
<div class="width-min max-w-full flex items-center overflow-hidden">
|
||||||
<Link
|
<Link
|
||||||
class={css.lastSeenLink}
|
class="max-w-200px overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-300px"
|
||||||
href={props.item.link}
|
href={props.item.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title={props.item.text}
|
title={props.item.text}
|
||||||
|
@ -41,12 +47,12 @@ function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
||||||
{props.item.text}
|
{props.item.text}
|
||||||
</Link>
|
</Link>
|
||||||
{props.item.suffix && (
|
{props.item.suffix && (
|
||||||
<span class={css.lastSeenSuffix}>
|
<span class="whitespace-nowrap text-xs">
|
||||||
{props.item.suffix}
|
{props.item.suffix}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<i class={css.lastSeenSource}>
|
<i class="ml-2 whitespace-nowrap text-xs text-text-secondary lg:ml-0">
|
||||||
{'@ '}
|
{'@ '}
|
||||||
<Link href={props.item.sourceLink} target="_blank">
|
<Link href={props.item.sourceLink} target="_blank">
|
||||||
{props.item.source}
|
{props.item.source}
|
||||||
|
@ -56,7 +62,7 @@ function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
|
||||||
</i>
|
</i>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.first}>
|
<Show when={props.first}>
|
||||||
<div class={css.lastSeenTrigger} />
|
<div class="before:ml-1em before:whitespace-nowrap before:text-xs before:text-text-secondary before:content-['<'] lg:before:content-['(click_to_expand)'] md:before:content-['(expand)']" />
|
||||||
</Show>
|
</Show>
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
)
|
)
|
||||||
|
@ -77,7 +83,7 @@ export function PageMain(props: {
|
||||||
: <i>{props.author}</i>
|
: <i>{props.author}</i>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={css.testimonial}>
|
<div class="mb-2">
|
||||||
"
|
"
|
||||||
{props.text}
|
{props.text}
|
||||||
" -
|
" -
|
||||||
|
@ -102,7 +108,7 @@ export function PageMain(props: {
|
||||||
{' '}
|
{' '}
|
||||||
<span innerHTML={item.subtitle} />
|
<span innerHTML={item.subtitle} />
|
||||||
<TextComment
|
<TextComment
|
||||||
class={css.comment}
|
class="mb-2 ml-12 text-text-secondary"
|
||||||
innerHTML={item.comment}
|
innerHTML={item.comment}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -167,7 +173,7 @@ export function PageMain(props: {
|
||||||
if (!props.data.lastSeen?.length) return
|
if (!props.data.lastSeen?.length) return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details class={css.lastSeen}>
|
<details class="open:mb-1">
|
||||||
<LastSeenItem first item={props.data.lastSeen[0]} />
|
<LastSeenItem first item={props.data.lastSeen[0]} />
|
||||||
<For each={props.data.lastSeen.slice(1)}>
|
<For each={props.data.lastSeen.slice(1)}>
|
||||||
{it => <LastSeenItem item={it} />}
|
{it => <LastSeenItem item={it} />}
|
||||||
|
@ -182,7 +188,7 @@ export function PageMain(props: {
|
||||||
<>
|
<>
|
||||||
#be15dc
|
#be15dc
|
||||||
{' '}
|
{' '}
|
||||||
<div class={css.favColor} />
|
<div class="mb-0.5 inline-block h-10px w-10px border border-#ccc bg-[#be15dc] align-middle" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -290,7 +296,7 @@ export function PageMain(props: {
|
||||||
|
|
||||||
{testimonials}
|
{testimonials}
|
||||||
|
|
||||||
<TextComment class={css.commentInline}>
|
<TextComment class="mb-2 ml-2em text-text-secondary">
|
||||||
feel free to leave yours :3
|
feel free to leave yours :3
|
||||||
</TextComment>
|
</TextComment>
|
||||||
</section>
|
</section>
|
||||||
|
@ -312,7 +318,7 @@ export function PageMain(props: {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Show when={props.data.webring}>
|
<Show when={props.data.webring}>
|
||||||
<section class={css.webring}>
|
<section class="mt-4 flex items-center justify-between text-xs">
|
||||||
<Link href={props.data.webring!.prev.url}>
|
<Link href={props.data.webring!.prev.url}>
|
||||||
<
|
<
|
||||||
{' '}
|
{' '}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import { fetchShouts } from '~/backend/service/shoutbox'
|
import { fetchShouts } from '~/backend/service/shoutbox'
|
||||||
import { getRequestIp } from '~/backend/utils/request'
|
|
||||||
import { getCsrfToken } from '~/backend/utils/csrf'
|
import { getCsrfToken } from '~/backend/utils/csrf'
|
||||||
|
import { getRequestIp } from '~/backend/utils/request'
|
||||||
|
|
||||||
import { Shoutbox as ShoutboxSolid } from './Shoutbox'
|
import { Shoutbox as ShoutboxSolid } from './Shoutbox'
|
||||||
|
|
||||||
|
|
|
@ -1,75 +0,0 @@
|
||||||
@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,21 +1,16 @@
|
||||||
/* eslint-disable no-alert */
|
|
||||||
/** @jsxImportSource solid-js */
|
/** @jsxImportSource solid-js */
|
||||||
import { type ComponentProps, Show, createSignal, onMount } from 'solid-js'
|
/* eslint-disable no-alert */
|
||||||
import { QueryClient, QueryClientProvider, createQuery, keepPreviousData } from '@tanstack/solid-query'
|
import type { ShoutsData } from '~/backend/service/shoutbox'
|
||||||
|
import { createQuery, keepPreviousData, QueryClient, QueryClientProvider } from '@tanstack/solid-query'
|
||||||
import { format } from 'date-fns/format'
|
import { format } from 'date-fns/format'
|
||||||
|
|
||||||
import { Button } from '~/components/ui/Button/Button'
|
import { type ComponentProps, createSignal, onMount, Show } from 'solid-js'
|
||||||
import { Checkbox } from '~/components/ui/Checkbox/Checkbox'
|
import { Button } from '../../../ui/Button.tsx'
|
||||||
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 css from './Shoutbox.module.css'
|
import { Checkbox } from '../../../ui/Checkbox/Checkbox.tsx'
|
||||||
|
import { SectionTitle } from '../../../ui/Section.tsx'
|
||||||
|
import { TextArea } from '../../../ui/TextArea.tsx'
|
||||||
|
import { TextComment } from '../../../ui/TextComment.tsx'
|
||||||
|
|
||||||
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())
|
||||||
|
@ -61,27 +56,21 @@ function ShoutboxInner(props: {
|
||||||
|
|
||||||
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" />
|
||||||
<Icon
|
|
||||||
glyph={GravityClock}
|
|
||||||
size={16}
|
|
||||||
title="awaiting moderation"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: `#${props.serial}`
|
: `#${props.serial}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={css.shout}>
|
<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={css.header}>
|
<div class="flex flex-row gap-2 text-text-secondary">
|
||||||
{icon}
|
{icon}
|
||||||
<time class={css.time} datetime={props.createdAt}>
|
<time class="whitespace-nowrap" datetime={props.createdAt}>
|
||||||
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
|
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<div class={css.text}>
|
<div class="whitespace-pre-wrap">
|
||||||
{props.text}
|
{props.text}
|
||||||
{props.reply && (
|
{props.reply && (
|
||||||
<div class={css.reply}>
|
<div class="mt-1.5">
|
||||||
<b>reply: </b>
|
<b>reply: </b>
|
||||||
{props.reply}
|
{props.reply}
|
||||||
</div>
|
</div>
|
||||||
|
@ -138,7 +127,7 @@ function ShoutboxInner(props: {
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle>shoutbox!</SectionTitle>
|
<SectionTitle>shoutbox!</SectionTitle>
|
||||||
<TextComment class={pageCss.comment}>
|
<TextComment class="mb-2 ml-12 text-text-secondary">
|
||||||
disclaimer: shouts
|
disclaimer: shouts
|
||||||
{' '}
|
{' '}
|
||||||
<i>are</i>
|
<i>are</i>
|
||||||
|
@ -146,13 +135,13 @@ function ShoutboxInner(props: {
|
||||||
pre-moderated, but they do not reflect my views.
|
pre-moderated, but they do not reflect my views.
|
||||||
</TextComment>
|
</TextComment>
|
||||||
|
|
||||||
<div class={css.form}>
|
<div class="w-full flex flex-col gap-2">
|
||||||
<input type="hidden" name="_csrf" value={props.csrf} />
|
<input type="hidden" name="_csrf" value={props.csrf} />
|
||||||
<div class={css.formInput}>
|
<div class="w-full flex gap-2">
|
||||||
<TextArea
|
<TextArea
|
||||||
ref={messageInput}
|
ref={messageInput}
|
||||||
disabled={sending() || !jsEnabled()}
|
disabled={sending() || !jsEnabled()}
|
||||||
class={css.textarea}
|
class="w-full"
|
||||||
grow
|
grow
|
||||||
maxRows={5}
|
maxRows={5}
|
||||||
name="message"
|
name="message"
|
||||||
|
@ -166,20 +155,20 @@ function ShoutboxInner(props: {
|
||||||
disabled={sending() || !jsEnabled()}
|
disabled={sending() || !jsEnabled()}
|
||||||
title="submit"
|
title="submit"
|
||||||
>
|
>
|
||||||
<Icon glyph={GravityMegaphone} size={16} />
|
<div class="i-gravity-ui-megaphone size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class={css.formControls}>
|
<div class="flex flex-row justify-between">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
ref={privateCheckbox}
|
ref={privateCheckbox}
|
||||||
label="make it private"
|
label="make it private"
|
||||||
name="private"
|
name="private"
|
||||||
/>
|
/>
|
||||||
<Show when={shouts.data && shouts.data.pageCount > 1}>
|
<Show when={shouts.data && shouts.data.pageCount > 1}>
|
||||||
<div class={css.pagination}>
|
<div class="flex gap-2 text-text-secondary">
|
||||||
<Show when={page() > 0}>
|
<Show when={page() > 0}>
|
||||||
<a
|
<a
|
||||||
class={css.paginationLink}
|
class="text-text-secondary underline underline-offset-2"
|
||||||
rel="external"
|
rel="external"
|
||||||
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
|
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
|
||||||
onClick={onPageClick(false)}
|
onClick={onPageClick(false)}
|
||||||
|
@ -191,7 +180,7 @@ function ShoutboxInner(props: {
|
||||||
<span>{page() + 1}</span>
|
<span>{page() + 1}</span>
|
||||||
<Show when={page() < shouts.data!.pageCount - 1}>
|
<Show when={page() < shouts.data!.pageCount - 1}>
|
||||||
<a
|
<a
|
||||||
class={css.paginationLink}
|
class="text-text-secondary underline underline-offset-2"
|
||||||
rel="external"
|
rel="external"
|
||||||
href={`?shouts_page=${page() + 1}`}
|
href={`?shouts_page=${page() + 1}`}
|
||||||
onClick={onPageClick(true)}
|
onClick={onPageClick(true)}
|
||||||
|
@ -205,7 +194,7 @@ function ShoutboxInner(props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={css.shouts}>
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
{shoutsRender()}
|
{shoutsRender()}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
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'
|
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'
|
||||||
|
|
||||||
export async function fetchMainPageData() {
|
export async function fetchMainPageData() {
|
||||||
const [
|
const [
|
||||||
|
|
24
src/components/ui/Button.tsx
Normal file
24
src/components/ui/Button.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/** @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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,22 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
/** @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,41 +1,18 @@
|
||||||
.input {
|
.input {
|
||||||
display: none;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
display: flex;
|
@apply flex items-center gap-2 cursor-pointer select-none;
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
width: 1em;
|
@apply bg-control-bg border-control-outline hover:bg-control-bg-hover pos-relative h-4 w-4 border rounded-md transition-all;
|
||||||
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;
|
|
||||||
height: 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;
|
||||||
background: var(--text-primary);
|
|
||||||
border-radius: 2px;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
}
|
||||||
|
|
16
src/components/ui/Emoji.tsx
Normal file
16
src/components/ui/Emoji.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
/** @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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +0,0 @@
|
||||||
.emoji {
|
|
||||||
display: inline-block;
|
|
||||||
height: 1em;
|
|
||||||
object-fit: contain;
|
|
||||||
overflow: hidden;
|
|
||||||
vertical-align: middle;
|
|
||||||
width: 1em;
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
/** @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)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
.wrap {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
/** @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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
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.
|
|
|
@ -1,13 +0,0 @@
|
||||||
/** @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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
/** @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>
|
|
||||||
)
|
|
||||||
}
|
|
17
src/components/ui/Link.tsx
Normal file
17
src/components/ui/Link.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/** @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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,3 +0,0 @@
|
||||||
.link {
|
|
||||||
color: var(--text-accent);
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
/** @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>
|
|
||||||
)
|
|
||||||
}
|
|
14
src/components/ui/Section.tsx
Normal file
14
src/components/ui/Section.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
/** @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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,37 +0,0 @@
|
||||||
@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;
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
/** @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>
|
|
||||||
)
|
|
||||||
}
|
|
70
src/components/ui/TextArea.tsx
Normal file
70
src/components/ui/TextArea.tsx
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
/** @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(
|
||||||
|
'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 focus:bg-control-bg-active focus:outline-1 focus:outline-text-primary focus:hover:cursor-text',
|
||||||
|
my.class,
|
||||||
|
)}
|
||||||
|
onInput={onInput}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,39 +0,0 @@
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,68 +0,0 @@
|
||||||
/** @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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
16
src/components/ui/TextComment.tsx
Normal file
16
src/components/ui/TextComment.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
/** @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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,10 +0,0 @@
|
||||||
.comment {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: '// ';
|
|
||||||
position: absolute;
|
|
||||||
left: -2em;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
/** @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)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
43
src/components/ui/TextTable.tsx
Normal file
43
src/components/ui/TextTable.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
/** @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_4fr] md:grid-cols-[1fr_2fr] overflow-hidden border-spacing-0 line-height-18px',
|
||||||
|
props.wrap ? 'whitespace-pre-wrap' : 'text-ellipsis whitespace-pre',
|
||||||
|
props.fill && 'w-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rows()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,43 +0,0 @@
|
||||||
.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%;
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
/** @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,6 +1,6 @@
|
||||||
---
|
---
|
||||||
import { ClientRouter } 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'
|
||||||
|
|
||||||
|
@ -81,18 +81,11 @@ const finalOg = { ...defaultOgTags, ...og }
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
*:focus {
|
*:not(:active, summary):focus {
|
||||||
outline-color: var(--text-primary)
|
outline: 1px solid var(--text-primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg);
|
@apply text-sm m-0 p-0 overflow-x-hidden overflow-y-auto bg-bg font-mono text-text-primary;
|
||||||
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/Link'
|
import { Link } from '../../components/ui/Link.tsx'
|
||||||
import BaseLayout, { type Props } from '../BaseLayout.astro'
|
import BaseLayout, { type Props } from '../BaseLayout.astro'
|
||||||
|
|
||||||
import Header from './Header.astro'
|
import Header from './Header.astro'
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
---
|
---
|
||||||
import karin from '~/assets/karin.gif'
|
import karin from '~/assets/karin.gif'
|
||||||
import { Link } from '~/components/ui/Link/Link'
|
import { Link } from '../../components/ui/Link.tsx'
|
||||||
|
|
||||||
const PAGES = [
|
const PAGES = [
|
||||||
{ name: 'hewwo', path: '/' },
|
{ name: 'hewwo', path: '/' },
|
||||||
{ name: 'donate', path: ['/donate', '/$'] },
|
{ name: 'donate', path: ['/donate', '/$'] },
|
||||||
]
|
]
|
||||||
---
|
---
|
||||||
<header class="header">
|
<header class="pos-relative flex items-center justify-center gap-2">
|
||||||
<img
|
<img
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="gif"
|
class="pos-absolute right-0 top-0 h-64px w-64px motion-reduce:hidden @dark:filter-brightness-90"
|
||||||
src={karin.src}
|
src={karin.src}
|
||||||
transition:persist
|
transition:persist
|
||||||
/>
|
/>
|
||||||
|
@ -20,7 +20,7 @@ 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="delimiter"> / </span>,
|
<span class="select-none text-text-secondary"> / </span>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ const PAGES = [
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<span class="active">{page.name}</span>,
|
<span class="font-bold">{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
|
||||||
|
@ -49,38 +49,3 @@ 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>
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
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}`
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
// import { telegramNotify } from '~/backend/bot/notify'
|
// import { telegramNotify } from '~/backend/bot/notify'
|
||||||
|
|
||||||
import { html } from '@mtcute/node'
|
|
||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
|
import { html } from '@mtcute/node'
|
||||||
|
|
||||||
import { env } from '~/backend/env'
|
|
||||||
import { telegramNotify } from '~/backend/bot/notify'
|
import { telegramNotify } from '~/backend/bot/notify'
|
||||||
|
import { env } from '~/backend/env'
|
||||||
|
|
||||||
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) {
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
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 { getRequestIp } from '~/backend/utils/request'
|
|
||||||
import { verifyCsrfToken } from '~/backend/utils/csrf'
|
import { verifyCsrfToken } from '~/backend/utils/csrf'
|
||||||
|
import { getRequestIp } from '~/backend/utils/request'
|
||||||
import { HttpResponse } from '~/backend/utils/response'
|
import { HttpResponse } from '~/backend/utils/response'
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
|
|
||||||
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
|
||||||
import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency'
|
import { AVAILABLE_CURRENCIES, convertCurrencySync, fetchConvertRates } from '~/backend/service/currency'
|
||||||
import { Link } from '~/components/ui/Link/Link'
|
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
|
||||||
|
import { Link } from '../components/ui/Link.tsx'
|
||||||
|
import { SectionTitle } from '../components/ui/Section.tsx'
|
||||||
|
|
||||||
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)) {
|
||||||
|
|
6
src/utils/cn.ts
Normal file
6
src/utils/cn.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(...inputs))
|
||||||
|
}
|
37
uno.config.ts
Normal file
37
uno.config.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { defineConfig, presetIcons, presetUno } from 'unocss'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [
|
||||||
|
presetUno(),
|
||||||
|
presetIcons(),
|
||||||
|
],
|
||||||
|
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