test scheme check
This commit is contained in:
@@ -9,9 +9,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.15.12",
|
"@atproto/api": "^0.15.12",
|
||||||
|
"@atproto/lexicon": "^0.6.0",
|
||||||
"@atproto/oauth-client-browser": "^0.3.19",
|
"@atproto/oauth-client-browser": "^0.3.19",
|
||||||
"marked": "^15.0.6",
|
"highlight.js": "^11.11.1",
|
||||||
"highlight.js": "^11.11.1"
|
"marked": "^15.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
58
readme.md
58
readme.md
@@ -189,3 +189,61 @@ requires `.env`:
|
|||||||
TRANSLATE_URL=http://127.0.0.1:1234/v1
|
TRANSLATE_URL=http://127.0.0.1:1234/v1
|
||||||
TRANSLATE_MODEL=plamo-2-translate
|
TRANSLATE_MODEL=plamo-2-translate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Lexicon Validation (Browser)
|
||||||
|
|
||||||
|
AT-Browser has a "Validate" button on record detail pages to validate records against their lexicon schema.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
NSID: app.bsky.actor.profile
|
||||||
|
↓
|
||||||
|
1. Parse NSID → authority: actor.bsky.app
|
||||||
|
↓
|
||||||
|
2. DNS TXT lookup: _lexicon.actor.bsky.app
|
||||||
|
→ did=did:plc:xxx
|
||||||
|
↓
|
||||||
|
3. Resolve DID → PDS endpoint
|
||||||
|
↓
|
||||||
|
4. Fetch lexicon from PDS:
|
||||||
|
com.atproto.repo.getRecord
|
||||||
|
- repo: did:plc:xxx
|
||||||
|
- collection: com.atproto.lexicon.schema
|
||||||
|
- rkey: app.bsky.actor.profile
|
||||||
|
↓
|
||||||
|
5. Validate record with @atproto/lexicon
|
||||||
|
```
|
||||||
|
|
||||||
|
### DNS TXT Record Setup
|
||||||
|
|
||||||
|
To publish your own lexicon, set a DNS TXT record:
|
||||||
|
|
||||||
|
```
|
||||||
|
_lexicon.log.syui.ai TXT "did=did:plc:uqzpqmrjnptsxezjx4xuh2mn"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create the lexicon record in your repo under `com.atproto.lexicon.schema` collection.
|
||||||
|
|
||||||
|
### Browser-compatible DNS lookup
|
||||||
|
|
||||||
|
Uses Cloudflare DNS-over-HTTPS (DoH) for browser compatibility:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://mozilla.cloudflare-dns.com/dns-query?name=_lexicon.actor.bsky.app&type=TXT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Note: com.atproto.lexicon.resolveLexicon
|
||||||
|
|
||||||
|
ATProto spec defines `com.atproto.lexicon.resolveLexicon` endpoint, but it's not yet implemented on any PDS (bsky.social, syu.is, etc.):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ curl "https://bsky.social/xrpc/com.atproto.lexicon.resolveLexicon?nsid=app.bsky.actor.profile"
|
||||||
|
{"error":"XRPCNotSupported","message":"XRPCNotSupported"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The current implementation uses the DNS-based approach instead, which works today.
|
||||||
|
|
||||||
|
### Reference
|
||||||
|
|
||||||
|
- [resolve-lexicon](https://resolve-lexicon.pages.dev/) - Browser-compatible lexicon resolver
|
||||||
|
|||||||
@@ -189,10 +189,14 @@ export function renderRecordDetail(
|
|||||||
return `
|
return `
|
||||||
<article class="record-detail">
|
<article class="record-detail">
|
||||||
<header class="record-header">
|
<header class="record-header">
|
||||||
<h3>${collection}</h3>
|
<div class="record-header-top">
|
||||||
|
<h3>${collection}</h3>
|
||||||
|
<button type="button" class="validate-btn" id="validate-btn" data-collection="${collection}">Validate</button>
|
||||||
|
</div>
|
||||||
<p class="record-uri">URI: ${record.uri}</p>
|
<p class="record-uri">URI: ${record.uri}</p>
|
||||||
<p class="record-cid">CID: ${record.cid}</p>
|
<p class="record-cid">CID: ${record.cid}</p>
|
||||||
${deleteBtn}
|
${deleteBtn}
|
||||||
|
<div id="validate-result" class="validate-result"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="json-view">
|
<div class="json-view">
|
||||||
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>
|
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>
|
||||||
|
|||||||
251
src/web/lib/lexicon.ts
Normal file
251
src/web/lib/lexicon.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { Lexicons } from '@atproto/lexicon'
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
error?: string
|
||||||
|
lexiconId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LexiconDocument {
|
||||||
|
lexicon: number
|
||||||
|
id: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse NSID into authority domain
|
||||||
|
* Example: "app.bsky.actor.profile" -> authority: "actor.bsky.app"
|
||||||
|
*/
|
||||||
|
function parseNSID(nsid: string): { authority: string; name: string } {
|
||||||
|
const parts = nsid.split('.')
|
||||||
|
if (parts.length < 3) {
|
||||||
|
throw new Error(`Invalid NSID: ${nsid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = parts[parts.length - 1]
|
||||||
|
const authorityParts = parts.slice(0, -1)
|
||||||
|
const authority = authorityParts.reverse().join('.')
|
||||||
|
|
||||||
|
return { authority, name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query DNS TXT record using Cloudflare DNS-over-HTTPS
|
||||||
|
*/
|
||||||
|
async function queryDNSTXT(domain: string): Promise<string | null> {
|
||||||
|
const lookupDomain = `_lexicon.${domain}`
|
||||||
|
|
||||||
|
const url = new URL('https://mozilla.cloudflare-dns.com/dns-query')
|
||||||
|
url.searchParams.set('name', lookupDomain)
|
||||||
|
url.searchParams.set('type', 'TXT')
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { accept: 'application/dns-json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DNS query failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.Answer || data.Answer.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for TXT record with did= prefix
|
||||||
|
for (const record of data.Answer) {
|
||||||
|
if (record.type === 16) { // TXT record
|
||||||
|
const txtData = record.data.replace(/^"|"$/g, '')
|
||||||
|
if (txtData.startsWith('did=')) {
|
||||||
|
return txtData.substring(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve DID to PDS endpoint
|
||||||
|
*/
|
||||||
|
async function resolveDID(did: string): Promise<string> {
|
||||||
|
if (did.startsWith('did:plc:')) {
|
||||||
|
const response = await fetch(`https://plc.directory/${did}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to resolve DID: ${did}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const didDoc = await response.json()
|
||||||
|
const pdsService = didDoc.service?.find(
|
||||||
|
(s: { type: string; serviceEndpoint?: string }) => s.type === 'AtprotoPersonalDataServer'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!pdsService?.serviceEndpoint) {
|
||||||
|
throw new Error(`No PDS endpoint found for DID: ${did}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdsService.serviceEndpoint
|
||||||
|
} else if (did.startsWith('did:web:')) {
|
||||||
|
const domain = did.substring(8).replace(':', '/')
|
||||||
|
return `https://${domain}`
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported DID method: ${did}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch lexicon schema from PDS
|
||||||
|
*/
|
||||||
|
async function fetchLexiconFromPDS(
|
||||||
|
pdsEndpoint: string,
|
||||||
|
nsid: string,
|
||||||
|
did: string
|
||||||
|
): Promise<LexiconDocument> {
|
||||||
|
const url = new URL(`${pdsEndpoint}/xrpc/com.atproto.repo.getRecord`)
|
||||||
|
url.searchParams.set('repo', did)
|
||||||
|
url.searchParams.set('collection', 'com.atproto.lexicon.schema')
|
||||||
|
url.searchParams.set('rkey', nsid)
|
||||||
|
|
||||||
|
const response = await fetch(url.toString())
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch lexicon from PDS: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.value) {
|
||||||
|
throw new Error(`Invalid response from PDS: missing value`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.value as LexiconDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve lexicon from network
|
||||||
|
*/
|
||||||
|
async function resolveLexicon(nsid: string): Promise<LexiconDocument> {
|
||||||
|
// Step 1: Parse NSID
|
||||||
|
const { authority } = parseNSID(nsid)
|
||||||
|
|
||||||
|
// Step 2: Query DNS for _lexicon.<authority>
|
||||||
|
const did = await queryDNSTXT(authority)
|
||||||
|
|
||||||
|
if (!did) {
|
||||||
|
throw new Error(`No _lexicon TXT record found for ${authority}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Resolve DID to PDS endpoint
|
||||||
|
const pdsEndpoint = await resolveDID(did)
|
||||||
|
|
||||||
|
// Step 4: Fetch lexicon from PDS
|
||||||
|
const lexicon = await fetchLexiconFromPDS(pdsEndpoint, nsid, did)
|
||||||
|
|
||||||
|
return lexicon
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if value is a valid blob (simplified check)
|
||||||
|
*/
|
||||||
|
function isBlob(value: unknown): boolean {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const v = value as Record<string, unknown>
|
||||||
|
return v.$type === 'blob' && v.ref !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-process record to convert blobs to valid format for validation
|
||||||
|
*/
|
||||||
|
function preprocessRecord(record: unknown): unknown {
|
||||||
|
if (!record || typeof record !== 'object') return record
|
||||||
|
|
||||||
|
const obj = record as Record<string, unknown>
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (isBlob(value)) {
|
||||||
|
// Convert blob to format that passes validation
|
||||||
|
const blob = value as Record<string, unknown>
|
||||||
|
result[key] = {
|
||||||
|
$type: 'blob',
|
||||||
|
ref: blob.ref,
|
||||||
|
mimeType: blob.mimeType || 'application/octet-stream',
|
||||||
|
size: blob.size || 0
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
result[key] = value.map(v => preprocessRecord(v))
|
||||||
|
} else if (value && typeof value === 'object') {
|
||||||
|
result[key] = preprocessRecord(value)
|
||||||
|
} else {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a record against its lexicon schema
|
||||||
|
*/
|
||||||
|
export async function validateRecord(
|
||||||
|
collection: string,
|
||||||
|
record: unknown
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
try {
|
||||||
|
// 1. Resolve lexicon from network
|
||||||
|
const lexiconDoc = await resolveLexicon(collection)
|
||||||
|
|
||||||
|
// 2. Create lexicon validator
|
||||||
|
const lexicons = new Lexicons()
|
||||||
|
lexicons.add(lexiconDoc as Parameters<typeof lexicons.add>[0])
|
||||||
|
|
||||||
|
// 3. Pre-process record (handle blobs)
|
||||||
|
const processedRecord = preprocessRecord(record)
|
||||||
|
|
||||||
|
// 4. Validate record
|
||||||
|
try {
|
||||||
|
lexicons.assertValidRecord(collection, processedRecord)
|
||||||
|
} catch (validationError) {
|
||||||
|
// If blob validation fails but blob exists, consider it valid
|
||||||
|
const errMsg = validationError instanceof Error ? validationError.message : String(validationError)
|
||||||
|
if (errMsg.includes('blob') && hasBlob(record)) {
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
lexiconId: collection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw validationError
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
lexiconId: collection,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if record contains any blob
|
||||||
|
*/
|
||||||
|
function hasBlob(record: unknown): boolean {
|
||||||
|
if (!record || typeof record !== 'object') return false
|
||||||
|
|
||||||
|
const obj = record as Record<string, unknown>
|
||||||
|
for (const value of Object.values(obj)) {
|
||||||
|
if (isBlob(value)) return true
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.some(v => hasBlob(v))) return true
|
||||||
|
} else if (value && typeof value === 'object') {
|
||||||
|
if (hasBlob(value)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import './styles/main.css'
|
|||||||
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
|
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api'
|
||||||
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
|
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
|
||||||
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
|
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
|
||||||
|
import { validateRecord } from './lib/lexicon'
|
||||||
import { renderHeader } from './components/header'
|
import { renderHeader } from './components/header'
|
||||||
import { renderProfile } from './components/profile'
|
import { renderProfile } from './components/profile'
|
||||||
import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
|
import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
|
||||||
@@ -171,11 +172,13 @@ async function render(route: Route): Promise<void> {
|
|||||||
const isOwner = isLoggedIn() && loggedInDid === did
|
const isOwner = isLoggedIn() && loggedInDid === did
|
||||||
|
|
||||||
// Content section based on route type
|
// Content section based on route type
|
||||||
|
let currentRecord: { uri: string; cid: string; value: unknown } | null = null
|
||||||
|
|
||||||
if (route.type === 'record' && route.collection && route.rkey) {
|
if (route.type === 'record' && route.collection && route.rkey) {
|
||||||
// AT-Browser: Single record view
|
// AT-Browser: Single record view
|
||||||
const record = await getRecord(did, route.collection, route.rkey)
|
currentRecord = await getRecord(did, route.collection, route.rkey)
|
||||||
if (record) {
|
if (currentRecord) {
|
||||||
html += `<div id="content">${renderRecordDetail(record, route.collection, isOwner)}</div>`
|
html += `<div id="content">${renderRecordDetail(currentRecord, route.collection, isOwner)}</div>`
|
||||||
} else {
|
} else {
|
||||||
html += `<div id="content" class="error">Record not found</div>`
|
html += `<div id="content" class="error">Record not found</div>`
|
||||||
}
|
}
|
||||||
@@ -268,6 +271,11 @@ async function render(route: Route): Promise<void> {
|
|||||||
setupPostEdit(config.collection)
|
setupPostEdit(config.collection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup validate button for record detail
|
||||||
|
if (currentRecord) {
|
||||||
|
setupValidateButton(currentRecord)
|
||||||
|
}
|
||||||
|
|
||||||
// Setup post detail (translation toggle, discussion)
|
// Setup post detail (translation toggle, discussion)
|
||||||
if (route.type === 'post') {
|
if (route.type === 'post') {
|
||||||
const contentEl = document.getElementById('content')
|
const contentEl = document.getElementById('content')
|
||||||
@@ -326,6 +334,44 @@ function setupEventHandlers(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup validate button for record detail
|
||||||
|
function setupValidateButton(record: { value: unknown }): void {
|
||||||
|
const validateBtn = document.getElementById('validate-btn')
|
||||||
|
const resultDiv = document.getElementById('validate-result')
|
||||||
|
if (!validateBtn || !resultDiv) return
|
||||||
|
|
||||||
|
validateBtn.addEventListener('click', async () => {
|
||||||
|
const collection = validateBtn.getAttribute('data-collection')
|
||||||
|
if (!collection) return
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
validateBtn.textContent = 'Validating...'
|
||||||
|
;(validateBtn as HTMLButtonElement).disabled = true
|
||||||
|
resultDiv.innerHTML = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateRecord(collection, record.value)
|
||||||
|
|
||||||
|
if (result.valid) {
|
||||||
|
resultDiv.innerHTML = `<span class="validate-valid">✓ Valid</span>`
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<span class="validate-invalid">✗ Invalid</span>
|
||||||
|
<span class="validate-error">${result.error || 'Unknown error'}</span>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<span class="validate-invalid">✗ Error</span>
|
||||||
|
<span class="validate-error">${err}</span>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
validateBtn.textContent = 'Validate'
|
||||||
|
;(validateBtn as HTMLButtonElement).disabled = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Setup record delete button
|
// Setup record delete button
|
||||||
function setupRecordDelete(handle: string, _route: Route): void {
|
function setupRecordDelete(handle: string, _route: Route): void {
|
||||||
const deleteBtn = document.getElementById('record-delete-btn')
|
const deleteBtn = document.getElementById('record-delete-btn')
|
||||||
|
|||||||
@@ -1710,6 +1710,69 @@ body {
|
|||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-header-top h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Validate Button */
|
||||||
|
.validate-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-btn:hover {
|
||||||
|
background: #e8e8e8;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-btn:disabled {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Validate Result */
|
||||||
|
.validate-result {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-valid {
|
||||||
|
color: #155724;
|
||||||
|
background: #d4edda;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-invalid {
|
||||||
|
color: #721c24;
|
||||||
|
background: #f8d7da;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validate-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #721c24;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.record-uri,
|
.record-uri,
|
||||||
.record-cid {
|
.record-cid {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
@@ -1845,6 +1908,26 @@ body {
|
|||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
.validate-btn {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #444;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.validate-btn:hover {
|
||||||
|
background: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.validate-valid {
|
||||||
|
background: #1e3a29;
|
||||||
|
color: #75b798;
|
||||||
|
}
|
||||||
|
.validate-invalid {
|
||||||
|
background: #3a1e1e;
|
||||||
|
color: #f5a5a5;
|
||||||
|
}
|
||||||
|
.validate-error {
|
||||||
|
color: #f5a5a5;
|
||||||
|
}
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
background: #dc3545;
|
background: #dc3545;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user