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:
Timothy Atapagra 2025-10-28 20:18:13 -04:00
parent c2387e72c9
commit 3fe2cfad3b
10 changed files with 347 additions and 257 deletions

View file

@ -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
}

View file

@ -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}`)

View file

@ -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()

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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;

View file

@ -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;
}

View file

@ -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 */
}
}

View file

@ -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 {