mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
feat(api-nest, web-vite): enhance library resolver and UI components
- Updated the LibraryResolver to include a default value for the `includeContent` argument, improving performance by conditionally stripping content from search results. - Refactored the LabelPickerModal component to improve documentation clarity. - Enhanced the LibraryItemCard component by adding site name metadata and updating icons for better visual representation. - Improved button accessibility and styling across LibraryPage and ReaderPage components, ensuring consistent button types and focus styles. - Updated CSS styles for various components to utilize CSS variables for better maintainability and responsiveness. These changes collectively enhance the user experience and maintainability of the codebase.
This commit is contained in:
parent
c2387e72c9
commit
3fe2cfad3b
10 changed files with 347 additions and 257 deletions
|
|
@ -92,7 +92,8 @@ export class LibraryResolver {
|
|||
first = 20,
|
||||
@Args('after', { type: () => String, nullable: true }) after?: string,
|
||||
@Args('query', { type: () => String, nullable: true }) query?: string,
|
||||
@Args('includeContent', { type: () => Boolean, nullable: true }) includeContent?: boolean,
|
||||
@Args('includeContent', { type: () => Boolean, nullable: true, defaultValue: false })
|
||||
includeContent = false,
|
||||
): Promise<typeof SearchResult> {
|
||||
try {
|
||||
// Convert query string to search input format
|
||||
|
|
@ -108,16 +109,26 @@ export class LibraryResolver {
|
|||
)
|
||||
|
||||
// Transform to legacy format with edges and pageInfo
|
||||
const edges: SearchItemEdge[] = items.map((item, index) => ({
|
||||
cursor: nextCursor && index === items.length - 1 ? nextCursor : item.id,
|
||||
node: mapEntityToGraph(item),
|
||||
}))
|
||||
// Each edge should have cursor = item.id, not nextCursor
|
||||
const edges: SearchItemEdge[] = items.map((item) => {
|
||||
const graphItem = mapEntityToGraph(item)
|
||||
|
||||
// Strip content if includeContent is false for better performance
|
||||
if (!includeContent && graphItem.content) {
|
||||
graphItem.content = null
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: item.id, // Each edge cursor should be the item's ID
|
||||
node: graphItem,
|
||||
}
|
||||
})
|
||||
|
||||
const pageInfo: SearchPageInfo = {
|
||||
hasNextPage: !!nextCursor,
|
||||
hasPreviousPage: !!after,
|
||||
startCursor: items.length > 0 ? items[0].id : null,
|
||||
endCursor: nextCursor,
|
||||
endCursor: items.length > 0 ? items[items.length - 1].id : null, // Last item's ID, not nextCursor
|
||||
totalCount: null, // Not currently tracked
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,18 @@ async function bootstrap() {
|
|||
],
|
||||
})
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe())
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true, // Automatically transform payloads to DTO instances
|
||||
whitelist: true, // Strip properties that don't have decorators
|
||||
forbidNonWhitelisted: false, // Allow non-whitelisted properties (for GraphQL flexibility)
|
||||
skipUndefinedProperties: false, // Validate undefined properties
|
||||
skipNullProperties: true, // Skip validation for null properties (fixes GraphQL null handling)
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true, // Convert primitive types automatically
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await app.listen(port, () => {
|
||||
Logger.log(`App is listening on port ${port}`)
|
||||
|
|
|
|||
|
|
@ -9,15 +9,6 @@ interface LabelPickerModalProps {
|
|||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* LabelPickerModal
|
||||
* @param props.itemId - Target library item ID.
|
||||
* @param props.currentLabels - Current label names applied.
|
||||
* @param props.onUpdate - Called with the updated label name list.
|
||||
* @param props.onClose - Close handler.
|
||||
*/
|
||||
export function LabelPickerModal({ itemId, currentLabels, onUpdate, onClose }: LabelPickerModalProps) {
|
||||
|
||||
// Preset colors matching legacy implementation
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Red', value: '#FF5D99' },
|
||||
|
|
@ -28,6 +19,13 @@ const PRESET_COLORS = [
|
|||
{ name: 'Purple', value: '#CE88EF' },
|
||||
]
|
||||
|
||||
/**
|
||||
* LabelPickerModal
|
||||
* @param props.itemId - Target library item ID.
|
||||
* @param props.currentLabels - Current label names applied.
|
||||
* @param props.onUpdate - Called with the updated label name list.
|
||||
* @param props.onClose - Close handler.
|
||||
*/
|
||||
export function LabelPickerModal({ itemId, currentLabels, onUpdate, onClose }: LabelPickerModalProps) {
|
||||
const { data: allLabels, loading: loadingLabels, fetchLabels } = useLabels()
|
||||
const { setLibraryItemLabels, loading: updating } = useSetLibraryItemLabels()
|
||||
|
|
|
|||
|
|
@ -281,19 +281,37 @@ const LibraryItemCard: React.FC<LibraryItemCardProps> = ({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata bar - Author, Reading time, Saved date */}
|
||||
{/* Metadata bar - Site name, Author, Reading time, Saved date */}
|
||||
<div className="card-metadata">
|
||||
{/* Author name */}
|
||||
{item.author && (
|
||||
<span className="metadata-author">{item.author}</span>
|
||||
)}
|
||||
|
||||
{/* Reading time with clock icon */}
|
||||
{readingTime && (
|
||||
{/* Site name/source with globe icon */}
|
||||
{item.siteName && (
|
||||
<div className="metadata-item">
|
||||
<svg className="metadata-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
</svg>
|
||||
<span>{item.siteName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author name with user icon */}
|
||||
{item.author && (
|
||||
<div className="metadata-item">
|
||||
<svg className="metadata-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
<span>{item.author}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading time with book-open icon */}
|
||||
{readingTime && (
|
||||
<div className="metadata-item">
|
||||
<svg className="metadata-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
||||
</svg>
|
||||
<span>{readingTime}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -787,7 +787,8 @@ const LibraryPage: React.FC = () => {
|
|||
{searching && <span className="search-spinner">⏳</span>}
|
||||
</div>
|
||||
<button
|
||||
className="add-article-btn"
|
||||
type="button"
|
||||
className="btn btn-primary add-article-btn"
|
||||
onClick={() => setShowAddLinkModal(true)}
|
||||
>
|
||||
+ Add
|
||||
|
|
@ -799,7 +800,8 @@ const LibraryPage: React.FC = () => {
|
|||
<div className="filter-controls-left">
|
||||
<div className="label-filter-wrapper">
|
||||
<button
|
||||
className="label-filter-toggle-btn"
|
||||
type="button"
|
||||
className="btn btn-secondary label-filter-toggle-btn"
|
||||
onClick={() => setShowLabelFilter(!showLabelFilter)}
|
||||
>
|
||||
🏷️ Labels{' '}
|
||||
|
|
@ -845,14 +847,16 @@ const LibraryPage: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
<button
|
||||
className="view-toggle-btn"
|
||||
type="button"
|
||||
className="btn btn-icon view-toggle-btn"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
title={`Switch to ${viewMode === 'grid' ? 'list' : 'grid'} view`}
|
||||
>
|
||||
{viewMode === 'grid' ? '☰' : '⊞'}
|
||||
</button>
|
||||
<button
|
||||
className="multi-select-toggle-btn"
|
||||
type="button"
|
||||
className="btn btn-secondary multi-select-toggle-btn"
|
||||
onClick={() => {
|
||||
setIsMultiSelectMode(!isMultiSelectMode)
|
||||
if (isMultiSelectMode) {
|
||||
|
|
@ -878,7 +882,8 @@ const LibraryPage: React.FC = () => {
|
|||
<option value="AUTHOR">Author</option>
|
||||
</select>
|
||||
<button
|
||||
className="sort-order-btn"
|
||||
type="button"
|
||||
className="btn btn-icon sort-order-btn"
|
||||
onClick={() =>
|
||||
setSortOrder(sortOrder === 'DESC' ? 'ASC' : 'DESC')
|
||||
}
|
||||
|
|
@ -936,10 +941,10 @@ const LibraryPage: React.FC = () => {
|
|||
{isMultiSelectMode && (
|
||||
<div className="bulk-actions-bar">
|
||||
<div className="bulk-select-controls">
|
||||
<button onClick={selectAll} className="bulk-control-btn">
|
||||
<button type="button" onClick={selectAll} className="btn btn-secondary bulk-control-btn">
|
||||
Select All
|
||||
</button>
|
||||
<button onClick={deselectAll} className="bulk-control-btn">
|
||||
<button type="button" onClick={deselectAll} className="btn btn-secondary bulk-control-btn">
|
||||
Deselect All
|
||||
</button>
|
||||
<span className="selected-count">
|
||||
|
|
@ -949,38 +954,44 @@ const LibraryPage: React.FC = () => {
|
|||
{selectedItems.size > 0 && (
|
||||
<div className="bulk-action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBulkArchive(true)}
|
||||
className="bulk-action-btn"
|
||||
className="btn btn-secondary bulk-action-btn"
|
||||
>
|
||||
Archive Selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBulkArchive(false)}
|
||||
className="bulk-action-btn"
|
||||
className="btn btn-secondary bulk-action-btn"
|
||||
>
|
||||
Unarchive Selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBulkMoveToFolderAction('inbox')}
|
||||
className="bulk-action-btn"
|
||||
className="btn btn-secondary bulk-action-btn"
|
||||
>
|
||||
Move to Inbox
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBulkMoveToFolderAction('archive')}
|
||||
className="bulk-action-btn"
|
||||
className="btn btn-secondary bulk-action-btn"
|
||||
>
|
||||
Move to Archive
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkMarkAsReadAction}
|
||||
className="bulk-action-btn"
|
||||
className="btn btn-secondary bulk-action-btn"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkDelete}
|
||||
className="bulk-action-btn bulk-action-btn-danger"
|
||||
className="btn btn-danger bulk-action-btn bulk-action-btn-danger"
|
||||
>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
|
@ -992,12 +1003,14 @@ const LibraryPage: React.FC = () => {
|
|||
{/* Folder Tabs: Inbox, Archive, Trash */}
|
||||
<div className="library-folder-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`folder-tab ${activeFolder === 'inbox' ? 'active' : ''}`}
|
||||
onClick={() => setActiveFolder('inbox')}
|
||||
>
|
||||
Inbox
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`folder-tab ${
|
||||
activeFolder === 'archive' ? 'active' : ''
|
||||
}`}
|
||||
|
|
@ -1006,6 +1019,7 @@ const LibraryPage: React.FC = () => {
|
|||
Archive
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`folder-tab ${activeFolder === 'trash' ? 'active' : ''}`}
|
||||
onClick={() => setActiveFolder('trash')}
|
||||
>
|
||||
|
|
@ -1047,7 +1061,8 @@ const LibraryPage: React.FC = () => {
|
|||
</p>
|
||||
{!searchQuery && (
|
||||
<button
|
||||
className="add-article-btn"
|
||||
type="button"
|
||||
className="btn btn-primary add-article-btn"
|
||||
onClick={() => setShowAddLinkModal(true)}
|
||||
>
|
||||
+ Add Your First Article
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ const ReaderPage: React.FC = () => {
|
|||
<div className="reader-error">
|
||||
<h2>Error Loading Article</h2>
|
||||
<p>{error.message}</p>
|
||||
<button onClick={handleBack} className="back-button">
|
||||
<button type="button" onClick={handleBack} className="back-button">
|
||||
← Back to Library
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -194,7 +194,7 @@ const ReaderPage: React.FC = () => {
|
|||
<p>
|
||||
The article you're looking for doesn't exist or has been deleted.
|
||||
</p>
|
||||
<button onClick={handleBack} className="back-button">
|
||||
<button type="button" onClick={handleBack} className="back-button">
|
||||
← Back to Library
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -207,7 +207,7 @@ const ReaderPage: React.FC = () => {
|
|||
return (
|
||||
<div className="reader-page">
|
||||
<div className="reader-header">
|
||||
<button onClick={handleBack} className="back-button">
|
||||
<button type="button" onClick={handleBack} className="back-button">
|
||||
← Back to Library
|
||||
</button>
|
||||
<h1>{item.title}</h1>
|
||||
|
|
@ -260,7 +260,7 @@ const ReaderPage: React.FC = () => {
|
|||
return (
|
||||
<div className="reader-page">
|
||||
<div className="reader-header">
|
||||
<button onClick={handleBack} className="back-button">
|
||||
<button type="button" onClick={handleBack} className="back-button">
|
||||
← Back to Library
|
||||
</button>
|
||||
<h1>{item.title}</h1>
|
||||
|
|
@ -309,9 +309,11 @@ const ReaderPage: React.FC = () => {
|
|||
{/* Toolbar with label edit button */}
|
||||
<div className="reader-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar-button"
|
||||
onClick={() => setShowLabelModal(true)}
|
||||
title="Edit labels (l)"
|
||||
aria-label="Edit labels"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
|
|
@ -322,16 +324,20 @@ const ReaderPage: React.FC = () => {
|
|||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<title>Label icon</title>
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
||||
</svg>
|
||||
<span>Labels</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar-button"
|
||||
onClick={() => setShowEditInfoModal(true)}
|
||||
title="Edit info (e)"
|
||||
aria-label="Edit article information"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
|
|
@ -342,9 +348,11 @@ const ReaderPage: React.FC = () => {
|
|||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<title>Edit icon</title>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
<span>Edit Info</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@
|
|||
background: #ffdb58;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Overlay for mobile menu */
|
||||
.nav-overlay {
|
||||
display: none;
|
||||
|
|
@ -66,6 +71,12 @@
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-close-btn:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Navigation sections */
|
||||
.nav-section {
|
||||
padding: 1rem 0;
|
||||
|
|
@ -96,6 +107,13 @@
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: -2px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #ffd234;
|
||||
color: #2a2a2a;
|
||||
|
|
@ -154,6 +172,12 @@
|
|||
color: #898989;
|
||||
}
|
||||
|
||||
.shortcuts-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Shortcut items */
|
||||
.shortcuts-list {
|
||||
padding: 0.5rem 0;
|
||||
|
|
@ -178,6 +202,13 @@
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.shortcut-item:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: -2px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.shortcut-icon {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1rem;
|
||||
|
|
@ -213,6 +244,13 @@
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.filter-shortcut-item:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: -2px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.filter-shortcut-icon {
|
||||
margin-right: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
|
|
|
|||
|
|
@ -207,13 +207,15 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metadata-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.7;
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,35 +3,35 @@
|
|||
.articles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
gap: var(--space-6); /* 1.5rem */
|
||||
padding: var(--space-3); /* 1rem */
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1600px) {
|
||||
.articles-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1.25rem;
|
||||
gap: var(--space-5); /* 1.25rem */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.articles-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
gap: var(--space-3); /* 1rem */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.articles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
gap: var(--space-3); /* 1rem */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.articles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0.5rem;
|
||||
padding: var(--space-2); /* 0.5rem */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,90 @@
|
|||
max-width: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: #1a1a1a;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
/* ===== SHARED BUTTON STYLES ===== */
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: background-color var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Primary button (blue) */
|
||||
.btn-primary {
|
||||
background: var(--color-action-blue);
|
||||
color: var(--color-text-on-accent);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #3a8eef;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Secondary button (dark with border) */
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Icon button (square) */
|
||||
.btn-icon {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--space-2);
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Danger button */
|
||||
.btn-danger {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-color: #8b0000;
|
||||
color: var(--color-state-danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #8b0000;
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
/* ===== TIER 1: Top Bar (Search + Add) ===== */
|
||||
|
|
@ -13,10 +96,10 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #2a2a2a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
|
@ -30,18 +113,18 @@
|
|||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-body);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
border-color: var(--color-action-blue);
|
||||
box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
|
|
@ -53,26 +136,9 @@
|
|||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Specific button overrides - inherits from .btn and .btn-primary */
|
||||
.add-article-btn {
|
||||
background: #4a9eff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.add-article-btn:hover {
|
||||
background: #3a8eef;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.add-article-btn:active {
|
||||
transform: translateY(0);
|
||||
/* All base styles from .btn and .btn-primary */
|
||||
}
|
||||
|
||||
/* ===== TIER 2: Filters Bar ===== */
|
||||
|
|
@ -80,204 +146,151 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.filter-controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Label Filter */
|
||||
/* Label Filter - inherits from .btn and .btn-secondary */
|
||||
.label-filter-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label-filter-toggle-btn {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #d9d9d9;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
white-space: nowrap;
|
||||
/* All base styles from .btn and .btn-secondary */
|
||||
}
|
||||
|
||||
.label-filter-toggle-btn:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* View Toggle */
|
||||
/* View Toggle - inherits from .btn and .btn-icon */
|
||||
.view-toggle-btn {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #898989;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
/* All base styles from .btn and .btn-icon */
|
||||
font-size: 16px;
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.view-toggle-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
/* Multi-Select Toggle */
|
||||
/* Multi-Select Toggle - inherits from .btn and .btn-secondary */
|
||||
.multi-select-toggle-btn {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #898989;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multi-select-toggle-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #d9d9d9;
|
||||
/* All base styles from .btn and .btn-secondary */
|
||||
}
|
||||
|
||||
/* Sort Controls */
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sort-controls label {
|
||||
color: #898989;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-body);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #d9d9d9;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
padding: var(--space-2) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
font-size: var(--font-size-body);
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.sort-select:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #555;
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.sort-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
.sort-select:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Sort Order Button - inherits from .btn and .btn-icon */
|
||||
.sort-order-btn {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #898989;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sort-order-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #d9d9d9;
|
||||
/* All base styles from .btn and .btn-icon */
|
||||
}
|
||||
|
||||
/* ===== TIER 3: Folder Tabs ===== */
|
||||
.library-folder-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-bg-primary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.folder-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #898989;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folder-tab:hover {
|
||||
background: #252525;
|
||||
color: #d9d9d9;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.folder-tab:focus-visible {
|
||||
outline: 2px solid var(--color-action-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.folder-tab.active {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
border-bottom: 2px solid #4a9eff;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-action-blue);
|
||||
}
|
||||
|
||||
.selection-indicator {
|
||||
margin-left: auto;
|
||||
color: #898989;
|
||||
font-size: 13px;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #252525;
|
||||
border-radius: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-small);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* ===== Library Stats ===== */
|
||||
.library-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg-primary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
color: #d9d9d9;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-xlarge);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #898989;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-small);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
|
@ -287,94 +300,70 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.bulk-select-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Bulk control button - inherits from .btn and .btn-secondary */
|
||||
.bulk-control-btn {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #898989;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.bulk-control-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #d9d9d9;
|
||||
/* All base styles from .btn and .btn-secondary */
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
color: #898989;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.bulk-action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Bulk action button - inherits from .btn and .btn-secondary */
|
||||
.bulk-action-btn {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #d9d9d9;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulk-action-btn:hover {
|
||||
background: #3a3a3a;
|
||||
/* All base styles from .btn and .btn-secondary */
|
||||
font-size: var(--font-size-small);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
/* Danger variant - inherits from .btn and .btn-danger */
|
||||
.bulk-action-btn-danger {
|
||||
color: #ff6b6b;
|
||||
border-color: #8b0000;
|
||||
}
|
||||
|
||||
.bulk-action-btn-danger:hover {
|
||||
background: #8b0000;
|
||||
color: white;
|
||||
/* Additional danger styling from .btn-danger */
|
||||
}
|
||||
|
||||
/* ===== Toast Notifications ===== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
top: var(--space-5);
|
||||
right: var(--space-5);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: var(--font-weight-medium);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
background: var(--color-state-success);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
background: var(--color-state-danger);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
|
|
@ -393,7 +382,7 @@
|
|||
.library-filters-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.filter-controls-left {
|
||||
|
|
@ -408,7 +397,7 @@
|
|||
@media (max-width: 768px) {
|
||||
.library-top-bar {
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
|
|
@ -421,13 +410,13 @@
|
|||
|
||||
.library-folder-tabs {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.library-stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.bulk-actions-bar {
|
||||
|
|
|
|||
Loading…
Reference in a new issue