refactor: unocss

This commit is contained in:
alina 🌸 2025-01-25 13:42:13 +03:00
parent 040298b3ef
commit 434a78eb90
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
94 changed files with 3895 additions and 3805 deletions

View file

@ -1,12 +1,16 @@
import { defineConfig } from 'astro/config'
import solid from '@astrojs/solid-js'
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
export default defineConfig({
output: 'server',
integrations: [
solid(),
UnoCSS({
injectReset: true,
}),
],
vite: {
esbuild: { jsx: 'automatic' },

View file

@ -1,29 +1,23 @@
import antfu from '@antfu/eslint-config'
export default antfu({
stylistic: {
indent: 4,
},
ignores: [
'public',
'drizzle',
],
typescript: true,
astro: true,
solid: true,
yaml: false,
unocss: true,
rules: {
'antfu/no-top-level-await': 'off',
'curly': ['error', 'multi-line'],
'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
'n/prefer-global/buffer': 'off',
'style/quotes': ['error', 'single', { avoidEscape: true }],
'test/consistent-test-it': 'off',
'test/prefer-lowercase-title': 'off',
'import/order': ['error', {
'newlines-between': 'always',
'pathGroups': [
{
pattern: '~/**',
group: 'parent',
},
],
}],
'antfu/if-newline': 'off',
'style/max-statements-per-line': ['error', { max: 2 }],
'ts/no-redeclare': 'off',

View file

@ -17,11 +17,14 @@
"@astrojs/solid-js": "^5.0.4",
"@fuman/fetch": "0.0.10",
"@fuman/utils": "0.0.10",
"@iconify-json/gravity-ui": "^1.2.4",
"@mtcute/dispatcher": "^0.17.0",
"@mtcute/node": "^0.17.0",
"@tanstack/solid-query": "^5.51.21",
"@unocss/postcss": "^65.4.3",
"@unocss/reset": "^65.4.3",
"astro": "^5.1.9",
"astro-loading-indicator": "^0.5.0",
"astro-loading-indicator": "0.7.0",
"better-sqlite3": "^11.1.2",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
@ -31,19 +34,19 @@
"parse-duration": "^1.1.0",
"rate-limiter-flexible": "^5.0.3",
"solid-js": "^1.8.19",
"tailwind-merge": "^2.6.0",
"typescript": "^5.7.3",
"unocss": "^65.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.1"
},
"devDependencies": {
"@antfu/eslint-config": "^2.24.0",
"@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",
"postcss-custom-media": "^10.0.8",
"postcss-import": "^16.1.0",
"postcss-mixins": "^10.0.1",
"postcss-nesting": "^12.1.5"
"eslint-plugin-solid": "0.14.5"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +0,0 @@
export default {
plugins: {
'postcss-import': {},
'postcss-mixins': {},
'postcss-custom-media': {},
'postcss-nesting': {},
},
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
import UnoCSS from '@unocss/postcss'
export default {
plugins: [
UnoCSS(),
],
}

View file

@ -1,7 +1,7 @@
import { assertMatches } from '@fuman/utils'
import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher'
import { html } from '@mtcute/node'
import parseDuration from 'parse-duration'
import { assertMatches } from '@fuman/utils'
import { env } from '../env'
import {

View file

@ -7,7 +7,8 @@ const UserSchema = z.object({
host: z
.string()
.describe('The local host is represented with `null`.')
.optional().nullable(),
.optional()
.nullable(),
avatarUrl: z.string().optional().nullable(),
avatarBlurhash: z.string().optional().nullable(),
avatarDecorations: z
@ -21,7 +22,8 @@ const UserSchema = z.object({
offsetY: z.number().optional().nullable(),
}),
)
.optional().nullable(),
.optional()
.nullable(),
isAdmin: z.boolean().optional().nullable(),
isModerator: z.boolean().optional().nullable(),
isSilenced: z.boolean().optional().nullable(),
@ -38,7 +40,8 @@ const UserSchema = z.object({
faviconUrl: z.string().optional().nullable(),
themeColor: z.string().optional().nullable(),
})
.optional().nullable(),
.optional()
.nullable(),
emojis: z.record(z.string()).optional().nullable(),
onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(),
badgeRoles: z
@ -49,7 +52,8 @@ const UserSchema = z.object({
displayOrder: z.number().optional().nullable(),
}),
)
.optional().nullable(),
.optional()
.nullable(),
})
export type MkUser = z.infer<typeof UserSchema>
@ -63,17 +67,20 @@ const NoteSchema = z.object({
user: z
.object({})
.catchall(z.any())
.optional().nullable(),
.optional()
.nullable(),
replyId: z.string().optional().nullable(),
renoteId: z.string().optional().nullable(),
reply: z
.object({})
.catchall(z.any())
.optional().nullable(),
.optional()
.nullable(),
renote: z
.object({})
.catchall(z.any())
.optional().nullable(),
.optional()
.nullable(),
isHidden: z.boolean().optional().nullable(),
visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(),
mentions: z.array(z.string()).optional().nullable(),
@ -93,9 +100,11 @@ const NoteSchema = z.object({
votes: z.number().optional().nullable(),
}),
)
.optional().nullable(),
.optional()
.nullable(),
})
.optional().nullable(),
.optional()
.nullable(),
emojis: z.record(z.string(), z.any()).optional().nullable(),
channelId: z.string().optional().nullable(),
channel: z
@ -107,7 +116,8 @@ const NoteSchema = z.object({
allowRenoteToExternal: z.boolean().optional().nullable(),
userId: z.string().optional().nullable(),
})
.optional().nullable(),
.optional()
.nullable(),
localOnly: z.boolean().optional().nullable(),
reactionAcceptance: z.string().optional().nullable(),
reactionEmojis: z.record(z.string(), z.string()).optional().nullable(),

View file

@ -1,9 +1,9 @@
import 'dotenv/config'
import { z } from 'zod'
import { zodValidateSync } from '~/utils/zod'
import 'dotenv/config'
export const env = zodValidateSync(
z.object({
UMAMI_HOST: z.string().url(),

View file

@ -1,5 +1,5 @@
import { z } from 'zod'
import { AsyncResource } from '@fuman/utils'
import { z } from 'zod'
import { env } from '../env'
import { ffetch } from '../utils/fetch.ts'

View file

@ -1,10 +1,10 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils'
import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed'
const TTL = 3 * 60 * 60 * 1000 // 3 hours
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours

View file

@ -1,10 +1,10 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils'
import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://git.stupid.fish/api/v1/users/teidesu/activities/feeds?only-performed-by=true'
const TTL = 1 * 60 * 60 * 1000 // 1 hour
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours

View file

@ -1,10 +1,10 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils'
import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://api.github.com/users/teidesu/events/public?per_page=1'
const TTL = 1 * 60 * 60 * 1000 // 1 hour
const STALE_TTL = 4 * 60 * 60 * 1000 // 4 hours

View file

@ -1,8 +1,8 @@
import { bskyLastSeen } from './bsky.ts'
import { forgejoLastSeen } from './forgejo.ts'
import { githubLastSeen } from './github'
import { lastfm } from './listenbrainz.ts'
import { shikimoriLastSeen } from './shikimori'
import { forgejoLastSeen } from './forgejo.ts'
export interface LastSeenItem {
source: string

View file

@ -1,9 +1,9 @@
import { z } from 'zod'
import type { LastSeenItem } from './index.ts'
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_STALE_TTL = 1000 * 60 * 60 // 1 hour

View file

@ -1,10 +1,10 @@
import type { LastSeenItem } from './index.ts'
import { AsyncResource } from '@fuman/utils'
import { z } from 'zod'
import { ffetch } from '../../utils/fetch.ts'
import type { LastSeenItem } from './index.ts'
const ENDPOINT = 'https://shikimori.one/api/users/698215/history?limit=1'
const TTL = 3 * 60 * 60 * 1000 // 3 hours
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours

View file

@ -1,12 +1,12 @@
import { BotKeyboard, html } from '@mtcute/node'
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
import { tg } from '../bot'
import { ShoutboxAction } from '../bot/shoutbox.js'
import { shouts, shoutsBans } from '../models/index.js'
import { URL_REGEX } from '../utils/url.js'
import { db } from '../db'
import { env } from '../env'
import { tg } from '../bot'
import { shouts, shoutsBans } from '../models/index.js'
import { URL_REGEX } from '../utils/url.js'
const SHOUTS_PER_PAGE = 5
@ -17,9 +17,7 @@ const filter = or(
const fetchTotal = db.select({
count: sql<number>`count(1)`,
}).from(shouts)
.where(filter)
.prepare()
}).from(shouts).where(filter).prepare()
const fetchList = db.select({
createdAt: shouts.createdAt,
@ -27,12 +25,7 @@ const fetchList = db.select({
pending: shouts.pending,
serial: shouts.serial,
reply: shouts.reply,
}).from(shouts)
.where(filter)
.limit(SHOUTS_PER_PAGE)
.orderBy(desc(shouts.createdAt))
.offset(sql.placeholder('offset'))
.prepare()
}).from(shouts).where(filter).limit(SHOUTS_PER_PAGE).orderBy(desc(shouts.createdAt)).offset(sql.placeholder('offset')).prepare()
export function fetchShouts(page: number, ip: string) {
return {
@ -49,8 +42,7 @@ export type ShoutsData = ReturnType<typeof fetchShouts>
const fetchNextSerial = db.select({
serial: sql<number>`coalesce(max(serial), 0) + 1`,
}).from(shouts)
.prepare()
}).from(shouts).prepare()
export function approveShout(id: string) {
const nextSerial = fetchNextSerial.get({})!.serial

View file

@ -2,8 +2,8 @@ import { ffetchAddons, ffetchBase } from '@fuman/fetch'
import { ffetchZodAdapter } from '@fuman/fetch/zod'
import { z } from 'zod'
import { isBotUserAgent } from '../utils/bot'
import { env } from '~/backend/env'
import { isBotUserAgent } from '../utils/bot'
const ffetch = ffetchBase.extend({
addons: [

View file

@ -27,11 +27,11 @@ export function verifyCsrfToken(ip: string, token: string) {
const saltedData = buf.subarray(0, -8)
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
}
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 (ip !== correctIp) return false

View 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>
)
}

View file

@ -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;
}
}

View file

@ -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>
)
}

View file

@ -1,10 +1,10 @@
---
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { umamiLogThisVisit } from '~/backend/service/umami'
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 { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
umamiLogThisVisit(Astro.request, '/donate')

View file

@ -1,12 +1,12 @@
/** @jsxImportSource solid-js */
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 { PaymentMethod } from './constants'
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'
export function PaymentMethods(props: { data: PageData }) {

View file

@ -1,8 +1,8 @@
import type { PaymentMethod } from './constants'
import { randomBytes } from 'node:crypto'
import { umamiFetchStats } from '~/backend/service/umami'
import type { PaymentMethod } from './constants'
import { PAYMENT_METHODS } from './constants'
import { deriveKey, dumbHash, xorContinuous } from './crypto-common'

View file

@ -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 { RandomWord } from '~/components/interactive/RandomWord'
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { PageMain as PageMainSolid } from './PageMain'
import { PARTTIME_VARIANTS } from './constants'
import { fetchMainPageData } from './data'
import { PageMain as PageMainSolid } from './PageMain'
import Shoutbox from './Shoutbox/Shoutbox.astro'
umamiLogThisVisit(Astro.request)

View file

@ -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;
}
}

View file

@ -1,24 +1,24 @@
/** @jsxImportSource solid-js */
import { For, type JSX, Show } from 'solid-js'
import { Dynamic } from 'solid-js/web'
import type { PageData } from './data'
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
import { intlFormatDistance } from 'date-fns'
import { Emoji } from '~/components/ui/Emoji/Emoji'
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
import { Link } from '~/components/ui/Link/Link'
import { TextComment } from '~/components/ui/TextComment/TextComment'
import { TextTable } from '~/components/ui/TextTable/TextTable'
import jsLogo from '~/assets/javascript.png'
import ukFlag from '~/assets/flag-united-kingdom_1f1ec-1f1e7.png'
import ruFlag from '~/assets/flag-russia_1f1f7-1f1fa.png'
import cherry from '~/assets/cherry-blossom_1f338.png'
import { For, type JSX, Show } from 'solid-js'
import { Dynamic } from 'solid-js/web'
import axolotl from '~/assets/axolotl.png'
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
import { randomInt } from '~/utils/random'
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 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 type { PageData } from './data'
function formatTimeRelative(time: number) {
return intlFormatDistance(
@ -29,11 +29,17 @@ function formatTimeRelative(time: number) {
function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
return (
<Dynamic component={props.first ? 'summary' : 'div'} class={css.lastSeenItem}>
<div class={css.lastSeenLinkWrap}>
<div class={css.lastSeenLinkWrapInner}>
<Dynamic
component={props.first ? 'summary' : 'div'}
class={cn(
'flex flex-row items-center justify-between',
props.first && 'pos-relative list-none cursor-pointer rounded-md hover:bg-control-bg-hover active:select-none [&::-webkit-details-marker]:hidden',
)}
>
<div class="max-w-full flex items-center overflow-hidden">
<div class="width-min max-w-full flex items-center overflow-hidden">
<Link
class={css.lastSeenLink}
class="max-w-200px overflow-hidden text-ellipsis whitespace-nowrap lg:max-w-300px"
href={props.item.link}
target="_blank"
title={props.item.text}
@ -41,12 +47,12 @@ function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
{props.item.text}
</Link>
{props.item.suffix && (
<span class={css.lastSeenSuffix}>
<span class="whitespace-nowrap text-xs">
{props.item.suffix}
</span>
)}
</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">
{props.item.source}
@ -56,7 +62,7 @@ function LastSeenItem(props: { first?: boolean, item: TLastSeenItem }) {
</i>
</div>
<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>
</Dynamic>
)
@ -77,7 +83,7 @@ export function PageMain(props: {
: <i>{props.author}</i>
return (
<div class={css.testimonial}>
<div class="mb-2">
"
{props.text}
"&nbsp;-&nbsp;
@ -102,7 +108,7 @@ export function PageMain(props: {
{' '}
<span innerHTML={item.subtitle} />
<TextComment
class={css.comment}
class="mb-2 ml-12 text-text-secondary"
innerHTML={item.comment}
/>
</div>
@ -167,7 +173,7 @@ export function PageMain(props: {
if (!props.data.lastSeen?.length) return
return (
<details class={css.lastSeen}>
<details class="open:mb-1">
<LastSeenItem first item={props.data.lastSeen[0]} />
<For each={props.data.lastSeen.slice(1)}>
{it => <LastSeenItem item={it} />}
@ -182,7 +188,7 @@ export function PageMain(props: {
<>
#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}
<TextComment class={css.commentInline}>
<TextComment class="mb-2 ml-2em text-text-secondary">
feel free to leave yours :3
</TextComment>
</section>
@ -312,7 +318,7 @@ export function PageMain(props: {
</section>
<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}>
&lt;
{' '}

View file

@ -1,7 +1,7 @@
---
import { fetchShouts } from '~/backend/service/shoutbox'
import { getRequestIp } from '~/backend/utils/request'
import { getCsrfToken } from '~/backend/utils/csrf'
import { getRequestIp } from '~/backend/utils/request'
import { Shoutbox as ShoutboxSolid } from './Shoutbox'

View file

@ -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;
}

View file

@ -1,21 +1,16 @@
/* eslint-disable no-alert */
/** @jsxImportSource solid-js */
import { type ComponentProps, Show, createSignal, onMount } from 'solid-js'
import { QueryClient, QueryClientProvider, createQuery, keepPreviousData } from '@tanstack/solid-query'
/* eslint-disable no-alert */
import type { ShoutsData } from '~/backend/service/shoutbox'
import { createQuery, keepPreviousData, QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { format } from 'date-fns/format'
import { Button } from '~/components/ui/Button/Button'
import { Checkbox } from '~/components/ui/Checkbox/Checkbox'
import { GravityClock } from '~/components/ui/Icons/glyphs/GravityClock'
import { GravityMegaphone } from '~/components/ui/Icons/glyphs/GravityMegaphone'
import { Icon } from '~/components/ui/Icons/Icon'
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
import { TextArea } from '~/components/ui/TextArea/TextArea'
import { TextComment } from '~/components/ui/TextComment/TextComment'
import type { ShoutsData } from '~/backend/service/shoutbox'
import pageCss from '../PageMain.module.css'
import { type ComponentProps, createSignal, onMount, Show } from 'solid-js'
import { Button } from '../../../ui/Button.tsx'
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> {
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 icon = props.pending
? (
<Icon
glyph={GravityClock}
size={16}
title="awaiting moderation"
/>
)
? <div class="i-gravity-ui-clock size-4" title="awaiting moderation" />
: `#${props.serial}`
return (
<div class={css.shout}>
<div class={css.header}>
<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">
{icon}
<time class={css.time} datetime={props.createdAt}>
<time class="whitespace-nowrap" datetime={props.createdAt}>
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
</time>
</div>
<div class={css.text}>
<div class="whitespace-pre-wrap">
{props.text}
{props.reply && (
<div class={css.reply}>
<div class="mt-1.5">
<b>reply: </b>
{props.reply}
</div>
@ -138,7 +127,7 @@ function ShoutboxInner(props: {
<section>
<SectionTitle>shoutbox!</SectionTitle>
<TextComment class={pageCss.comment}>
<TextComment class="mb-2 ml-12 text-text-secondary">
disclaimer: shouts
{' '}
<i>are</i>
@ -146,13 +135,13 @@ function ShoutboxInner(props: {
pre-moderated, but they do not reflect my&nbsp;views.
</TextComment>
<div class={css.form}>
<div class="w-full flex flex-col gap-2">
<input type="hidden" name="_csrf" value={props.csrf} />
<div class={css.formInput}>
<div class="w-full flex gap-2">
<TextArea
ref={messageInput}
disabled={sending() || !jsEnabled()}
class={css.textarea}
class="w-full"
grow
maxRows={5}
name="message"
@ -166,20 +155,20 @@ function ShoutboxInner(props: {
disabled={sending() || !jsEnabled()}
title="submit"
>
<Icon glyph={GravityMegaphone} size={16} />
<div class="i-gravity-ui-megaphone size-5" />
</Button>
</div>
<div class={css.formControls}>
<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={css.pagination}>
<div class="flex gap-2 text-text-secondary">
<Show when={page() > 0}>
<a
class={css.paginationLink}
class="text-text-secondary underline underline-offset-2"
rel="external"
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
onClick={onPageClick(false)}
@ -191,7 +180,7 @@ function ShoutboxInner(props: {
<span>{page() + 1}</span>
<Show when={page() < shouts.data!.pageCount - 1}>
<a
class={css.paginationLink}
class="text-text-secondary underline underline-offset-2"
rel="external"
href={`?shouts_page=${page() + 1}`}
onClick={onPageClick(true)}
@ -205,7 +194,7 @@ function ShoutboxInner(props: {
</div>
</div>
<div class={css.shouts}>
<div class="mt-4 flex flex-col gap-2">
{shoutsRender()}
</div>
</section>

View file

@ -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 { umamiFetchStats } from '~/backend/service/umami'
import { webring } from '~/backend/service/webring'
import { obfuscateEmail } from '~/backend/utils/obfuscate-email'
export async function fetchMainPageData() {
const [

View 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,
)}
/>
)
}

View file

@ -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;
}

View file

@ -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,
)}
/>
)
}

View file

@ -1,41 +1,18 @@
.input {
display: none;
@apply hidden;
}
.label {
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
&:active {
user-select: none;
}
@apply flex items-center gap-2 cursor-pointer select-none;
}
.box {
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);
}
@apply bg-control-bg border-control-outline hover:bg-control-bg-hover pos-relative h-4 w-4 border rounded-md transition-all;
}
.input:checked + .label .box::before {
content: '';
display: 'block';
width: 8px;
height: 8px;
background: var(--text-primary);
border-radius: 2px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@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;
}

View 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,
)}
/>
)
}

View file

@ -1,8 +0,0 @@
.emoji {
display: inline-block;
height: 1em;
object-fit: contain;
overflow: hidden;
vertical-align: middle;
width: 1em;
}

View file

@ -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)}
/>
)
}

View file

@ -1,3 +0,0 @@
.wrap {
display: inline-flex;
}

View file

@ -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>
)
}

View file

@ -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.

View file

@ -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>
)
}

View file

@ -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>
)
}

View 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>
)
}

View file

@ -1,3 +0,0 @@
.link {
color: var(--text-accent);
}

View file

@ -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>
)
}

View 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>
)
}

View file

@ -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;
}

View file

@ -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>
)
}

View 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}
/>
)
}

View file

@ -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);
}
}

View file

@ -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}
/>
)
}

View 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,
)}
/>
)
}

View file

@ -1,10 +0,0 @@
.comment {
color: var(--text-secondary);
position: relative;
&:before {
content: '// ';
position: absolute;
left: -2em;
}
}

View file

@ -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)}
/>
)
}

View 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>
)
}

View file

@ -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%;
}

View file

@ -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>
)
}

View file

@ -1,6 +1,6 @@
---
import { ClientRouter } from 'astro:transitions'
import LoadingIndicator from 'astro-loading-indicator/component'
import { ClientRouter } from 'astro:transitions'
import cherry from '~/assets/cherry-blossom_1f338.png'
@ -81,18 +81,11 @@ const finalOg = { ...defaultOgTags, ...og }
box-sizing: border-box;
}
*:focus {
outline-color: var(--text-primary)
*:not(:active, summary):focus {
outline: 1px solid var(--text-primary)
}
body {
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;
@apply text-sm m-0 p-0 overflow-x-hidden overflow-y-auto bg-bg font-mono text-text-primary;
}
</style>

View file

@ -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 Header from './Header.astro'

View file

@ -1,16 +1,16 @@
---
import karin from '~/assets/karin.gif'
import { Link } from '~/components/ui/Link/Link'
import { Link } from '../../components/ui/Link.tsx'
const PAGES = [
{ name: 'hewwo', path: '/' },
{ name: 'donate', path: ['/donate', '/$'] },
]
---
<header class="header">
<header class="pos-relative flex items-center justify-center gap-2">
<img
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}
transition:persist
/>
@ -20,7 +20,7 @@ const PAGES = [
for (const page of PAGES) {
if (elements.length > 0) {
elements.push(
<span class="delimiter"> / </span>,
<span class="select-none text-text-secondary"> / </span>,
)
}
@ -33,7 +33,7 @@ const PAGES = [
if (isActive) {
elements.push(
<span class="active">{page.name}</span>,
<span class="font-bold">{page.name}</span>,
)
} else {
const href = Array.isArray(page.path) ? page.path[0] : page.path
@ -49,38 +49,3 @@ const PAGES = [
return elements
})()}
</header>
<style>
.header {
align-items: center;
display: flex;
gap: 8px;
justify-content: center;
position: relative;
}
.gif {
height: 64px;
width: 64px;
position: absolute;
right: 0;
top: 0;
@media (prefers-color-scheme: dark) {
filter: brightness(0.9);
}
@media (prefers-reduced-motion: reduce) {
display: none;
}
}
.active {
font-weight: bold;
}
.delimiter {
color: var(--text-secondary);
user-select: none
}
</style>

View file

@ -1,10 +1,10 @@
import type { APIRoute } from 'astro'
import { html } from '@mtcute/node'
import { telegramNotify } from '~/backend/bot/notify'
import { MisskeyWebhookBodySchema, type MkNote, type MkUser } from '~/backend/domain/misskey'
import { env } from '~/backend/env'
import { zodValidate } from '~/utils/zod'
import { telegramNotify } from '~/backend/bot/notify'
function misskeyMentionUser(user: MkUser, server: string): string {
const fullUsername = user.host ? `@${user.username}@${user.host}` : `@${user.username}`

View file

@ -1,10 +1,10 @@
// import { telegramNotify } from '~/backend/bot/notify'
import { html } from '@mtcute/node'
import type { APIRoute } from 'astro'
import { html } from '@mtcute/node'
import { env } from '~/backend/env'
import { telegramNotify } from '~/backend/bot/notify'
import { env } from '~/backend/env'
export const POST: APIRoute = async (ctx) => {
if (new URL(ctx.request.url).searchParams.get('secret') !== env.QBT_WEBHOOK_SECRET) {

View file

@ -1,11 +1,11 @@
import type { APIRoute } from 'astro'
import { RateLimiterMemory } from 'rate-limiter-flexible'
import { z } from 'zod'
import { fromError } from 'zod-validation-error'
import { RateLimiterMemory } from 'rate-limiter-flexible'
import { createShout, fetchShouts, isShoutboxBanned } from '~/backend/service/shoutbox'
import { getRequestIp } from '~/backend/utils/request'
import { verifyCsrfToken } from '~/backend/utils/csrf'
import { getRequestIp } from '~/backend/utils/request'
import { HttpResponse } from '~/backend/utils/response'
const schema = z.object({

View file

@ -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 { 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')
if (!currentCurrency || !AVAILABLE_CURRENCIES.includes(currentCurrency)) {

6
src/utils/cn.ts Normal file
View 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
View 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',
},
},
})