Browse Source

bug-fixes

main
Silberengel 4 weeks ago
parent
commit
0ffbb2870c
  1. 66
      src/lib/components/NavBar.svelte
  2. 5
      src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts
  3. 8
      src/routes/api/user/level/+server.ts
  4. 239
      src/routes/repos/[npub]/[repo]/+page.svelte
  5. 474
      src/routes/verify/+page.svelte

66
src/lib/components/NavBar.svelte

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
import { onMount } from 'svelte';
import { userStore } from '../stores/user-store.js';
import { clearActivity, updateActivity } from '../services/activity-tracker.js';
import { determineUserLevel, decodePubkey } from '../services/nostr/user-level-service.js';
let userPubkey = $state<string | null>(null);
let mobileMenuOpen = $state(false);
@ -61,14 +62,72 @@ @@ -61,14 +62,72 @@
}
async function login() {
if (!isNIP07Available()) {
alert('Nostr extension not found. Please install a Nostr extension like nos2x or Alby to login.');
return;
}
try {
if (!isNIP07Available()) {
alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.');
// Get public key directly from NIP-07
let pubkey: string;
try {
pubkey = await getPublicKeyWithNIP07();
if (!pubkey) {
throw new Error('No public key returned from extension');
}
} catch (err) {
console.error('Failed to get public key from NIP-07:', err);
alert('Failed to connect to Nostr extension. Please make sure your extension is unlocked and try again.');
return;
}
userPubkey = await getPublicKeyWithNIP07();
// Convert npub to hex for API calls
let pubkeyHex: string;
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
// Already hex format
pubkeyHex = pubkey.toLowerCase();
userPubkey = pubkey;
} else {
// Try to decode as npub
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkeyHex = decoded.data as string;
userPubkey = pubkey; // Keep original npub format
} else {
throw new Error('Invalid pubkey format');
}
} catch (decodeErr) {
console.error('Failed to decode pubkey:', decodeErr);
alert('Invalid public key format. Please try again.');
return;
}
}
// Determine user level (checks relay write access)
const levelResult = await determineUserLevel(userPubkey, pubkeyHex);
// Update user store
userStore.setUser(
levelResult.userPubkey,
levelResult.userPubkeyHex,
levelResult.level,
levelResult.error || null
);
// Update activity tracking on successful login
updateActivity();
// Show success message
if (levelResult.level === 'unlimited') {
console.log('Unlimited access granted!');
} else if (levelResult.level === 'rate_limited') {
console.log('Logged in with rate-limited access.');
}
} catch (err) {
console.error('Login error:', err);
const errorMessage = err instanceof Error ? err.message : String(err);
alert(`Failed to login: ${errorMessage}. Please make sure your Nostr extension is unlocked and try again.`);
}
}
@ -99,7 +158,6 @@ @@ -99,7 +158,6 @@
<a href="/repos" class:active={isActive('/repos')} onclick={closeMobileMenu}>Repositories</a>
<a href="/search" class:active={isActive('/search')} onclick={closeMobileMenu}>Search</a>
<a href="/signup" class:active={isActive('/signup')} onclick={closeMobileMenu}>Register</a>
<a href="/verify" class:active={isActive('/verify')} onclick={closeMobileMenu}>Verify Repo</a>
<a href="/docs" class:active={isActive('/docs')} onclick={closeMobileMenu}>Docs</a>
</div>
</nav>

5
src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts

@ -14,13 +14,14 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -14,13 +14,14 @@ export const GET: RequestHandler = createRepoGetHandler(
const { maintainers, owner } = await maintainerService.getMaintainers(context.repoOwnerPubkey, context.repo);
// If userPubkey provided, check if they're a maintainer
// SECURITY: Do NOT leak userPubkey in response - only return boolean status
if (context.userPubkeyHex) {
const isMaintainer = maintainers.includes(context.userPubkeyHex);
return json({
maintainers,
owner,
isMaintainer,
userPubkey: context.userPubkeyHex
isMaintainer
// SECURITY: Removed userPubkey leak - client already knows their own pubkey
});
}

8
src/routes/api/user/level/+server.ts

@ -93,9 +93,9 @@ export const POST: RequestHandler = async (event) => { @@ -93,9 +93,9 @@ export const POST: RequestHandler = async (event) => {
logger.info({ userPubkeyHex, level: cached.level }, '[API] Using cached user level (proof event signature validated)');
return json({
level: cached.level,
userPubkeyHex,
verified: true,
cached: true
// SECURITY: Removed userPubkeyHex - client already knows their own pubkey
});
}
@ -120,10 +120,10 @@ export const POST: RequestHandler = async (event) => { @@ -120,10 +120,10 @@ export const POST: RequestHandler = async (event) => {
);
return json({
level: cachedOnRelayDown.level,
userPubkeyHex,
verified: true,
cached: true,
relayDown: true
// SECURITY: Removed userPubkeyHex - client already knows their own pubkey
});
}
@ -153,8 +153,8 @@ export const POST: RequestHandler = async (event) => { @@ -153,8 +153,8 @@ export const POST: RequestHandler = async (event) => {
return json({
level: 'unlimited',
userPubkeyHex,
verified: true
// SECURITY: Removed userPubkeyHex - client already knows their own pubkey
});
} else {
// User is logged in but no write access - rate limited
@ -171,9 +171,9 @@ export const POST: RequestHandler = async (event) => { @@ -171,9 +171,9 @@ export const POST: RequestHandler = async (event) => {
return json({
level: 'rate_limited',
userPubkeyHex,
verified: true,
error: verification.error
// SECURITY: Removed userPubkeyHex - client already knows their own pubkey
});
}
} catch (err) {

239
src/routes/repos/[npub]/[repo]/+page.svelte

@ -14,6 +14,8 @@ @@ -14,6 +14,8 @@
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { userStore } from '$lib/stores/user-store.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import type { NostrEvent } from '$lib/types/nostr.js';
// Get page data for OpenGraph metadata - use $derived to make it reactive
const pageData = $derived($page.data as {
@ -123,6 +125,8 @@ @@ -123,6 +125,8 @@
// Verification status
let verificationStatus = $state<{ verified: boolean; error?: string; message?: string } | null>(null);
let showVerificationDialog = $state(false);
let verificationFileContent = $state<string | null>(null);
let loadingVerification = $state(false);
// Issues
@ -988,6 +992,63 @@ @@ -988,6 +992,63 @@
}
}
async function generateVerificationFileForRepo() {
if (!pageData.repoOwnerPubkey || !userPubkeyHex) {
error = 'Unable to generate verification file: missing repository or user information';
return;
}
try {
// Fetch the repository announcement event
const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]);
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [pageData.repoOwnerPubkey],
'#d': [repo],
limit: 1
}
]);
if (events.length === 0) {
error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.';
return;
}
const announcement = events[0] as NostrEvent;
verificationFileContent = generateVerificationFile(announcement, pageData.repoOwnerPubkey);
showVerificationDialog = true;
} catch (err) {
console.error('Failed to generate verification file:', err);
error = `Failed to generate verification file: ${err instanceof Error ? err.message : String(err)}`;
}
}
function copyVerificationToClipboard() {
if (!verificationFileContent) return;
navigator.clipboard.writeText(verificationFileContent).then(() => {
alert('Verification file content copied to clipboard!');
}).catch((err) => {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard. Please select and copy manually.');
});
}
function downloadVerificationFile() {
if (!verificationFileContent) return;
const blob = new Blob([verificationFileContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = VERIFICATION_FILE_PATH;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function loadBranches() {
try {
const response = await fetch(`/api/repos/${npub}/${repo}/branches`);
@ -1721,20 +1782,21 @@ @@ -1721,20 +1782,21 @@
{#if isMaintainer}
<a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a>
{/if}
{#if pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey && verificationStatus?.verified !== true}
<button
onclick={generateVerificationFileForRepo}
class="verify-button"
title="Generate verification file"
>
Generate Verification File
</button>
{/if}
{#if isMaintainer}
<button onclick={() => {
if (!userPubkey || !isMaintainer) return;
showCreateBranchDialog = true;
}} class="create-branch-button">+ New Branch</button>
{/if}
<span class="auth-status">
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" />
{#if isMaintainer}
Maintainer
{:else}
Authenticated (Contributor)
{/if}
</span>
{/if}
</div>
</div>
@ -1749,8 +1811,8 @@ @@ -1749,8 +1811,8 @@
{#if verificationStatus}
<span class="verification-status" class:verified={verificationStatus.verified} class:unverified={!verificationStatus.verified}>
{#if verificationStatus.verified}
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" />
Verified
<img src="/icons/check-circle.svg" alt="Verified Repo Ownership" class="icon-inline" />
Verified Repo Ownership
{:else}
<img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" />
Unverified
@ -2508,6 +2570,46 @@ @@ -2508,6 +2570,46 @@
</div>
</div>
{/if}
<!-- Verification File Dialog -->
{#if showVerificationDialog && verificationFileContent}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Repository verification file"
onclick={() => showVerificationDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showVerificationDialog = false)}
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal verification-modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>Repository Verification File</h3>
<p class="verification-instructions">
Create a file named <code>{VERIFICATION_FILE_PATH}</code> in the root of your git repository and paste the content below into it.
Then commit and push the file to your repository.
</p>
<div class="verification-file-content">
<div class="file-header">
<span class="filename">{VERIFICATION_FILE_PATH}</span>
<div class="file-actions">
<button onclick={copyVerificationToClipboard} class="copy-button">Copy</button>
<button onclick={downloadVerificationFile} class="download-button">Download</button>
</div>
</div>
<pre class="file-content"><code>{verificationFileContent}</code></pre>
</div>
<div class="modal-actions">
<button onclick={() => showVerificationDialog = false} class="cancel-button">Close</button>
</div>
</div>
</div>
{/if}
</div>
<style>
@ -2601,6 +2703,33 @@ @@ -2601,6 +2703,33 @@
transform: scale(1.1);
}
.verify-button {
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--accent-text, white);
border: 1px solid var(--accent);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
}
.verify-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.verify-button:disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--success-bg, #10b981);
color: var(--success-text, white);
}
.repo-banner {
width: 100%;
height: 200px;
@ -3113,13 +3242,6 @@ @@ -3113,13 +3242,6 @@
font-family: 'IBM Plex Serif', serif;
}
.auth-status {
font-size: 0.875rem;
color: var(--text-primary);
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.repo-view {
flex: 1;
@ -3344,6 +3466,85 @@ @@ -3344,6 +3466,85 @@
color: var(--text-primary);
}
.verification-modal {
max-width: 800px;
min-width: 600px;
}
.verification-instructions {
color: var(--text-secondary);
margin-bottom: 1rem;
line-height: 1.6;
}
.verification-instructions code {
background: var(--bg-secondary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: monospace;
color: var(--accent);
}
.verification-file-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
overflow: hidden;
margin-bottom: 1rem;
}
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.filename {
font-family: monospace;
font-weight: 500;
color: var(--text-primary);
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.copy-button,
.download-button {
padding: 0.375rem 0.75rem;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-button:hover,
.download-button:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.file-content {
margin: 0;
padding: 1rem;
overflow-x: auto;
background: var(--bg-primary);
}
.file-content code {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: var(--text-primary);
white-space: pre;
}
.modal label {
display: block;
margin-bottom: 1rem;
@ -4000,10 +4201,6 @@ @@ -4000,10 +4201,6 @@
}
/* Theme-aware icon colors */
.auth-status .icon-inline {
filter: brightness(0) saturate(100%) invert(1);
opacity: 0.8;
}
.verification-status.verified .icon-inline {
/* Green checkmark for verified */

474
src/routes/verify/+page.svelte

@ -1,474 +0,0 @@ @@ -1,474 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
let npub = $state('');
let repoName = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
let verificationContent = $state<string | null>(null);
let announcementEvent = $state<NostrEvent | null>(null);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
async function generateVerification() {
if (!npub.trim() || !repoName.trim()) {
error = 'Please enter both npub and repository name';
return;
}
loading = true;
error = null;
verificationContent = null;
announcementEvent = null;
try {
// Decode npub to get pubkey
let ownerPubkey: string;
try {
const decoded = nip19.decode(npub.trim());
if (decoded.type !== 'npub') {
error = 'Invalid npub format';
loading = false;
return;
}
ownerPubkey = decoded.data as string;
} catch (err) {
error = `Failed to decode npub: ${err instanceof Error ? err.message : String(err)}`;
loading = false;
return;
}
// Fetch repository announcement from Nostr
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [ownerPubkey],
'#d': [repoName.trim()],
limit: 1
}
]);
if (events.length === 0) {
error = `No repository announcement found for ${npub}/${repoName.trim()}. Make sure you've published the repository announcement to Nostr.`;
loading = false;
return;
}
const announcement = events[0] as NostrEvent;
announcementEvent = announcement;
// Generate verification file content
verificationContent = generateVerificationFile(announcement, ownerPubkey);
} catch (err) {
error = `Failed to generate verification file: ${err instanceof Error ? err.message : String(err)}`;
console.error('Error generating verification:', err);
} finally {
loading = false;
}
}
function copyToClipboard() {
if (!verificationContent) return;
navigator.clipboard.writeText(verificationContent).then(() => {
alert('Verification file content copied to clipboard!');
}).catch((err) => {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard. Please select and copy manually.');
});
}
function downloadFile() {
if (!verificationContent) return;
const blob = new Blob([verificationContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = VERIFICATION_FILE_PATH;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
<svelte:head>
<title>Generate Repository Verification File - GitRepublic</title>
</svelte:head>
<div class="verify-container">
<div class="verify-content">
<h1>Generate Repository Verification File</h1>
<p class="description">
This tool helps you generate a verification file for a repository that isn't saved to the server yet.
The verification file proves ownership by linking your Nostr repository announcement to your git repository.
</p>
<div class="form-section">
<h2>Repository Information</h2>
<form onsubmit={(e) => { e.preventDefault(); generateVerification(); }}>
<div class="form-group">
<label for="npub">Repository Owner (npub):</label>
<input
id="npub"
type="text"
bind:value={npub}
placeholder="npub1..."
disabled={loading}
required
/>
<small>Your Nostr public key in npub format</small>
</div>
<div class="form-group">
<label for="repo">Repository Name:</label>
<input
id="repo"
type="text"
bind:value={repoName}
placeholder="my-repo"
disabled={loading}
required
/>
<small>The repository identifier (d-tag) from your announcement</small>
</div>
<button type="submit" disabled={loading || !npub.trim() || !repoName.trim()} class="generate-button">
{loading ? 'Generating...' : 'Generate Verification File'}
</button>
</form>
</div>
{#if error}
<div class="error-message">
<strong>Error:</strong> {error}
</div>
{/if}
{#if verificationContent}
<div class="verification-section">
<h2>Verification File Generated</h2>
<p class="instructions">
<strong>Next steps:</strong>
</p>
<ol class="steps">
<li>Copy the verification file content below</li>
<li>Create a file named <code>{VERIFICATION_FILE_PATH}</code> in the root of your git repository</li>
<li>Paste the content into that file</li>
<li>Commit and push the file to your repository</li>
<li>Once the repository is saved to the server, verification will be automatic</li>
</ol>
{#if announcementEvent}
<div class="announcement-info">
<h3>Announcement Details</h3>
<ul>
<li><strong>Event ID:</strong> <code>{announcementEvent.id}</code></li>
<li><strong>Created:</strong> {new Date(announcementEvent.created_at * 1000).toLocaleString()}</li>
</ul>
</div>
{/if}
<div class="verification-file">
<div class="file-header">
<span class="filename">{VERIFICATION_FILE_PATH}</span>
<div class="file-actions">
<button onclick={copyToClipboard} class="copy-button">Copy</button>
<button onclick={downloadFile} class="download-button">Download</button>
</div>
</div>
<pre class="file-content"><code>{verificationContent}</code></pre>
</div>
</div>
{/if}
<div class="info-section">
<h2>About Verification</h2>
<p>
Repository verification proves that you own both the Nostr repository announcement and the git repository.
There are two methods:
</p>
<ul>
<li><strong>Self-transfer event</strong> (preferred): A Nostr event that transfers ownership to yourself, proving you control the private key.</li>
<li><strong>Verification file</strong> (this method): A file in your repository containing the announcement event ID and signature.</li>
</ul>
<p>
The verification file method is useful when your repository isn't on the server yet, as you can generate
the file and commit it to your repository before the server fetches it.
</p>
</div>
</div>
</div>
<style>
.verify-container {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.verify-content {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 2rem;
}
h1 {
margin-top: 0;
color: var(--text-primary);
}
.description {
color: var(--text-secondary);
margin-bottom: 2rem;
line-height: 1.6;
}
.form-section {
margin-bottom: 2rem;
}
.form-section h2 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--text-primary);
font-size: 1.25rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
font-family: monospace;
}
.form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.generate-button {
padding: 0.75rem 1.5rem;
background: var(--accent);
color: var(--accent-text, white);
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.generate-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.generate-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
padding: 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.375rem;
color: #dc2626;
margin-bottom: 2rem;
}
.verification-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.verification-section h2 {
margin-top: 0;
color: var(--text-primary);
}
.instructions {
color: var(--text-primary);
margin-bottom: 1rem;
}
.steps {
color: var(--text-primary);
margin-bottom: 2rem;
padding-left: 1.5rem;
}
.steps li {
margin-bottom: 0.5rem;
line-height: 1.6;
}
.steps code {
background: var(--bg-secondary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: monospace;
color: var(--accent);
}
.announcement-info {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1.5rem;
}
.announcement-info h3 {
margin-top: 0;
margin-bottom: 0.75rem;
color: var(--text-primary);
font-size: 1rem;
}
.announcement-info ul {
margin: 0;
padding-left: 1.5rem;
color: var(--text-primary);
}
.announcement-info li {
margin-bottom: 0.5rem;
}
.announcement-info code {
background: var(--bg-primary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.875rem;
word-break: break-all;
}
.verification-file {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
overflow: hidden;
}
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.filename {
font-family: monospace;
font-weight: 500;
color: var(--text-primary);
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.copy-button,
.download-button {
padding: 0.375rem 0.75rem;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-button:hover,
.download-button:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.file-content {
margin: 0;
padding: 1rem;
overflow-x: auto;
background: var(--bg-primary);
}
.file-content code {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: var(--text-primary);
white-space: pre;
}
.info-section {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.info-section h2 {
margin-top: 0;
color: var(--text-primary);
}
.info-section p,
.info-section ul {
color: var(--text-secondary);
line-height: 1.6;
}
.info-section ul {
margin-top: 0.5rem;
padding-left: 1.5rem;
}
.info-section li {
margin-bottom: 0.5rem;
}
.info-section strong {
color: var(--text-primary);
}
</style>
Loading…
Cancel
Save