initial commit

This commit is contained in:
alina 🌸 2024-08-03 06:30:05 +03:00
commit 46f11643e9
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
130 changed files with 13717 additions and 0 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
dist
node_modules
.runtime
.astro
.vscode
.env

49
.github/workflows/publish.yaml vendored Normal file
View file

@ -0,0 +1,49 @@
name: Release - Tag and publish Docker image
on:
push:
branches: [ main ]
jobs:
create_release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
env:
DONATE_PAGE_DATA: ${{ vars.DONATE_PAGE_DATA }}
run: |
echo "$DONATE_PAGE_DATA" > src/components/pages/PageDonate/data.json
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/teidesu/tei.su
tags: type=sha
flavor: latest=true
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
.vscode

2
.runtime/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM node:20-slim@sha256:a22f79e64de59efd3533828aecc9817bfdc1cd37dde598aa27d6065e7b1f0abc AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV ASTRO_TELEMETRY_DISABLED=1
COPY . /app
WORKDIR /app
ENV HOME="/app"
RUN chmod -R 777 /app
RUN corepack enable && corepack prepare
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
EXPOSE 4321
CMD [ "pnpm", "run", "start:prod" ]

15
README.md Normal file
View file

@ -0,0 +1,15 @@
# tei.su
source code for my place on the internet :3
finally with a modern stack (astro + solid)
## dev
```bash
cp .env.example .env
# fill the variables
echo '[]' > src/components/pages/PageDonate/data.json
pnpm dev
```

26
astro.config.mjs Normal file
View file

@ -0,0 +1,26 @@
import { defineConfig } from 'astro/config'
import solid from '@astrojs/solid-js'
import node from '@astrojs/node'
// https://astro.build/config
export default defineConfig({
output: 'server',
integrations: [
solid(),
],
vite: {
esbuild: { jsx: 'automatic' },
define: {
'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString().split('T')[0]),
},
},
adapter: node({
mode: 'standalone',
}),
security: {
checkOrigin: true,
},
server: {
host: true,
},
})

10
drizzle.config.ts Normal file
View file

@ -0,0 +1,10 @@
import type { Config } from 'drizzle-kit'
export default {
out: './drizzle',
schema: './src/backend/models/index.ts',
dialect: 'sqlite',
dbCredentials: {
url: '.runtime/data.db',
},
} satisfies Config

View file

@ -0,0 +1,8 @@
CREATE TABLE `shouts` (
`id` text PRIMARY KEY NOT NULL,
`serial` integer DEFAULT 0 NOT NULL,
`from_ip` text,
`pending` integer DEFAULT true NOT NULL,
`text` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);

View file

@ -0,0 +1,71 @@
{
"version": "6",
"dialect": "sqlite",
"id": "4ec9eb07-fd65-4fd0-9e47-10bdc1522d0e",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"shouts": {
"name": "shouts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"serial": {
"name": "serial",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"from_ip": {
"name": "from_ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pending": {
"name": "pending",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1722558165317,
"tag": "0000_legal_gamma_corps",
"breakpoints": true
}
]
}

31
eslint.config.js Normal file
View file

@ -0,0 +1,31 @@
import antfu from '@antfu/eslint-config'
export default antfu({
stylistic: {
indent: 4,
},
typescript: true,
astro: true,
solid: true,
rules: {
'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',
'node/prefer-global/process': 'off',
},
})

45
package.json Normal file
View file

@ -0,0 +1,45 @@
{
"name": "tei.su",
"type": "module",
"version": "0.0.1",
"packageManager": "pnpm@9.5.0+sha512.140036830124618d624a2187b50d04289d5a087f326c9edfc0ccd733d76c4f52c3a313d4fc148794a2a9d81553016004e6742e8cf850670268a7387fc220c903",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"start:prod": "drizzle-kit migrate && node dist/server/entry.mjs",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.1",
"@astrojs/node": "^8.3.2",
"@astrojs/solid-js": "^4.4.0",
"@mtcute/dispatcher": "^0.16.0",
"@mtcute/node": "^0.16.3",
"@tanstack/solid-query": "^5.51.21",
"astro": "^4.12.3",
"astro-loading-indicator": "^0.5.0",
"better-sqlite3": "^11.1.2",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"dotenv": "^16.4.5",
"drizzle-kit": "^0.23.1",
"drizzle-orm": "^0.32.1",
"solid-js": "^1.8.19",
"typescript": "^5.5.4",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.1"
},
"devDependencies": {
"@antfu/eslint-config": "^2.24.0",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.0.2",
"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"
}
}

7856
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

8
postcss.config.js Normal file
View file

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

View file

@ -0,0 +1,26 @@
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:web:tei.su",
"alsoKnownAs": [
"at://tei.su"
],
"verificationMethod": [
{
"id": "did:web:tei.su#atproto",
"type": "Multikey",
"controller": "did:web:tei.su",
"publicKeyMultibase": "zQ3shf6oh9xDdYdLCHQeJskxP3pazTQ1CkSBGVynpX79fH3Na"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.stupid.fish"
}
]
}

BIN
public/88x31.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

22
public/ataturk-thing.html Normal file
View file

@ -0,0 +1,22 @@
<!-- Random Turkish Identification Number Generator -->
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title></title>
</head>
<body>
<p id="tc"></p>
<script type="text/javascript">
var a = "" + Math.floor(900000001 * Math.random() + 1e8),
b = a.split("").map(function (t) {
return parseInt(t, 10)
}),
c = b[0] + b[2] + b[4] + b[6] + b[8],
d = b[1] + b[3] + b[5] + b[7],
e = (7 * c - d) % 10;
document.getElementById("tc").textContent = a + ("" + e) + ("" + (d + c + e) % 10)
</script>
</body>
</html>

View file

@ -0,0 +1 @@
i lost source code for this one lol

View file

@ -0,0 +1 @@
.repl-output{background:#ededed;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;border:1px solid #dbdbdb;border-radius:4px;height:85vh;max-height:85vh;width:45vw;max-width:45vw;overflow:auto;position:relative}.repl-output pre{padding:8px}.repl-spacer{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}.repl-header.level{margin:0!important;padding:8px 16px;border-bottom:1px solid #dbdbdb;right:0;-webkit-box-shadow:0 1px 8px -1px #555;box-shadow:0 1px 8px -1px #555}.repl-header.level,.repl-input{position:-webkit-sticky;position:sticky;background:#fff;top:0;left:0}.repl-input{bottom:0;border-top:1px solid #dbdbdb;-webkit-box-shadow:0 -1px 8px -1px #555;box-shadow:0 -1px 8px -1px #555}.repl-input button,.repl-input textarea{border:none!important;outline:none!important;-webkit-box-shadow:none!important;box-shadow:none!important;height:48px;min-height:48px!important;max-height:384px!important;background:#fff}.repl-input button{width:48px}.repl-history-item pre{background:transparent!important;font-size:14px;color:#010101}.repl-history-item .message{margin:4px!important}.repl-history-item .message-body{padding:8px;overflow-y:auto;max-width:100%}.repl-placeholder{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;color:#000;font-size:24px}.repl-history-item{border-bottom:1px solid #dbdbdb;padding:4px 0}.repl-history-item,.repl-history-item__code{background-color:#fdfdfd}.repl-history-item:first-child{padding-top:12px}.repl-history-item:last-child{padding-bottom:18px}.repl-input__field textarea{resize:none;height:100%;word-break:keep-all;white-space:nowrap;overflow:auto}.hljs{background:transparent!important;padding:0!important}.page-title{font-size:21px;font-weight:500;text-align:center;border-bottom:1px solid #dbdbdb;margin:8px 25%;padding:4px}.is-family-monospace input,.is-family-monospace textarea{font-family:monospace!important}.vue-codemirror{margin:8px 0}.CodeMirror,.vue-codemirror{line-height:1em;font-family:monospace;height:100%!important;max-height:80vh!important;position:relative;overflow:hidden;width:45vw;max-width:45vw}.CodeMirror-scroll{overflow:auto;height:100%;position:relative;outline:none}.hide-btn{margin-right:8px}.repl-full-width{width:100%!important;max-width:100%!important}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><link rel=stylesheet href=https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css><title>Cheerio REPL</title><link href=css/chunk-6f440a42.0068aa0c.css rel=prefetch><link href=js/chunk-6f440a42.cb49e7e1.js rel=prefetch><link href=css/app.5f428797.css rel=preload as=style><link href=css/chunk-vendors.526cbe67.css rel=preload as=style><link href=js/app.b5356de7.js rel=preload as=script><link href=js/chunk-vendors.16b365e4.js rel=preload as=script><link href=css/chunk-vendors.526cbe67.css rel=stylesheet><link href=css/app.5f428797.css rel=stylesheet></head><body><noscript><strong>We're sorry but cheerio-repl doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=js/chunk-vendors.16b365e4.js></script><script src=js/app.b5356de7.js></script></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

2
public/keys Normal file
View file

@ -0,0 +1,2 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBCVyAT4PpOb4poB9OrOQTY5a/a9QfNnsEnbRjHPDrbU alina@tei.su
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHXaJrbD5SHp3HDtRX7YxrjO7wpcoY/L41Oc78IdT/l4 git@tei.su

1
public/keys@git Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHXaJrbD5SHp3HDtRX7YxrjO7wpcoY/L41Oc78IdT/l4 git@tei.su

1
public/keys@ssh Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBCVyAT4PpOb4poB9OrOQTY5a/a9QfNnsEnbRjHPDrbU alina@tei.su

23
public/oauth.blank.html Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf8" http-equiv="content-type" />
<title>OAuth Blank</title>
</head>
<body>
Please, <b>do not copy</b> data from the address bar or the field below to third-party services. This way you may
<b>lose</b> your data and/or account.<br />
Your token: <br />
<textarea id="token" readonly>JavaScript is not available</textarea>
<script>
var q = location.search.match(/[\?&](?:access_)?token=(.*?)(?:&|$)/)
q1 = location.hash.match(/[#&](?:access_)?token=(.*?)(?:&|$)/),
i = document.getElementById('token')
i.value = q ? decodeURIComponent(q[1]) : q1 ? decodeURIComponent(q1[1]) : 'Token not found'
i.onfocus = i.select
</script>
<style>
#token { min-width: 480px; min-height: 200px; }
</style>
</body>
</html>

BIN
public/placeholder.m4a Normal file

Binary file not shown.

196
public/proxifier.html Normal file
View file

@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Proxifier keygen</title>
</head>
<body>
<div>
<label for="product">Product:</label>
<select id="product">
<option value="0">Proxifier Standard Edition</option>
<option value="1">Proxifier Portable Edition</option>
<option value="2">Proxifier for Mac</option>
</select>
</div>
<div>
<label for="exp-year">Exp. year:</label>
<input type="number" min="2000" value="2077" id="exp-year" />
</div>
<div>
<label for="exp-month">Exp. month:</label>
<input type="number" min="1" max="12" value="12" id="exp-month" />
</div>
<div>
<label for="fourth">4th part:</label>
<input
maxlength="5"
pattern="[0-9A-Z]+"
id="fourth"
placeholder="random"
value="SORRY"
/>
</div>
<button onclick="updateKey()">Generate</button>
<hr />
<p
style="text-align: center; font-weight: bold; font-size: 1.4em"
id="key"
></p>
</body>
<script>
const keyEl = document.querySelector('#key')
const productEl = document.querySelector('#product')
const expYearEl = document.querySelector('#exp-year')
const expMonthEl = document.querySelector('#exp-month')
const fourthEl = document.querySelector('#fourth')
window.onload = updateKey
productEl.onchange = updateKey
expYearEl.onchange = updateKey
expMonthEl.onchange = updateKey
fourthEl.onchange = updateKey
function updateKey() {
const expiry =
(parseInt(expYearEl.value) - 2000) * 12 +
(parseInt(expMonthEl.value) - 1)
keyEl.innerText = generateKey(
parseInt(productEl.value),
fourthEl.value,
expiry
)
}
// below is based on
// https://github.com/thedroidgeek/proxifier-keygen/blob/master/ProxifierKeygen/Keygen.cs
function randRange(from, to) {
return from + Math.round(Math.random() * (to - from))
}
function compileString(str) {
let result = 0
for (let i = str.length - 1; i >= 0; i--) {
result *= 32
const c = str[i]
if (c === 'W') {
continue
}
if (c === 'X') {
result += 24
} else if (c === 'Y') {
result += 1
} else if (c === 'Z') {
result += 18
} else if (c <= 57) {
// '0' to '9'
result += c.charCodeAt(0) - 48
} // 'A' to 'V'
else {
result += c.charCodeAt(0) - 55
}
}
return result >>> 0
}
function decompileString(value, length) {
value >>>= 0
let result = ''
for (let i = 0; i < length; i++) {
const tmp = value % 32
value = Math.trunc(value / 32)
if (tmp === 0) {
result += 'W'
} else if (tmp === 24) {
result += 'X'
} else if (tmp === 1) {
result += 'Y'
} else if (tmp === 18) {
result += 'Z'
} else if (tmp <= 9) {
result += String.fromCharCode(tmp + 48)
} else {
result += String.fromCharCode(tmp + 55)
}
}
return result
}
function crc32LikeThingy(data) {
data = new Uint8Array(data)
let result = -1
for (let i = 0; i < 12; i++) {
result ^= data[i] << 24
for (let j = 0; j < 8; j++) {
if (result < 0) {
result <<= 1
result ^= 0x4c11db7
} else {
result <<= 1
}
}
}
return result
}
function chunk(str, size) {
let chunks = []
let pos = 0
while (pos < str.length) {
chunks.push(str.substr(pos, size))
pos += size
}
return chunks
}
function generateKey(
product = 0,
fourthKeyPart = '',
expirationDate = 0
) {
const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const param1 =
randRange(
0x2580,
0xffff
) /* < 0x2580 ==> outdated key message */ +
(product << 21)
const param2 =
randRange(0, 0xffff) +
(expirationDate << 16) /* 0 ==> no expiration */
while (fourthKeyPart.length < 5) {
fourthKeyPart += charset[randRange(0, charset.length - 1)]
}
const param3 = compileString(fourthKeyPart)
const data = new ArrayBuffer(12)
const intArr = new Int32Array(data)
intArr[0] = param1
intArr[1] = param2
intArr[2] = param3
const value1 = crc32LikeThingy(data) & 0x1ffffff
const value2 = value1 ^ (value1 << 7)
const value3 = param1 ^ value2 ^ 0x12345678
const value4 = param2 ^ value2 ^ 0x87654321
let key = decompileString(value3, 7)
key += decompileString(value4, 7)
key += key[2] // 15th char becomes 3rd
key += fourthKeyPart
key += decompileString(value1, 5)
// 3rd char doesn't affect the key check, as long as it's not a 'Y' ==> Proxifier v2 key (outdated)
let rndChar = charset[randRange(0, charset.Length - 1)]
if (rndChar === 'Y') rndChar = 'Z'
return chunk(key, 5).join('-')
}
</script>
</html>

3
public/robots.txt Normal file
View file

@ -0,0 +1,3 @@
User-Agent: *
Disallow: /donate
Disallow: /nudes

59
public/spring.html Normal file
View file

@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>spring rofl</title>
<script src="https://cdn.jsdelivr.net/npm/rebound@0.1.0/dist/rebound.js"></script>
<style>
.thingy {
width: 100px;
height: 100px;
background: red;
margin: 10px;
display: inline-block;
overflow: hidden;
}
.thingy.plain.active {
max-height: 100px;
}
.thingy.plain {
max-height: 0;
transition: max-height 0.25s ease-out;
}
</style>
</head>
<body>
<button id="btn">toggle</button>
<div style="height: 120px; background: lightblue">
<div id="plain" class="plain thingy active"></div>
<div id="fancy" class="thingy" style="max-height: 100px;"></div>
</div>
<script>
var plain = document.getElementById('plain')
var fancy = document.getElementById('fancy')
var fancyActive = true
var springSystem = new rebound.SpringSystem();
var spring = springSystem.createSpring(95, 13);
spring.addListener({
onSpringUpdate: function(spring) {
var val = spring.getCurrentValue();
val = rebound.MathUtil
.mapValueInRange(val, 0, 1, 100, 0);
fancy.style.maxHeight = val + 'px';
}
});
document.getElementById('btn').addEventListener('click', function () {
plain.classList.toggle('active')
spring.setEndValue(fancyActive + 0)
fancyActive = !fancyActive
})
</script>
</body>
</html>

BIN
public/test_sticker.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
public/test_voice.ogg Normal file

Binary file not shown.

BIN
src/assets/axolotl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
src/assets/javascript.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
src/assets/karin.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

17
src/backend/bot.ts Normal file
View file

@ -0,0 +1,17 @@
import { Dispatcher } from '@mtcute/dispatcher'
import { TelegramClient } from '@mtcute/node'
import { env } from '~/backend/env'
import { shoutboxDp } from './bot/shoutbox'
export const tg = new TelegramClient({
apiId: env.TG_API_ID,
apiHash: env.TG_API_HASH,
storage: '.runtime/bot.session',
})
const dp = Dispatcher.for(tg)
dp.extend(shoutboxDp)
await tg.start({ botToken: env.TG_BOT_TOKEN })

11
src/backend/bot/notify.ts Normal file
View file

@ -0,0 +1,11 @@
import type { InputText, TelegramClient } from '@mtcute/node'
import { tg } from '~/backend/bot'
import { env } from '~/backend/env'
export function telegramNotify(text: InputText, options?: Parameters<TelegramClient['sendText']>[2]): void {
tg.sendText(env.TG_CHAT_ID, text, {
disableWebPreview: true,
...options,
}).catch(console.error)
}

View file

@ -0,0 +1,41 @@
import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher'
import { html } from '@mtcute/node'
import { env } from '../env'
import { approveShout, declineShout, deleteBySerial } from '../service/shoutbox'
export const ShoutboxAction = new CallbackDataBuilder('shoutbox', 'id', 'action')
const dp = Dispatcher.child()
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'approve' }), async (ctx) => {
if (ctx.chat.id !== env.TG_CHAT_ID) return
approveShout(ctx.match.id)
await ctx.editMessageWith(msg => ({
text: html`${msg.textWithEntities}<br><br>✅ Approved!`,
}))
})
dp.onCallbackQuery(ShoutboxAction.filter({ action: 'decline' }), async (ctx) => {
if (ctx.chat.id !== env.TG_CHAT_ID) return
declineShout(ctx.match.id)
await ctx.editMessageWith(msg => ({
text: html`${msg.textWithEntities}<br><br>❌ Declined!`,
}))
})
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_del')), async (ctx) => {
const serial = Number(ctx.command[1])
if (Number.isNaN(serial)) {
await ctx.answerText('invalid serial')
return
}
deleteBySerial(serial)
await ctx.answerText('deleted')
})
export { dp as shoutboxDp }

5
src/backend/db.ts Normal file
View file

@ -0,0 +1,5 @@
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
export const sqlite = new Database('.runtime/data.db')
export const db = drizzle(sqlite)

View file

@ -0,0 +1,244 @@
import { z } from 'zod'
const UserSchema = z.object({
id: z.string().optional().nullable(),
name: z.string().optional().nullable(),
username: z.string().optional().nullable(),
host: z
.string()
.describe('The local host is represented with `null`.')
.optional().nullable(),
avatarUrl: z.string().optional().nullable(),
avatarBlurhash: z.string().optional().nullable(),
avatarDecorations: z
.array(
z.object({
id: z.string().optional().nullable(),
angle: z.number().optional().nullable(),
flipH: z.boolean().optional().nullable(),
url: z.string().optional().nullable(),
offsetX: z.number().optional().nullable(),
offsetY: z.number().optional().nullable(),
}),
)
.optional().nullable(),
isAdmin: z.boolean().optional().nullable(),
isModerator: z.boolean().optional().nullable(),
isSilenced: z.boolean().optional().nullable(),
noindex: z.boolean().optional().nullable(),
isBot: z.boolean().optional().nullable(),
isCat: z.boolean().optional().nullable(),
speakAsCat: z.boolean().optional().nullable(),
instance: z
.object({
name: z.string().optional().nullable(),
softwareName: z.string().optional().nullable(),
softwareVersion: z.string().optional().nullable(),
iconUrl: z.string().optional().nullable(),
faviconUrl: z.string().optional().nullable(),
themeColor: z.string().optional().nullable(),
})
.optional().nullable(),
emojis: z.record(z.string()).optional().nullable(),
onlineStatus: z.enum(['unknown', 'online', 'active', 'offline']).optional().nullable(),
badgeRoles: z
.array(
z.object({
name: z.string().optional().nullable(),
iconUrl: z.string().optional().nullable(),
displayOrder: z.number().optional().nullable(),
}),
)
.optional().nullable(),
})
export type MkUser = z.infer<typeof UserSchema>
const NoteSchema = z.object({
id: z.string().optional().nullable(),
createdAt: z.string().optional().nullable(),
deletedAt: z.string().optional().nullable(),
text: z.string().optional().nullable(),
cw: z.string().optional().nullable(),
userId: z.string().optional().nullable(),
user: z
.object({})
.catchall(z.any())
.optional().nullable(),
replyId: z.string().optional().nullable(),
renoteId: z.string().optional().nullable(),
reply: z
.object({})
.catchall(z.any())
.optional().nullable(),
renote: z
.object({})
.catchall(z.any())
.optional().nullable(),
isHidden: z.boolean().optional().nullable(),
visibility: z.enum(['public', 'home', 'followers', 'specified']).optional().nullable(),
mentions: z.array(z.string()).optional().nullable(),
visibleUserIds: z.array(z.string()).optional().nullable(),
fileIds: z.array(z.string()).optional().nullable(),
files: z.array(z.object({}).catchall(z.any())).optional().nullable(),
tags: z.array(z.string()).optional().nullable(),
poll: z
.object({
expiresAt: z.string().optional().nullable(),
multiple: z.boolean().optional().nullable(),
choices: z
.array(
z.object({
isVoted: z.boolean().optional().nullable(),
text: z.string().optional().nullable(),
votes: z.number().optional().nullable(),
}),
)
.optional().nullable(),
})
.optional().nullable(),
emojis: z.record(z.string(), z.any()).optional().nullable(),
channelId: z.string().optional().nullable(),
channel: z
.object({
id: z.string().optional().nullable(),
name: z.string().optional().nullable(),
color: z.string().optional().nullable(),
isSensitive: z.boolean().optional().nullable(),
allowRenoteToExternal: z.boolean().optional().nullable(),
userId: z.string().optional().nullable(),
})
.optional().nullable(),
localOnly: z.boolean().optional().nullable(),
reactionAcceptance: z.string().optional().nullable(),
reactionEmojis: z.record(z.string(), z.string()).optional().nullable(),
reactions: z.record(z.string(), z.number()).optional().nullable(),
renoteCount: z.number().optional().nullable(),
repliesCount: z.number().optional().nullable(),
uri: z.string().optional().nullable(),
url: z.string().optional().nullable(),
reactionAndUserPairCache: z.array(z.string()).optional().nullable(),
clippedCount: z.number().optional().nullable(),
myReaction: z.string().optional().nullable(),
})
export type MkNote = z.infer<typeof NoteSchema>
const NotificationSchema = z.union([
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('note').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('mention').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('reply').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('renote').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('quote').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('reaction').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(),
reaction: z.string().optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('pollEnded').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.union([z.literal('follow'), z.literal('unfollow')]).optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('receiveFollowRequest').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('followRequestAccepted').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('roleAssigned').optional().nullable(),
role: z.record(z.any()).optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('achievementEarned').optional().nullable(),
achievement: z.string().optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('app').optional().nullable(),
body: z.string().optional().nullable(),
header: z.string().optional().nullable(),
icon: z.string().optional().nullable(),
}),
z.object({
id: z.string().optional().nullable(),
createdAt: z.string().datetime().optional().nullable(),
type: z.literal('edited').optional().nullable(),
user: UserSchema.optional().nullable(),
userId: z.string().optional().nullable(),
note: NoteSchema.optional().nullable(),
}),
] as const)
export const MisskeyWebhookBodySchema = z.object({
server: z.string(),
hookId: z.string(),
userId: z.string(),
eventId: z.string(),
createdAt: z.number(),
type: z.string(),
body: z.object({
notification: NotificationSchema,
}).partial(),
})

23
src/backend/env.ts Normal file
View file

@ -0,0 +1,23 @@
import 'dotenv/config'
import { z } from 'zod'
import { zodValidateSync } from '~/utils/zod'
export const env = zodValidateSync(
z.object({
UMAMI_HOST: z.string().url(),
UMAMI_TOKEN: z.string(),
UMAMI_SITE_ID: z.string().uuid(),
LASTFM_TOKEN: z.string(),
TG_API_ID: z.coerce.number(),
TG_API_HASH: z.string(),
TG_BOT_TOKEN: z.string(),
TG_CHAT_ID: z.coerce.number(),
CURRENCY_API_TOKEN: z.string(),
FAKE_DEEPL_SECRET: z.string(),
MK_WEBHOOK_SECRET: z.string(),
QBT_WEBHOOK_SECRET: z.string(),
}),
process.env,
)

View file

@ -0,0 +1 @@
export * from './shoutbox'

View file

@ -0,0 +1,13 @@
import { randomUUID } from 'node:crypto'
import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const shouts = sqliteTable('shouts', {
id: text('id').primaryKey().$defaultFn(randomUUID),
serial: integer('serial').notNull().default(0),
fromIp: text('from_ip'),
pending: integer('pending', { mode: 'boolean' }).notNull().default(true),
text: text('text'),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
})

View file

@ -0,0 +1,60 @@
import { z } from 'zod'
import { Reloadable } from '../utils/reloadable'
import { env } from '../env'
import { zodValidate } from '../../utils/zod'
export const AVAILABLE_CURRENCIES = ['RUB', 'USD', 'EUR']
const TTL = 60 * 60 * 1000 // 1 hour
const schema = z.object({
meta: z.object({
last_updated_at: z.string(),
}),
data: z.record(z.string(), z.object({
code: z.string(),
value: z.number(),
})),
})
const reloadable = new Reloadable({
name: 'currencies',
expiresIn: () => TTL,
async fetch() {
// https://api.currencyapi.com/v3/latest?apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6&currencies=USD%2CEUR
// apikey=cur_live_ZGgJCl3CfMM7TqXSdlUTiKlO2e81lLcOVX5mCXb6&currencies=USD%2CEUR
const res = await fetch(`https://api.currencyapi.com/v3/latest?${new URLSearchParams({
apikey: env.CURRENCY_API_TOKEN,
currencies: AVAILABLE_CURRENCIES.slice(1).join(','),
base_currency: AVAILABLE_CURRENCIES[0],
})}`)
if (!res.ok) {
throw new Error(`Failed to fetch currencies: ${res.status} ${await res.text()}`)
}
return zodValidate(schema, await res.json())
},
lazy: true,
swr: true,
})
export function convertCurrencySync(from: string, to: string, amount: number) {
if (from === to) return amount
if (!AVAILABLE_CURRENCIES.includes(from)) throw new Error(`Invalid currency: ${from}`)
if (!AVAILABLE_CURRENCIES.includes(to)) throw new Error(`Invalid currency: ${to}`)
const data = reloadable.getCached()
if (!data) throw new Error('currencies not available')
if (from !== AVAILABLE_CURRENCIES[0]) {
// convert to base currency first
amount /= data.data[from].value
}
return amount * data.data[to].value
}
export async function fetchConvertRates() {
await reloadable.get()
}

View file

@ -0,0 +1,118 @@
import { randomPick } from '~/utils/random'
const USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36',
'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; SM-A205U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; SM-A102U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; SM-N960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; LM-Q720) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; LM-X420) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 10; LM-Q710(FGN)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36 Edg/103.0.1264.37',
'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
'Mozilla/5.0 (Linux; Android 10; Pixel 3 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.53 Mobile Safari/537.36 EdgA/100.0.1185.50',
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 EdgiOS/100.1185.50 Mobile/15E148 Safari/605.1.15',
'Mozilla/5.0 (Windows Mobile 10; Android 10.0; Microsoft; Lumia 950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Mobile Safari/537.36 Edge/40.15254.603',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPod touch; CPU iPhone 15_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
]
const Tk = {
ac(input: string) {
const e = new TextEncoder().encode(input)
let f = 0
let a = 0
for (f = 0; f < e.length; f++) {
a += e[f]
a = Tk.yc(a, '+-a^+6')
}
a = Tk.yc(a, '+-3^+b+-f')
a ^= 0
if (a < 0) { a = (a & 0x7FFFFFFF) + 0x80000000 }
a %= 1e6
return `${a}.${a}`
},
yc(a: number, b: string) {
for (let c = 0; c < b.length - 2; c += 3) {
const d = b[c + 2]
const number = d >= 'a'
// @ts-expect-error lol
? d - 87
: Number.parseInt(d)
const number2 = b[c + 1] === '+'
? a >>> number
: a << number
a = b[c] === '+'
? a + number2 & 0xFFFFFFFF
: a ^ number2
}
return a
},
}
async function translate(text: string, fromLanguage: string, toLanguage: string) {
let json = null
const response = await fetch('https://translate.googleapis.com/translate_a/single?client=gtx&'
+ `sl=${encodeURIComponent(fromLanguage)}&tl=${encodeURIComponent(toLanguage)}&dt=t&ie=UTF-8&`
+ 'oe=UTF-8&otf=1&ssel=0&tsel=0&kc=7&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss'
+ `&tk=${Tk.ac(text)}`
+ '&source=input'
+ `&q=${encodeURIComponent(text)}`, {
headers: {
'User-Agent': randomPick(USER_AGENTS),
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error('Error while requesting translation')
}
const content = await response.text()
json = JSON.parse(content)
let sourceLanguage = null
sourceLanguage = json[2]
let result = ''
for (let i = 0; i < json[0]?.length; ++i) {
const block = json[0][i][0]
if (block == null) { continue }
const blockText = block.toString()
if (blockText !== 'null') { result += blockText }
}
return {
sourceLanguage,
originalText: text,
translatedText: result,
}
}
export async function translateChunked(text: string, fromLanguage: string, toLanguage: string) {
let result = ''
const chunks = text.match(/.{1,5000}/gs)!
const promises = []
for (let i = 0; i < chunks.length; ++i) {
promises.push(translate(chunks[i], fromLanguage, toLanguage))
}
const results = await Promise.all(promises)
for (let i = 0; i < results.length; ++i) {
result += results[i].translatedText
}
return {
sourceLanguage: results[0].sourceLanguage,
originalText: text,
translatedText: result,
}
}

View file

@ -0,0 +1,49 @@
import { z } from 'zod'
import { Reloadable } from '~/backend/utils/reloadable'
import { zodValidate } from '~/utils/zod'
const ENDPOINT = 'https://very.stupid.fish/api/users/notes'
const TTL = 3 * 60 * 60 * 1000 // 3 hours
const STALE_TTL = 8 * 60 * 60 * 1000 // 8 hours
const BODY = {
userId: '9o5tqc3ok6pf5hjx',
withRenotes: false,
withReplies: false,
withChannelNotes: false,
withFiles: false,
limit: 1,
allowPartial: true,
}
const schema = z.object({
id: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
text: z.nullable(z.string()),
})
export const fediLastSeen = new Reloadable<z.infer<typeof schema>>({
name: 'fedi-last-seen',
async fetch() {
const res = await fetch(ENDPOINT, {
method: 'POST',
body: JSON.stringify(BODY),
headers: {
'Content-Type': 'application/json',
},
})
if (!res.ok) {
throw new Error(`Failed to fetch fedi last seen: ${res.status} ${await res.text()}`)
}
const data = await zodValidate(z.array(schema), await res.json())
return data[0]
},
expiresIn: () => TTL,
lazy: true,
swr: true,
swrValidator: (_data, time) => Date.now() - time < STALE_TTL,
})

View file

@ -0,0 +1,41 @@
import { z } from 'zod'
import { Reloadable } from '~/backend/utils/reloadable'
import { zodValidate } from '~/utils/zod'
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
const schema = z.object({
id: z.string(),
type: z.string(),
payload: z.any(),
repo: z.object({ name: z.string(), url: z.string() }),
public: z.boolean(),
created_at: z.string(),
})
export const githubLastSeen = new Reloadable<z.infer<typeof schema>>({
name: 'github-last-seen',
async fetch() {
const res = await fetch(ENDPOINT, {
headers: {
'User-Agent': 'tei.su/1.0',
'X-GitHub-Api-Version': '2022-11-28',
},
})
if (!res.ok) {
throw new Error(`Failed to fetch github last seen: ${res.status} ${await res.text()}`)
}
const data = await zodValidate(z.array(schema), await res.json())
return data[0]
},
expiresIn: () => TTL,
lazy: true,
swr: true,
swrValidator: (_data, time) => Date.now() - time < STALE_TTL,
})

View file

@ -0,0 +1,107 @@
import { fediLastSeen } from './fedi'
import { githubLastSeen } from './github'
import { lastfm } from './lastfm'
import { shikimoriLastSeen } from './shikimori'
export interface LastSeenItem {
source: string
sourceLink: string
time: number
text: string
suffix?: string
link: string
}
export async function fetchLastSeen() {
const [
lastfmData,
fediData,
shikimoriData,
githubData,
] = await Promise.all([
lastfm.get(),
fediLastSeen.get(),
shikimoriLastSeen.get(),
githubLastSeen.get(),
])
const res: LastSeenItem[] = []
if (lastfmData) {
res.push({
source: 'last.fm',
sourceLink: 'https://last.fm/user/teidesu',
time: Number(lastfmData.date!.uts) * 1000,
text: `${lastfmData.name} ${lastfmData.artist['#text']}`,
link: lastfmData.url,
})
}
if (fediData) {
res.push({
source: 'fedi',
sourceLink: 'https://very.stupid.fish/@teidesu',
time: new Date(fediData.updatedAt).getTime(),
text: fediData.text?.slice(0, 40) || '[no text]',
link: `https://very.stupid.fish/notes/${fediData.id}`,
})
}
if (shikimoriData) {
// thx morr for this fucking awesome api
const mapper: Record<string, string> = {
'Просмотрено': 'completed',
'Прочитано': 'completed',
'Добавлено в список': 'added',
'Брошено': 'dropped',
}
let event = mapper[shikimoriData.description]
if (!event && shikimoriData.description.match(/^Просмотрен.*эпизод(ов)?$/)) {
event = 'watched'
}
if (!event && shikimoriData.description.match(/^(Просмотрено|Прочитано) и оценено/)) {
event = 'completed'
}
if (event) {
res.push({
source: 'shiki',
sourceLink: 'https://shikimori.one/teidesu',
time: new Date(shikimoriData.created_at).getTime(),
text: shikimoriData.target.name,
suffix: `: ${event}`,
link: `https://shikimori.one${shikimoriData.target.url}`,
})
}
}
if (githubData) {
const eventTextMapper: Record<string, () => string> = {
CreateEvent: () => `${githubData.payload.ref_type} created`,
DeleteEvent: () => `${githubData.payload.ref_type} deleted`,
ForkEvent: () => 'forked',
GollumEvent: () => 'wiki updated',
IssueCommentEvent: () => `issue comment ${githubData.payload.action}`,
IssuesEvent: () => `issue ${githubData.payload.action}`,
PublicEvent: () => 'made public',
PullRequestEvent: () => `pr ${githubData.payload.action}`,
PushEvent: () => `pushed ${githubData.payload.distinct_size} commits`,
ReleaseEvent: () => `release ${githubData.payload.action}`,
WatchEvent: () => 'starred',
}
if (eventTextMapper[githubData.type]) {
res.push({
source: 'github',
sourceLink: 'https://github.com/teidesu',
time: new Date(githubData.created_at).getTime(),
text: githubData.repo.name,
suffix: `: ${eventTextMapper[githubData.type]()}`,
link: `https://github.com/${githubData.repo.name}`,
})
}
}
return res.sort((a, b) => b.time - a.time)
}

View file

@ -0,0 +1,71 @@
import { z } from 'zod'
import { Reloadable } from '~/backend/utils/reloadable'
import { zodValidate } from '~/utils/zod'
import { env } from '~/backend/env'
const LASTFM_TTL = 1000 * 60 * 5 // 5 minutes
const LASTFM_STALE_TTL = 1000 * 60 * 60 // 1 hour
const LASTFM_USERNAME = 'teidesu'
const LASTFM_TOKEN = env.LASTFM_TOKEN
const LastfmTrack = z.object({
'artist': z.object({ 'mbid': z.string(), '#text': z.string() }),
'name': z.string(),
'url': z.string(),
'date': z.object({ uts: z.string() }).optional(),
'@attr': z.object({
nowplaying: z.literal('true'),
}).partial().optional(),
})
export type LastfmTrack = z.infer<typeof LastfmTrack>
const ResponseSchema = z.object({
recenttracks: z.object({
'track': z.array(LastfmTrack),
'@attr': z.object({
user: z.string(),
totalPages: z.string(),
page: z.string(),
perPage: z.string(),
total: z.string(),
}),
}),
})
export const lastfm = new Reloadable<LastfmTrack>({
name: 'last-track',
async fetch(prev) {
const params = new URLSearchParams({
method: 'user.getrecenttracks',
user: LASTFM_USERNAME,
api_key: LASTFM_TOKEN,
format: 'json',
limit: '1',
})
if (prev?.date) {
params.set('from', prev.date!.uts)
}
const res = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`)
if (!res.ok) {
throw new Error(`Failed to fetch last.fm data: ${res.status} ${await res.text()}`)
}
const data = await res.json()
const parsed = await zodValidate(ResponseSchema, data)
const track = parsed.recenttracks.track[0]
if (!track.date && track['@attr']?.nowplaying) {
track.date = { uts: Math.floor(Date.now() / 1000).toString() }
} else if (!track.date) {
throw new Error('no track found')
}
return track
},
expiresIn: () => LASTFM_TTL,
lazy: true,
swr: true,
swrValidator: (_data, time) => Date.now() - time < LASTFM_STALE_TTL,
})

View file

@ -0,0 +1,40 @@
import { z } from 'zod'
import { Reloadable } from '~/backend/utils/reloadable'
import { zodValidate } from '~/utils/zod'
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
const schema = z.object({
created_at: z.string(),
description: z.string(),
target: z.object({
name: z.string(),
url: z.string(),
}),
})
export const shikimoriLastSeen = new Reloadable<z.infer<typeof schema>>({
name: 'shikimori-last-seen',
async fetch() {
const res = await fetch(ENDPOINT, {
headers: {
'User-Agent': 'tei.su/1.0',
},
})
if (!res.ok) {
throw new Error(`Failed to fetch shikimori last seen: ${res.status} ${await res.text()}`)
}
const data = await zodValidate(z.array(schema), await res.json())
return data[0]
},
expiresIn: () => TTL,
lazy: true,
swr: true,
swrValidator: (_data, time) => Date.now() - time < STALE_TTL,
})

View file

@ -0,0 +1,155 @@
import { BotKeyboard, html } from '@mtcute/node'
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
import { ShoutboxAction } from '../bot/shoutbox.js'
import { shouts } from '../models/index.js'
import { URL_REGEX } from '../utils/url.js'
import { db } from '../db'
import { env } from '../env'
import { tg } from '../bot'
const SHOUTS_PER_PAGE = 5
const filter = or(
not(shouts.pending),
and(shouts.pending, eq(shouts.fromIp, sql.placeholder('fromIp'))),
)
const fetchTotal = db.select({
count: sql<number>`count(1)`,
}).from(shouts)
.where(filter)
.prepare()
const fetchList = db.select({
createdAt: shouts.createdAt,
text: shouts.text,
pending: shouts.pending,
serial: shouts.serial,
}).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 {
items: fetchList.all({
offset: page * SHOUTS_PER_PAGE,
fromIp: ip,
}),
pageCount: Math.ceil((fetchTotal.get({
fromIp: ip,
})?.count ?? 0) / SHOUTS_PER_PAGE),
}
}
export type ShoutsData = ReturnType<typeof fetchShouts>
const fetchNextSerial = db.select({
serial: sql<number>`coalesce(max(serial), 0) + 1`,
}).from(shouts)
.prepare()
export function approveShout(id: string) {
const nextSerial = fetchNextSerial.get({})!.serial
db.update(shouts)
.set({ pending: false, serial: nextSerial })
.where(eq(shouts.id, id))
.run()
}
export function declineShout(id: string) {
db.delete(shouts)
.where(eq(shouts.id, id))
.run()
}
export function deleteBySerial(serial: number) {
db.delete(shouts)
.where(eq(shouts.serial, serial))
.run()
// adjust serials
db.update(shouts)
.set({ serial: sql<number>`serial - 1` })
.where(and(
eq(shouts.pending, false),
gt(shouts.serial, sql.placeholder('serial')),
))
.run({ serial })
}
function validateShout(text: string, isPublic: boolean) {
if (text.length < 3) {
return 'too short, come on'
}
if (text.length > 300) {
return 'please keep it under 300 characters'
}
if (isPublic) {
const lineCount = text.split('\n').length
if (lineCount > 5) {
return 'too many lines, keep it under 5'
}
if (URL_REGEX.test(text)) {
return 'no links plz'
}
}
return true
}
export async function createShout(params: {
fromIp: string
private: boolean
text: string
}): Promise<boolean | string> {
let { text } = params
text = text.trim()
const validateResult = validateShout(text, !params.private)
const kindText = params.private ? 'private message' : 'shout'
if (params.private || validateResult !== true) {
const was = params.private ? '' : ` was auto-declined (${validateResult})`
await tg.sendText(
env.TG_CHAT_ID,
html`
${kindText} from <code>${params.fromIp}</code>${was}:
<br><br>
${text}
`,
)
}
if (!params.private && validateResult === true) {
const result = await db.insert(shouts)
.values(params)
.returning({ id: shouts.id })
.execute()
const id = result[0].id
await tg.sendText(
env.TG_CHAT_ID,
html`
${kindText} from <code>${params.fromIp}</code>:
<br><br>
${text}
`,
{
replyMarkup: BotKeyboard.inline([[
BotKeyboard.callback('✅ approve', ShoutboxAction.build({ id, action: 'approve' })),
BotKeyboard.callback('❌ decline', ShoutboxAction.build({ id, action: 'decline' })),
]]),
},
)
}
return validateResult
}

View file

@ -0,0 +1,51 @@
import { isBotUserAgent } from '../utils/bot'
import { env } from '~/backend/env'
export async function umamiFetchStats(page: string, startAt: number) {
if (import.meta.env.DEV) {
return Promise.resolve({ uniques: { value: 1337 } })
}
const res = await fetch(`${env.UMAMI_HOST}/api/websites/${env.UMAMI_SITE_ID}/stats?${new URLSearchParams({
endAt: Math.floor(Date.now()).toString(),
startAt: startAt.toString(),
url: page,
})}`, {
headers: {
Authorization: `Bearer ${env.UMAMI_TOKEN}`,
},
})
return await res.json()
}
export function umamiLogThisVisit(request: Request, path?: string, website = env.UMAMI_SITE_ID): void {
if (import.meta.env.DEV) return
if (isBotUserAgent(request.headers.get('user-agent') || '')) return
const language = request.headers.get('accept-language')?.split(';')[0].split(',')[0] || ''
fetch(`${env.UMAMI_HOST}/api/send`, {
body: JSON.stringify({
payload: {
hostname: request.headers.get('host') || '',
language,
referrer: request.headers.get('referer') || '',
screen: '',
title: '',
url: path ?? new URL(request.url).pathname,
website,
},
type: 'event',
}),
headers: {
'Content-Type': 'application/json',
'User-Agent': request.headers.get('user-agent') || '',
'X-Forwarded-For': request.headers.get('x-forwarded-for')?.[0] || '',
},
method: 'POST',
}).then(async (r) => {
if (!r.ok) throw new Error(`failed to log visit: ${r.status} ${await r.text()}`)
}).catch((err) => {
console.warn(err)
})
}

View file

@ -0,0 +1,39 @@
import { z } from 'zod'
import { Reloadable } from '~/backend/utils/reloadable'
import { zodValidate } from '~/utils/zod'
const WEBRING_URL = 'https://otomir23.me/webring/5/data'
const WEBRING_TTL = 1000 * 60 * 60 * 24 // 24 hours
const WebringItem = z.object({
id: z.number(),
name: z.string(),
url: z.string(),
})
export type WebringItem = z.infer<typeof WebringItem>
const WebringData = z.object({
prev: WebringItem,
next: WebringItem,
})
export type WebringData = z.infer<typeof WebringData>
export const webring = new Reloadable({
name: 'webring',
fetch: async () => {
const response = await fetch(WEBRING_URL)
if (!response.ok) {
const text = await response.text()
throw new Error(`Failed to fetch webring data: ${response.status} ${text}`)
}
const data = await response.json()
const parsed = await zodValidate(WebringData, data)
return parsed
},
expiresIn: () => WEBRING_TTL,
lazy: true,
swr: true,
})

3
src/backend/utils/bot.ts Normal file
View file

@ -0,0 +1,3 @@
export function isBotUserAgent(userAgent: string) {
return /bot|crawl|slurp|spider|mediapartners|mastodon|akkoma|pleroma|misskey|firefish|sharkey/i.test(userAgent)
}

View file

@ -0,0 +1,14 @@
import { randomPick } from '../../utils/random'
export function obfuscateEmail(email: string) {
const opener = randomPick(['[', '{', '(', '<', '|'])
const closer = {
'(': ')',
'[': ']',
'{': '}',
'<': '>',
'|': '|',
}[opener]
return email.replace(/@/g, ` ${opener}at${closer} `).replace(/\./g, ` ${opener}dot${closer} `)
}

View file

@ -0,0 +1,25 @@
/**
* A promise that can be resolved or rejected from outside.
*/
export type ControllablePromise<T = unknown> = Promise<T> & {
resolve: (val: T) => void
reject: (err?: unknown) => void
}
/**
* Creates a promise that can be resolved or rejected from outside.
*/
export function createControllablePromise<T = unknown>(): ControllablePromise<T> {
let _resolve: ControllablePromise<T>['resolve']
let _reject: ControllablePromise<T>['reject']
const promise = new Promise<T>((resolve, reject) => {
_resolve = resolve
_reject = reject
})
// ts doesn't like this, but it's fine
;(promise as ControllablePromise<T>).resolve = _resolve!
;(promise as ControllablePromise<T>).reject = _reject!
return promise as ControllablePromise<T>
}

View file

@ -0,0 +1,83 @@
import { type ControllablePromise, createControllablePromise } from './promise'
export interface ReloadableParams<T> {
name: string
// whether to avoid automatically reloading
lazy?: boolean
// whether to return old value while a new one is fetching
swr?: boolean
// if `swr` is enabled, whether the stale data can still be used
swrValidator?: (prev: T, prevTime: number) => boolean
fetch: (prev: T | null, prevTime: number) => Promise<T>
expiresIn: (data: T) => number
}
export class Reloadable<T> {
constructor(readonly params: ReloadableParams<T>) {}
private data: T | null = null
private lastFetchTime = 0
private expiresAt = 0
private updating?: ControllablePromise<void>
private timeout?: NodeJS.Timeout
async update(force = false): Promise<void> {
if (this.updating) {
await this.updating
return
}
if (!force && this.data && Date.now() < this.expiresAt) {
return
}
this.updating = createControllablePromise()
let result
try {
result = await this.params.fetch(this.data, this.lastFetchTime)
} catch (e) {
console.error(`Failed to fetch ${this.params.name}:`, e)
this.updating.resolve()
this.updating = undefined
return
}
this.updating.resolve()
this.updating = undefined
this.data = result
const expiresIn = this.params.expiresIn(result)
this.lastFetchTime = Date.now()
this.expiresAt = this.lastFetchTime + expiresIn
if (!this.params.lazy) {
if (this.timeout) {
clearTimeout(this.timeout)
}
this.timeout = setTimeout(() => {
this.update()
}, expiresIn)
}
}
async get(): Promise<T | null> {
if (this.params.swr && this.data) {
const validator = this.params.swrValidator
if (!validator || validator(this.data, this.expiresAt)) {
this.update().catch(() => {})
return this.data
}
}
await this.update()
return this.data
}
getCached(): T | null {
return this.data
}
}

View file

@ -0,0 +1,5 @@
import type { APIContext, AstroGlobal } from 'astro'
export function getRequestIp(ctx: AstroGlobal | APIContext) {
return ctx.request.headers.get('x-forwarded-for') ?? ctx.clientAddress
}

1466
src/backend/utils/url.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
.choice {
cursor: pointer;
display: inline-block;
position: relative;
text-decoration: underline dotted;
transition: opacity 200ms, transform 200ms;
/* prevent text selection on 2/3-ple click */
&:active {
user-select: none;
}
}

View file

@ -0,0 +1,41 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import { createSignal } from 'solid-js'
import { shuffle } from '~/utils/random'
import css from './RandomWord.module.css'
export interface RandomWordProps {
choices: JSX.Element[]
}
export function RandomWord(props: RandomWordProps) {
const [choice, setChoice] = createSignal<JSX.Element>()
let order: JSX.Element[] = []
function pickNew() {
if (order.length === 0) {
order = shuffle(props.choices)
}
setChoice(order.pop())
}
function onClick(evt: MouseEvent) {
evt.preventDefault()
pickNew()
}
pickNew()
return (
<div
class={css.choice}
onClick={onClick}
>
{choice()}
</div>
)
}

View file

@ -0,0 +1,4 @@
# i don't want this file to be in the repo as it *will* be crawled by github,
# and i don't want people to be able to reverse-search up me by my crypto wallets
# that's pretty much the main reason i do this obfuscation in the first place
data.json

View file

@ -0,0 +1,17 @@
---
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { umamiLogThisVisit } from '~/backend/service/umami'
import { PageDonate as PageDonateSolid, PaymentMethods } from './PageDonate'
import { fetchDonatePageData } from './data'
umamiLogThisVisit(Astro.request, '/donate')
const data = await fetchDonatePageData(Astro.request)
---
<DefaultLayout>
<PageDonateSolid {data}>
<PaymentMethods {data} slot="methods" client:idle />
</PageDonateSolid>
</DefaultLayout>

View file

@ -0,0 +1,96 @@
/** @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 { PageData } from './data'
import type { PaymentMethod } from './constants'
import { deriveKey, dumbHash, xorContinuous } from './crypto-common'
export function PaymentMethods(props: { data: PageData }) {
const [items, setItems] = createSignal<PaymentMethod[]>(
// eslint-disable-next-line solid/reactivity
props.data.encryptedData.map(it => ({
link: undefined,
name: it.name,
text: '[encrypted]',
})),
)
onMount(() => {
// force client-side
const key = deriveKey(navigator.userAgent, location.href, props.data.salt)
const keyHash = dumbHash(key)
const xor = [0]
const probeDec = xorContinuous(keyHash, props.data.probeEnc, xor)
if (probeDec !== props.data.probe) {
console.error(`Probe mismatch (expected: ${props.data.probe}, got: ${probeDec})`)
return
}
setItems(props.data.encryptedData.map(it => ({
link: it.link ? xorContinuous(keyHash, it.link!, xor) : undefined,
name: it.name,
text: xorContinuous(keyHash, it.text, xor),
})))
})
const itemsToRender = () => items().map(it => ({
name: it.name,
value: () => it.link
? <Link href={it.link} target="_blank">{it.text}</Link>
: it.text,
}))
return (
<TextTable
items={itemsToRender()}
minColumnWidth={12}
/>
)
}
export function PageDonate(props: { methods?: JSX.Element, data: PageData }) {
return (
<>
<section>heya</section>
<section>
i'm not really struggling with money, but if you like what i do and want to support me i
would totally appreciate it &lt;3
</section>
<section>when donating crypto, please use stablecoins (usdt/dai) or native token</section>
<section>
<SectionTitle>my payment addresses (in order of preference):</SectionTitle>
{props.methods}
</section>
<noscript>
<section>
<SectionTitle> looks like javascript is disabled.</SectionTitle>
that is why payment methods above aren't displayed.
<br />
to protect myself from osint and bot attacks, i do a little obfuscation.
<br />
i promise, there are no trackers here ^_^
<br />
<br />
if you are a weirdo using noscript or lynx or something instead of a browser, you can
dm me for payment details.
</section>
</noscript>
<section>
total page views so far:
{' '}
{props.data.pageViews}
</section>
</>
)
}

View file

@ -0,0 +1,9 @@
import data from './data.json' with { type: 'json' }
export interface PaymentMethod {
link?: string
name: string
text: string
}
export const PAYMENT_METHODS = data as PaymentMethod[]

View file

@ -0,0 +1,34 @@
const ascii = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
export function dumbHash(str: string) {
let hash = 0
const len = str.length
for (let s = 0; s < len; s++) {
hash += str.charCodeAt(s) * (s + 1) * (len - s)
}
hash >>>= 0
let res = ''
while (hash > 0) {
const q = hash % ascii.length
hash = ~~(hash / ascii.length)
res += ascii[q]
}
return res
}
export function deriveKey(userAgent: string, href: string, salt: string) {
return userAgent.trim() + href.replace(/#.*$/, '') + Math.floor(Date.now() / 100000) + salt
}
export function xorContinuous(key: string, str: string, posRef: number[]) {
let pos = posRef[0]
let ret = ''
for (let s = 0; s < str.length; s++) {
ret += String.fromCharCode(str.charCodeAt(s) ^ key.charCodeAt(pos))
pos = (pos + 1) % key.length
}
posRef[0] = pos
return ret
}

View file

@ -0,0 +1,42 @@
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'
export async function fetchDonatePageData(request: Request) {
const pageViews = await umamiFetchStats('/donate', 1700088965789)
.then(stats => `${stats.visitors.value + 9089}`) // value before umami
.catch((err) => {
console.error('Failed to fetch page views: ', err)
return '[error]'
})
const salt = randomBytes(12).toString('base64')
const probe = randomBytes(12).toString('base64')
const url = new URL(request.url, `${import.meta.env.DEV ? 'http' : 'https'}://${request.headers.get('host')}`)
const key = deriveKey(request.headers.get('user-agent') || '', url.href, salt)
const keyHash = dumbHash(key)
const xorPos = [0]
const probeEnc = xorContinuous(keyHash, probe, xorPos)
const encryptedData: PaymentMethod[] = PAYMENT_METHODS.map(it => ({
...it,
link: it.link ? xorContinuous(keyHash, it.link, xorPos) : undefined,
text: xorContinuous(keyHash, it.text, xorPos),
}))
return {
encryptedData,
probe,
probeEnc,
salt,
pageViews,
}
}
export type PageData = Awaited<ReturnType<typeof fetchDonatePageData>>

View file

@ -0,0 +1,21 @@
---
import DefaultLayout from '~/layouts/DefaultLayout/DefaultLayout.astro'
import { RandomWord } from '~/components/interactive/RandomWord/RandomWord'
import { umamiLogThisVisit } from '~/backend/service/umami'
import { PageMain as PageMainSolid } from './PageMain'
import { PARTTIME_VARIANTS } from './constants'
import { fetchMainPageData } from './data'
import Shoutbox from './Shoutbox/Shoutbox.astro'
umamiLogThisVisit(Astro.request)
const data = await fetchMainPageData()
---
<DefaultLayout>
<PageMainSolid {data}>
<RandomWord slot="part-time-words" choices={PARTTIME_VARIANTS} client:idle />
<Shoutbox slot="shoutbox" />
</PageMainSolid>
</DefaultLayout>

View file

@ -0,0 +1,128 @@
@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;
&: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 {
/* spaces are a crutch to avoid table resizing lol */
content: ' (click to expand)';
@mixin font-xs;
margin-left: 1em;
color: var(--text-secondary);
@media (--tablet) {
content: ' (expand)';
}
}
.lastSeen[open] .lastSeenTrigger::before {
content: '(click to collapse)';
@media (--tablet) {
content: '(collapse)';
}
}
.lastSeenLinkWrap {
display: flex;
align-items: center;
@media (--tablet) {
flex-direction: column;
justify-content: center;
align-items: start;
}
}
.lastSeenLinkWrapInner {
display: flex;
align-items: center;
}
.lastSeenLink {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
@media (--tablet) {
max-width: 300px;
}
}
.lastSeenSuffix {
@mixin font-xs;
}
.lastSeenSource {
@mixin font-xs;
color: var(--text-secondary);
margin-left: 8px;
@media (--tablet) {
margin-left: 0;
}
}

View file

@ -0,0 +1,325 @@
/** @jsxImportSource solid-js */
import { For, type JSX, Show } from 'solid-js'
import { Dynamic } from 'solid-js/web'
import { intlFormatDistance } from 'date-fns'
import { Emoji } from '~/components/ui/Emoji/Emoji'
import { SectionTitle } from '~/components/ui/SectionTitle/SectionTitle'
import { Link } from '~/components/ui/Link/Link'
import { TextComment } from '~/components/ui/TextComment/TextComment'
import { TextTable } from '~/components/ui/TextTable/TextTable'
import jsLogo from '~/assets/javascript.png'
import ukFlag from '~/assets/flag-united-kingdom_1f1ec-1f1e7.png'
import ruFlag from '~/assets/flag-russia_1f1f7-1f1fa.png'
import cherry from '~/assets/cherry-blossom_1f338.png'
import axolotl from '~/assets/axolotl.png'
import type { LastSeenItem as TLastSeenItem } from '~/backend/service/last-seen'
import { randomInt } from '~/utils/random'
import css from './PageMain.module.css'
import { SUBLINKS, TESTIMONIALS } from './constants'
import type { PageData } from './data'
function formatTimeRelative(time: number) {
return intlFormatDistance(
new Date(time),
new Date(),
)
}
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}>
<Link
class={css.lastSeenLink}
href={props.item.link}
target="_blank"
title={props.item.text}
>
{props.item.text}
</Link>
{props.item.suffix && (
<span class={css.lastSeenSuffix}>
{props.item.suffix}
</span>
)}
</div>
<i class={css.lastSeenSource}>
{'@ '}
<Link href={props.item.sourceLink} target="_blank">
{props.item.source}
</Link>
{', '}
{formatTimeRelative(props.item.time)}
</i>
</div>
<Show when={props.first}>
<div class={css.lastSeenTrigger} />
</Show>
</Dynamic>
)
}
export function PageMain(props: {
data: PageData
partTimeWords?: JSX.Element
shoutbox?: JSX.Element
}) {
const testimonials = TESTIMONIALS.map((props) => {
const link = props.href
? (
<Link href={props.href} target="_blank">
{props.author}
</Link>
)
: <i>{props.author}</i>
return (
<div class={css.testimonial}>
"
{props.text}
"&nbsp;-&nbsp;
{link}
</div>
)
})
/* eslint-disable solid/no-innerhtml */
const sublinks = SUBLINKS.map(item => (
<div>
-
{' '}
<Link href={item.link} target="_blank">
{item.title}
</Link>
:
{' '}
<span innerHTML={item.subtitle} />
<TextComment
class={css.comment}
innerHTML={item.comment}
/>
</div>
))
/* eslint-enable solid/no-innerhtml */
return (
<>
<section>{`h${'i'.repeat(randomInt(2, 5))}~`}</section>
<section>
i am
{' '}
<b>alina</b>
{' '}
aka
{' '}
<b>teidesu</b>
{' '}
🌸
<br />
full-time js/ts developer, part-time
{' '}
{props.partTimeWords}
{' '}
<br />
more about me as a dev on my
{' '}
<Link href="//github.com/teidesu" target="_blank">
github page
</Link>
</section>
<section>
<SectionTitle>
extremely interesting info (no):
</SectionTitle>
<TextTable
items={[
{ name: 'birthday', value: () => 'july 25 (leo ♌)' },
{
name: 'langs',
value: () => (
<>
<Emoji alt="🇷🇺" src={ruFlag.src} />
{' '}
native,
{' '}
<Emoji alt="🇬🇧" src={ukFlag.src} />
{' '}
c1,
{' '}
<Emoji alt="javascript" src={jsLogo.src} />
{' '}
native
</>
),
},
{
name: 'last seen',
value: () => {
if (!props.data.lastSeen?.length) return
return (
<details class={css.lastSeen}>
<LastSeenItem first item={props.data.lastSeen[0]} />
<For each={props.data.lastSeen.slice(1)}>
{it => <LastSeenItem item={it} />}
</For>
</details>
)
},
},
{
name: 'fav color',
value: () => (
<>
#be15dc
{' '}
<div class={css.favColor} />
</>
),
},
{
name: 'fav flower',
value: () => (
<>
cherry blossom
{' '}
<Emoji alt="🌸" src={cherry.src} />
, lilac
</>
),
},
{
name: 'fav animal',
value: () => (
<>
axolotl
{' '}
<Emoji alt="axolotl" src={axolotl.src} />
</>
),
},
{
name: 'fav anime',
value: () => (
<>
nichijou (
<Link href="//shikimori.one/animes/10165-nichijou" target="_blank">
shiki
</Link>
/
<Link href="//anilist.co/anime/10165/Nichijou" target="_blank">
anilist
</Link>
)
</>
),
},
{
name: 'fav music',
value: () => (
<>
hyperpop, digicore, happy hardcore
</>
),
},
]}
wrap
fill
/>
</section>
<section>
<SectionTitle>
contact me (in order of preference):
</SectionTitle>
<TextTable
items={[
{
name: 'telegram',
value: () => (
<Link href="//t.me/teidumb" target="_blank">
@teidumb
</Link>
),
},
{
name: 'fedi',
value: () => (
<Link href="https://very.stupid.fish/@teidesu" target="_blank">
@teidesu@very.stupid.fish
</Link>
),
},
{
name: 'imessage',
value: () => props.data.email,
},
{
name: 'email',
value: () => props.data.email,
},
{
name: 'phone',
value: () => 'secret :p',
},
{
name: 'post pigeons',
value: () => 'please don\'t',
},
]}
wrap
/>
</section>
<section>
<SectionTitle>
testimonials from THEM:
</SectionTitle>
{testimonials}
<TextComment class={css.commentInline}>
feel free to leave yours :3
</TextComment>
</section>
{props.shoutbox}
<section>
<SectionTitle>
top secret sub-pages:
</SectionTitle>
{sublinks}
</section>
<section>
total page views so far:
{' '}
{props.data.pageViews}
</section>
<Show when={props.data.webring}>
<section class={css.webring}>
<Link href={props.data.webring!.prev.url}>
&lt;
{' '}
{props.data.webring!.prev.name}
</Link>
<Link href="https://otomir23.me/webring" target="_blank">
rutg webring
</Link>
<Link href={props.data.webring!.next.url}>
{props.data.webring!.next.name}
{' '}
&gt;
</Link>
</section>
</Show>
</>
)
}

View file

@ -0,0 +1,18 @@
---
import { fetchShouts } from '~/backend/service/shoutbox'
import { getRequestIp } from '~/backend/utils/request'
import { Shoutbox as ShoutboxSolid } from './Shoutbox'
const url = new URL(Astro.request.url)
let page = Number(url.searchParams.get('shouts_page'))
if (Number.isNaN(page)) page = 0
const data = fetchShouts(page, getRequestIp(Astro))
---
<ShoutboxSolid
client:idle
initPage={page}
initPageData={data}
/>

View file

@ -0,0 +1,67 @@
@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;
}
.pagination {
display: flex;
gap: 8px;
justify-content: flex-end;
color: var(--text-secondary);
margin-top: 8px;
}
.paginationLink {
color: var(--text-secondary);
}

View file

@ -0,0 +1,196 @@
/* eslint-disable no-alert */
/** @jsxImportSource solid-js */
import { type ComponentProps, Show, createSignal } from 'solid-js'
import { QueryClient, QueryClientProvider, createQuery, keepPreviousData } 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 css from './Shoutbox.module.css'
async function fetchShouts(page: number): Promise<ShoutsData> {
return fetch(`/api/shoutbox?page=${page}`).then(r => r.json())
}
function ShoutboxInner(props: {
initPage: number
initPageData: ShoutsData
}) {
// eslint-disable-next-line solid/reactivity
const [page, setPage] = createSignal(props.initPage)
const shouts = createQuery(() => ({
queryKey: ['shouts', page()],
queryFn: () => fetchShouts(page()),
refetchInterval: 30000,
placeholderData: keepPreviousData,
initialData: props.initPageData,
}))
const [sending, setSending] = createSignal(false)
const onPageClick = (next: boolean) => (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const newPage = next ? page() + 1 : page() - 1
const link = e.currentTarget as HTMLAnchorElement
const href = link.href
history.replaceState(null, '', href)
setPage(newPage)
}
const shoutsRender = () => shouts.data?.items.map((props) => {
const icon = props.pending
? (
<Icon
glyph={GravityClock}
size={16}
title="awaiting moderation"
/>
)
: `#${props.serial}`
return (
<div class={css.shout}>
<div class={css.header}>
{icon}
<time class={css.time} datetime={props.createdAt}>
{format(props.createdAt, 'yyyy-MM-dd HH:mm')}
</time>
</div>
<div class={css.text}>
{props.text}
</div>
</div>
)
})
let form!: HTMLFormElement
const onSubmit = (e: Event) => {
e.preventDefault()
setSending(true)
const isPrivate = (form.elements.namedItem('private') as HTMLInputElement).checked
fetch('/api/shoutbox', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
// _csrf: shouts.data?.csrf,
message: (form.elements.namedItem('message') as HTMLInputElement).value,
private: isPrivate ? '' : undefined,
}),
})
.then(res => res.json())
.then((data) => {
if (data.error) {
alert(data.error + (data.message ? `: ${data.message}` : ''))
} else if (isPrivate) {
alert('private message sent')
form.reset()
} else {
alert('shout sent! it will appear after moderation')
shouts.refetch()
form.reset()
}
setSending(false)
})
}
return (
<section>
<SectionTitle>shoutbox!</SectionTitle>
<TextComment class={pageCss.comment}>
disclaimer: shouts
{' '}
<i>are</i>
{' '}
pre-moderated, but they do not reflect my&nbsp;views.
</TextComment>
<form action="/api/shoutbox" class={css.form} method="post" ref={form}>
{/* <input type="hidden" name="_csrf" value={shouts.data.csrf} /> */}
<div class={css.formInput}>
<TextArea
disabled={sending()}
class={css.textarea}
grow
maxRows={5}
name="message"
// placeholder={initData.shoutError || 'let the void hear you'}
placeholder="let the void hear you"
required
/>
<Button
type="submit"
onClick={onSubmit}
title="submit"
>
<Icon glyph={GravityMegaphone} size={16} />
</Button>
</div>
<Checkbox
label="make it private"
name="private"
/>
</form>
<div class={css.shouts}>
{shoutsRender()}
</div>
<Show when={shouts.data && shouts.data.pageCount > 1}>
<div class={css.pagination}>
<Show when={page() > 0}>
<a
class={css.paginationLink}
rel="external"
href={page() === 1 ? '/' : `?shouts_page=${page() - 1}`}
onClick={onPageClick(false)}
data-astro-reload
>
&lt; prev
</a>
</Show>
<span>{page() + 1}</span>
<Show when={page() < shouts.data!.pageCount - 1}>
<a
class={css.paginationLink}
rel="external"
href={`?shouts_page=${page() + 1}`}
onClick={onPageClick(true)}
data-astro-reload
>
next &gt;
</a>
</Show>
</div>
</Show>
</section>
)
}
export function Shoutbox(props: ComponentProps<typeof ShoutboxInner>) {
const client = new QueryClient()
return (
<QueryClientProvider client={client}>
<ShoutboxInner {...props} />
</QueryClientProvider>
)
}

View file

@ -0,0 +1,65 @@
export const PARTTIME_VARIANTS = [
'anime girl',
'puppygirl',
'human being',
'shitposter',
'js fanatic',
'dumbass',
'delulu',
'silly goofball',
]
export const TESTIMONIALS = [
{ author: 'sanspie', href: 'https://akarpov.ru/about', text: 'lil purry cat(cute!)' },
{ author: 'attorelle', text: 'today she asks to dm her "tomato", tomorrow she\'ll ask to sign over apartment to her' },
{ author: 'astrra', href: 'https://astrra.space', text: 'why are you in my walls why are you in my walls why are you in my walls why are you in my walls' },
{ author: 'wffl', href: 'https://ihatereality.space', text: 'i knew a girl with the same name in my childhood' },
{ author: 'mo', href: 'https://mo.rijndael.cc', text: 'okay okay, i will write a review for you please don\'t be offended' },
{ author: 'svpra', text: 'i like to write all sorts of cute phrases on other people\'s websites when nobody asks about it. meow.' },
{ author: 'toil', href: 'https://toil.cc', text: 'i like cute anime girls (oops it\'s you) <3' },
]
export const SUBLINKS = [
{
link: '/nudes',
title: 'nudes',
subtitle: '( ͡° ͜ʖ ͡°)',
comment: 'a lot of them, actually',
},
{
link: '/cheerio/index.html',
title: 'cheerio',
subtitle: 'cheerio repl for debugging and stuff',
comment: 'made in 20 minutes, use it all the time, very useful /gen',
},
{
link: '/gdz',
title: 'gdz',
subtitle: 'custom frontend for gdz.ru (fetches <i>*everything*</i> for free)',
comment: 'i made it a long time ago and everything is very bad 💀<br />idk how it still works',
},
{
link: '/oauth.blank.html',
title: 'oauth.blank.html',
subtitle: 'thingy for redirect_uri',
comment: 'i promise it doesn\'t collect your tokens',
},
{
link: '/proxifier.html',
title: 'proxifier.html',
subtitle: 'proxifier keygen',
comment: 'basically a port of some c# implementation bc im lazy',
},
{
link: '/spring.html',
title: 'spring.html',
subtitle: 'no idea',
comment: 'spring physics in ui are fun',
},
{
link: '/test_voice.ogg',
title: 'test_voice.ogg',
subtitle: 'фильм земляне 2005 года смотреть всем',
comment: 'libopus encoded, valid for telegram',
},
]

View file

@ -0,0 +1,31 @@
import { obfuscateEmail } from '~/backend/utils/obfuscate-email'
import { webring } from '~/backend/service/webring'
import { umamiFetchStats } from '~/backend/service/umami'
import { fetchLastSeen } from '~/backend/service/last-seen'
export async function fetchMainPageData() {
const [
pageViews,
webringData,
lastSeen,
] = await Promise.all([
umamiFetchStats('/', 1700088965789)
.then(stats => `${stats.visitors.value + 321487}`) // value before umami
.catch((err) => {
console.error('Failed to fetch page views: ', err)
return '[error]'
}),
webring.get(),
fetchLastSeen(),
])
return {
email: obfuscateEmail('alina@tei.su'),
pageViews,
shouts: [],
webring: webringData,
lastSeen,
}
}
export type PageData = Awaited<ReturnType<typeof fetchMainPageData>>

22
src/components/shared.css Normal file
View file

@ -0,0 +1,22 @@
@custom-media --mobile (max-width: 480px);
@custom-media --tablet (max-width: 720px);
@define-mixin font-2xs {
font-size: 10px;
line-height: 12px;
}
@define-mixin font-xs {
font-size: 12px;
line-height: 14px;
}
@define-mixin font-sm {
font-size: 14px;
line-height: 16px;
}
@define-mixin font-md {
font-size: 16px;
line-height: 20px;
}

View file

@ -0,0 +1,22 @@
.button {
padding: 8px 12px;
border: 1px solid var(--control-outline);
background: var(--control-bg);
color: var(--text-primary);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s, transform 0.2s;
transition-timing-function: ease-in-out;
&:hover {
background: var(--control-bg-hover);
}
&:active {
transform: scale(0.95);
}
}
.square {
height: min-content;
padding: 12px;
}

View file

@ -0,0 +1,25 @@
/** @jsxImportSource solid-js */
import { splitProps } from 'solid-js'
import type { JSX } from 'solid-js/jsx-runtime'
import clsx from 'clsx'
import css from './Button.module.css'
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
square?: boolean
}
export function Button(props: ButtonProps) {
const [my, rest] = splitProps(props, ['square', 'class'])
return (
<button
{...rest}
class={clsx(
css.button,
my.square && css.square,
my.class,
)}
/>
)
}

View file

@ -0,0 +1,41 @@
.input {
display: none;
}
.label {
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
&:active {
user-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);
}
}
.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%);
}

View file

@ -0,0 +1,31 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js/jsx-runtime'
import { splitProps } from 'solid-js'
import css from './Checkbox.module.css'
export interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
class?: string
label?: JSX.Element
}
export function Checkbox(props: CheckboxProps) {
const [my, rest] = splitProps(props, ['label', 'class'])
const id = `checkbox-${Math.random().toString(36).slice(2)}`
return (
<div class={my.class}>
<input
{...rest}
type="checkbox"
class={css.input}
id={id}
/>
<label class={css.label} for={id} tabIndex={0}>
<div class={css.box} />
{my.label}
</label>
</div>
)
}

View file

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

View file

@ -0,0 +1,14 @@
/** @jsxImportSource solid-js */
import type { JSX } from 'solid-js'
import clsx from 'clsx'
import css from './Emoji.module.css'
export function Emoji(props: JSX.HTMLElementTags['img']) {
return (
<img
{...props}
class={clsx(css.emoji, props.class)}
/>
)
}

View file

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

View file

@ -0,0 +1,27 @@
/** @jsxImportSource solid-js */
import type { Component, JSX } from 'solid-js'
import { splitProps } from 'solid-js'
import clsx from 'clsx'
import css from './Icon.module.css'
export interface IconProps extends JSX.HTMLAttributes<HTMLSpanElement> {
glyph: Component
size?: number
}
export function Icon(props: IconProps) {
const [my, rest] = splitProps(props, ['glyph', 'size', 'class'])
return (
<span
{...rest}
class={clsx(css.wrap, my.class)}
style={{
'font-size': `${my.size ?? 24}px`,
}}
>
{my.glyph({})}
</span>
)
}

View file

@ -0,0 +1,23 @@
Files matching Gravity*.tsx are licensed under:
The MIT License (MIT)
Copyright (c) 2022 YANDEX LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -0,0 +1,13 @@
/** @jsxImportSource solid-js */
export function GravityClock() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16">
<path
fill="currentColor"
fill-rule="evenodd"
d="M13.5 8a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0M8.75 4.5a.75.75 0 0 0-1.5 0V8a.75.75 0 0 0 .3.6l2 1.5a.75.75 0 1 0 .9-1.2l-1.7-1.275z"
clip-rule="evenodd"
/>
</svg>
)
}

View file

@ -0,0 +1,13 @@
/** @jsxImportSource solid-js */
export function GravityMegaphone() {
return (
<svg height="1em" viewBox="0 0 16 16" width="1em" xmlns="http://www.w3.org/2000/svg">
<path
clip-rule="evenodd"
d="M11.113 11.615c.374.814.713.885.887.885c.174 0 .513-.071.887-.885c.377-.816.613-2.077.613-3.615c0-1.538-.236-2.799-.613-3.615c-.374-.814-.713-.885-.887-.885c-.174 0-.513.071-.887.885C10.736 5.2 10.5 6.462 10.5 8c0 1.538.236 2.799.613 3.615M9 8c0 1.469.197 2.815.59 3.857L2.902 9.31a1.402 1.402 0 0 1 0-2.62l6.686-2.548C9.196 5.185 9 6.532 9 8m3 6c2 0 3-2.686 3-6s-1-6-3-6c-.661 0-1.317.12-1.934.356L2.369 5.288a2.902 2.902 0 0 0 0 5.424l.827.315a2.5 2.5 0 1 0 4.67 1.78l2.2.837A5.433 5.433 0 0 0 12 14m-5.537-1.729L4.6 11.563a1 1 0 1 0 1.862.71Z"
fill="currentColor"
fill-rule="evenodd"
/>
</svg>
)
}

View file

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

View file

@ -0,0 +1,16 @@
/** @jsxImportSource solid-js */
import clsx from 'clsx'
import type { JSX } from 'solid-js/jsx-runtime'
import css from './Link.module.css'
export function Link(props: JSX.AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a
{...props}
class={clsx(css.link, props.class)}
>
{props.children}
</a>
)
}

Some files were not shown because too many files have changed in this diff Show more