feat: favicon, sidebar tooltip, fix Meilisearch auth and test warnings
- Add SVG favicon using pine/paper design tokens; wire into index.html - Add SidebarLabel component with ResizeObserver overflow detection and CSS-only tooltip (pine-deep bg, IBM Plex Mono, 0.08s fade, no delay) - Fix Meilisearch invalid_api_key: default api key to None instead of '' so no Authorization header is sent when auth is disabled - Stub ResizeObserver in test setup for jsdom compatibility - Wrap async renders in act() and advance fake timers inside act() to eliminate all act() warnings in App.test.tsx
This commit is contained in:
parent
39761b4ce7
commit
08a1211341
@ -5,7 +5,7 @@ class Settings:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.storage_dir = os.environ.get("STORAGE_DIR", "/data/documents")
|
self.storage_dir = os.environ.get("STORAGE_DIR", "/data/documents")
|
||||||
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
|
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
|
||||||
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
|
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY") or None
|
||||||
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
|
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")
|
||||||
self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")
|
self.cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>DocHub</title>
|
<title>DocHub</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
17
frontend/public/favicon.svg
Normal file
17
frontend/public/favicon.svg
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<!-- Parchment background -->
|
||||||
|
<rect width="32" height="32" rx="7" fill="#faf9f6"/>
|
||||||
|
<!-- Document shape in pine -->
|
||||||
|
<rect x="7" y="5" width="13" height="17" rx="2" fill="#2f5d50"/>
|
||||||
|
<!-- Folded corner -->
|
||||||
|
<path d="M20 5 L25 10 L20 10 Z" fill="#e7efec"/>
|
||||||
|
<rect x="20" y="5" width="5" height="5" rx="1" fill="#234840"/>
|
||||||
|
<path d="M20 5 L25 10 H20 Z" fill="#e7efec"/>
|
||||||
|
<!-- Text lines on doc -->
|
||||||
|
<rect x="9.5" y="13" width="8" height="1.5" rx="0.75" fill="#e7efec" opacity="0.85"/>
|
||||||
|
<rect x="9.5" y="16" width="6" height="1.5" rx="0.75" fill="#e7efec" opacity="0.6"/>
|
||||||
|
<rect x="9.5" y="19" width="7" height="1.5" rx="0.75" fill="#e7efec" opacity="0.6"/>
|
||||||
|
<!-- Magnifier circle -->
|
||||||
|
<circle cx="21" cy="23" r="5" fill="none" stroke="#2f5d50" stroke-width="2.5"/>
|
||||||
|
<line x1="24.8" y1="26.8" x2="27.5" y2="29.5" stroke="#2f5d50" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 979 B |
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { listDocuments, type DocumentFile } from '../api/client';
|
import { listDocuments, type DocumentFile } from '../api/client';
|
||||||
|
|
||||||
interface FileExplorerProps {
|
interface FileExplorerProps {
|
||||||
@ -37,6 +37,27 @@ export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SidebarLabel({ text }: { text: string }) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [overflows, setOverflows] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const check = () => setOverflows(el.scrollWidth > el.clientWidth);
|
||||||
|
check();
|
||||||
|
const ro = new ResizeObserver(check);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={overflows ? 'sidebar-label has-tip' : 'sidebar-label'} data-tip={text}>
|
||||||
|
<span ref={ref} className="label-text">{text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FileRow({
|
function FileRow({
|
||||||
file,
|
file,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
@ -56,7 +77,7 @@ function FileRow({
|
|||||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
<path d="M14 2v6h6" />
|
<path d="M14 2v6h6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="filename">{file.name}</span>
|
<SidebarLabel text={file.name} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -96,7 +117,7 @@ function FolderNode({
|
|||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span className="foldername">{name}</span>
|
<SidebarLabel text={name} />
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="tree-children">
|
<div className="tree-children">
|
||||||
|
|||||||
@ -234,12 +234,6 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-item .foldername {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-item:hover {
|
.tree-item:hover {
|
||||||
background: rgba(27, 27, 24, 0.045);
|
background: rgba(27, 27, 24, 0.045);
|
||||||
}
|
}
|
||||||
@ -278,10 +272,43 @@ body {
|
|||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-row .filename {
|
/* ---------- Sidebar label + tooltip ---------- */
|
||||||
|
.sidebar-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-label.has-tip::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: fixed;
|
||||||
|
left: 282px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
top: var(--tip-y, 50%);
|
||||||
|
background: var(--pine-deep);
|
||||||
|
color: var(--paper);
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
box-shadow: 0 4px 14px rgba(27, 27, 24, 0.18);
|
||||||
|
z-index: 200;
|
||||||
|
transition: opacity 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-label.has-tip:hover::after {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-icon {
|
.file-icon {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import App from '../src/App';
|
import App from '../src/App';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
@ -39,8 +39,10 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders the DocHub heading', () => {
|
test('renders the DocHub heading', async () => {
|
||||||
render(<App />);
|
await act(async () => {
|
||||||
|
render(<App />);
|
||||||
|
});
|
||||||
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -149,18 +151,25 @@ test('jumping to a search result clears the search input so it does not show sta
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('typing a query does not show a false "no results" message before the debounced search resolves', async () => {
|
test('typing a query does not show a false "no results" message before the debounced search resolves', async () => {
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||||
|
|
||||||
render(<App />);
|
// Settle the initial FileExplorer fetch before switching to fake timers.
|
||||||
|
await act(async () => {
|
||||||
|
render(<App />);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
|
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
|
||||||
fireEvent.change(searchInput, { target: { value: '检索' } });
|
fireEvent.change(searchInput, { target: { value: '检索' } });
|
||||||
|
|
||||||
expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(300);
|
// Advance the debounce and flush the resulting state updates inside act.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
|
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
|
||||||
|
|
||||||
|
|||||||
@ -1 +1,8 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// jsdom doesn't implement ResizeObserver; provide a no-op stub for tests.
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user