feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save, dirty tracking, save-on-blur - PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load - CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header - FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading, file type routing (code→editor, pdf→viewer, csv→table, images→display) - 10MB size gate, binary detection, base64 encoding for non-text files
This commit is contained in:
parent
29a3370e79
commit
252fca70df
22 changed files with 8116 additions and 227 deletions
|
|
@ -1,133 +1,352 @@
|
|||
<script lang="ts">
|
||||
interface FileNode {
|
||||
import { appRpc } from './rpc.ts';
|
||||
import CodeEditor from './CodeEditor.svelte';
|
||||
import PdfViewer from './PdfViewer.svelte';
|
||||
import CsvTable from './CsvTable.svelte';
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
let { cwd }: Props = $props();
|
||||
|
||||
interface DirEntry {
|
||||
name: string;
|
||||
type: 'file' | 'dir';
|
||||
children?: FileNode[];
|
||||
size: number;
|
||||
}
|
||||
|
||||
// Demo directory tree
|
||||
const TREE: FileNode[] = [
|
||||
{
|
||||
name: 'src', type: 'dir', children: [
|
||||
{
|
||||
name: 'lib', type: 'dir', children: [
|
||||
{
|
||||
name: 'stores', type: 'dir', children: [
|
||||
{ name: 'workspace.svelte.ts', type: 'file' },
|
||||
{ name: 'agents.svelte.ts', type: 'file' },
|
||||
{ name: 'health.svelte.ts', type: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'adapters', type: 'dir', children: [
|
||||
{ name: 'claude-messages.ts', type: 'file' },
|
||||
{ name: 'agent-bridge.ts', type: 'file' },
|
||||
],
|
||||
},
|
||||
{ name: 'agent-dispatcher.ts', type: 'file' },
|
||||
],
|
||||
},
|
||||
{ name: 'App.svelte', type: 'file' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'src-tauri', type: 'dir', children: [
|
||||
{
|
||||
name: 'src', type: 'dir', children: [
|
||||
{ name: 'lib.rs', type: 'file' },
|
||||
{ name: 'btmsg.rs', type: 'file' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: 'Cargo.toml', type: 'file' },
|
||||
{ name: 'package.json', type: 'file' },
|
||||
{ name: 'vite.config.ts', type: 'file' },
|
||||
];
|
||||
|
||||
let openDirs = $state<Set<string>>(new Set(['src', 'src/lib', 'src/lib/stores']));
|
||||
// Tree state: track loaded children and open/closed per path
|
||||
let childrenCache = $state<Map<string, DirEntry[]>>(new Map());
|
||||
let openDirs = $state<Set<string>>(new Set());
|
||||
let loadingDirs = $state<Set<string>>(new Set());
|
||||
let selectedFile = $state<string | null>(null);
|
||||
|
||||
function toggleDir(path: string) {
|
||||
const s = new Set(openDirs);
|
||||
if (s.has(path)) s.delete(path);
|
||||
else s.add(path);
|
||||
openDirs = s;
|
||||
// File viewer state
|
||||
let fileContent = $state<string | null>(null);
|
||||
let fileEncoding = $state<'utf8' | 'base64'>('utf8');
|
||||
let fileSize = $state(0);
|
||||
let fileError = $state<string | null>(null);
|
||||
let fileLoading = $state(false);
|
||||
let isDirty = $state(false);
|
||||
let editorContent = $state('');
|
||||
|
||||
// Extension-based type detection
|
||||
const CODE_EXTS = new Set([
|
||||
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
|
||||
'py', 'rs', 'go', 'css', 'scss', 'less',
|
||||
'html', 'svelte', 'vue',
|
||||
'json', 'md', 'yaml', 'yml', 'toml', 'sh', 'bash',
|
||||
'xml', 'sql', 'c', 'cpp', 'h', 'java', 'php',
|
||||
]);
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']);
|
||||
const PDF_EXTS = new Set(['pdf']);
|
||||
const CSV_EXTS = new Set(['csv', 'tsv']);
|
||||
|
||||
function getExt(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function selectFile(path: string) {
|
||||
selectedFile = path;
|
||||
type FileType = 'code' | 'image' | 'pdf' | 'csv' | 'text';
|
||||
|
||||
function detectFileType(name: string): FileType {
|
||||
const ext = getExt(name);
|
||||
if (PDF_EXTS.has(ext)) return 'pdf';
|
||||
if (CSV_EXTS.has(ext)) return 'csv';
|
||||
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||
if (CODE_EXTS.has(ext)) return 'code';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/** Map extension to CodeMirror language name. */
|
||||
function extToLang(name: string): string {
|
||||
const ext = getExt(name);
|
||||
const map: Record<string, string> = {
|
||||
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||||
mjs: 'javascript', cjs: 'javascript',
|
||||
py: 'python', rs: 'rust', go: 'go',
|
||||
css: 'css', scss: 'css', less: 'css',
|
||||
html: 'html', svelte: 'html', vue: 'html',
|
||||
json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml',
|
||||
toml: 'toml', sh: 'bash', bash: 'bash',
|
||||
xml: 'xml', sql: 'sql', c: 'c', cpp: 'cpp', h: 'c',
|
||||
java: 'java', php: 'php',
|
||||
};
|
||||
return map[ext] ?? 'text';
|
||||
}
|
||||
|
||||
let selectedType = $derived<FileType>(selectedFile ? detectFileType(selectedFile) : 'text');
|
||||
let selectedName = $derived(selectedFile ? selectedFile.split('/').pop() ?? '' : '');
|
||||
|
||||
/** Load directory entries via RPC. */
|
||||
async function loadDir(dirPath: string) {
|
||||
if (childrenCache.has(dirPath)) return;
|
||||
const key = dirPath;
|
||||
loadingDirs = new Set([...loadingDirs, key]);
|
||||
try {
|
||||
const result = await appRpc.request["files.list"]({ path: dirPath });
|
||||
if (result.error) {
|
||||
console.error(`[files.list] ${dirPath}: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
const next = new Map(childrenCache);
|
||||
next.set(dirPath, result.entries);
|
||||
childrenCache = next;
|
||||
} catch (err) {
|
||||
console.error('[files.list]', err);
|
||||
} finally {
|
||||
const s = new Set(loadingDirs);
|
||||
s.delete(key);
|
||||
loadingDirs = s;
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle a directory open/closed. Lazy-loads on first open. */
|
||||
async function toggleDir(dirPath: string) {
|
||||
const s = new Set(openDirs);
|
||||
if (s.has(dirPath)) {
|
||||
s.delete(dirPath);
|
||||
openDirs = s;
|
||||
} else {
|
||||
s.add(dirPath);
|
||||
openDirs = s;
|
||||
await loadDir(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** Select and load a file. */
|
||||
async function selectFile(filePath: string) {
|
||||
if (selectedFile === filePath) return;
|
||||
selectedFile = filePath;
|
||||
isDirty = false;
|
||||
fileContent = null;
|
||||
fileError = null;
|
||||
fileLoading = true;
|
||||
|
||||
const type = detectFileType(filePath);
|
||||
|
||||
// PDF uses its own loader via PdfViewer
|
||||
if (type === 'pdf') {
|
||||
fileLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Images: read as base64 for display
|
||||
if (type === 'image') {
|
||||
try {
|
||||
const result = await appRpc.request["files.read"]({ path: filePath });
|
||||
if (result.error) {
|
||||
fileError = result.error;
|
||||
return;
|
||||
}
|
||||
fileContent = result.content ?? '';
|
||||
fileEncoding = result.encoding;
|
||||
fileSize = result.size;
|
||||
} catch (err) {
|
||||
fileError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
fileLoading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await appRpc.request["files.read"]({ path: filePath });
|
||||
if (result.error) {
|
||||
fileError = result.error;
|
||||
return;
|
||||
}
|
||||
fileContent = result.content ?? '';
|
||||
fileEncoding = result.encoding;
|
||||
fileSize = result.size;
|
||||
editorContent = fileContent;
|
||||
} catch (err) {
|
||||
fileError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
fileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save current file. */
|
||||
async function saveFile() {
|
||||
if (!selectedFile || !isDirty) return;
|
||||
try {
|
||||
const result = await appRpc.request["files.write"]({
|
||||
path: selectedFile,
|
||||
content: editorContent,
|
||||
});
|
||||
if (result.ok) {
|
||||
isDirty = false;
|
||||
fileContent = editorContent;
|
||||
} else if (result.error) {
|
||||
console.error('[files.write]', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[files.write]', err);
|
||||
}
|
||||
}
|
||||
|
||||
function onEditorChange(newContent: string) {
|
||||
editorContent = newContent;
|
||||
isDirty = newContent !== fileContent;
|
||||
}
|
||||
|
||||
function fileIcon(name: string): string {
|
||||
if (name.endsWith('.ts') || name.endsWith('.svelte.ts')) return '⟨/⟩';
|
||||
if (name.endsWith('.svelte')) return '◈';
|
||||
if (name.endsWith('.rs')) return '⊕';
|
||||
if (name.endsWith('.toml')) return '⚙';
|
||||
if (name.endsWith('.json')) return '{}';
|
||||
return '·';
|
||||
const ext = getExt(name);
|
||||
if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) return '</>';
|
||||
if (ext === 'svelte' || ext === 'vue') return '~';
|
||||
if (ext === 'rs') return 'Rs';
|
||||
if (ext === 'py') return 'Py';
|
||||
if (ext === 'go') return 'Go';
|
||||
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '{}';
|
||||
if (ext === 'md') return 'M';
|
||||
if (ext === 'css' || ext === 'scss') return '#';
|
||||
if (ext === 'html') return '<>';
|
||||
if (IMAGE_EXTS.has(ext)) return 'Im';
|
||||
if (ext === 'pdf') return 'Pd';
|
||||
if (CSV_EXTS.has(ext)) return 'Tb';
|
||||
return '..';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Load root directory on mount
|
||||
$effect(() => {
|
||||
if (cwd) {
|
||||
loadDir(cwd);
|
||||
const s = new Set(openDirs);
|
||||
s.add(cwd);
|
||||
openDirs = s;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="file-browser">
|
||||
<!-- Tree panel -->
|
||||
<div class="fb-tree">
|
||||
{#snippet renderNode(node: FileNode, path: string, depth: number)}
|
||||
{#if node.type === 'dir'}
|
||||
<button
|
||||
class="fb-row fb-dir"
|
||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||
onclick={() => toggleDir(path)}
|
||||
aria-expanded={openDirs.has(path)}
|
||||
>
|
||||
<span class="fb-chevron" class:open={openDirs.has(path)}>›</span>
|
||||
<span class="fb-icon dir-icon">📁</span>
|
||||
<span class="fb-name">{node.name}</span>
|
||||
</button>
|
||||
{#if openDirs.has(path) && node.children}
|
||||
{#each node.children as child}
|
||||
{@render renderNode(child, `${path}/${child.name}`, depth + 1)}
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
class="fb-row fb-file"
|
||||
class:selected={selectedFile === path}
|
||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||
onclick={() => selectFile(path)}
|
||||
title={path}
|
||||
>
|
||||
<span class="fb-icon file-type">{fileIcon(node.name)}</span>
|
||||
<span class="fb-name">{node.name}</span>
|
||||
</button>
|
||||
{#snippet renderEntries(dirPath: string, depth: number)}
|
||||
{#if childrenCache.has(dirPath)}
|
||||
{#each childrenCache.get(dirPath) ?? [] as entry}
|
||||
{@const fullPath = `${dirPath}/${entry.name}`}
|
||||
{#if entry.type === 'dir'}
|
||||
<button
|
||||
class="fb-row fb-dir"
|
||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||
onclick={() => toggleDir(fullPath)}
|
||||
aria-expanded={openDirs.has(fullPath)}
|
||||
>
|
||||
<span class="fb-chevron" class:open={openDirs.has(fullPath)}>
|
||||
{#if loadingDirs.has(fullPath)}...{:else}>{/if}
|
||||
</span>
|
||||
<span class="fb-name">{entry.name}</span>
|
||||
</button>
|
||||
{#if openDirs.has(fullPath)}
|
||||
{@render renderEntries(fullPath, depth + 1)}
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
class="fb-row fb-file"
|
||||
class:selected={selectedFile === fullPath}
|
||||
style:padding-left="{0.5 + depth * 0.875}rem"
|
||||
onclick={() => selectFile(fullPath)}
|
||||
title={`${entry.name} (${formatSize(entry.size)})`}
|
||||
>
|
||||
<span class="fb-icon file-type">{fileIcon(entry.name)}</span>
|
||||
<span class="fb-name">{entry.name}</span>
|
||||
{#if selectedFile === fullPath && isDirty}
|
||||
<span class="dirty-dot" title="Unsaved changes"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else if loadingDirs.has(dirPath)}
|
||||
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#each TREE as node}
|
||||
{@render renderNode(node, node.name, 0)}
|
||||
{/each}
|
||||
{@render renderEntries(cwd, 0)}
|
||||
</div>
|
||||
|
||||
{#if selectedFile}
|
||||
<div class="fb-preview">
|
||||
<div class="fb-preview-label">{selectedFile}</div>
|
||||
<div class="fb-preview-content">(click to open in editor)</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Viewer panel -->
|
||||
<div class="fb-viewer">
|
||||
{#if !selectedFile}
|
||||
<div class="fb-empty">Select a file to view</div>
|
||||
{:else if fileLoading}
|
||||
<div class="fb-empty">Loading...</div>
|
||||
{:else if fileError}
|
||||
<div class="fb-error">{fileError}</div>
|
||||
{:else if selectedType === 'pdf'}
|
||||
<PdfViewer filePath={selectedFile} />
|
||||
{:else if selectedType === 'csv' && fileContent != null}
|
||||
<CsvTable content={fileContent} filename={selectedName} />
|
||||
{:else if selectedType === 'image' && fileContent}
|
||||
{@const ext = getExt(selectedName)}
|
||||
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
|
||||
<div class="fb-image-wrap">
|
||||
<div class="fb-image-label">{selectedName} ({formatSize(fileSize)})</div>
|
||||
<img
|
||||
class="fb-image"
|
||||
src="data:{mime};base64,{fileEncoding === 'base64' ? fileContent : btoa(fileContent)}"
|
||||
alt={selectedName}
|
||||
/>
|
||||
</div>
|
||||
{:else if selectedType === 'code' && fileContent != null}
|
||||
<div class="fb-editor-header">
|
||||
<span class="fb-editor-path" title={selectedFile}>
|
||||
{selectedName}
|
||||
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
||||
</span>
|
||||
<span class="fb-editor-size">{formatSize(fileSize)}</span>
|
||||
</div>
|
||||
<CodeEditor
|
||||
content={fileContent}
|
||||
lang={extToLang(selectedFile)}
|
||||
onsave={saveFile}
|
||||
onchange={onEditorChange}
|
||||
onblur={saveFile}
|
||||
/>
|
||||
{:else if fileContent != null}
|
||||
<!-- Raw text fallback -->
|
||||
<div class="fb-editor-header">
|
||||
<span class="fb-editor-path" title={selectedFile}>
|
||||
{selectedName}
|
||||
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
|
||||
</span>
|
||||
<span class="fb-editor-size">{formatSize(fileSize)}</span>
|
||||
</div>
|
||||
<CodeEditor
|
||||
content={fileContent}
|
||||
lang="text"
|
||||
onsave={saveFile}
|
||||
onchange={onEditorChange}
|
||||
onblur={saveFile}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* ── Tree panel ── */
|
||||
.fb-tree {
|
||||
flex: 1;
|
||||
width: 14rem;
|
||||
min-width: 10rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
background: var(--ctp-mantle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fb-tree::-webkit-scrollbar { width: 0.25rem; }
|
||||
|
|
@ -163,25 +382,25 @@
|
|||
.fb-chevron {
|
||||
display: inline-block;
|
||||
width: 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-overlay1);
|
||||
transition: transform 0.12s;
|
||||
transform: rotate(0deg);
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
}
|
||||
|
||||
.fb-chevron.open { transform: rotate(90deg); }
|
||||
|
||||
.fb-icon {
|
||||
flex-shrink: 0;
|
||||
font-style: normal;
|
||||
}
|
||||
.fb-icon { flex-shrink: 0; font-style: normal; }
|
||||
|
||||
.file-type {
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--ctp-overlay1);
|
||||
font-family: var(--term-font-family);
|
||||
font-family: var(--term-font-family, monospace);
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fb-name {
|
||||
|
|
@ -192,26 +411,104 @@
|
|||
|
||||
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
|
||||
|
||||
.fb-preview {
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
padding: 0.5rem 0.75rem;
|
||||
.fb-loading {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-style: italic;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.dirty-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-peach);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Viewer panel ── */
|
||||
.fb-viewer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--ctp-base);
|
||||
}
|
||||
|
||||
.fb-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fb-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--ctp-red);
|
||||
font-size: 0.8rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.fb-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fb-preview-label {
|
||||
font-size: 0.75rem;
|
||||
.fb-editor-path {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--term-font-family);
|
||||
margin-bottom: 0.2rem;
|
||||
font-family: var(--term-font-family, monospace);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-preview-content {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-overlay0);
|
||||
.dirty-indicator {
|
||||
color: var(--ctp-peach);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fb-editor-size {
|
||||
font-size: 0.65rem;
|
||||
color: var(--ctp-overlay0);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.fb-image-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fb-image-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: var(--term-font-family, monospace);
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.fb-image {
|
||||
max-width: 100%;
|
||||
max-height: calc(100% - 2rem);
|
||||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue