From 7a764f5fe1af3490795de4071191ead8a5ed5370 Mon Sep 17 00:00:00 2001 From: im0o <0ojixueseno0@gmail.com> Date: Mon, 10 Aug 2026 04:10:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=90=AD=E5=87=BA=E5=89=8D=E7=AB=AF=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=E5=92=8C=E5=85=A5=E7=AB=99=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计基调定为:层次靠底色和一像素描边而不是投影,一屏最多两层容器,间距给足。 tokens.css 顶部写了这三条规则,后面的组件都是它们的推论。 入站列表把两套写法合并成一张表:老式顶层端口字段(mixed-port 这些)和 listeners 数组。 只画 listeners 的话,一台只用 mixed-port 的机器打开会是空板,而那个端口正在跑—— 这等于对用户撒谎。合并逻辑在 shared/inbounds.ts,带 10 个测试。 后端顺带能提供打包后的静态文件了,一个进程就能跑起来。 --- .gitignore | 2 +- scripts/api-smoke.ts | 13 ++- server/routes.ts | 21 +++- shared/inbounds.test.ts | 86 ++++++++++++++++ shared/inbounds.ts | 143 ++++++++++++++++++++++++++ tsconfig.json | 11 +- vite.config.ts | 31 ++++++ web/App.vue | 197 ++++++++++++++++++++++++++++++++++++ web/api.ts | 94 +++++++++++++++++ web/index.html | 13 +++ web/main.ts | 5 + web/styles/tokens.css | 143 ++++++++++++++++++++++++++ web/views/InboundList.vue | 207 ++++++++++++++++++++++++++++++++++++++ web/views/TokenGate.vue | 93 +++++++++++++++++ 14 files changed, 1054 insertions(+), 5 deletions(-) create mode 100644 shared/inbounds.test.ts create mode 100644 shared/inbounds.ts create mode 100644 vite.config.ts create mode 100644 web/App.vue create mode 100644 web/api.ts create mode 100644 web/index.html create mode 100644 web/main.ts create mode 100644 web/styles/tokens.css create mode 100644 web/views/InboundList.vue create mode 100644 web/views/TokenGate.vue diff --git a/.gitignore b/.gitignore index 92bfdee..0d326c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ node_modules/ dist/ +web-dist/ .output/ -.nuxt/ .vite/ *.log diff --git a/scripts/api-smoke.ts b/scripts/api-smoke.ts index a5f7fe4..60506a6 100644 --- a/scripts/api-smoke.ts +++ b/scripts/api-smoke.ts @@ -11,7 +11,7 @@ * 用法:bun run scripts/api-smoke.ts [令牌] * 不给令牌就从配置文件旁边的 .patchbay-token 读。 */ -import { readFile } from 'node:fs/promises' +import { copyFile, readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { readEnv } from '../server/env.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' @@ -22,6 +22,10 @@ const token = process.argv[2] ?? (await readFile(join(dirname(env.configPath), '.patchbay-token'), 'utf8')).trim() +// 每次从模板重置。不重置的话第二次运行会写入和上次完全相同的内容, +// 而「内容没变就不重载」是刻意的行为,会让「内核已重载」这一项看起来失败。 +await copyFile('./dev/config.yaml', env.configPath) + let failures = 0 function check(name: string, actual: unknown, expected: unknown) { if (JSON.stringify(actual) === JSON.stringify(expected)) { @@ -56,7 +60,12 @@ check( ) check('白名单外的透传被拒', (await call('GET', '/api/kernel/configs/geo')).status, 403) check('不存在的接口', (await call('GET', '/api/nope')).status, 404) -check('非 api 路径', (await call('GET', '/index.html')).status, 404) +// 静态文件不要令牌:它只是个空壳,所有数据都要带令牌才拿得到。 +check( + '前端页面不需要令牌', + (await fetch(`${base}/`)).status, + 200, +) // ── 配置读写 ──────────────────────────────────────────── const snapshot = await call('GET', '/api/config') diff --git a/server/routes.ts b/server/routes.ts index 7fbb71c..425037d 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -42,12 +42,31 @@ function applyFailureStatus(stage: string): number { return 500 // write / reload:服务端或内核的问题 } +/** 打包后的前端。开发时前端由 Vite 提供并把 /api 转发过来,走不到这里。 */ +const WEB_DIST = new URL('../web-dist/', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1') + +/** + * 静态文件不需要令牌——它们只是一个空壳,所有数据都要带令牌才拿得到。 + * 单页应用的路由由前端接管,所以找不到的路径一律回 index.html。 + */ +async function serveStatic(path: string): Promise { + const target = Bun.file(WEB_DIST + (path === '/' ? 'index.html' : path.slice(1))) + if (await target.exists()) return new Response(target) + + const shell = Bun.file(`${WEB_DIST}index.html`) + if (await shell.exists()) return new Response(shell) + return new Response('前端还没打包。先运行 bun run build,或者开发时用 bun run dev:web。', { + status: 404, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }) +} + export function createRouter(env: Env, token: string) { return async function handle(req: Request): Promise { const url = new URL(req.url) const path = url.pathname - if (!path.startsWith('/api/')) return json({ error: '没有这个接口' }, 404) + if (!path.startsWith('/api/')) return serveStatic(path) if (!tokenMatches(token, tokenFromRequest(req))) { return json({ error: '令牌不对。它在配置文件旁边的 .patchbay-token 里。' }, 401) diff --git a/shared/inbounds.test.ts b/shared/inbounds.test.ts new file mode 100644 index 0000000..2aa5e20 --- /dev/null +++ b/shared/inbounds.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { openDocument } from '../vendor/config-editor/index.ts' +import { collectInbounds, collectOutboundNames, outboundToProxyField } from './inbounds.ts' + +const fixture = openDocument( + readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'), +).data + +describe('合并两套写法的入站', () => { + it('只有旧写法时也能列出来,不会是空表', () => { + const list = collectInbounds(fixture) + expect(list).toHaveLength(1) + expect(list[0]!.port).toBe('7777') + expect(list[0]!.type).toBe('mixed') + expect(list[0]!.isDefault).toBe(true) + expect(list[0]!.legacyKey).toBe('mixed-port') + }) + + it('端口写 0 表示没启用,不该出现在表里', () => { + const list = collectInbounds({ 'mixed-port': 7777, 'socks-port': 0, port: 0 }) + expect(list.map((i) => i.legacyKey)).toEqual(['mixed-port']) + }) + + it('新旧两套同时存在时都要列出来', () => { + const list = collectInbounds({ + 'mixed-port': 7777, + listeners: [{ name: '容器A', type: 'mixed', port: 7778, proxy: 'AUTO' }], + }) + expect(list.map((i) => i.name)).toEqual(['HTTP 和 SOCKS5 共用', '容器A']) + expect(list[0]!.isDefault).toBe(true) + expect(list[1]!.isDefault).toBe(false) + expect(list[1]!.listenerIndex).toBe(0) + }) + + it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => { + expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' }) + }) +}) + +describe('出站去向的四种形态', () => { + const of = (proxy?: string) => { + // 用扩展而不是直接写 proxy: undefined —— 配置里「没有这个键」和「这个键是空」 + // 是两回事,测试要还原真实的文件形状。 + const listener = { name: 'x', type: 'mixed', port: 1, ...(proxy === undefined ? {} : { proxy }) } + return collectInbounds({ listeners: [listener] })[0]!.outbound + } + + it('没写 proxy 就是跟着规则走', () => { + expect(of()).toEqual({ kind: 'rules' }) + expect(of('')).toEqual({ kind: 'rules' }) + }) + + it('DIRECT 和 GLOBAL 是内核自带的,单独成一类', () => { + expect(of('DIRECT')).toEqual({ kind: 'direct' }) + expect(of('GLOBAL')).toEqual({ kind: 'global' }) + }) + + it('其余都是指名道姓的出站', () => { + expect(of('AUTO')).toEqual({ kind: 'named', name: 'AUTO' }) + }) + + it('写回时能还原成原来的字段值', () => { + expect(outboundToProxyField({ kind: 'rules' })).toBeUndefined() + expect(outboundToProxyField({ kind: 'direct' })).toBe('DIRECT') + expect(outboundToProxyField({ kind: 'global' })).toBe('GLOBAL') + expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO') + }) +}) + +describe('能被入站引用的出站名字', () => { + it('列出内置出站、策略组和自有线路', () => { + const names = collectOutboundNames(fixture) + expect(names.groups).toEqual(['PROXY', 'AUTO']) + expect(names.builtins).toContain('DIRECT') + expect(names.builtins).toContain('GLOBAL') + }) + + it('订阅带来的线路不在其中——它们不在内核的名字表上', () => { + const names = collectOutboundNames(fixture) + // 样本里唯一的订阅是 subs,它带来的线路无法被按名字引用 + expect(names.ownProxies).toEqual([]) + expect([...names.groups, ...names.ownProxies, ...names.builtins]).not.toContain('subs') + }) +}) diff --git a/shared/inbounds.ts b/shared/inbounds.ts new file mode 100644 index 0000000..0821907 --- /dev/null +++ b/shared/inbounds.ts @@ -0,0 +1,143 @@ +/** + * 把配置文件里两套写法的入站合并成界面上的一张表。 + * + * mihomo 有新旧两套声明入站的方式: + * 旧:顶层的 mixed-port / port / socks-port / redir-port / tproxy-port, + * 每种只能有一个,而且没法给它单独指定出站 + * 新:listeners 数组,数量不限,每个都能有自己的出站和附加规则 + * + * 界面上必须两边都画。只画 listeners 的话,一台只用 mixed-port 的机器打开是空板—— + * 而实际上那个端口正在跑,这等于对用户撒谎。 + */ +import type { ConfigObject } from '../vendor/config-editor/index.ts' + +/** 内核自带、不需要配置就存在的出站。 */ +export const BUILTIN_OUTBOUNDS = [ + 'DIRECT', + 'REJECT', + 'REJECT-DROP', + 'PASS', + 'PASS-RULE', + 'COMPATIBLE', + 'GLOBAL', +] as const + +/** 旧写法的顶层端口字段,以及它们各自等价于哪种入站类型。 */ +const LEGACY_PORTS: Array<{ key: string; type: string; label: string }> = [ + { key: 'mixed-port', type: 'mixed', label: 'HTTP 和 SOCKS5 共用' }, + { key: 'port', type: 'http', label: 'HTTP' }, + { key: 'socks-port', type: 'socks', label: 'SOCKS5' }, + { key: 'redir-port', type: 'redir', label: '透明代理(REDIRECT)' }, + { key: 'tproxy-port', type: 'tproxy', label: '透明代理(TPROXY)' }, +] + +/** 一路入站的出站去向。四选一,对应界面上那个下拉框。 */ +export type OutboundMode = + | { kind: 'rules' } // 没写 proxy,流量进分流规则 + | { kind: 'direct' } // proxy: DIRECT + | { kind: 'global' } // proxy: GLOBAL + | { kind: 'named'; name: string } // proxy: 某个策略组或自有线路 + +export interface Inbound { + /** 界面上用来定位这一行 */ + id: string + name: string + type: string + /** 端口。旧写法一定是单个数字,新写法可以是 "200,302" 或 "401-429" 这类写法 */ + port: string + listen: string | null + outbound: OutboundMode + /** 挂在这路入站上的附加规则组名,没有就是 null */ + ruleSet: string | null + /** 旧写法声明的入站。界面上不可删除,也不能单独指定出站 */ + isDefault: boolean + /** 旧写法对应的顶层字段名,用于写回。新写法为 null */ + legacyKey: string | null + /** 在 listeners 数组里的下标,用于写回。旧写法为 null */ + listenerIndex: number | null + /** 给人看的一句话说明,主要是给默认入站解释它为什么不能改出站 */ + note: string | null +} + +function outboundOf(proxy: unknown): OutboundMode { + if (typeof proxy !== 'string' || proxy === '') return { kind: 'rules' } + if (proxy === 'DIRECT') return { kind: 'direct' } + if (proxy === 'GLOBAL') return { kind: 'global' } + return { kind: 'named', name: proxy } +} + +/** 把出站去向写回成配置里 proxy 字段该有的值。返回 undefined 表示不写这个字段。 */ +export function outboundToProxyField(mode: OutboundMode): string | undefined { + if (mode.kind === 'rules') return undefined + if (mode.kind === 'direct') return 'DIRECT' + if (mode.kind === 'global') return 'GLOBAL' + return mode.name +} + +export function collectInbounds(data: ConfigObject): Inbound[] { + const result: Inbound[] = [] + + for (const { key, type, label } of LEGACY_PORTS) { + const raw = data[key] + const port = typeof raw === 'number' ? raw : Number(raw) + // 0 和缺省都表示没启用这个端口 + if (!Number.isFinite(port) || port <= 0) continue + result.push({ + id: `legacy:${key}`, + name: label, + type, + port: String(port), + listen: null, + outbound: { kind: 'rules' }, + ruleSet: null, + isDefault: true, + legacyKey: key, + listenerIndex: null, + note: `由配置文件顶层的 ${key} 声明。这种写法没法单独指定出站,流量一律走分流规则。想让这个端口固定走某个出站,要把它改成命名入站。`, + }) + } + + const listeners = Array.isArray(data.listeners) ? data.listeners : [] + listeners.forEach((entry, index) => { + if (typeof entry !== 'object' || entry === null) return + const item = entry as Record + result.push({ + id: `listener:${String(index)}`, + name: typeof item.name === 'string' ? item.name : `第 ${String(index + 1)} 个入站`, + type: typeof item.type === 'string' ? item.type : '', + port: item.port === undefined || item.port === null ? '' : String(item.port), + listen: typeof item.listen === 'string' ? item.listen : null, + outbound: outboundOf(item.proxy), + ruleSet: typeof item.rule === 'string' && item.rule ? item.rule : null, + isDefault: false, + legacyKey: null, + listenerIndex: index, + note: null, + }) + }) + + return result +} + +/** + * 所有可以被入站的出站字段引用的名字。 + * + * 订阅带来的线路**不在这里**——内核按名字找出站时查的是一张由内置出站、顶层 proxies + * 和 proxy-groups 构成的表,订阅线路住在订阅对象里,不在表上。想让入站固定走订阅里的 + * 某条线路,得先建一个只装它的策略组。 + */ +export function collectOutboundNames(data: ConfigObject): { + builtins: string[] + groups: string[] + ownProxies: string[] +} { + const groups = (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []) + .map((g) => (typeof g === 'object' && g !== null ? (g as Record).name : null)) + .filter((n): n is string => typeof n === 'string') + + const ownProxies = (Array.isArray(data.proxies) ? data.proxies : []) + .map((p) => (typeof p === 'object' && p !== null ? (p as Record).name : null)) + .filter((n): n is string => typeof n === 'string') + + return { builtins: [...BUILTIN_OUTBOUNDS], groups, ownProxies } +} diff --git a/tsconfig.json b/tsconfig.json index 5aa14a6..9803927 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,8 +22,17 @@ "baseUrl": ".", "paths": { "~/*": ["./web/*"], + "@shared/*": ["./shared/*"], "@config-editor": ["./vendor/config-editor/index.ts"] } }, - "include": ["server/**/*.ts", "web/**/*.ts", "web/**/*.vue", "vendor/**/*.ts", "*.ts"] + "include": [ + "server/**/*.ts", + "web/**/*.ts", + "web/**/*.vue", + "shared/**/*.ts", + "vendor/**/*.ts", + "scripts/**/*.ts", + "*.ts" + ] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..3c5f07b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +/** + * 开发时前端跑在 5173,后端跑在 7580,接口请求转发过去。 + * 打包后前端是纯静态文件,由后端一起提供,不存在跨域问题。 + */ +export default defineConfig({ + root: 'web', + plugins: [vue()], + resolve: { + alias: { + '~': fileURLToPath(new URL('./web', import.meta.url)), + '@shared': fileURLToPath(new URL('./shared', import.meta.url)), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://127.0.0.1:7580', + changeOrigin: false, + }, + }, + }, + build: { + outDir: '../web-dist', + emptyOutDir: true, + }, +}) diff --git a/web/App.vue b/web/App.vue new file mode 100644 index 0000000..9dc29ca --- /dev/null +++ b/web/App.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/web/api.ts b/web/api.ts new file mode 100644 index 0000000..7d56e6f --- /dev/null +++ b/web/api.ts @@ -0,0 +1,94 @@ +/** + * 后端接口的客户端。 + * + * 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1 + * (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。 + */ +import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' + +const TOKEN_KEY = 'patchbay.token' + +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? '' +} + +export function setToken(value: string): void { + localStorage.setItem(TOKEN_KEY, value.trim()) +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +/** 接口报错时抛这个,界面据此决定是提示重填令牌还是显示错误内容。 */ +export class ApiError extends Error { + constructor( + readonly status: number, + readonly payload: Record, + ) { + super(typeof payload.message === 'string' ? payload.message : `请求失败(${String(status)})`) + this.name = 'ApiError' + } + + get unauthorized(): boolean { + return this.status === 401 + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(path, { + method, + headers: { + Authorization: `Bearer ${getToken()}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await res.text() + const payload = text ? (JSON.parse(text) as Record) : {} + if (!res.ok) throw new ApiError(res.status, payload) + return payload as T +} + +export interface ConfigSnapshot { + data: ConfigObject + yaml: string + revision: string + diagnostics: ConfigDiagnostic[] +} + +export interface ApplySuccess { + ok: true + snapshot: ConfigSnapshot + reloaded: boolean + reloadMessage: string +} + +export interface ApplyFailure { + ok: false + stage: 'stale' | 'self-check' | 'kernel-check' | 'write' | 'reload' + message: string + hint: string + restored: boolean +} + +export const api = { + readConfig: () => request('GET', '/api/config'), + + applyConfig: (data: ConfigObject, baseRevision: string) => + request('PUT', '/api/config', { data, baseRevision }), + + listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'), + + restoreBackup: (name: string) => + request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), + + kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), + + /** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */ + providers: () => + request<{ providers: Record }>( + 'GET', + '/api/kernel/providers/proxies', + ), +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f364be5 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Patchbay + + +
+ + + diff --git a/web/main.ts b/web/main.ts new file mode 100644 index 0000000..31a7727 --- /dev/null +++ b/web/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles/tokens.css' + +createApp(App).mount('#app') diff --git a/web/styles/tokens.css b/web/styles/tokens.css new file mode 100644 index 0000000..bf2b184 --- /dev/null +++ b/web/styles/tokens.css @@ -0,0 +1,143 @@ +/* + * 设计基调。 + * + * 三条规则,其余都是它们的推论: + * 1. 层次靠底色深浅和一像素描边,不靠投影 + * 2. 不要卡片套卡片。一屏最多两层容器,再深就换成分割线 + * 3. 信息密度偏低。行高、间距给足,宁可多滚动也不要挤 + * + * 颜色只留一个强调色,用在「当前选中」和「主要操作」上。状态色(成功/警告/危险) + * 只在真的表示状态时用,不拿来装饰。 + */ + +:root { + /* 中性色阶。带一点点冷调,避免纯灰的死板 */ + --c-bg: #fbfbfc; + --c-surface: #ffffff; + --c-surface-sunken: #f4f5f7; + --c-border: #e4e6ea; + --c-border-strong: #d0d3d9; + + --c-text: #1c1e21; + --c-text-soft: #5c6169; + --c-text-faint: #8b9098; + + /* 强调色:偏青的蓝,冷静,不抢戏 */ + --c-accent: #2b7a78; + --c-accent-soft: #e6f2f1; + --c-accent-text: #1f5c5a; + + --c-danger: #b3261e; + --c-danger-soft: #fdedec; + --c-warn: #8a6100; + --c-warn-soft: #fdf5e3; + --c-ok: #1f6b3a; + --c-ok-soft: #e9f5ed; + + /* 间距。低密度的来源,别调小 */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 48px; + + --r-sm: 6px; + --r-md: 10px; + --r-lg: 14px; + + --f-sans: + system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', + sans-serif; + --f-mono: ui-monospace, 'Cascadia Code', 'JetBrains Mono', Consolas, monospace; + + --t-xs: 12px; + --t-sm: 13px; + --t-base: 14px; + --t-lg: 16px; + --t-xl: 20px; + --t-2xl: 26px; + + --line: 1.7; +} + +@media (prefers-color-scheme: dark) { + :root { + --c-bg: #16181c; + --c-surface: #1d2025; + --c-surface-sunken: #121417; + --c-border: #2b2f36; + --c-border-strong: #3a3f48; + + --c-text: #e6e8eb; + --c-text-soft: #9ba1a9; + --c-text-faint: #6c727a; + + --c-accent: #4db6ac; + --c-accent-soft: #1b3330; + --c-accent-text: #7fd4cb; + + --c-danger: #f2b8b5; + --c-danger-soft: #3a1f1e; + --c-warn: #e8c37a; + --c-warn-soft: #33290f; + --c-ok: #86d6a0; + --c-ok-soft: #16301f; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + background: var(--c-bg); + color: var(--c-text); + font-family: var(--f-sans); + font-size: var(--t-base); + line-height: var(--line); + -webkit-font-smoothing: antialiased; +} + +h1, +h2, +h3, +p, +figure { + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +code, +kbd, +.mono { + font-family: var(--f-mono); + font-size: 0.92em; +} + +:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} + +::selection { + background: var(--c-accent-soft); + color: var(--c-accent-text); +} diff --git a/web/views/InboundList.vue b/web/views/InboundList.vue new file mode 100644 index 0000000..a25a2bb --- /dev/null +++ b/web/views/InboundList.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/web/views/TokenGate.vue b/web/views/TokenGate.vue new file mode 100644 index 0000000..844d78c --- /dev/null +++ b/web/views/TokenGate.vue @@ -0,0 +1,93 @@ + + + + +