diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index af4e184a9..32e2f6d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ Format: weekly entries grouped by feature area. --- +## 2026-03-25 — Desktop Polish + +### Added +- Add inbox redesign with toolbar, filters, triage mode, link previews, and waveform visualizer +- Add embedded tweet rendering with TweetCard component +- Add reminder detail view with content routing +- Add live URL title extraction and bot-page-title detection +- Auto-title voice memos from first transcribed sentence +- Add folder icon picker and NoteIconDisplay across tree and sidebar +- Add HugeIcons rendering infrastructure +- Sync accent color across devices with tint CSS variables +- Add compact capture mode +- Add PageToolbar and Pill UI primitives +- Add Cmd+Z undo for all task CRUD and bulk operations +- Add WCAG 2.1 AA accessibility pass — keyboard nav, ARIA, focus indicators +- Add account section, recovery key dialog, shortcuts section, and capture shortcut to settings +- Add Gelasio, Geist, and Inter font families + +### Fixed +- Fix inbox filing auto-link and metascraper field mapping +- Fix graph crash on null node data +- Fix tag count casting and prune orphaned definitions +- Fix project ID resolution before creating onboarding task + +### Changed +- Extract shared settings primitives and redesign all settings pages +- Replace custom toast notifications with Sonner +- Remove non-Twitter platform support and simplify social URL parsing +- Simplify sidebar with NoteIconDisplay and compact task count +- Extract DetailHeader and simplify reminder TypeIcon +- Migrate task design tokens and wire undoable task actions + +### Performance +- Parallelize vault open with window creation to reduce cold-start time +- Optimize startup I/O and large-vault indexing speed + +--- + ## 2026-03-19 — Tasks Refinement ### Added diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6f2d6a9cf..3efa8dc0f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -66,7 +66,8 @@ "test:component": "vitest run --config config/vitest.config.ts tests/component", "test:e2e": "playwright test --config config/playwright.config.ts", "ipc:generate": "node scripts/generate-ipc-invoke-map.js", - "ipc:check": "node scripts/generate-ipc-invoke-map.js --check" + "ipc:check": "node scripts/generate-ipc-invoke-map.js --check", + "generate:icons": "node scripts/generate-icons.mjs" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.58", @@ -186,6 +187,7 @@ "react-day-picker": "^9.13.0", "react-pdf": "^10.3.0", "react-player": "^3.4.0", + "react-tweet": "^3.3.0", "sharp": "0.34.5", "sigma": "^3.0.2", "sodium-native": "^5.0.10", @@ -209,9 +211,12 @@ "@electron/rebuild": "^4.0.3", "@fontsource-variable/crimson-pro": "^5.2.8", "@fontsource-variable/dm-sans": "^5.2.8", + "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/playfair-display": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", + "@fontsource/gelasio": "^5.2.8", "@fontsource/instrument-serif": "^5.2.8", "@playwright/test": "^1.57.0", "@testing-library/jest-dom": "^6.9.1", diff --git a/apps/desktop/scripts/generate-icons.mjs b/apps/desktop/scripts/generate-icons.mjs new file mode 100644 index 000000000..bcf22036d --- /dev/null +++ b/apps/desktop/scripts/generate-icons.mjs @@ -0,0 +1,171 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const BUILD_DIR = join(__dirname, '..', 'build') + +const ICON_SVG = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +const ICONSET_SIZES = [ + { name: 'icon_16x16.png', size: 16 }, + { name: 'icon_16x16@2x.png', size: 32 }, + { name: 'icon_32x32.png', size: 32 }, + { name: 'icon_32x32@2x.png', size: 64 }, + { name: 'icon_128x128.png', size: 128 }, + { name: 'icon_128x128@2x.png', size: 256 }, + { name: 'icon_256x256.png', size: 256 }, + { name: 'icon_256x256@2x.png', size: 512 }, + { name: 'icon_512x512.png', size: 512 }, + { name: 'icon_512x512@2x.png', size: 1024 } +] + +const ICO_SIZES = [16, 32, 48, 64, 128, 256] + +async function renderPng(size) { + return sharp(Buffer.from(ICON_SVG)).resize(size, size).png().toBuffer() +} + +function buildIco(pngBuffers, sizes) { + const headerSize = 6 + const dirEntrySize = 16 + const dirSize = dirEntrySize * pngBuffers.length + let dataOffset = headerSize + dirSize + + const header = Buffer.alloc(headerSize) + header.writeUInt16LE(0, 0) + header.writeUInt16LE(1, 2) + header.writeUInt16LE(pngBuffers.length, 4) + + const dirEntries = [] + const offsets = [] + for (let i = 0; i < pngBuffers.length; i++) { + offsets.push(dataOffset) + dataOffset += pngBuffers[i].length + } + + for (let i = 0; i < pngBuffers.length; i++) { + const entry = Buffer.alloc(dirEntrySize) + entry.writeUInt8(sizes[i] >= 256 ? 0 : sizes[i], 0) + entry.writeUInt8(sizes[i] >= 256 ? 0 : sizes[i], 1) + entry.writeUInt8(0, 2) + entry.writeUInt8(0, 3) + entry.writeUInt16LE(1, 4) + entry.writeUInt16LE(32, 6) + entry.writeUInt32LE(pngBuffers[i].length, 8) + entry.writeUInt32LE(offsets[i], 12) + dirEntries.push(entry) + } + + return Buffer.concat([header, ...dirEntries, ...pngBuffers]) +} + +async function generateIcns() { + if (process.platform !== 'darwin') { + console.warn(' [skip] .icns generation requires macOS (iconutil)') + return + } + + const iconsetDir = join(BUILD_DIR, 'icon.iconset') + mkdirSync(iconsetDir, { recursive: true }) + + await Promise.all( + ICONSET_SIZES.map(async ({ name, size }) => { + const buf = await renderPng(size) + writeFileSync(join(iconsetDir, name), buf) + }) + ) + + const output = join(BUILD_DIR, 'icon.icns') + execFileSync('iconutil', ['-c', 'icns', '-o', output, iconsetDir]) + rmSync(iconsetDir, { recursive: true }) + console.log(' icon.icns') +} + +async function generateIco() { + const pngBuffers = await Promise.all(ICO_SIZES.map((s) => renderPng(s))) + const ico = buildIco(pngBuffers, ICO_SIZES) + writeFileSync(join(BUILD_DIR, 'icon.ico'), ico) + console.log(' icon.ico') +} + +async function generatePng() { + const buf = await renderPng(1024) + writeFileSync(join(BUILD_DIR, 'icon.png'), buf) + console.log(' icon.png') +} + +async function main() { + mkdirSync(BUILD_DIR, { recursive: true }) + console.log('Generating app icons...') + await Promise.all([generateIcns(), generateIco(), generatePng()]) + console.log('Done.') +} + +main().catch((err) => { + console.error('Icon generation failed:', err) + process.exit(1) +}) diff --git a/apps/desktop/scripts/preview-icon-variants.mjs b/apps/desktop/scripts/preview-icon-variants.mjs new file mode 100644 index 000000000..e9ec18a23 --- /dev/null +++ b/apps/desktop/scripts/preview-icon-variants.mjs @@ -0,0 +1,375 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PREVIEW_DIR = join(__dirname, '..', 'build', 'previews') + +const COLORS = { + inbox: '#6366f1', + journal: '#8b5cf6', + task: '#d4944a', + note: '#4a9e8e' +} + +const SHARED_DEFS = ` + + + + + + + + + + + + + + + + + + ` + +const SHARED_BASE = ` + + + ` + +const LOGO = ` + + + + ` + +function makeSvg(extraDefs, extraContent) { + return ` + + ${SHARED_DEFS}${extraDefs} + + ${SHARED_BASE} + ${extraContent} + ${LOGO} +` +} + +// --- Variant 1: Conic sweep border --- +const V1_DEFS = ` + + + + + + + + + + + + + + + + + + + + + + ` + +const V1_CONTENT = ` + + + + ` + +// --- Variant 2: 4 corner gems --- +const V2_DEFS = ` + + + + ` + +const V2_CONTENT = ` + + + + ` + +// --- Variant 3: 4 edge accents --- +const V3_DEFS = `` +const V3_CONTENT = ` + + + + ` + +// --- Variant 4: Bottom spectrum bar --- +const V4_DEFS = ` + + + + + + + + + + + + + ` + +const V4_CONTENT = ` + + + ` + +// --- Variant 5: Corner radial glows --- +const V5_DEFS = ` + + + + + + + + + + + + + + + + + + + ` + +const V5_CONTENT = ` + + + + + + ` + +// --- Variant 6: Quadrant tint shift --- +const V6_DEFS = ` + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const V6_BASE = ` + + + + + + + + + ` + +function makeSvgV6() { + return ` + + ${SHARED_DEFS}${V6_DEFS} + + ${V6_BASE} + ${LOGO} +` +} + +// --- 3D Logo definitions --- +const M_PATH = 'M20 70 L20 30 L35 45 L50 25 L65 45 L80 30 L80 70 L50 70' +const FOLD_PATH = 'M50 70 L50 85 L80 70' +const FOLD_FILL_PATH = 'M50 70 L50 85 L80 70 Z' + +function make3dLogoSvg(borderDefs, borderContent, logoContent) { + return ` + + ${SHARED_DEFS}${borderDefs} + + ${SHARED_BASE} + ${borderContent} + ${logoContent} +` +} + +// --- V7: Subtle emboss — light shadow + highlight edges --- +const V7_LOGO = ` + + + + + + + + + + + + + + + ` + +// --- V8: Medium depth — filled fold with gradient + stronger emboss --- +const V8_DEFS_EXTRA = ` + + + + + + + + + + + + + + + + + + ` + +const V8_LOGO = ` + + + + + + + + + + + + + + + + + + + + + + ` + +// --- V9: Heavy relief — thick bevel, dramatic fold, strong 3D --- +const V9_DEFS_EXTRA = ` + + + + + + + + + + + + + + + + + + + + ` + +const V9_LOGO = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + +const VARIANTS = [ + { name: 'v1-conic-sweep', svg: makeSvg(V1_DEFS, V1_CONTENT) }, + { name: 'v2-corner-gems', svg: makeSvg(V2_DEFS, V2_CONTENT) }, + { name: 'v3-edge-accents', svg: makeSvg(V3_DEFS, V3_CONTENT) }, + { name: 'v4-bottom-spectrum', svg: makeSvg(V4_DEFS, V4_CONTENT) }, + { name: 'v5-corner-glows', svg: makeSvg(V5_DEFS, V5_CONTENT) }, + { name: 'v6-quadrant-tint', svg: makeSvgV6() }, + { name: 'v7-3d-subtle', svg: make3dLogoSvg(V1_DEFS, V1_CONTENT, V7_LOGO) }, + { name: 'v8-3d-medium', svg: make3dLogoSvg(V1_DEFS + V8_DEFS_EXTRA, V1_CONTENT, V8_LOGO) }, + { name: 'v9-3d-heavy', svg: make3dLogoSvg(V1_DEFS + V9_DEFS_EXTRA, V1_CONTENT, V9_LOGO) } +] + +async function main() { + mkdirSync(PREVIEW_DIR, { recursive: true }) + console.log('Generating 9 icon variants...\n') + + for (const { name, svg } of VARIANTS) { + const buf = await sharp(Buffer.from(svg)).resize(512, 512).png().toBuffer() + writeFileSync(join(PREVIEW_DIR, `${name}.png`), buf) + console.log(` ${name}.png`) + } + + console.log(`\nDone. Previews at: ${PREVIEW_DIR}`) +} + +main().catch((err) => { + console.error('Preview generation failed:', err) + process.exit(1) +}) diff --git a/apps/desktop/scripts/test-metascraper.mjs b/apps/desktop/scripts/test-metascraper.mjs new file mode 100644 index 000000000..8c600d313 --- /dev/null +++ b/apps/desktop/scripts/test-metascraper.mjs @@ -0,0 +1,81 @@ +import metascraper from 'metascraper' +import metascraperAuthor from 'metascraper-author' +import metascraperDate from 'metascraper-date' +import metascraperDescription from 'metascraper-description' +import metascraperImage from 'metascraper-image' +import metascraperLogo from 'metascraper-logo' +import metascraperPublisher from 'metascraper-publisher' +import metascraperTitle from 'metascraper-title' +import metascraperUrl from 'metascraper-url' + +const scraper = metascraper([ + metascraperAuthor(), + metascraperDate(), + metascraperDescription(), + metascraperImage(), + metascraperLogo(), + metascraperPublisher(), + metascraperTitle(), + metascraperUrl() +]) + +const URL_TO_TEST = + process.argv[2] || + 'https://eksisozluk.com/23-mart-2026-donald-trump-aciklamalari--8085786?day=2026-03-23' + +const USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + +async function test() { + console.log(`\n--- Testing: ${URL_TO_TEST} ---\n`) + + try { + const response = await fetch(URL_TO_TEST, { + headers: { + 'User-Agent': USER_AGENT, + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5' + } + }) + + console.log(`HTTP Status: ${response.status} ${response.statusText}`) + console.log(`Content-Type: ${response.headers.get('content-type')}`) + + const html = await response.text() + console.log(`HTML length: ${html.length} chars\n`) + + const metadata = await scraper({ html, url: URL_TO_TEST }) + + console.log('=== Metascraper Result ===') + for (const [key, value] of Object.entries(metadata)) { + const display = value ? String(value).slice(0, 120) : '(null)' + console.log(` ${key}: ${display}`) + } + console.log('') + + // Also check raw OG tags for comparison + console.log('=== Raw OG Tags (from HTML) ===') + const ogTags = html.matchAll(/([^<]*)<\/title>/i) + if (titleTag) console.log(` : ${titleTag[1].slice(0, 120)}`) + + console.log('') + } catch (err) { + console.error('FAILED:', err.message) + if (err.cause) console.error('Cause:', err.cause) + } +} + +test() diff --git a/apps/desktop/src/main/database/queries/notes.ts b/apps/desktop/src/main/database/queries/notes.ts index 44983fd30..0d5af92af 100644 --- a/apps/desktop/src/main/database/queries/notes.ts +++ b/apps/desktop/src/main/database/queries/notes.ts @@ -317,6 +317,7 @@ export function getAllTags(db: DrizzleDb): { tag: string; count: number }[] { .groupBy(noteTags.tag) .orderBy(desc(count())) .all() + .map((row) => ({ tag: row.tag, count: Number(row.count) })) } /** diff --git a/apps/desktop/src/main/database/queries/tags.test.ts b/apps/desktop/src/main/database/queries/tags.test.ts index 9ec506340..f744159a0 100644 --- a/apps/desktop/src/main/database/queries/tags.test.ts +++ b/apps/desktop/src/main/database/queries/tags.test.ts @@ -198,6 +198,47 @@ describe('getAllTagsWithCounts', () => { expect(result[1].name).toBe('alpha') }) + it('returns numeric count values for merged note + task tags', () => { + // #given: "work" on 3 notes + 2 tasks = 5 (not "32" from string concat) + insertNote(indexDb, 'n1') + insertNote(indexDb, 'n2') + insertNote(indexDb, 'n3') + insertNoteTag(indexDb, 'n1', 'work') + insertNoteTag(indexDb, 'n2', 'work') + insertNoteTag(indexDb, 'n3', 'work') + insertTask(dataDb, 't1') + insertTask(dataDb, 't2') + insertTaskTag(dataDb, 't1', 'work') + insertTaskTag(dataDb, 't2', 'work') + + // #when + const result = getAllTagsWithCounts(indexDb, dataDb) + + // #then + const work = result.find((t) => t.name === 'work') + expect(work?.count).toBe(5) + expect(typeof work?.count).toBe('number') + }) + + it('all count values are typeof number', () => { + // #given + insertNote(indexDb, 'n1') + insertNote(indexDb, 'n2') + insertNoteTag(indexDb, 'n1', 'alpha') + insertNoteTag(indexDb, 'n2', 'alpha') + insertNoteTag(indexDb, 'n1', 'beta') + insertTask(dataDb, 't1') + insertTaskTag(dataDb, 't1', 'gamma') + + // #when + const result = getAllTagsWithCounts(indexDb, dataDb) + + // #then + for (const tag of result) { + expect(typeof tag.count).toBe('number') + } + }) + it('normalises tag casing when merging counts', () => { // #given: "Work" in notes, "work" in tasks — same tag, different case stored insertNote(indexDb, 'n1') diff --git a/apps/desktop/src/main/database/queries/tags.ts b/apps/desktop/src/main/database/queries/tags.ts index d9927d714..b612c6e27 100644 --- a/apps/desktop/src/main/database/queries/tags.ts +++ b/apps/desktop/src/main/database/queries/tags.ts @@ -2,7 +2,7 @@ import { eq, and, inArray } from 'drizzle-orm' import { noteTags } from '@memry/db-schema/schema/notes-cache' import { taskTags } from '@memry/db-schema/schema/task-relations' import type { TagWithCount } from '@memry/contracts/tags-api' -import { getAllTags, getAllTagDefinitions, getOrCreateTag } from './notes' +import { getAllTags, getAllTagDefinitions, getOrCreateTag, deleteTagDefinition } from './notes' import { getAllTaskTags } from './tasks' type IndexDb = Parameters<typeof getAllTags>[0] @@ -38,7 +38,13 @@ export function getAllTagsWithCounts(indexDb: IndexDb, dataDb: DataDb): TagWithC } } - return [...merged.values()].sort((a, b) => b.count - a.count) + for (const def of definitions) { + if (!merged.has(def.name)) { + deleteTagDefinition(dataDb, def.name) + } + } + + return [...merged.values()].filter((tag) => tag.count > 0).sort((a, b) => b.count - a.count) } export function mergeTagInNotes( diff --git a/apps/desktop/src/main/database/queries/tasks.ts b/apps/desktop/src/main/database/queries/tasks.ts index 9f67f9acd..811e7975e 100644 --- a/apps/desktop/src/main/database/queries/tasks.ts +++ b/apps/desktop/src/main/database/queries/tasks.ts @@ -626,6 +626,7 @@ export function getAllTaskTags(db: DrizzleDb): { tag: string; count: number }[] .groupBy(taskTags.tag) .orderBy(desc(count())) .all() + .map((row) => ({ tag: row.tag, count: Number(row.count) })) } // ============================================================================ diff --git a/apps/desktop/src/main/inbox/filing.test.ts b/apps/desktop/src/main/inbox/filing.test.ts index 5b569869f..acd6cd2f6 100644 --- a/apps/desktop/src/main/inbox/filing.test.ts +++ b/apps/desktop/src/main/inbox/filing.test.ts @@ -114,7 +114,7 @@ describe('Inbox Filing Operations', () => { }) it('should not create folder if it already exists', async () => { - mockGetFolders.mockResolvedValue(['existing-folder']) + mockGetFolders.mockResolvedValue([{ path: 'existing-folder', icon: null }]) const itemId = seedInboxItem(testDb.db, { id: 'item-1', title: 'Test Item' }) await fileToFolder(itemId, 'existing-folder') diff --git a/apps/desktop/src/main/inbox/filing.ts b/apps/desktop/src/main/inbox/filing.ts index 5815a0063..f6e23dd08 100644 --- a/apps/desktop/src/main/inbox/filing.ts +++ b/apps/desktop/src/main/inbox/filing.ts @@ -161,7 +161,7 @@ async function ensureFolderExists(folderPath: string): Promise<void> { try { const existingFolders = await getFolders() - if (!existingFolders.includes(folderPath)) { + if (!existingFolders.some((f) => f.path === folderPath)) { await createFolder(folderPath) log.debug(`Created folder: ${folderPath}`) } diff --git a/apps/desktop/src/main/inbox/metadata.test.ts b/apps/desktop/src/main/inbox/metadata.test.ts index c7e5b23ec..10fe4f5a4 100644 --- a/apps/desktop/src/main/inbox/metadata.test.ts +++ b/apps/desktop/src/main/inbox/metadata.test.ts @@ -11,7 +11,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { mkdirSync, rmSync } from 'fs' import * as path from 'path' import * as os from 'os' -import { fetchUrlMetadata, downloadImage, isValidUrl, extractDomain } from './metadata' +import { + fetchUrlMetadata, + downloadImage, + isValidUrl, + extractDomain, + titleFromUrl, + isBotPageTitle +} from './metadata' // Mock fetch globally const mockFetch = vi.fn() @@ -196,6 +203,133 @@ describe('URL Metadata Extraction', () => { }) }) + // ========================================================================== + // titleFromUrl + // ========================================================================== + describe('titleFromUrl', () => { + it('should extract readable title from URL slug with trailing ID', () => { + // #given + const url = 'https://eksisozluk.com/okunmasi-gereken-kitaplar--77859' + + // #when + const result = titleFromUrl(url) + + // #then + expect(result).toBe('Okunmasi Gereken Kitaplar') + }) + + it('should handle slugs with double-dash numeric IDs', () => { + const url = + 'https://eksisozluk.com/23-mart-2026-donald-trump-aciklamalari--8085786?day=2026-03-23' + + const result = titleFromUrl(url) + + expect(result).toBe('23 Mart 2026 Donald Trump Aciklamalari') + }) + + it('should strip file extensions', () => { + const url = 'https://martinfowler.com/articles/practical-test-pyramid.html' + + const result = titleFromUrl(url) + + expect(result).toBe('Practical Test Pyramid') + }) + + it('should take the last meaningful path segment', () => { + const url = 'https://example.com/blog/2026/my-awesome-post' + + const result = titleFromUrl(url) + + expect(result).toBe('My Awesome Post') + }) + + it('should fallback to domain when path is empty', () => { + const url = 'https://example.com/' + + const result = titleFromUrl(url) + + expect(result).toBe('example.com') + }) + + it('should fallback to domain for root URL without trailing slash', () => { + const url = 'https://example.com' + + const result = titleFromUrl(url) + + expect(result).toBe('example.com') + }) + + it('should handle underscores as word separators', () => { + const url = 'https://example.com/my_great_article' + + const result = titleFromUrl(url) + + expect(result).toBe('My Great Article') + }) + + it('should handle single trailing numeric ID with single dash', () => { + const url = 'https://example.com/some-article-12345' + + const result = titleFromUrl(url) + + expect(result).toBe('Some Article') + }) + + it('should return raw string for invalid URLs', () => { + const result = titleFromUrl('not a url') + + expect(result).toBe('not a url') + }) + + it('should handle URLs with query params', () => { + const url = 'https://example.com/great-post?ref=twitter&utm_source=x' + + const result = titleFromUrl(url) + + expect(result).toBe('Great Post') + }) + + it('should strip www from domain fallback', () => { + const url = 'https://www.example.com' + + const result = titleFromUrl(url) + + expect(result).toBe('example.com') + }) + }) + + // ========================================================================== + // isBotPageTitle + // ========================================================================== + describe('isBotPageTitle', () => { + it('should detect Cloudflare challenge page', () => { + expect(isBotPageTitle('Just a moment...')).toBe(true) + }) + + it('should detect attention required page', () => { + expect(isBotPageTitle('Attention Required! | Cloudflare')).toBe(true) + }) + + it('should detect access denied page', () => { + expect(isBotPageTitle('Access Denied')).toBe(true) + }) + + it('should be case-insensitive', () => { + expect(isBotPageTitle('JUST A MOMENT...')).toBe(true) + expect(isBotPageTitle('access denied')).toBe(true) + }) + + it('should not flag legitimate titles', () => { + expect(isBotPageTitle('How to Build Offline-First Apps')).toBe(false) + expect(isBotPageTitle('23 mart 2026 donald trump açıklamaları')).toBe(false) + }) + + it('should not flag empty or undefined', () => { + expect(isBotPageTitle('')).toBe(false) + expect(isBotPageTitle(undefined as unknown as string)).toBe(false) + }) + }) + // ========================================================================== // T429: downloadImage // ========================================================================== diff --git a/apps/desktop/src/main/inbox/metadata.ts b/apps/desktop/src/main/inbox/metadata.ts index 02a86328c..2f35d9dc9 100644 --- a/apps/desktop/src/main/inbox/metadata.ts +++ b/apps/desktop/src/main/inbox/metadata.ts @@ -249,3 +249,39 @@ export function extractDomain(url: string): string { return url } } + +const BOT_PAGE_TITLES = ['just a moment...', 'attention required!', 'access denied'] + +export function isBotPageTitle(title: string): boolean { + if (!title) return false + const lower = title.toLowerCase() + return BOT_PAGE_TITLES.some((bot) => lower.startsWith(bot)) +} + +export function titleFromUrl(url: string): string { + try { + const parsed = new URL(url) + const segments = parsed.pathname.split('/').filter(Boolean) + + if (segments.length === 0) { + return parsed.hostname.replace(/^www\./, '') + } + + const last = segments[segments.length - 1] + + const cleaned = last + .replace(/\.[a-z]+$/, '') + .replace(/--\d+$/, '') + .replace(/-\d+$/, '') + .replace(/[-_]+/g, ' ') + .trim() + + if (!cleaned) { + return parsed.hostname.replace(/^www\./, '') + } + + return cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) + } catch { + return url + } +} diff --git a/apps/desktop/src/main/inbox/social.test.ts b/apps/desktop/src/main/inbox/social.test.ts index 67195d068..7d0926f3d 100644 --- a/apps/desktop/src/main/inbox/social.test.ts +++ b/apps/desktop/src/main/inbox/social.test.ts @@ -1,13 +1,4 @@ -/** - * Social Media Post Extraction Tests - * - * Tests for extraction of metadata from social media posts - * (Twitter/X, LinkedIn, Mastodon, Bluesky, Threads). - * - * @module main/inbox/social.test - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { extractSocialPost, detectSocialPlatform, @@ -15,21 +6,9 @@ import { createFallbackSocialMetadata } from './social' -// Mock fetch globally -const mockFetch = vi.fn() -global.fetch = mockFetch - -// Mock url-utils module vi.mock('../lib/url-utils', () => ({ detectSocialPlatform: vi.fn(), - isSocialPost: vi.fn(), - extractDomain: vi.fn((url) => { - try { - return new URL(url).hostname - } catch { - return url - } - }) + isSocialPost: vi.fn() })) import { @@ -39,245 +18,115 @@ import { describe('Social Media Post Extraction', () => { beforeEach(() => { - mockFetch.mockReset() vi.mocked(mockDetectSocialPlatform).mockReset() vi.mocked(mockIsSocialPost).mockReset() }) - afterEach(() => { - vi.clearAllMocks() - }) - - // ========================================================================== - // T431: detectSocialPlatform and isSocialPost - // ========================================================================== - describe('Platform Detection', () => { - it('should detect Twitter/X URLs', () => { + describe('Platform Detection (re-exports)', () => { + it('should re-export detectSocialPlatform', () => { vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') expect(detectSocialPlatform('https://twitter.com/user/status/123')).toBe('twitter') - expect(detectSocialPlatform('https://x.com/user/status/123')).toBe('twitter') - }) - - it('should detect LinkedIn URLs', () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('linkedin') - expect(detectSocialPlatform('https://linkedin.com/posts/user_123')).toBe('linkedin') - }) - - it('should detect Mastodon URLs', () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('mastodon') - expect(detectSocialPlatform('https://mastodon.social/@user/123')).toBe('mastodon') }) - it('should detect Bluesky URLs', () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('bluesky') - expect(detectSocialPlatform('https://bsky.app/profile/user/post/123')).toBe('bluesky') + it('should re-export isSocialPost', () => { + vi.mocked(mockIsSocialPost).mockReturnValue(true) + expect(isSocialPost('https://twitter.com/user/status/123')).toBe(true) }) + }) - it('should return null for non-social URLs', () => { + describe('extractSocialPost', () => { + it('should return error for non-social URLs', () => { vi.mocked(mockDetectSocialPlatform).mockReturnValue(null) - expect(detectSocialPlatform('https://example.com')).toBeNull() - }) - it('should identify post URLs vs profile URLs', () => { - vi.mocked(mockIsSocialPost).mockReturnValueOnce(true).mockReturnValueOnce(false) + const result = extractSocialPost('https://example.com/not-social') - expect(isSocialPost('https://twitter.com/user/status/123')).toBe(true) - expect(isSocialPost('https://twitter.com/user')).toBe(false) + expect(result.success).toBe(false) + expect(result.error).toContain('not from a recognized') }) - }) - // ========================================================================== - // T432: extractSocialPost - Twitter/X - // ========================================================================== - describe('extractSocialPost - Twitter/X', () => { - beforeEach(() => { + it('should return partial metadata for profile URLs (not posts)', () => { vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - }) - - it('should extract metadata from Twitter oEmbed', async () => { - const oembedResponse = { - author_name: 'Test User', - author_url: 'https://twitter.com/testuser', - html: '<blockquote class="twitter-tweet"><p lang="en" dir="ltr">This is a test tweet!</p>— Test User (@testuser) <a href="https://twitter.com/testuser/status/123">December 28, 2025</a></blockquote>' - } - - mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(oembedResponse) - }) + vi.mocked(mockIsSocialPost).mockReturnValue(false) - const result = await extractSocialPost('https://twitter.com/testuser/status/123') + const result = extractSocialPost('https://twitter.com/user') expect(result.success).toBe(true) - expect(result.metadata).toBeDefined() - expect(result.metadata?.platform).toBe('twitter') - expect(result.metadata?.authorName).toBe('Test User') - expect(result.metadata?.authorHandle).toBe('@testuser') - expect(result.metadata?.postContent).toContain('test tweet') - }) - - it('should handle protected/deleted tweets', async () => { - mockFetch - .mockResolvedValueOnce({ ok: false, status: 404 }) - .mockResolvedValueOnce({ ok: false, status: 404 }) - - const result = await extractSocialPost('https://twitter.com/user/status/deleted') - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') + expect(result.metadata?.extractionStatus).toBe('partial') + expect(result.metadata?.tweetId).toBeUndefined() }) - it('should handle Twitter API errors', async () => { - mockFetch - .mockResolvedValueOnce({ ok: false, status: 500 }) - .mockResolvedValueOnce({ ok: false, status: 500 }) + it('should extract tweetId from twitter.com status URL', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - const result = await extractSocialPost('https://twitter.com/user/status/123') + const result = extractSocialPost('https://twitter.com/elonmusk/status/1234567890') - expect(result.success).toBe(false) - expect(result.error).toContain('500') + expect(result.success).toBe(true) + expect(result.metadata?.tweetId).toBe('1234567890') + expect(result.metadata?.platform).toBe('twitter') + expect(result.metadata?.postUrl).toBe('https://twitter.com/elonmusk/status/1234567890') }) - it('should parse HTML entities in tweet content', async () => { - const oembedResponse = { - author_name: 'User', - author_url: 'https://twitter.com/user', - html: '<blockquote class="twitter-tweet"><p>Test & more</p>— User (@user) <a href="#">Date</a></blockquote>' - } - - mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(oembedResponse) - }) + it('should extract tweetId from x.com status URL', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - const result = await extractSocialPost('https://twitter.com/user/status/123') + const result = extractSocialPost('https://x.com/user/status/9876543210') - expect(result.metadata?.postContent).toContain('&') - expect(result.metadata?.postContent).toContain('more') + expect(result.success).toBe(true) + expect(result.metadata?.tweetId).toBe('9876543210') }) - }) - // ========================================================================== - // T433: extractSocialPost - Mastodon and Bluesky - // ========================================================================== - describe('extractSocialPost - Mastodon', () => { - beforeEach(() => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('mastodon') + it('should handle URLs with query params and fragments', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') vi.mocked(mockIsSocialPost).mockReturnValue(true) - }) - - it('should extract metadata from Mastodon oEmbed', async () => { - const oembedResponse = { - author_name: 'User Name (@user@mastodon.social)', - title: 'This is a toot!', - html: '<iframe>content</iframe>' - } - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(oembedResponse) - }) - const result = await extractSocialPost('https://mastodon.social/@user/123') + const result = extractSocialPost('https://twitter.com/user/status/111222333?s=20&t=abc#top') expect(result.success).toBe(true) - expect(result.metadata?.platform).toBe('mastodon') - expect(result.metadata?.authorHandle).toContain('@user') + expect(result.metadata?.tweetId).toBe('111222333') }) - it('should handle Mastodon API errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404 - }) + it('should extract handle from URL path', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - const result = await extractSocialPost('https://mastodon.social/@user/123') + const result = extractSocialPost('https://twitter.com/rauchg/status/123456') - expect(result.success).toBe(false) + expect(result.metadata?.authorHandle).toBe('@rauchg') }) - it('should use instance-specific oEmbed endpoint', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ author_name: 'User' }) - }) + it('should be synchronous (no network requests)', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - await extractSocialPost('https://fosstodon.org/@user/123') + const mockFetch = vi.fn() + global.fetch = mockFetch - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('fosstodon.org/api/oembed'), - expect.anything() - ) - }) - }) + extractSocialPost('https://twitter.com/user/status/123') - describe('extractSocialPost - Bluesky', () => { - beforeEach(() => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('bluesky') - vi.mocked(mockIsSocialPost).mockReturnValue(true) + expect(mockFetch).not.toHaveBeenCalled() }) - it('should extract metadata from Bluesky API', async () => { - const apiResponse = { - thread: { - post: { - author: { - displayName: 'Test User', - handle: 'testuser.bsky.social', - avatar: 'https://avatar.url' - }, - record: { - text: 'This is a Bluesky post!', - createdAt: '2025-01-02T12:00:00Z' - }, - likeCount: 10, - repostCount: 5, - replyCount: 2 - } - } - } - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(apiResponse) - }) - - const result = await extractSocialPost( - 'https://bsky.app/profile/testuser.bsky.social/post/abc123' - ) - - expect(result.success).toBe(true) - expect(result.metadata?.platform).toBe('bluesky') - expect(result.metadata?.authorName).toBe('Test User') - expect(result.metadata?.authorHandle).toBe('@testuser.bsky.social') - expect(result.metadata?.postContent).toBe('This is a Bluesky post!') - expect(result.metadata?.metrics?.likes).toBe(10) - }) + it('should set extractionStatus to partial (react-tweet handles full fetch)', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - it('should handle invalid Bluesky URL format', async () => { - const result = await extractSocialPost('https://bsky.app/invalid/path') + const result = extractSocialPost('https://twitter.com/user/status/123') - expect(result.success).toBe(false) - expect(result.error).toContain('Invalid') + expect(result.metadata?.extractionStatus).toBe('partial') }) - it('should fall back to partial metadata on API error', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400 - }) + it('should return empty postContent (react-tweet handles display)', () => { + vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') + vi.mocked(mockIsSocialPost).mockReturnValue(true) - const result = await extractSocialPost('https://bsky.app/profile/user.bsky.social/post/abc') + const result = extractSocialPost('https://twitter.com/user/status/123') - expect(result.success).toBe(true) - expect(result.metadata?.extractionStatus).toBe('partial') + expect(result.metadata?.postContent).toBe('') }) }) - // ========================================================================== - // T434: createFallbackSocialMetadata and edge cases - // ========================================================================== describe('createFallbackSocialMetadata', () => { it('should create fallback metadata with URL', () => { const fallback = createFallbackSocialMetadata( @@ -302,94 +151,7 @@ describe('Social Media Post Extraction', () => { it('should handle "other" platform type', () => { const fallback = createFallbackSocialMetadata('https://unknown.social/post', 'other') - expect(fallback.platform).toBe('other') }) }) - - describe('extractSocialPost - LinkedIn', () => { - beforeEach(() => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('linkedin') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - }) - - it('should return partial metadata for LinkedIn (no public API)', async () => { - const result = await extractSocialPost('https://linkedin.com/posts/user_activity-123') - - expect(result.success).toBe(true) - expect(result.metadata?.platform).toBe('linkedin') - expect(result.metadata?.extractionStatus).toBe('partial') - }) - }) - - describe('extractSocialPost - Threads', () => { - beforeEach(() => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('threads') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - }) - - it('should extract username from Threads URL', async () => { - const result = await extractSocialPost('https://www.threads.net/@username/post/abc123') - - expect(result.success).toBe(true) - expect(result.metadata?.platform).toBe('threads') - expect(result.metadata?.authorHandle).toBe('@username') - expect(result.metadata?.extractionStatus).toBe('partial') - }) - - it('should handle Threads URLs without username', async () => { - const result = await extractSocialPost('https://www.threads.net/post/abc123') - - expect(result.success).toBe(true) - expect(result.metadata?.authorHandle).toBe('') - }) - }) - - describe('extractSocialPost - Edge Cases', () => { - it('should return error for unrecognized platform', async () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue(null) - - const result = await extractSocialPost('https://example.com/not-social') - - expect(result.success).toBe(false) - expect(result.error).toContain('not from a recognized') - }) - - it('should handle non-post URLs (profile pages)', async () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') - vi.mocked(mockIsSocialPost).mockReturnValue(false) - - const result = await extractSocialPost('https://twitter.com/user') - - expect(result.success).toBe(true) - expect(result.metadata?.extractionStatus).toBe('partial') - }) - - it('should handle network timeouts gracefully', async () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - - mockFetch - .mockRejectedValueOnce(new Error('Network timeout')) - .mockRejectedValueOnce(new Error('Network timeout')) - - const result = await extractSocialPost('https://twitter.com/user/status/123') - - expect(result.success).toBe(false) - expect(result.error).toContain('timeout') - }) - - it('should handle AbortError for timeouts', async () => { - vi.mocked(mockDetectSocialPlatform).mockReturnValue('twitter') - vi.mocked(mockIsSocialPost).mockReturnValue(true) - - const abortError = new Error('Aborted') - abortError.name = 'AbortError' - mockFetch.mockRejectedValueOnce(abortError).mockRejectedValueOnce(abortError) - - const result = await extractSocialPost('https://twitter.com/user/status/123') - - expect(result.success).toBe(false) - }) - }) }) diff --git a/apps/desktop/src/main/inbox/social.ts b/apps/desktop/src/main/inbox/social.ts index 90d88bf1e..78c9ee6dc 100644 --- a/apps/desktop/src/main/inbox/social.ts +++ b/apps/desktop/src/main/inbox/social.ts @@ -1,30 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ -// External social media APIs return dynamic JSON structures - type safety not feasible without extensive runtime validation - -/** - * Social Media Post Extraction - * - * Handles extraction of metadata from social media posts (Twitter/X, LinkedIn, - * Mastodon, Bluesky, Threads). Uses oEmbed when available, falls back to - * metascraper for basic metadata extraction. - * - * @module main/inbox/social - */ - -import { createLogger } from '../lib/logger' import type { SocialMetadata } from '@memry/contracts/inbox-api' -import { - detectSocialPlatform, - isSocialPost, - extractDomain, - type SocialPlatform -} from '../lib/url-utils' - -const log = createLogger('Inbox:Social') - -// ============================================================================ -// Types -// ============================================================================ +import { detectSocialPlatform, isSocialPost, type SocialPlatform } from '../lib/url-utils' export interface SocialExtractionResult { success: boolean @@ -32,562 +7,21 @@ export interface SocialExtractionResult { error?: string } -interface OEmbedResponse { - type: string - version: string - title?: string - author_name?: string - author_url?: string - provider_name?: string - provider_url?: string - html?: string - width?: number - height?: number - url?: string -} - -interface TwitterOEmbedResponse extends OEmbedResponse { - author_name: string - author_url: string - html: string -} - -// ============================================================================ -// Constants -// ============================================================================ - -/** oEmbed endpoints for supported platforms */ -const OEMBED_ENDPOINTS: Record<SocialPlatform, string | null> = { - twitter: 'https://publish.twitter.com/oembed', - linkedin: null, // LinkedIn oEmbed requires authentication - mastodon: null, // Mastodon uses instance-specific oEmbed - bluesky: null, // Bluesky doesn't have oEmbed yet - threads: null // Threads doesn't have public oEmbed -} - -/** Request timeout in milliseconds */ -const FETCH_TIMEOUT = 10000 - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Fetch with timeout support - */ -async function fetchWithTimeout( - url: string, - options: RequestInit = {}, - timeout = FETCH_TIMEOUT -): Promise<Response> { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeout) - - try { - const response = await fetch(url, { - ...options, - signal: controller.signal - }) - return response - } finally { - clearTimeout(timeoutId) - } -} - -/** - * Parse Twitter oEmbed HTML to extract post content - * - * Twitter oEmbed returns HTML like: - * <blockquote class="twitter-tweet"><p lang="en" dir="ltr">Tweet content...</p> - * — Author Name (@handle) <a href="...">Date</a></blockquote> - */ -function parseTwitterEmbedHtml(html: string): { - content: string - authorName: string - authorHandle: string - timestamp?: string -} { - // Extract content from <p> tag - const contentMatch = html.match(/<p[^>]*>([\s\S]*?)<\/p>/) - let content = contentMatch ? contentMatch[1] : '' - - // Clean up HTML entities and tags - content = content - .replace(/<a[^>]*>(.*?)<\/a>/g, '$1') // Keep link text, remove tag - .replace(/<br\s*\/?>/g, '\n') // Convert br to newlines - .replace(/—/g, '—') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/<[^>]+>/g, '') // Remove remaining HTML tags - .trim() - - // Extract author info from "— Author Name (@handle)" - const authorMatch = html.match(/—\s*([^(]+)\s*\(@([^)]+)\)/) - const authorName = authorMatch ? authorMatch[1].trim() : '' - const authorHandle = authorMatch ? authorMatch[2].trim() : '' - - // Extract timestamp from the date link - const timestampMatch = html.match(/<a href="[^"]*">([A-Za-z]+ \d+, \d+)<\/a>\s*<\/blockquote>/) - const timestamp = timestampMatch ? timestampMatch[1] : undefined - - return { content, authorName, authorHandle, timestamp } -} - -/** - * Extract handle from Twitter/X author URL - * e.g., "https://twitter.com/username" -> "username" - */ -function extractHandleFromUrl(authorUrl: string): string { - try { - const url = new URL(authorUrl) - const pathParts = url.pathname.split('/').filter(Boolean) - return pathParts[0] || '' - } catch { - return '' - } -} - -/** - * Extract tweet ID from a Twitter/X URL - * e.g., "https://x.com/user/status/123456" -> "123456" - */ function extractTweetId(url: string): string | null { const match = url.match(/\/status\/(\d+)/) return match ? match[1] : null } -// ============================================================================ -// Platform-Specific Extractors -// ============================================================================ - -/** - * Fetch full tweet text via Twitter's Syndication API. - * This endpoint powers Twitter's embed widget and returns the complete tweet - * text — including content behind the "Show more" fold that oEmbed omits. - */ -async function fetchTweetViaSyndication(tweetId: string): Promise<{ - text: string - authorName: string - authorHandle: string - authorAvatar?: string - timestamp?: string - mediaUrls: string[] -} | null> { - try { - const syndicationUrl = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&lang=en&token=x` - log.debug(`Fetching tweet via Syndication API: ${syndicationUrl}`) - - const response = await fetchWithTimeout(syndicationUrl, { - headers: { - Accept: 'application/json', - 'User-Agent': 'Memry/1.0' - } - }) - - if (!response.ok) { - log.debug(`Syndication API returned ${response.status}`) - return null - } - - const data = await response.json() - - log.info(`[DEBUG] Syndication API response keys: ${Object.keys(data).join(', ')}`) - log.info(`[DEBUG] Syndication text length: ${(data.text || '').length}`) - log.info(`[DEBUG] Syndication text:\n${data.text}`) - - if (!data.text) { - log.debug('Syndication API returned no text') - return null - } - - const mediaUrls: string[] = [] - if (data.mediaDetails && Array.isArray(data.mediaDetails)) { - for (const media of data.mediaDetails) { - if (media.media_url_https) { - mediaUrls.push(media.media_url_https as string) - } - } - } - if (data.photos && Array.isArray(data.photos)) { - for (const photo of data.photos) { - if (photo.url) { - mediaUrls.push(photo.url as string) - } - } - } - - return { - text: data.text, - authorName: data.user?.name || '', - authorHandle: data.user?.screen_name || '', - authorAvatar: data.user?.profile_image_url_https, - timestamp: data.created_at, - mediaUrls - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.debug(`Syndication API failed: ${message}`) - return null - } -} - -/** - * Extract metadata from Twitter/X posts. - * - * Strategy: - * 1. Try Syndication API first — returns full tweet text (including "Show more" content) - * 2. Fall back to oEmbed API — only returns above-the-fold text for long tweets - */ -async function extractTwitterPost(url: string): Promise<SocialExtractionResult> { - const tweetId = extractTweetId(url) - - // --- Attempt 1: Syndication API (full text) --- - if (tweetId) { - const syndication = await fetchTweetViaSyndication(tweetId) - if (syndication) { - const metadata: SocialMetadata = { - platform: 'twitter', - postUrl: url, - authorName: syndication.authorName || 'Unknown', - authorHandle: syndication.authorHandle ? `@${syndication.authorHandle}` : '', - authorAvatar: syndication.authorAvatar, - postContent: syndication.text, - timestamp: syndication.timestamp, - mediaUrls: syndication.mediaUrls, - extractionStatus: 'full' - } - - log.info( - `Twitter syndication extraction successful: ${metadata.authorHandle}, content length: ${metadata.postContent.length}` - ) - return { success: true, metadata } - } - log.info('Syndication API failed, falling back to oEmbed') - } - - // --- Attempt 2: oEmbed API (may truncate long tweets) --- - const endpoint = OEMBED_ENDPOINTS.twitter - if (!endpoint) { - return { - success: false, - metadata: null, - error: 'Twitter oEmbed endpoint not configured' - } - } - - try { - const oembedUrl = `${endpoint}?url=${encodeURIComponent(url)}&omit_script=true&dnt=true` - log.debug(`Fetching Twitter oEmbed: ${oembedUrl}`) - - const response = await fetchWithTimeout(oembedUrl, { - headers: { - Accept: 'application/json', - 'User-Agent': 'Memry/1.0' - } - }) - - if (!response.ok) { - if (response.status === 404) { - return { success: false, metadata: null, error: 'Tweet not found or protected' } - } - return { success: false, metadata: null, error: `Twitter oEmbed returned ${response.status}` } - } - - const data = (await response.json()) as TwitterOEmbedResponse - const parsed = parseTwitterEmbedHtml(data.html || '') - const authorHandle = parsed.authorHandle || extractHandleFromUrl(data.author_url || '') - - const metadata: SocialMetadata = { - platform: 'twitter', - postUrl: url, - authorName: data.author_name || parsed.authorName || 'Unknown', - authorHandle: authorHandle ? `@${authorHandle}` : '', - authorAvatar: undefined, - postContent: parsed.content || data.title || '', - timestamp: parsed.timestamp, - mediaUrls: [], - extractionStatus: parsed.content ? 'full' : 'partial' - } - - log.info( - `Twitter oEmbed extraction successful: @${authorHandle}, content length: ${metadata.postContent.length}` - ) - return { success: true, metadata } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.error('Twitter extraction failed:', message) - return { success: false, metadata: null, error: `Twitter extraction failed: ${message}` } - } -} - -/** - * Extract metadata from Mastodon posts - * - * Mastodon instances have their own oEmbed endpoints at /api/oembed - */ -async function extractMastodonPost(url: string): Promise<SocialExtractionResult> { - try { - const domain = extractDomain(url) - if (!domain) { - return { success: false, metadata: null, error: 'Invalid Mastodon URL' } - } - - // Mastodon oEmbed endpoint - const oembedUrl = `https://${domain}/api/oembed?url=${encodeURIComponent(url)}` - log.debug(`Fetching Mastodon oEmbed: ${oembedUrl}`) - - const response = await fetchWithTimeout(oembedUrl, { - headers: { - Accept: 'application/json', - 'User-Agent': 'Memry/1.0' - } - }) - - if (!response.ok) { - return { - success: false, - metadata: null, - error: `Mastodon oEmbed returned ${response.status}` - } - } - - const data = (await response.json()) as OEmbedResponse - - // Parse author info from author_name (format varies by instance) - // Common format: "Username (@handle@instance.social)" - let authorName = data.author_name || '' - let authorHandle = '' - - const handleMatch = authorName.match(/\(@([^)]+)\)/) - if (handleMatch) { - authorHandle = `@${handleMatch[1]}` - authorName = authorName.replace(/\s*\(@[^)]+\)/, '').trim() - } - - // Extract content from HTML if available - let postContent = data.title || '' - if (data.html) { - // Simple HTML stripping for Mastodon embeds - postContent = data.html - .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') - .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') - .replace(/<[^>]+>/g, ' ') - .replace(/\s+/g, ' ') - .trim() - } - - const metadata: SocialMetadata = { - platform: 'mastodon', - postUrl: url, - authorName: authorName || 'Unknown', - authorHandle, - postContent, - mediaUrls: [], - extractionStatus: postContent ? 'full' : 'partial' - } - - log.info(`Mastodon extraction successful: ${authorHandle}`) - - return { success: true, metadata } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.error('Mastodon extraction failed:', message) - return { - success: false, - metadata: null, - error: `Mastodon extraction failed: ${message}` - } - } -} - -/** - * Extract metadata from Bluesky posts - * - * Bluesky uses AT Protocol. We can try to fetch post data via their public API. - * URL format: https://bsky.app/profile/{handle}/post/{postId} - */ -async function extractBlueskyPost(url: string): Promise<SocialExtractionResult> { +function extractHandleFromPath(url: string): string { try { - // Parse Bluesky URL to extract handle and post ID - const urlObj = new URL(url) - const pathParts = urlObj.pathname.split('/').filter(Boolean) - - // Expected format: /profile/{handle}/post/{postId} - if (pathParts.length < 4 || pathParts[0] !== 'profile' || pathParts[2] !== 'post') { - return { success: false, metadata: null, error: 'Invalid Bluesky post URL format' } - } - - const handle = pathParts[1] - const postId = pathParts[3] - - // Bluesky public API endpoint - const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://${handle}/app.bsky.feed.post/${postId}&depth=0` - log.debug(`Fetching Bluesky post: ${apiUrl}`) - - const response = await fetchWithTimeout(apiUrl, { - headers: { - Accept: 'application/json', - 'User-Agent': 'Memry/1.0' - } - }) - - if (!response.ok) { - // Try alternative: resolve handle first - if (response.status === 400) { - // Handle might need DID resolution - fall back to partial extraction - return { - success: true, - metadata: { - platform: 'bluesky', - postUrl: url, - authorName: handle, - authorHandle: `@${handle}`, - postContent: '', // Can't get content without API - mediaUrls: [], - extractionStatus: 'partial' - } - } - } - return { - success: false, - metadata: null, - error: `Bluesky API returned ${response.status}` - } - } - - const data = await response.json() - const post = data.thread?.post - - if (!post) { - return { success: false, metadata: null, error: 'Post not found in response' } - } - - const author = post.author || {} - const record = post.record || {} - - const metadata: SocialMetadata = { - platform: 'bluesky', - postUrl: url, - authorName: author.displayName || author.handle || handle, - authorHandle: `@${author.handle || handle}`, - authorAvatar: author.avatar, - postContent: record.text || '', - timestamp: record.createdAt, - mediaUrls: (post.embed?.images || []).map((img: { fullsize: string }) => img.fullsize), - metrics: { - likes: post.likeCount, - reposts: post.repostCount, - replies: post.replyCount - }, - extractionStatus: 'full' - } - - log.info(`Bluesky extraction successful: @${author.handle}`) - - return { success: true, metadata } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - log.error('Bluesky extraction failed:', message) - return { - success: false, - metadata: null, - error: `Bluesky extraction failed: ${message}` - } - } -} - -/** - * Extract metadata from LinkedIn posts - * - * LinkedIn has very limited public access. We extract what we can from the URL - * and basic page metadata. - */ -function extractLinkedInPost(url: string): SocialExtractionResult { - // LinkedIn doesn't have a public oEmbed API that works without authentication - // Return partial metadata with the URL - return { - success: true, - metadata: { - platform: 'linkedin', - postUrl: url, - authorName: '', - authorHandle: '', - postContent: '', - mediaUrls: [], - extractionStatus: 'partial' - } - } -} - -/** - * Extract metadata from Threads posts - * - * Threads doesn't have a public API yet. We can only store the URL. - */ -function extractThreadsPost(url: string): SocialExtractionResult { - // Parse URL to get username at minimum - // Format: https://www.threads.net/@username/post/... - try { - const urlObj = new URL(url) - const pathParts = urlObj.pathname.split('/').filter(Boolean) - - let authorHandle = '' - if (pathParts.length > 0 && pathParts[0].startsWith('@')) { - authorHandle = pathParts[0] - } - - return { - success: true, - metadata: { - platform: 'threads', - postUrl: url, - authorName: '', - authorHandle, - postContent: '', - mediaUrls: [], - extractionStatus: 'partial' - } - } + const pathParts = new URL(url).pathname.split('/').filter(Boolean) + return pathParts[0] ? `@${pathParts[0]}` : '' } catch { - return { - success: true, - metadata: { - platform: 'threads', - postUrl: url, - authorName: '', - authorHandle: '', - postContent: '', - mediaUrls: [], - extractionStatus: 'partial' - } - } + return '' } } -// ============================================================================ -// Main Extraction Function -// ============================================================================ - -/** - * Extract social media post metadata from a URL - * - * Detects the platform and uses the appropriate extraction method. - * Falls back gracefully if extraction fails. - * - * @param url - The social media post URL - * @returns Extraction result with metadata or error - * - * @example - * const result = await extractSocialPost('https://twitter.com/user/status/123') - * if (result.success && result.metadata) { - * console.log(result.metadata.authorName, result.metadata.postContent) - * } - */ -export async function extractSocialPost(url: string): Promise<SocialExtractionResult> { - // Detect platform +export function extractSocialPost(url: string): SocialExtractionResult { const platform = detectSocialPlatform(url) if (!platform) { @@ -598,9 +32,7 @@ export async function extractSocialPost(url: string): Promise<SocialExtractionRe } } - // Check if it's actually a post (not just a profile page) if (!isSocialPost(url)) { - log.debug(`URL is not a post, treating as profile/page: ${url}`) return { success: true, metadata: { @@ -615,42 +47,26 @@ export async function extractSocialPost(url: string): Promise<SocialExtractionRe } } - log.debug(`Extracting ${platform} post: ${url}`) + const tweetId = extractTweetId(url) ?? undefined + const authorHandle = extractHandleFromPath(url) - // Route to platform-specific extractor - switch (platform) { - case 'twitter': - return extractTwitterPost(url) - case 'mastodon': - return extractMastodonPost(url) - case 'bluesky': - return extractBlueskyPost(url) - case 'linkedin': - return extractLinkedInPost(url) - case 'threads': - return extractThreadsPost(url) - default: - return { - success: false, - metadata: null, - error: `Unsupported platform: ${platform}` - } + return { + success: true, + metadata: { + platform: 'twitter', + tweetId, + postUrl: url, + authorName: '', + authorHandle, + postContent: '', + mediaUrls: [], + extractionStatus: 'partial' + } } } -/** - * Check if a URL should be treated as a social post - * - * Re-exports from url-utils for convenience - */ export { detectSocialPlatform, isSocialPost } -/** - * Create fallback social metadata when extraction fails - * - * Ensures we always have some metadata to store, even if extraction fails. - * Uses the URL as the main identifier. - */ export function createFallbackSocialMetadata( url: string, platform: SocialPlatform | 'other', diff --git a/apps/desktop/src/main/inbox/transcription.ts b/apps/desktop/src/main/inbox/transcription.ts index 49b560a11..b6c224b1a 100644 --- a/apps/desktop/src/main/inbox/transcription.ts +++ b/apps/desktop/src/main/inbox/transcription.ts @@ -253,12 +253,26 @@ export async function transcribeAudio( log.info(`Success for item ${itemId}: "${transcription.substring(0, 50)}..."`) - // Update item with transcription + // Auto-title from transcription if still using default name + const currentItem = db + .select({ title: inboxItems.title }) + .from(inboxItems) + .where(eq(inboxItems.id, itemId)) + .get() + let autoTitle: string | undefined + if (currentItem?.title.startsWith('Voice memo (') && transcription.length > 0) { + const firstSentence = transcription.match(/^[^.!?]+[.!?]?/)?.[0] ?? transcription + autoTitle = + firstSentence.length > 60 ? firstSentence.slice(0, 57).trim() + '...' : firstSentence.trim() + } + + // Update item with transcription (+ auto-title if applicable) db.update(inboxItems) .set({ transcription, transcriptionStatus: 'complete', processingError: null, + ...(autoTitle ? { title: autoTitle } : {}), modifiedAt: new Date().toISOString() }) .where(eq(inboxItems.id, itemId)) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6605ac96d..7b61f1411 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -9,15 +9,18 @@ import { clipboard, screen, session, + nativeImage, Menu, MenuItem } from 'electron' import { join, resolve, normalize } from 'path' import { homedir } from 'node:os' -import { existsSync, readdirSync } from 'node:fs' +import { existsSync, readdirSync, statSync, createReadStream } from 'node:fs' +import { lookup as mimeLookup } from 'mime-types' import { config } from 'dotenv' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { registerAllHandlers } from './ipc' +import { applyGlobalCaptureShortcut } from './ipc/settings-handlers' import { autoOpenLastVault, closeVault } from './vault' import { getCurrentVaultPath } from './store' import { startSnoozeScheduler, stopSnoozeScheduler, checkDueItemsOnStartup } from './inbox/snooze' @@ -148,9 +151,9 @@ function configureCsp(): void { "default-src 'self' memry-file:", "script-src 'self'", "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: memry-file:", + "img-src 'self' data: memry-file: https://pbs.twimg.com", "font-src 'self' data:", - "connect-src 'self' memry-file: https://*.memrynote.com wss://*.memrynote.com http://127.0.0.1:*", + "connect-src 'self' memry-file: https://*.memrynote.com wss://*.memrynote.com https://cdn.syndication.twimg.com https://react-tweet.vercel.app http://127.0.0.1:*", "media-src 'self' memry-file:", "worker-src 'self' blob:", "object-src 'none'", @@ -162,7 +165,7 @@ function configureCsp(): void { if (is.dev) { policy[1] = "script-src 'self' 'unsafe-eval' 'unsafe-inline'" policy[5] = - "connect-src 'self' memry-file: https://*.memrynote.com wss://*.memrynote.com ws://localhost:* http://localhost:* http://127.0.0.1:*" + "connect-src 'self' memry-file: https://*.memrynote.com wss://*.memrynote.com https://cdn.syndication.twimg.com https://react-tweet.vercel.app ws://localhost:* http://localhost:* http://127.0.0.1:*" } const cspString = policy.join('; ') @@ -234,6 +237,7 @@ function createWindow(): void { height: 900, show: false, autoHideMenuBar: true, + icon: join(__dirname, '../../build/icon.png'), ...(process.platform === 'darwin' ? { titleBarStyle: 'hidden', @@ -398,7 +402,6 @@ void app.whenReady().then(async () => { return new Response(null, { status: 403, statusText: 'Forbidden' }) } - const { existsSync } = await import('fs') if (!existsSync(filePath)) { // Return empty 1x1 transparent PNG for missing image files (null thumbnails) // This avoids console errors and broken image icons @@ -424,11 +427,9 @@ void app.whenReady().then(async () => { } try { - const { statSync, createReadStream } = await import('fs') - const { lookup } = await import('mime-types') const stats = statSync(filePath) const fileSize = stats.size - const mimeType = lookup(filePath) || 'application/octet-stream' + const mimeType = mimeLookup(filePath) || 'application/octet-stream' // Check for Range header (needed for video/audio seeking) const rangeHeader = request.headers.get('Range') @@ -514,6 +515,13 @@ void app.whenReady().then(async () => { return clipboard.readText() }) + ipcMain.on('quick-capture:resize', (_event, height: number) => { + if (!quickCaptureWindow || quickCaptureWindow.isDestroyed()) return + const clamped = Math.max(120, Math.min(400, Math.round(height))) + const [width] = quickCaptureWindow.getSize() + quickCaptureWindow.setSize(width, clamped) + }) + // Deep link handler for memry:// protocol (T041e) // macOS: deep links arrive via open-url event app.on('open-url', (event, url) => { @@ -582,36 +590,41 @@ void app.whenReady().then(async () => { .initPersistence() .catch((err) => mainLog.warn('Early CRDT persistence init failed (non-fatal)', err)) - // Register global shortcut for quick capture (Cmd+Shift+Space) - registerQuickCaptureShortcut() - - // Auto-open the last vault if one was previously open - await autoOpenLastVault() - - // Start the snooze scheduler for inbox items - // This checks for due items on startup and then every minute - try { - checkDueItemsOnStartup() - startSnoozeScheduler() - } catch (error) { - // Snooze scheduler is non-critical - log and continue - mainLog.warn('snooze scheduler failed to start:', error) - } - - // Start the reminder scheduler for notes/journal/highlights - // This checks for due reminders on startup and then every minute - try { - startReminderScheduler() - } catch (error) { - // Reminder scheduler is non-critical - log and continue - mainLog.warn('reminder scheduler failed to start:', error) + // Register global shortcut for quick capture from keyboard settings (fallback: hardcoded default) + const globalCaptureResult = applyGlobalCaptureShortcut() + if (!globalCaptureResult.registered) { + registerQuickCaptureShortcut() } + // Configure CSP and cert pinning before the window loads configureCsp() configureCertificatePinning() + if (process.platform === 'darwin' && !app.isPackaged) { + const iconPath = join(__dirname, '../../build/icon.png') + app.dock?.setIcon(nativeImage.createFromPath(iconPath)) + } + createWindow() + // Open the last vault and start schedulers concurrently with renderer load. + // The renderer subscribes to vault status events and updates automatically. + void autoOpenLastVault() + .then(() => { + try { + checkDueItemsOnStartup() + startSnoozeScheduler() + } catch (error) { + mainLog.warn('snooze scheduler failed to start:', error) + } + try { + startReminderScheduler() + } catch (error) { + mainLog.warn('reminder scheduler failed to start:', error) + } + }) + .catch((err) => mainLog.error('autoOpenLastVault failed:', err)) + app.on('activate', function () { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. @@ -642,7 +655,7 @@ function showQuickCaptureWindow(): void { const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize const windowWidth = 480 - const windowHeight = 200 + const windowHeight = 82 // Calculate center position const x = Math.round((screenWidth - windowWidth) / 2) diff --git a/apps/desktop/src/main/ipc/account-handlers.ts b/apps/desktop/src/main/ipc/account-handlers.ts new file mode 100644 index 000000000..0cc9ad21d --- /dev/null +++ b/apps/desktop/src/main/ipc/account-handlers.ts @@ -0,0 +1,91 @@ +/** + * Account IPC Handlers + * + * Handles account-level IPC requests: account info, sign-out. + * Device management (list/remove) uses existing SYNC_CHANNELS in sync-handlers. + * + * @module main/ipc/account-handlers + */ + +import { ipcMain } from 'electron' +import sodium from 'libsodium-wrappers-sumo' +import { AccountChannels } from '@memry/contracts/ipc-channels' +import { KEYCHAIN_ENTRIES } from '@memry/contracts/crypto' +import { asc } from 'drizzle-orm' +import { syncDevices } from '@memry/db-schema/schema/sync-devices' +import { createLogger } from '../lib/logger' +import { getDatabase, isDatabaseInitialized } from '../database/client' +import { store } from '../store' +import { teardownSession } from '../sync/session-teardown' +import { retrieveKey } from '../crypto' +import { getValidAccessToken } from '../sync/token-manager' + +const log = createLogger('IPC:Account') + +export interface AccountInfo { + email: string | null + joinedAt: number | null +} + +function getAccountInfo(): AccountInfo { + const email = store.get('sync').email ?? null + + let joinedAt: number | null = null + if (isDatabaseInitialized()) { + const db = getDatabase() + const earliest = db + .select({ linkedAt: syncDevices.linkedAt }) + .from(syncDevices) + .orderBy(asc(syncDevices.linkedAt)) + .limit(1) + .get() + if (earliest) { + joinedAt = earliest.linkedAt.getTime() + } + } + + return { email, joinedAt } +} + +export function registerAccountHandlers(): void { + ipcMain.handle(AccountChannels.invoke.GET_INFO, () => { + log.info('account:getInfo requested') + return getAccountInfo() + }) + + ipcMain.handle(AccountChannels.invoke.SIGN_OUT, async () => { + log.info('account:signOut requested') + const result = await teardownSession('logout') + return { + success: true, + ...(result.keychainFailures.length > 0 && { + keychainWarning: `Failed to remove: ${result.keychainFailures.join(', ')}` + }) + } + }) + + ipcMain.handle(AccountChannels.invoke.GET_RECOVERY_KEY, async () => { + log.info('account:getRecoveryKey requested') + const token = await getValidAccessToken() + if (!token) { + return { success: false, error: 'Not authenticated' } + } + try { + const masterKey = await retrieveKey(KEYCHAIN_ENTRIES.MASTER_KEY) + if (!masterKey) { + return { success: false, error: 'Recovery key not available on this device' } + } + const encoded = sodium.to_base64(masterKey, sodium.base64_variants.URLSAFE_NO_PADDING) + return { success: true, key: encoded } + } catch (err) { + log.error('Failed to retrieve recovery key', err) + return { success: false, error: 'Failed to retrieve recovery key' } + } + }) +} + +export function unregisterAccountHandlers(): void { + ipcMain.removeHandler(AccountChannels.invoke.GET_INFO) + ipcMain.removeHandler(AccountChannels.invoke.SIGN_OUT) + ipcMain.removeHandler(AccountChannels.invoke.GET_RECOVERY_KEY) +} diff --git a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts index 861fd65b6..727caa453 100644 --- a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts +++ b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts @@ -2,6 +2,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ export interface MainIpcInvokeHandlers { + "account:getInfo": (...args: []) => Awaited<import("./account-handlers").AccountInfo> + "account:getRecoveryKey": (...args: []) => Awaited<Promise<{ success: boolean; error: string; key?: undefined; } | { success: boolean; key: string; error?: undefined; }>> + "account:signOut": (...args: []) => Awaited<Promise<{ keychainWarning?: string | undefined; success: boolean; }>> "ai-inline:get-server-port": (...args: []) => Awaited<number | null> "ai-inline:get-settings": (...args: []) => Awaited<import("../../../../../packages/contracts/src/ai-inline-channels").AIInlineSettings> "ai-inline:set-settings": (...args: [Partial<import("../../../../../packages/contracts/src/ai-inline-channels").AIInlineSettings>]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> @@ -48,7 +51,6 @@ export interface MainIpcInvokeHandlers { "inbox:add-tag": (...args: [any, any]) => Awaited<Promise<{ success: boolean; error?: string | undefined; }>> "inbox:archive": (...args: [any]) => Awaited<Promise<{ success: boolean; error?: string | undefined; }>> "inbox:bulk-archive": (...args: [any]) => Awaited<Promise<import("../../../../../packages/contracts/src/inbox-api").BulkResponse>> - "inbox:bulk-archive-older-than": (...args: [any]) => Awaited<Promise<import("../../../../../packages/contracts/src/inbox-api").BulkResponse>> "inbox:bulk-file": (...args: [any]) => Awaited<Promise<import("../../../../../packages/contracts/src/inbox-api").BulkResponse>> "inbox:bulk-snooze": (...args: [any]) => Awaited<Promise<{ success: boolean; processedCount: number; errors: { itemId: string; error: string; }[]; }>> "inbox:bulk-tag": (...args: [any]) => Awaited<Promise<import("../../../../../packages/contracts/src/inbox-api").BulkResponse>> @@ -75,6 +77,7 @@ export interface MainIpcInvokeHandlers { "inbox:list": (...args: [any]) => Awaited<Promise<import("../../../../../packages/contracts/src/inbox-api").InboxListResponse>> "inbox:list-archived": (...args: [any]) => Awaited<Promise<import("../../../../../packages/contracts/src/inbox-api").ArchivedListResponse>> "inbox:mark-viewed": (...args: [any]) => Awaited<Promise<{ success: boolean; error?: string | undefined; }>> + "inbox:preview-link": (...args: [string]) => Awaited<Promise<{ title: string; domain: string; favicon: string | undefined; image: string | undefined; description: string | undefined; } | { title: string; domain: string; favicon?: undefined; image?: undefined; description?: undefined; }>> "inbox:remove-tag": (...args: [any, any]) => Awaited<Promise<{ success: boolean; error?: string | undefined; }>> "inbox:retry-metadata": (...args: [any]) => Awaited<Promise<{ success: boolean; error?: string | undefined; }>> "inbox:retry-transcription": (...args: [any]) => Awaited<Promise<{ success: boolean; error?: string | undefined; }>> @@ -112,7 +115,7 @@ export interface MainIpcInvokeHandlers { "notes:get-file": (...args: [string]) => Awaited<Promise<import("../vault/notes").FileMetadata | null>> "notes:get-folder-config": (...args: [string]) => Awaited<Promise<import("../../../../../packages/contracts/src/templates-api").FolderConfig | null>> "notes:get-folder-template": (...args: [string]) => Awaited<Promise<string | null>> - "notes:get-folders": (...args: []) => Awaited<Promise<string[]>> + "notes:get-folders": (...args: []) => Awaited<Promise<import("../../../../../packages/contracts/src/templates-api").FolderInfo[]>> "notes:get-links": (...args: [string]) => Awaited<Promise<import("../vault/notes").NoteLinksResponse>> "notes:get-local-only-count": (...args: []) => Awaited<Promise<{ count: number; }>> "notes:get-positions": (...args: [{ folderPath: string; }]) => Awaited<Promise<{ success: boolean; positions: { path: string; position: number; folderPath: string; }[]; error?: undefined; } | { success: boolean; positions: never[]; error: string; }>> @@ -131,7 +134,7 @@ export interface MainIpcInvokeHandlers { "notes:resolve-by-title": (...args: [string]) => Awaited<Promise<{ id: string; path: string; title: string; fileType: import("../../../../../packages/shared/src/file-types").FileType; } | null>> "notes:restore-version": (...args: [string]) => Awaited<Promise<{ success: boolean; note: import("../vault/notes").Note; error?: undefined; } | { success: boolean; note: null; error: string; }>> "notes:reveal-in-finder": (...args: [string]) => Awaited<Promise<void>> - "notes:set-folder-config": (...args: [{ folderPath: string; config: { template?: string | undefined; inherit?: boolean | undefined; }; }]) => Awaited<Promise<{ success: boolean; error?: undefined; } | { success: boolean; error: string; }>> + "notes:set-folder-config": (...args: [{ folderPath: string; config: { icon?: string | null | undefined; template?: string | undefined; inherit?: boolean | undefined; }; }]) => Awaited<Promise<{ success: boolean; error?: undefined; } | { success: boolean; error: string; }>> "notes:set-local-only": (...args: [{ id: string; localOnly: boolean; }]) => Awaited<Promise<{ success: boolean; note: import("../vault/notes").Note; error?: undefined; } | { success: boolean; note: null; error: string; }>> "notes:show-import-dialog": (...args: []) => Awaited<Promise<{ canceled: boolean; filePaths: string[]; }>> "notes:update": (...args: [{ id: string; title?: string | undefined; content?: string | undefined; tags?: string[] | undefined; frontmatter?: Record<string, unknown> | undefined; emoji?: string | null | undefined; }]) => Awaited<Promise<{ success: boolean; note: import("../vault/notes").Note; error?: undefined; } | { success: boolean; note: null; error: string; }>> @@ -171,7 +174,7 @@ export interface MainIpcInvokeHandlers { "settings:getAISettings": (...args: []) => Awaited<import("./settings-handlers").AISettings> "settings:getBackupSettings": (...args: []) => Awaited<{ autoBackup: boolean; frequencyHours: 1 | 6 | 12 | 24; maxBackups: number; lastBackupAt: string | null; }> "settings:getEditorSettings": (...args: []) => Awaited<{ width: "medium" | "narrow" | "wide"; spellCheck: boolean; autoSaveDelay: number; showWordCount: boolean; toolbarMode: "floating" | "sticky"; }> - "settings:getGeneralSettings": (...args: []) => Awaited<{ theme: "light" | "dark" | "white" | "system"; fontSize: "small" | "medium" | "large"; fontFamily: "system" | "serif" | "sans-serif" | "monospace"; accentColor: string; startOnBoot: boolean; language: string; onboardingCompleted: boolean; }> + "settings:getGeneralSettings": (...args: []) => Awaited<{ theme: "light" | "dark" | "white" | "system"; fontSize: "small" | "medium" | "large"; fontFamily: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter"; accentColor: string; startOnBoot: boolean; language: string; onboardingCompleted: boolean; }> "settings:getGraphSettings": (...args: []) => Awaited<{ layout: "forceatlas2" | "circular" | "random"; showLabels: boolean; showEdgeLabels: boolean; animateLayout: boolean; showTagEdges: boolean; }> "settings:getJournalSettings": (...args: []) => Awaited<{ defaultTemplate: string | null; showSchedule: boolean; showTasks: boolean; showAIConnections: boolean; showStatsFooter: boolean; }> "settings:getKeyboardSettings": (...args: []) => Awaited<{ overrides: Record<string, { key: string; modifiers: { meta?: boolean | undefined; ctrl?: boolean | undefined; shift?: boolean | undefined; alt?: boolean | undefined; }; }>; globalCapture: { key: string; modifiers: { meta?: boolean | undefined; ctrl?: boolean | undefined; shift?: boolean | undefined; alt?: boolean | undefined; }; } | null; }> @@ -180,13 +183,14 @@ export interface MainIpcInvokeHandlers { "settings:getTabSettings": (...args: []) => Awaited<import("./settings-handlers").TabSettings> "settings:getTaskSettings": (...args: []) => Awaited<{ defaultProjectId: string | null; defaultSortOrder: "createdAt" | "priority" | "dueDate" | "manual"; weekStartDay: "sunday" | "monday"; staleInboxDays: number; }> "settings:loadAIModel": (...args: []) => Awaited<Promise<{ success: boolean; message: string; error?: undefined; } | { success: boolean; error: string; message?: undefined; } | { success: boolean; message?: undefined; error?: undefined; }>> + "settings:registerGlobalCapture": (...args: []) => Awaited<Promise<import("./settings-handlers").GlobalCaptureResult>> "settings:reindexEmbeddings": (...args: []) => Awaited<Promise<{ success: boolean; computed: number; skipped: number; error?: string | undefined; }>> "settings:resetKeyboardSettings": (...args: []) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> "settings:set": (...args: [{ key: string; value: string; }]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> "settings:setAISettings": (...args: [Partial<import("./settings-handlers").AISettings>]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> "settings:setBackupSettings": (...args: [Partial<{ autoBackup: boolean; frequencyHours: 1 | 6 | 12 | 24; maxBackups: number; lastBackupAt: string | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setEditorSettings": (...args: [Partial<{ width: "medium" | "narrow" | "wide"; spellCheck: boolean; autoSaveDelay: number; showWordCount: boolean; toolbarMode: "floating" | "sticky"; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> - "settings:setGeneralSettings": (...args: [Partial<{ theme: "light" | "dark" | "white" | "system"; fontSize: "small" | "medium" | "large"; fontFamily: "system" | "serif" | "sans-serif" | "monospace"; accentColor: string; startOnBoot: boolean; language: string; onboardingCompleted: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> + "settings:setGeneralSettings": (...args: [Partial<{ theme: "light" | "dark" | "white" | "system"; fontSize: "small" | "medium" | "large"; fontFamily: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter"; accentColor: string; startOnBoot: boolean; language: string; onboardingCompleted: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setGraphSettings": (...args: [Partial<{ layout: "forceatlas2" | "circular" | "random"; showLabels: boolean; showEdgeLabels: boolean; animateLayout: boolean; showTagEdges: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setJournalSettings": (...args: [Partial<import("./settings-handlers").JournalSettings>]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> "settings:setKeyboardSettings": (...args: [Partial<{ overrides: Record<string, { key: string; modifiers: { meta?: boolean | undefined; ctrl?: boolean | undefined; shift?: boolean | undefined; alt?: boolean | undefined; }; }>; globalCapture: { key: string; modifiers: { meta?: boolean | undefined; ctrl?: boolean | undefined; shift?: boolean | undefined; alt?: boolean | undefined; }; } | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> @@ -210,7 +214,7 @@ export interface MainIpcInvokeHandlers { "sync:get-recovery-phrase": (...args: []) => Awaited<string | null> "sync:get-status": (...args: []) => Awaited<import("../../../../../packages/contracts/src/ipc-sync-ops").GetSyncStatusResult | { status: string; pendingCount: number; }> "sync:get-storage-breakdown": (...args: []) => Awaited<Promise<import("../../../../../packages/contracts/src/ipc-sync-ops").StorageBreakdownResult | null>> - "sync:get-synced-settings": (...args: []) => Awaited<{ general?: { theme?: "light" | "dark" | "white" | "system" | undefined; fontSize?: "small" | "medium" | "large" | undefined; fontFamily?: "system" | "serif" | "sans-serif" | "monospace" | undefined; accentColor?: string | undefined; startOnBoot?: boolean | undefined; language?: string | undefined; } | undefined; editor?: { width?: "medium" | "narrow" | "wide" | undefined; spellCheck?: boolean | undefined; autoSaveDelay?: number | undefined; showWordCount?: boolean | undefined; toolbarMode?: "floating" | "sticky" | undefined; } | undefined; tasks?: { defaultProjectId?: string | null | undefined; defaultSortOrder?: "createdAt" | "priority" | "dueDate" | "manual" | undefined; weekStartDay?: "sunday" | "monday" | undefined; staleInboxDays?: number | undefined; showCompleted?: boolean | undefined; sortBy?: string | undefined; } | undefined; keyboard?: { overrides?: Record<string, unknown> | undefined; } | undefined; notes?: { defaultFolder?: string | undefined; editorFontSize?: number | undefined; spellCheck?: boolean | undefined; } | undefined; sync?: { autoSync?: boolean | undefined; syncIntervalMinutes?: number | undefined; } | undefined; } | null> + "sync:get-synced-settings": (...args: []) => Awaited<{ general?: { theme?: "light" | "dark" | "white" | "system" | undefined; fontSize?: "small" | "medium" | "large" | undefined; fontFamily?: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter" | undefined; accentColor?: string | undefined; startOnBoot?: boolean | undefined; language?: string | undefined; } | undefined; editor?: { width?: "medium" | "narrow" | "wide" | undefined; spellCheck?: boolean | undefined; autoSaveDelay?: number | undefined; showWordCount?: boolean | undefined; toolbarMode?: "floating" | "sticky" | undefined; } | undefined; tasks?: { defaultProjectId?: string | null | undefined; defaultSortOrder?: "createdAt" | "priority" | "dueDate" | "manual" | undefined; weekStartDay?: "sunday" | "monday" | undefined; staleInboxDays?: number | undefined; showCompleted?: boolean | undefined; sortBy?: string | undefined; } | undefined; keyboard?: { overrides?: Record<string, unknown> | undefined; } | undefined; notes?: { defaultFolder?: string | undefined; editorFontSize?: number | undefined; spellCheck?: boolean | undefined; } | undefined; sync?: { autoSync?: boolean | undefined; syncIntervalMinutes?: number | undefined; } | undefined; } | null> "sync:get-upload-progress": (...args: [{ sessionId: string; }]) => Awaited<Promise<{ progress: number; uploadedChunks: number; totalChunks: number; status: "uploading"; } | null>> "sync:link-via-qr": (...args: [{ qrData: string; oauthToken?: string | undefined; provider?: string | undefined; }]) => Awaited<Promise<import("../../../../../packages/contracts/src/ipc-devices").LinkViaQrResult>> "sync:link-via-recovery": (...args: [{ recoveryPhrase: string; }]) => Awaited<Promise<{ success: boolean; error: string; deviceId?: undefined; } | { success: boolean; deviceId: string; error?: undefined; }>> @@ -255,12 +259,12 @@ export interface MainIpcInvokeHandlers { "tasks:list": (...args: [{ projectId?: string | undefined; statusId?: string | null | undefined; parentId?: string | null | undefined; includeCompleted?: boolean | undefined; includeArchived?: boolean | undefined; dueBefore?: string | undefined; dueAfter?: string | undefined; tags?: string[] | undefined; search?: string | undefined; sortBy?: "modified" | "created" | "position" | "priority" | "dueDate" | undefined; sortOrder?: "asc" | "desc" | undefined; limit?: number | undefined; offset?: number | undefined; }]) => Awaited<Promise<{ tasks: { tags: string[]; linkedNoteIds: string[]; hasSubtasks: boolean; subtaskCount: number; completedSubtaskCount: number; id: string; title: string; clock: import("../../../../../packages/contracts/src/sync-api").VectorClock | null; syncedAt: string | null; createdAt: string; modifiedAt: string; position: number; description: string | null; projectId: string; priority: number; statusId: string | null; parentId: string | null; dueDate: string | null; dueTime: string | null; startDate: string | null; repeatConfig: unknown; repeatFrom: string | null; sourceNoteId: string | null; archivedAt: string | null; fieldClocks: import("../../../../../packages/contracts/src/sync-api").FieldClocks | null; completedAt: string | null; }[]; total: number; hasMore: boolean; }>> "tasks:move": (...args: [{ taskId: string; position: number; targetProjectId?: string | undefined; targetStatusId?: string | null | undefined; targetParentId?: string | null | undefined; }]) => Awaited<Promise<{ success: boolean; task: null; error: string; } | { success: boolean; task: { linkedNoteIds: string[]; id: string; title: string; clock: import("../../../../../packages/contracts/src/sync-api").VectorClock | null; syncedAt: string | null; createdAt: string; modifiedAt: string; position: number; description: string | null; projectId: string; priority: number; statusId: string | null; parentId: string | null; dueDate: string | null; dueTime: string | null; startDate: string | null; repeatConfig: unknown; repeatFrom: string | null; sourceNoteId: string | null; archivedAt: string | null; fieldClocks: import("../../../../../packages/contracts/src/sync-api").FieldClocks | null; completedAt: string | null; }; error?: undefined; }>> "tasks:project-archive": (...args: [string]) => Awaited<Promise<{ success: boolean; error?: undefined; } | { success: boolean; error: string; }>> - "tasks:project-create": (...args: [{ name: string; description?: string | null | undefined; color?: string | undefined; icon?: string | null | undefined; statuses?: { name: string; type: "todo" | "in_progress" | "done"; order: number; color?: string | undefined; }[] | undefined; }]) => Awaited<Promise<{ success: boolean; project: { id: string; name: string; clock: import("../../../../../packages/contracts/src/sync-api").VectorClock | null; syncedAt: string | null; createdAt: string; modifiedAt: string; position: number; color: string; description: string | null; icon: string | null; isInbox: boolean; archivedAt: string | null; fieldClocks: import("../../../../../packages/contracts/src/sync-api").FieldClocks | null; }; error?: undefined; } | { success: boolean; project: null; error: string; }>> + "tasks:project-create": (...args: [{ name: string; description?: string | null | undefined; color?: string | undefined; icon?: string | null | undefined; statuses?: { name: string; type: "todo" | "in_progress" | "done"; order: number; color?: string | undefined; }[] | undefined; }]) => Awaited<Promise<{ success: boolean; project: { id: string; name: string; clock: import("../../../../../packages/contracts/src/sync-api").VectorClock | null; syncedAt: string | null; createdAt: string; modifiedAt: string; position: number; color: string; icon: string | null; description: string | null; isInbox: boolean; archivedAt: string | null; fieldClocks: import("../../../../../packages/contracts/src/sync-api").FieldClocks | null; }; error?: undefined; } | { success: boolean; project: null; error: string; }>> "tasks:project-delete": (...args: [string]) => Awaited<Promise<{ success: boolean; error?: undefined; } | { success: boolean; error: string; }>> "tasks:project-get": (...args: [string]) => Awaited<Promise<import("../database/queries/projects").ProjectWithStatuses | undefined>> "tasks:project-list": (...args: []) => Awaited<Promise<{ projects: import("../database/queries/projects").ProjectWithStats[]; }>> "tasks:project-reorder": (...args: [{ projectIds: string[]; positions: number[]; }]) => Awaited<Promise<{ success: boolean; error?: undefined; } | { success: boolean; error: string; }>> - "tasks:project-update": (...args: [{ id: string; name?: string | undefined; description?: string | null | undefined; color?: string | undefined; icon?: string | null | undefined; statuses?: { name: string; type: "todo" | "in_progress" | "done"; order: number; id?: string | undefined; color?: string | undefined; }[] | undefined; }]) => Awaited<Promise<{ success: boolean; project: null; error: string; } | { success: boolean; project: { id: string; name: string; clock: import("../../../../../packages/contracts/src/sync-api").VectorClock | null; syncedAt: string | null; createdAt: string; modifiedAt: string; position: number; color: string; description: string | null; icon: string | null; isInbox: boolean; archivedAt: string | null; fieldClocks: import("../../../../../packages/contracts/src/sync-api").FieldClocks | null; }; error?: undefined; }>> + "tasks:project-update": (...args: [{ id: string; name?: string | undefined; description?: string | null | undefined; color?: string | undefined; icon?: string | null | undefined; statuses?: { name: string; type: "todo" | "in_progress" | "done"; order: number; id?: string | undefined; color?: string | undefined; }[] | undefined; }]) => Awaited<Promise<{ success: boolean; project: null; error: string; } | { success: boolean; project: { id: string; name: string; clock: import("../../../../../packages/contracts/src/sync-api").VectorClock | null; syncedAt: string | null; createdAt: string; modifiedAt: string; position: number; color: string; icon: string | null; description: string | null; isInbox: boolean; archivedAt: string | null; fieldClocks: import("../../../../../packages/contracts/src/sync-api").FieldClocks | null; }; error?: undefined; }>> "tasks:reorder": (...args: [{ taskIds: string[]; positions: number[]; }]) => Awaited<Promise<{ success: boolean; error?: undefined; } | { success: boolean; error: string; }>> "tasks:seed-demo": (...args: []) => Awaited<Promise<{ success: boolean; message: string; }>> "tasks:seed-performance-test": (...args: []) => Awaited<Promise<{ success: boolean; message: string; }>> @@ -284,6 +288,7 @@ export interface MainIpcInvokeHandlers { "vault:get-status": (...args: []) => Awaited<Promise<import("../../../../../packages/contracts/src/vault-api").VaultStatus>> "vault:reindex": (...args: []) => Awaited<Promise<void>> "vault:remove": (...args: [string]) => Awaited<Promise<void>> + "vault:reveal": (...args: []) => Awaited<Promise<void>> "vault:select": (...args: [{ path?: string | undefined; }]) => Awaited<Promise<import("../../../../../packages/contracts/src/vault-api").SelectVaultResponse>> "vault:switch": (...args: [string]) => Awaited<Promise<import("../../../../../packages/contracts/src/vault-api").SelectVaultResponse>> "vault:update-config": (...args: [{ excludePatterns?: string[] | undefined; defaultNoteFolder?: string | undefined; journalFolder?: string | undefined; attachmentsFolder?: string | undefined; }]) => Awaited<Promise<import("../../../../../packages/contracts/src/vault-api").VaultConfig>> diff --git a/apps/desktop/src/main/ipc/inbox-batch-handlers.ts b/apps/desktop/src/main/ipc/inbox-batch-handlers.ts index cab501f96..2197f91a6 100644 --- a/apps/desktop/src/main/ipc/inbox-batch-handlers.ts +++ b/apps/desktop/src/main/ipc/inbox-batch-handlers.ts @@ -2,13 +2,12 @@ import { ipcMain } from 'electron' import { InboxChannels } from '@memry/contracts/ipc-channels' import { BulkArchiveSchema, - BulkArchiveOlderThanSchema, BulkFileSchema, BulkTagSchema, type BulkResponse } from '@memry/contracts/inbox-api' import { inboxItems, inboxItemTags } from '@memry/db-schema/schema/inbox' -import { eq, and, isNull, lt } from 'drizzle-orm' +import { eq, and } from 'drizzle-orm' import { generateId } from '../lib/id' import { bulkFileToFolder } from '../inbox/filing' import { bulkSnoozeItems } from '../inbox/snooze' @@ -31,7 +30,6 @@ export interface InboxBatchHandlers { handleBulkFile: (input: unknown) => Promise<BulkResponse> handleBulkTag: (input: unknown) => Promise<BulkResponse> handleFileAllStale: () => Promise<BulkResponse> - handleBulkArchiveOlderThan: (input: unknown) => Promise<BulkResponse> } export function createInboxBatchHandlers(deps: InboxBatchHandlerDeps): InboxBatchHandlers { @@ -217,56 +215,12 @@ export function createInboxBatchHandlers(deps: InboxBatchHandlerDeps): InboxBatc } } - async function handleBulkArchiveOlderThan(input: unknown): Promise<BulkResponse> { - try { - const parsed = BulkArchiveOlderThanSchema.parse(input) - const db = deps.requireDatabase() - const cutoff = new Date() - cutoff.setDate(cutoff.getDate() - parsed.olderThanDays) - const cutoffIso = cutoff.toISOString() - - const oldItems = db - .select({ id: inboxItems.id }) - .from(inboxItems) - .where( - and( - isNull(inboxItems.filedAt), - isNull(inboxItems.archivedAt), - lt(inboxItems.createdAt, cutoffIso) - ) - ) - .all() - - if (oldItems.length === 0) { - return { success: true, processedCount: 0, errors: [] } - } - - const errors: Array<{ itemId: string; error: string }> = [] - let processedCount = 0 - - for (const item of oldItems) { - const result = await deps.archiveItem(item.id) - if (result.success) { - processedCount++ - } else { - errors.push({ itemId: item.id, error: result.error || 'Unknown error' }) - } - } - - return { success: errors.length === 0, processedCount, errors } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - return { success: false, processedCount: 0, errors: [{ itemId: '', error: message }] } - } - } - return { handleBulkArchive, handleBulkSnooze, handleBulkFile, handleBulkTag, - handleFileAllStale, - handleBulkArchiveOlderThan + handleFileAllStale } } @@ -276,9 +230,6 @@ export function registerInboxBatchHandlers(handlers: InboxBatchHandlers): void { ipcMain.handle(InboxChannels.invoke.BULK_ARCHIVE, (_, input) => handlers.handleBulkArchive(input)) ipcMain.handle(InboxChannels.invoke.BULK_TAG, (_, input) => handlers.handleBulkTag(input)) ipcMain.handle(InboxChannels.invoke.FILE_ALL_STALE, () => handlers.handleFileAllStale()) - ipcMain.handle(InboxChannels.invoke.BULK_ARCHIVE_OLDER_THAN, (_, input) => - handlers.handleBulkArchiveOlderThan(input) - ) } export function unregisterInboxBatchHandlers(): void { @@ -287,5 +238,4 @@ export function unregisterInboxBatchHandlers(): void { ipcMain.removeHandler(InboxChannels.invoke.BULK_ARCHIVE) ipcMain.removeHandler(InboxChannels.invoke.BULK_TAG) ipcMain.removeHandler(InboxChannels.invoke.FILE_ALL_STALE) - ipcMain.removeHandler(InboxChannels.invoke.BULK_ARCHIVE_OLDER_THAN) } diff --git a/apps/desktop/src/main/ipc/inbox-handlers.test.ts b/apps/desktop/src/main/ipc/inbox-handlers.test.ts index b0da25073..0343454f7 100644 --- a/apps/desktop/src/main/ipc/inbox-handlers.test.ts +++ b/apps/desktop/src/main/ipc/inbox-handlers.test.ts @@ -59,12 +59,22 @@ vi.mock('../inbox/attachments', () => ({ vi.mock('../inbox/metadata', () => ({ fetchUrlMetadata: vi.fn(), - downloadImage: vi.fn() + downloadImage: vi.fn(), + titleFromUrl: vi.fn((url: string) => url), + isBotPageTitle: vi.fn(() => false), + extractDomain: vi.fn((url: string) => { + try { + return new URL(url).hostname + } catch { + return url + } + }) })) vi.mock('../inbox/filing', () => ({ fileToFolder: vi.fn(), convertToNote: vi.fn(), + convertToTask: vi.fn(), linkToNote: vi.fn(), linkToNotes: vi.fn(), bulkFileToFolder: vi.fn() @@ -73,8 +83,7 @@ vi.mock('../inbox/filing', () => ({ vi.mock('../inbox/social', () => ({ extractSocialPost: vi.fn(), detectSocialPlatform: vi.fn(), - isSocialPost: vi.fn(), - createFallbackSocialMetadata: vi.fn() + isSocialPost: vi.fn() })) vi.mock('../inbox/capture', () => ({ @@ -123,6 +132,23 @@ vi.mock('../inbox/snooze', () => ({ bulkSnoozeItems: vi.fn() })) +vi.mock('../sync/inbox-sync', () => ({ + getInboxSyncService: vi.fn(() => null) +})) + +vi.mock('../sync/offline-clock', () => ({ + incrementInboxClockOffline: vi.fn() +})) + +vi.mock('../lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }) +})) + // Import after mocking import { registerInboxHandlers, unregisterInboxHandlers } from './inbox-handlers' import { getDatabase } from '../database' diff --git a/apps/desktop/src/main/ipc/inbox-handlers.ts b/apps/desktop/src/main/ipc/inbox-handlers.ts index 78a4ab2d0..ad7de46eb 100644 --- a/apps/desktop/src/main/ipc/inbox-handlers.ts +++ b/apps/desktop/src/main/ipc/inbox-handlers.ts @@ -22,8 +22,7 @@ import { type InboxItemListItem, type FileResponse, type SuggestionsResponse, - type ImageMetadata, - type SocialMetadata + type ImageMetadata } from '@memry/contracts/inbox-api' import sharp from 'sharp' import { getDatabase, type DrizzleDb } from '../database' @@ -40,7 +39,13 @@ import { ALLOWED_VIDEO_TYPES, ALLOWED_DOCUMENT_TYPES } from '../inbox/attachments' -import { fetchUrlMetadata, downloadImage } from '../inbox/metadata' +import { + fetchUrlMetadata, + downloadImage, + titleFromUrl, + isBotPageTitle, + extractDomain +} from '../inbox/metadata' import { fileToFolder, convertToNote, @@ -48,12 +53,7 @@ import { linkToNote, linkToNotes } from '../inbox/filing' -import { - extractSocialPost, - detectSocialPlatform, - isSocialPost, - createFallbackSocialMetadata -} from '../inbox/social' +import { extractSocialPost, detectSocialPlatform, isSocialPost } from '../inbox/social' import { createLogger } from '../lib/logger' import { captureVoice, type CaptureVoiceInput } from '../inbox/capture' import { findDuplicateByUrl, findDuplicateByContent } from '../inbox/duplicates' @@ -160,7 +160,8 @@ async function fetchAndUpdateMetadata(itemId: string, url: string, retryCount = const now = new Date().toISOString() db.update(inboxItems) .set({ - title: metadata.title || url, + title: + metadata.title && !isBotPageTitle(metadata.title) ? metadata.title : titleFromUrl(url), content: metadata.description || null, thumbnailPath, processingStatus: 'complete', @@ -169,7 +170,12 @@ async function fetchAndUpdateMetadata(itemId: string, url: string, retryCount = metadata: { url, fetchStatus: 'complete', - ...metadata + siteName: metadata.publisher || undefined, + description: metadata.description || undefined, + heroImage: metadata.image || undefined, + favicon: metadata.logo || undefined, + author: metadata.author || undefined, + publishedDate: metadata.date || undefined } }) .where(eq(inboxItems.id, itemId)) @@ -204,6 +210,7 @@ async function fetchAndUpdateMetadata(itemId: string, url: string, retryCount = try { db.update(inboxItems) .set({ + title: titleFromUrl(url), processingStatus: 'failed', processingError: errorMessage, modifiedAt: new Date().toISOString(), @@ -228,168 +235,31 @@ async function fetchAndUpdateMetadata(itemId: string, url: string, retryCount = } // ============================================================================ -// Background Social Post Extraction +// Social Post Metadata (synchronous — react-tweet handles fetching in renderer) // ============================================================================ -/** - * Fetch social post metadata in background and update the inbox item - * - * Uses platform-specific extractors (oEmbed for Twitter, API for Bluesky, etc.) - * Falls back to regular metadata extraction if social extraction fails. - * - * @param itemId - The inbox item ID to update - * @param url - The social media post URL - * @param retryCount - Current retry count (auto-retry once on failure) - */ -async function fetchAndUpdateSocialMetadata( - itemId: string, - url: string, - retryCount = 0 -): Promise<void> { - let db: ReturnType<typeof getDatabase> - - try { - db = requireDatabase() - } catch { - logger.warn('No database available, skipping social metadata fetch') - return - } +function storeSocialMetadata(itemId: string, url: string): void { + const db = requireDatabase() + const result = extractSocialPost(url) - const platform = detectSocialPlatform(url) - logger.info(`Fetching ${platform} metadata for ${url}`) + if (!result.success || !result.metadata) return - try { - // Update status to processing - db.update(inboxItems) - .set({ - processingStatus: 'processing', - modifiedAt: new Date().toISOString() - }) - .where(eq(inboxItems.id, itemId)) - .run() - - // Attempt social extraction - const result = await extractSocialPost(url) - - if (result.success && result.metadata) { - const metadata = result.metadata - - // Build title from author and platform - let title = metadata.authorName || metadata.authorHandle || url - if (metadata.platform !== 'other') { - const platformName = metadata.platform.charAt(0).toUpperCase() + metadata.platform.slice(1) - if (metadata.authorHandle) { - title = `${platformName} post by ${metadata.authorHandle}` - } else if (metadata.authorName) { - title = `${platformName} post by ${metadata.authorName}` - } else { - title = `${platformName} post` - } - } - - const content = metadata.postContent || null - - // Update item with social metadata - const now = new Date().toISOString() - db.update(inboxItems) - .set({ - title, - content, - processingStatus: 'complete', - processingError: null, - modifiedAt: now, - metadata: metadata - }) - .where(eq(inboxItems.id, itemId)) - .run() - - // Emit success event - emitInboxEvent(InboxChannels.events.METADATA_COMPLETE, { - id: itemId, - metadata - }) - - logger.info(`Successfully updated social item ${itemId}: ${title}`) - } else { - // Social extraction failed, try regular metadata as fallback - logger.info(`Social extraction failed, falling back to regular metadata: ${result.error}`) - - // Try regular metadata extraction - try { - const regularMetadata = await fetchUrlMetadata(url) - - // Use regular metadata with social fallback - const fallbackSocial = createFallbackSocialMetadata(url, platform || 'other', result.error) - - // Merge regular metadata into social metadata - const mergedMetadata: SocialMetadata = { - ...fallbackSocial, - postContent: regularMetadata.description || '', - extractionStatus: 'partial' - } - - const now = new Date().toISOString() - db.update(inboxItems) - .set({ - title: regularMetadata.title || url, - content: regularMetadata.description || null, - processingStatus: 'complete', - processingError: null, - modifiedAt: now, - metadata: mergedMetadata - }) - .where(eq(inboxItems.id, itemId)) - .run() - - emitInboxEvent(InboxChannels.events.METADATA_COMPLETE, { - id: itemId, - metadata: mergedMetadata - }) + const metadata = result.metadata + const title = metadata.authorHandle ? `Tweet by ${metadata.authorHandle}` : 'Tweet' - logger.info(`Used fallback metadata for ${itemId}`) - } catch (fallbackError) { - throw new Error( - `Social extraction failed: ${result.error}; Fallback also failed: ${fallbackError instanceof Error ? fallbackError.message : 'Unknown'}` - ) - } - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - logger.error(`Error fetching social metadata for ${url}: ${errorMessage}`) - - // Auto-retry once after delay - if (retryCount < 1) { - logger.info(`Scheduling retry for ${itemId} in ${METADATA_RETRY_DELAY}ms`) - setTimeout(() => { - fetchAndUpdateSocialMetadata(itemId, url, retryCount + 1).catch((e) => logger.error(e)) - }, METADATA_RETRY_DELAY) - return - } - - // Update item with error status but still store as social type with fallback metadata - try { - const fallbackMetadata = createFallbackSocialMetadata(url, platform || 'other', errorMessage) - - db.update(inboxItems) - .set({ - processingStatus: 'failed', - processingError: errorMessage, - modifiedAt: new Date().toISOString(), - metadata: fallbackMetadata - }) - .where(eq(inboxItems.id, itemId)) - .run() + db.update(inboxItems) + .set({ + title, + processingStatus: 'complete', + processingError: null, + modifiedAt: new Date().toISOString(), + metadata + }) + .where(eq(inboxItems.id, itemId)) + .run() - // Emit error event - emitInboxEvent(InboxChannels.events.PROCESSING_ERROR, { - id: itemId, - operation: 'metadata', - error: errorMessage - }) - } catch (dbError) { - logger.error('Failed to update social error status:', dbError) - } - } + emitInboxEvent(InboxChannels.events.METADATA_COMPLETE, { id: itemId, metadata }) + logger.info(`Stored social metadata for ${itemId}: ${title}`) } // ============================================================================ @@ -577,8 +447,8 @@ async function handleCaptureText(input: unknown): Promise<CaptureResponse> { /** * Capture a URL with background metadata extraction * - * Automatically detects social media posts (Twitter, Bluesky, Mastodon, etc.) - * and uses specialized extraction for richer metadata display. + * Automatically detects Twitter/X posts and uses specialized extraction + * for richer metadata display. */ async function handleCaptureLink(input: unknown): Promise<CaptureResponse> { try { @@ -608,7 +478,7 @@ async function handleCaptureLink(input: unknown): Promise<CaptureResponse> { .values({ id, type: itemType, - title: parsed.url, // Will be updated when metadata is fetched + title: titleFromUrl(parsed.url), content: null, sourceUrl: parsed.url, createdAt: now, @@ -657,19 +527,19 @@ async function handleCaptureLink(input: unknown): Promise<CaptureResponse> { emitInboxEvent(InboxChannels.events.CAPTURED, { item: toListItem(created, tags) }) syncInboxCreate(db, id) - // Trigger background metadata fetch (don't await - non-blocking) - // Use specialized social extraction for social posts - setImmediate(() => { - if (isSocial) { - fetchAndUpdateSocialMetadata(id, parsed.url).catch((err) => { - logger.error('Background social metadata fetch error:', err) - }) - } else { + if (isSocial) { + try { + storeSocialMetadata(id, parsed.url) + } catch (err) { + logger.error('Social metadata storage error:', err) + } + } else { + setImmediate(() => { fetchAndUpdateMetadata(id, parsed.url).catch((err) => { logger.error('Background metadata fetch error:', err) }) - } - }) + }) + } return { success: true, item } } catch (error) { @@ -1253,6 +1123,25 @@ export function registerInboxHandlers(): void { // Metadata handlers ipcMain.handle(InboxChannels.invoke.RETRY_METADATA, (_, id) => handleRetryMetadata(id)) + ipcMain.handle(InboxChannels.invoke.PREVIEW_LINK, async (_, url: string) => { + try { + const metadata = await fetchUrlMetadata(url) + const domain = extractDomain(url) + const title = + metadata.title && !isBotPageTitle(metadata.title) ? metadata.title : titleFromUrl(url) + return { + title, + domain, + favicon: metadata.logo, + image: metadata.image, + description: metadata.description + } + } catch { + const domain = extractDomain(url) + return { title: titleFromUrl(url), domain } + } + }) + logger.info('Inbox handlers registered') } diff --git a/apps/desktop/src/main/ipc/inbox-query-handlers.test.ts b/apps/desktop/src/main/ipc/inbox-query-handlers.test.ts new file mode 100644 index 000000000..054309588 --- /dev/null +++ b/apps/desktop/src/main/ipc/inbox-query-handlers.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { + createTestDataDb, + seedInboxItems, + seedInboxItemTags, + type TestDatabaseResult +} from '@tests/utils/test-db' +import { createInboxQueryHandlers, type InboxQueryHandlerDeps } from './inbox-query-handlers' +import { inboxItemTags } from '@memry/db-schema/schema/inbox' +import { eq } from 'drizzle-orm' + +describe('inbox-query-handlers › handleGetPatterns', () => { + let testDb: TestDatabaseResult + let deps: InboxQueryHandlerDeps + + beforeEach(() => { + testDb = createTestDataDb() + deps = { + requireDatabase: () => testDb.db as ReturnType<InboxQueryHandlerDeps['requireDatabase']>, + getItemTags: (db, itemId) => { + return db + .select({ tag: inboxItemTags.tag }) + .from(inboxItemTags) + .where(eq(inboxItemTags.itemId, itemId)) + .all() + .map((r) => r.tag) + }, + toListItem: (row, tags) => ({ ...row, tags }) as never + } + }) + + afterEach(() => { + testDb.close() + }) + + it('returns 24x7 heatmap grid with correct counts for captured items', async () => { + // #given — 3 items captured on a Wednesday at 14:xx (hour 14, dow 3 in SQLite = Wednesday) + const wed14 = '2026-03-18T14:30:00.000Z' // Wednesday March 18 2026 at 14:30 UTC + seedInboxItems(testDb.db, [ + { id: 'item-1', type: 'note', title: 'Note 1', createdAt: wed14 }, + { + id: 'item-2', + type: 'link', + title: 'Link 1', + createdAt: wed14, + sourceUrl: 'https://example.com/page' + }, + { id: 'item-3', type: 'note', title: 'Note 2', createdAt: '2026-03-18T10:00:00.000Z' } + ]) + + // #when + const handlers = createInboxQueryHandlers(deps) + const result = await handlers.handleGetPatterns() + + // #then — timeHeatmap should be 24 rows × 7 cols + expect(result.timeHeatmap).toHaveLength(24) + expect(result.timeHeatmap[0]).toHaveLength(7) + + // Wednesday = SQLite %w 3 → index (3+6)%7 = 2 + const wedIdx = 2 + expect(result.timeHeatmap[14][wedIdx]).toBe(2) // 2 items at hour 14 + expect(result.timeHeatmap[10][wedIdx]).toBe(1) // 1 item at hour 10 + + // All other slots should be 0 + expect(result.timeHeatmap[0][0]).toBe(0) + expect(result.timeHeatmap[23][6]).toBe(0) + }) + + it('returns empty 24x7 grid when no items exist', async () => { + // #given — empty database + + // #when + const handlers = createInboxQueryHandlers(deps) + const result = await handlers.handleGetPatterns() + + // #then — still returns 24x7 grid, all zeros + expect(result.timeHeatmap).toHaveLength(24) + expect(result.timeHeatmap[0]).toHaveLength(7) + const allZero = result.timeHeatmap.every((row) => row.every((v) => v === 0)) + expect(allZero).toBe(true) + }) + + it('returns type distribution with correct percentages', async () => { + // #given — 3 notes and 2 links + seedInboxItems(testDb.db, [ + { type: 'note', title: 'N1' }, + { type: 'note', title: 'N2' }, + { type: 'note', title: 'N3' }, + { type: 'link', title: 'L1' }, + { type: 'link', title: 'L2' } + ]) + + // #when + const handlers = createInboxQueryHandlers(deps) + const result = await handlers.handleGetPatterns() + + // #then + expect(result.typeDistribution).toHaveLength(2) + const noteType = result.typeDistribution.find((t) => t.type === 'note') + const linkType = result.typeDistribution.find((t) => t.type === 'link') + expect(noteType?.count).toBe(3) + expect(noteType?.percentage).toBe(60) + expect(linkType?.count).toBe(2) + expect(linkType?.percentage).toBe(40) + }) + + it('returns top tags sorted by count', async () => { + // #given — 2 items tagged "work", 1 tagged "urgent" + const ids = seedInboxItems(testDb.db, [ + { id: 'tagged-1', type: 'note', title: 'Tagged 1' }, + { id: 'tagged-2', type: 'note', title: 'Tagged 2' } + ]) + seedInboxItemTags(testDb.db, ids[0], ['work', 'urgent']) + seedInboxItemTags(testDb.db, ids[1], ['work']) + + // #when + const handlers = createInboxQueryHandlers(deps) + const result = await handlers.handleGetPatterns() + + // #then + expect(result.topTags.length).toBeGreaterThan(0) + expect(result.topTags[0].tag).toBe('work') + expect(result.topTags[0].count).toBe(2) + }) + + it('returns top domains extracted from sourceUrl', async () => { + // #given + seedInboxItems(testDb.db, [ + { type: 'link', title: 'L1', sourceUrl: 'https://github.com/foo' }, + { type: 'link', title: 'L2', sourceUrl: 'https://github.com/bar' }, + { type: 'link', title: 'L3', sourceUrl: 'https://www.example.com/page' } + ]) + + // #when + const handlers = createInboxQueryHandlers(deps) + const result = await handlers.handleGetPatterns() + + // #then + expect(result.topDomains).toHaveLength(2) + expect(result.topDomains[0]).toEqual({ domain: 'github.com', count: 2 }) + expect(result.topDomains[1]).toEqual({ domain: 'example.com', count: 1 }) + }) + + it('excludes items older than 12 weeks', async () => { + // #given — item from 13 weeks ago + const old = new Date() + old.setDate(old.getDate() - 91) + seedInboxItems(testDb.db, [{ type: 'note', title: 'Old', createdAt: old.toISOString() }]) + + // #when + const handlers = createInboxQueryHandlers(deps) + const result = await handlers.handleGetPatterns() + + // #then — should not appear in heatmap + const allZero = result.timeHeatmap.every((row) => row.every((v) => v === 0)) + expect(allZero).toBe(true) + expect(result.typeDistribution).toHaveLength(0) + }) +}) diff --git a/apps/desktop/src/main/ipc/inbox-query-handlers.ts b/apps/desktop/src/main/ipc/inbox-query-handlers.ts index d9bdba218..8f51ae3ec 100644 --- a/apps/desktop/src/main/ipc/inbox-query-handlers.ts +++ b/apps/desktop/src/main/ipc/inbox-query-handlers.ts @@ -10,10 +10,14 @@ import { type ArchivedListResponse, type FilingHistoryResponse, type FilingHistoryEntry, - type CapturePattern + type CapturePattern, + type InboxItemType } from '@memry/contracts/inbox-api' import { inboxItems, inboxItemTags } from '@memry/db-schema/schema/inbox' -import { eq, desc, asc, and, isNull, sql } from 'drizzle-orm' +import { eq, desc, asc, and, isNull, sql, gte } from 'drizzle-orm' +import { createLogger } from '../lib/logger' + +const logger = createLogger('IPC:InboxQuery') import { getStaleThreshold as getStaleThresholdDays, setStaleThreshold as setStaleThresholdDays, @@ -271,12 +275,114 @@ export function createInboxQueryHandlers(deps: InboxQueryHandlerDeps): InboxQuer } async function handleGetPatterns(): Promise<CapturePattern> { - return { - timeHeatmap: [], + const emptyResult: CapturePattern = { + timeHeatmap: Array.from({ length: 24 }, () => new Array<number>(7).fill(0)), typeDistribution: [], topDomains: [], topTags: [] } + + let db: ReturnType<typeof deps.requireDatabase> + try { + db = deps.requireDatabase() + } catch (err) { + logger.warn('Database not ready for patterns query', err) + return emptyResult + } + + try { + const twelveWeeksAgo = new Date() + twelveWeeksAgo.setDate(twelveWeeksAgo.getDate() - 84) + const cutoff = twelveWeeksAgo.toISOString() + + const heatmapRows = db + .select({ + hour: sql<number>`cast(strftime('%H', ${inboxItems.createdAt}) as integer)`, + dow: sql<number>`cast(strftime('%w', ${inboxItems.createdAt}) as integer)`, + count: sql<number>`count(*)` + }) + .from(inboxItems) + .where(gte(inboxItems.createdAt, cutoff)) + .groupBy( + sql`strftime('%H', ${inboxItems.createdAt})`, + sql`strftime('%w', ${inboxItems.createdAt})` + ) + .all() + + const timeHeatmap: number[][] = Array.from({ length: 24 }, () => new Array<number>(7).fill(0)) + for (const row of heatmapRows) { + const dayIdx = (row.dow + 6) % 7 + if (row.hour >= 0 && row.hour < 24 && dayIdx >= 0 && dayIdx < 7) { + timeHeatmap[row.hour][dayIdx] = row.count + } + } + + const typeRows = db + .select({ + type: inboxItems.type, + count: sql<number>`count(*)` + }) + .from(inboxItems) + .where(gte(inboxItems.createdAt, cutoff)) + .groupBy(inboxItems.type) + .orderBy(desc(sql`count(*)`)) + .all() + + const totalForTypes = typeRows.reduce((sum, r) => sum + r.count, 0) + const typeDistribution = typeRows.map((r) => ({ + type: r.type as InboxItemType, + count: r.count, + percentage: totalForTypes > 0 ? Math.round((r.count / totalForTypes) * 100) : 0, + trend: 'stable' as const + })) + + const tagRows = db + .select({ + tag: inboxItemTags.tag, + count: sql<number>`count(*)` + }) + .from(inboxItemTags) + .groupBy(inboxItemTags.tag) + .orderBy(desc(sql`count(*)`)) + .limit(10) + .all() + + const linkRows = db + .select({ sourceUrl: inboxItems.sourceUrl }) + .from(inboxItems) + .where( + and( + sql`${inboxItems.sourceUrl} IS NOT NULL`, + sql`${inboxItems.sourceUrl} != ''`, + gte(inboxItems.createdAt, cutoff) + ) + ) + .all() + + const domainCounts = new Map<string, number>() + for (const row of linkRows) { + try { + const hostname = new URL(row.sourceUrl!).hostname.replace(/^www\./, '') + domainCounts.set(hostname, (domainCounts.get(hostname) ?? 0) + 1) + } catch { + // skip malformed URLs + } + } + const topDomains = [...domainCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([domain, count]) => ({ domain, count })) + + return { + timeHeatmap, + typeDistribution, + topDomains, + topTags: tagRows + } + } catch (err) { + logger.error('Failed to compute capture patterns', err) + return emptyResult + } } return { diff --git a/apps/desktop/src/main/ipc/index.test.ts b/apps/desktop/src/main/ipc/index.test.ts index 7a457cc5b..dbdc495cf 100644 --- a/apps/desktop/src/main/ipc/index.test.ts +++ b/apps/desktop/src/main/ipc/index.test.ts @@ -37,7 +37,9 @@ const hoisted = vi.hoisted(() => ({ registerGraphHandlers: vi.fn(), unregisterGraphHandlers: vi.fn(), registerAIInlineHandlers: vi.fn(), - unregisterAIInlineHandlers: vi.fn() + unregisterAIInlineHandlers: vi.fn(), + registerAccountHandlers: vi.fn(), + unregisterAccountHandlers: vi.fn() })) vi.mock('./vault-handlers', () => ({ @@ -113,6 +115,10 @@ vi.mock('./ai-inline-handlers', () => ({ registerAIInlineHandlers: hoisted.registerAIInlineHandlers, unregisterAIInlineHandlers: hoisted.unregisterAIInlineHandlers })) +vi.mock('./account-handlers', () => ({ + registerAccountHandlers: hoisted.registerAccountHandlers, + unregisterAccountHandlers: hoisted.unregisterAccountHandlers +})) import { areHandlersRegistered, registerAllHandlers, unregisterAllHandlers } from './index' diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index 9400f51bc..d27e1b44d 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -19,6 +19,7 @@ import { registerCryptoHandlers, unregisterCryptoHandlers } from './crypto-handl import { registerSearchHandlers, unregisterSearchHandlers } from './search-handlers' import { registerGraphHandlers, unregisterGraphHandlers } from './graph-handlers' import { registerAIInlineHandlers, unregisterAIInlineHandlers } from './ai-inline-handlers' +import { registerAccountHandlers, unregisterAccountHandlers } from './account-handlers' import { createLogger } from '../lib/logger' const ipcLog = createLogger('IPC') @@ -101,6 +102,9 @@ export function registerAllHandlers(): void { // Register AI inline editing handlers registerAIInlineHandlers() + // Register account handlers + registerAccountHandlers() + handlersRegistered = true ipcLog.info('all handlers registered') } @@ -132,6 +136,7 @@ export function unregisterAllHandlers(): void { unregisterSearchHandlers() unregisterGraphHandlers() unregisterAIInlineHandlers() + unregisterAccountHandlers() handlersRegistered = false ipcLog.info('all handlers unregistered') diff --git a/apps/desktop/src/main/ipc/settings-handlers.test.ts b/apps/desktop/src/main/ipc/settings-handlers.test.ts index 31d89abc4..804da8b81 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.test.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.test.ts @@ -57,6 +57,13 @@ vi.mock('../lib/embeddings', () => ({ isModelLoading: vi.fn() })) +const mockUpdateField = vi.fn() +vi.mock('../sync/settings-sync', () => ({ + getSettingsSyncManager: vi.fn(() => ({ + updateField: mockUpdateField + })) +})) + vi.mock('../inbox/suggestions', () => ({ getEmbeddingCount: vi.fn(() => 4), reindexAllEmbeddings: vi.fn(() => Promise.resolve({ success: true, computed: 1, skipped: 0 })) @@ -66,6 +73,7 @@ import { registerSettingsHandlers, unregisterSettingsHandlers } from './settings import { getDatabase } from '../database' import * as settingsQueries from '@main/database/queries/settings' import * as embeddings from '../lib/embeddings' +import { getSettingsSyncManager } from '../sync/settings-sync' function invokeSyncHandler<T>(channel: string, ...args: unknown[]): T { const listener = syncListeners.get(channel) @@ -86,6 +94,7 @@ describe('settings-handlers', () => { removeHandlerCalls.length = 0 syncListeners.clear() mockSend.mockClear() + mockUpdateField.mockClear() ;(getDatabase as Mock).mockReturnValue({}) }) @@ -112,13 +121,17 @@ describe('settings-handlers', () => { }) }) - it('returns the startup theme synchronously', () => { + it('returns the startup theme and accent color synchronously', () => { registerSettingsHandlers() - ;(settingsQueries.getSetting as Mock).mockReturnValue(JSON.stringify({ theme: 'light' })) + ;(settingsQueries.getSetting as Mock).mockReturnValue( + JSON.stringify({ theme: 'light', accentColor: '#6366f1' }) + ) - const result = invokeSyncHandler<string>(SettingsChannels.sync.GET_STARTUP_THEME) + const result = invokeSyncHandler<{ theme: string; accentColor?: string }>( + SettingsChannels.sync.GET_STARTUP_THEME + ) - expect(result).toBe('light') + expect(result).toEqual({ theme: 'light', accentColor: '#6366f1' }) }) it('returns defaults when no database is open', async () => { @@ -320,4 +333,123 @@ describe('settings-handlers', () => { }) expect(result).toEqual({ success: false, error: 'No vault open' }) }) + + describe('cross-device settings sync', () => { + it('#given sync manager exists #when accentColor is set #then syncs via updateField', async () => { + registerSettingsHandlers() + + // #when + await invokeHandler(SettingsChannels.invoke.SET_GENERAL_SETTINGS, { + accentColor: '#ef4444' + }) + + // #then + expect(mockUpdateField).toHaveBeenCalledWith('general.accentColor', '#ef4444', 'local') + }) + + it('#given sync manager exists #when theme is set #then syncs via updateField', async () => { + registerSettingsHandlers() + + // #when + await invokeHandler(SettingsChannels.invoke.SET_GENERAL_SETTINGS, { theme: 'dark' }) + + // #then + expect(mockUpdateField).toHaveBeenCalledWith('general.theme', 'dark', 'local') + }) + + it('#given sync manager exists #when fontSize is set #then syncs via updateField', async () => { + registerSettingsHandlers() + + // #when + await invokeHandler(SettingsChannels.invoke.SET_GENERAL_SETTINGS, { fontSize: 'large' }) + + // #then + expect(mockUpdateField).toHaveBeenCalledWith('general.fontSize', 'large', 'local') + }) + + it('#given sync manager exists #when fontFamily is set #then syncs via updateField', async () => { + registerSettingsHandlers() + + // #when + await invokeHandler(SettingsChannels.invoke.SET_GENERAL_SETTINGS, { + fontFamily: 'monospace' + }) + + // #then + expect(mockUpdateField).toHaveBeenCalledWith('general.fontFamily', 'monospace', 'local') + }) + + it('#given sync manager exists #when startOnBoot is set #then does NOT sync (device-specific)', async () => { + registerSettingsHandlers() + + // #when + await invokeHandler(SettingsChannels.invoke.SET_GENERAL_SETTINGS, { startOnBoot: true }) + + // #then + expect(mockUpdateField).not.toHaveBeenCalled() + }) + + it('#given sync manager exists #when multiple syncable fields updated #then syncs each', async () => { + registerSettingsHandlers() + + // #when + await invokeHandler(SettingsChannels.invoke.SET_GENERAL_SETTINGS, { + accentColor: '#10b981', + theme: 'white' + }) + + // #then + expect(mockUpdateField).toHaveBeenCalledWith('general.accentColor', '#10b981', 'local') + expect(mockUpdateField).toHaveBeenCalledWith('general.theme', 'white', 'local') + expect(mockUpdateField).toHaveBeenCalledTimes(2) + }) + + it('#given no sync manager #when accentColor is set #then does not throw', async () => { + registerSettingsHandlers() + ;(getSettingsSyncManager as Mock).mockReturnValue(null) + + // #when / #then — should not throw + const result = await invokeHandler(SettingsChannels.invoke.SET_GENERAL_SETTINGS, { + accentColor: '#ef4444' + }) + expect(result).toEqual({ success: true }) + }) + }) + + describe('startup accent color', () => { + it('#given general settings with accentColor #when startup theme requested #then returns accent color', () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue( + JSON.stringify({ theme: 'dark', accentColor: '#ef4444' }) + ) + + const result = invokeSyncHandler<{ theme: string; accentColor?: string }>( + SettingsChannels.sync.GET_STARTUP_THEME + ) + + expect(result).toEqual({ theme: 'dark', accentColor: '#ef4444' }) + }) + + it('#given no accentColor saved #when startup theme requested #then returns theme with default accent', () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue(JSON.stringify({ theme: 'white' })) + + const result = invokeSyncHandler<{ theme: string; accentColor?: string }>( + SettingsChannels.sync.GET_STARTUP_THEME + ) + + expect(result).toEqual({ theme: 'white', accentColor: '#6366f1' }) + }) + + it('#given no settings in db #when startup theme requested #then returns defaults', () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue(null) + + const result = invokeSyncHandler<{ theme: string; accentColor?: string }>( + SettingsChannels.sync.GET_STARTUP_THEME + ) + + expect(result).toEqual({ theme: 'system', accentColor: '#6366f1' }) + }) + }) }) diff --git a/apps/desktop/src/main/ipc/settings-handlers.ts b/apps/desktop/src/main/ipc/settings-handlers.ts index 42317f6b4..b8065c068 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.ts @@ -7,7 +7,7 @@ * @module main/ipc/settings-handlers */ -import { ipcMain, BrowserWindow, app } from 'electron' +import { ipcMain, BrowserWindow, app, globalShortcut, systemPreferences } from 'electron' import { SettingsChannels } from '@memry/contracts/ipc-channels' import { GENERAL_SETTINGS_DEFAULTS, @@ -28,6 +28,7 @@ import type { import { GRAPH_SETTINGS_DEFAULTS } from '@memry/contracts/graph-api' import type { GraphSettings } from '@memry/contracts/graph-api' import { createLogger } from '../lib/logger' +import { getSettingsSyncManager } from '../sync/settings-sync' import { getDatabase } from '../database' import { getSetting, setSetting, deleteSetting } from '@main/database/queries/settings' import { initEmbeddingModel, getModelInfo, isModelLoaded, isModelLoading } from '../lib/embeddings' @@ -38,6 +39,14 @@ import { initEmbeddingModel, getModelInfo, isModelLoaded, isModelLoading } from const logger = createLogger('IPC:Settings') +const GENERAL_SYNCABLE_FIELDS: (keyof GeneralSettings)[] = [ + 'theme', + 'fontSize', + 'fontFamily', + 'accentColor', + 'language' +] + const SETTINGS_KEYS = { JOURNAL_DEFAULT_TEMPLATE: 'journal.defaultTemplate', JOURNAL_SHOW_SCHEDULE: 'journal.showSchedule', @@ -157,8 +166,15 @@ function readGroupSettings<T extends Record<string, unknown>>(groupKey: string, } } -function getStartupTheme(): GeneralSettings['theme'] { - return readGroupSettings('general', GENERAL_SETTINGS_DEFAULTS).theme +function getStartupTheme(): { theme: GeneralSettings['theme']; accentColor?: string } { + const settings = readGroupSettings('general', GENERAL_SETTINGS_DEFAULTS) + const result: { theme: GeneralSettings['theme']; accentColor?: string } = { + theme: settings.theme + } + if (settings.accentColor) { + result.accentColor = settings.accentColor + } + return result } /** @@ -517,12 +533,23 @@ export function registerSettingsHandlers(): void { SettingsChannels.invoke.SET_GENERAL_SETTINGS, (_event, updates: Partial<GeneralSettings>) => { const result = writeGroupSettings('general', GENERAL_SETTINGS_DEFAULTS, updates) - if (result.success && updates.startOnBoot !== undefined) { - try { - app.setLoginItemSettings({ openAtLogin: updates.startOnBoot }) - logger.info(`Start on boot ${updates.startOnBoot ? 'enabled' : 'disabled'}`) - } catch (err) { - logger.warn('Failed to set login item:', err) + if (result.success) { + if (updates.startOnBoot !== undefined) { + try { + app.setLoginItemSettings({ openAtLogin: updates.startOnBoot }) + logger.info(`Start on boot ${updates.startOnBoot ? 'enabled' : 'disabled'}`) + } catch (err) { + logger.warn('Failed to set login item:', err) + } + } + + const manager = getSettingsSyncManager() + if (manager) { + for (const field of GENERAL_SYNCABLE_FIELDS) { + if (updates[field] !== undefined) { + manager.updateField(`general.${field}`, updates[field], 'local') + } + } } } return result @@ -552,8 +579,13 @@ export function registerSettingsHandlers(): void { ) ipcMain.handle( SettingsChannels.invoke.SET_KEYBOARD_SETTINGS, - (_event, updates: Partial<KeyboardShortcuts>) => - writeGroupSettings('keyboard', KEYBOARD_SHORTCUTS_DEFAULTS, updates) + (_event, updates: Partial<KeyboardShortcuts>) => { + const result = writeGroupSettings('keyboard', KEYBOARD_SHORTCUTS_DEFAULTS, updates) + if ('globalCapture' in updates) { + applyGlobalCaptureShortcut() + } + return result + } ) ipcMain.handle(SettingsChannels.invoke.GET_SYNC_SETTINGS, () => @@ -602,9 +634,74 @@ export function registerSettingsHandlers(): void { return { success: true } }) + ipcMain.handle(SettingsChannels.invoke.REGISTER_GLOBAL_CAPTURE, async () => { + return applyGlobalCaptureShortcut() + }) + logger.info('Settings handlers registered') } +// ============================================================================ +// Global Capture Shortcut +// ============================================================================ + +function toElectronAccelerator(binding: { + key: string + modifiers: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean } +}): string { + const parts: string[] = [] + if (binding.modifiers.meta) parts.push('CommandOrControl') + if (binding.modifiers.ctrl && !binding.modifiers.meta) parts.push('Control') + if (binding.modifiers.alt) parts.push('Alt') + if (binding.modifiers.shift) parts.push('Shift') + parts.push(binding.key) + return parts.join('+') +} + +export interface GlobalCaptureResult { + success: boolean + registered: boolean + permissionRequired?: boolean + error?: string +} + +/** + * Read keyboard.globalCapture from settings and register/unregister OS shortcut. + * Safe to call at startup and on settings change. + */ +export function applyGlobalCaptureShortcut(): GlobalCaptureResult { + globalShortcut.unregisterAll() + + const settings = readGroupSettings('keyboard', KEYBOARD_SHORTCUTS_DEFAULTS) + const binding = settings.globalCapture + if (!binding) { + return { success: true, registered: false } + } + + if (process.platform === 'darwin') { + const hasPerm = systemPreferences.isTrustedAccessibilityClient(false) + if (!hasPerm) { + logger.warn('Global capture: accessibility permission not granted on macOS') + return { success: false, registered: false, permissionRequired: true } + } + } + + const accelerator = toElectronAccelerator(binding) + const registered = globalShortcut.register(accelerator, () => { + BrowserWindow.getAllWindows().forEach((win) => { + if (!win.isDestroyed()) win.webContents.send('quick-capture:open') + }) + }) + + if (!registered) { + logger.warn(`Global capture: failed to register ${accelerator} (may be in use)`) + return { success: false, registered: false, error: `Shortcut ${accelerator} is already in use` } + } + + logger.info(`Global capture: registered ${accelerator}`) + return { success: true, registered: true } +} + /** * Unregister all settings-related IPC handlers. */ @@ -639,6 +736,7 @@ export function unregisterSettingsHandlers(): void { ipcMain.removeHandler(SettingsChannels.invoke.SET_BACKUP_SETTINGS) ipcMain.removeHandler(SettingsChannels.invoke.GET_GRAPH_SETTINGS) ipcMain.removeHandler(SettingsChannels.invoke.SET_GRAPH_SETTINGS) + ipcMain.removeHandler(SettingsChannels.invoke.REGISTER_GLOBAL_CAPTURE) logger.info('Settings handlers unregistered') } diff --git a/apps/desktop/src/main/ipc/vault-handlers.ts b/apps/desktop/src/main/ipc/vault-handlers.ts index 7d2265244..c53d79a7c 100644 --- a/apps/desktop/src/main/ipc/vault-handlers.ts +++ b/apps/desktop/src/main/ipc/vault-handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { ipcMain, shell } from 'electron' import { VaultChannels, SelectVaultSchema, @@ -63,6 +63,15 @@ export function registerVaultHandlers(): void { // vault:reindex - Trigger manual reindex ipcMain.handle(VaultChannels.invoke.REINDEX, createHandler(reindex)) + + // vault:reveal - Reveal vault folder in OS file manager + ipcMain.handle( + VaultChannels.invoke.REVEAL, + createHandler(async () => { + const status = getStatus() + if (status.path) shell.showItemInFolder(status.path) + }) + ) } /** diff --git a/apps/desktop/src/main/lib/url-utils.test.ts b/apps/desktop/src/main/lib/url-utils.test.ts index 45847b432..1f187eb7f 100644 --- a/apps/desktop/src/main/lib/url-utils.test.ts +++ b/apps/desktop/src/main/lib/url-utils.test.ts @@ -43,24 +43,15 @@ describe('url-utils', () => { }) describe('social platform detection', () => { - it('detects common social platforms', () => { + it('detects Twitter/X URLs', () => { expect(detectSocialPlatform('https://twitter.com/user/status/123')).toBe('twitter') expect(detectSocialPlatform('https://x.com/user/status/123')).toBe('twitter') - expect(detectSocialPlatform('https://www.linkedin.com/feed/update/123')).toBe('linkedin') - expect(detectSocialPlatform('https://threads.net/@user/post/123')).toBe('threads') - expect(detectSocialPlatform('https://bsky.app/profile/user/post/123')).toBe('bluesky') - expect(detectSocialPlatform('https://mastodon.social/@user/123')).toBe('mastodon') expect(detectSocialPlatform('https://example.com')).toBeNull() }) - it('identifies social post urls', () => { + it('identifies Twitter post urls vs profiles', () => { expect(isSocialPost('https://twitter.com/user/status/123')).toBe(true) expect(isSocialPost('https://twitter.com/user')).toBe(false) - expect(isSocialPost('https://www.linkedin.com/feed/update/123')).toBe(true) - expect(isSocialPost('https://www.linkedin.com/in/user')).toBe(false) - expect(isSocialPost('https://threads.net/@user/post/123')).toBe(true) - expect(isSocialPost('https://bsky.app/profile/user/post/123')).toBe(true) - expect(isSocialPost('https://mastodon.social/@user/123')).toBe(true) expect(isSocialPost('https://example.com')).toBe(false) }) }) diff --git a/apps/desktop/src/main/lib/url-utils.ts b/apps/desktop/src/main/lib/url-utils.ts index 4c4011f62..ec61508e5 100644 --- a/apps/desktop/src/main/lib/url-utils.ts +++ b/apps/desktop/src/main/lib/url-utils.ts @@ -11,7 +11,7 @@ // Types // ============================================================================ -export type SocialPlatform = 'twitter' | 'linkedin' | 'mastodon' | 'bluesky' | 'threads' +export type SocialPlatform = 'twitter' // ============================================================================ // URL Validation @@ -123,39 +123,10 @@ export function detectSocialPlatform(url: string): SocialPlatform | null { const lowerDomain = domain.toLowerCase() - // Twitter/X if (lowerDomain === 'twitter.com' || lowerDomain === 'x.com') { return 'twitter' } - // LinkedIn - if (lowerDomain === 'linkedin.com' || lowerDomain.endsWith('.linkedin.com')) { - return 'linkedin' - } - - // Threads - if (lowerDomain === 'threads.net') { - return 'threads' - } - - // Bluesky - if (lowerDomain === 'bsky.app' || lowerDomain === 'bsky.social') { - return 'bluesky' - } - - // Mastodon - various instances - const mastodonIndicators = [ - 'mastodon', - 'fosstodon', - 'hachyderm', - 'infosec.exchange', - 'mstdn', - 'social.coop' - ] - if (mastodonIndicators.some((indicator) => lowerDomain.includes(indicator))) { - return 'mastodon' - } - return null } @@ -176,20 +147,7 @@ export function isSocialPost(url: string): boolean { switch (platform) { case 'twitter': - // Format: /username/status/12345 return /\/[^/]+\/status\/\d+/.test(path) - case 'linkedin': - // Format: /posts/... or /feed/update/... - return path.includes('/posts/') || path.includes('/feed/update/') - case 'threads': - // Format: /@username/post/... - return path.includes('/post/') - case 'bluesky': - // Format: /profile/user/post/... - return path.includes('/post/') - case 'mastodon': - // Format: /@username/12345 (numeric post ID) - return /\/@[^/]+\/\d+/.test(path) default: return false } diff --git a/apps/desktop/src/main/store.ts b/apps/desktop/src/main/store.ts index aedc1ddd5..345332a2e 100644 --- a/apps/desktop/src/main/store.ts +++ b/apps/desktop/src/main/store.ts @@ -45,6 +45,9 @@ const defaultData: StoreSchema = { sync: {} } +/** In-memory cache — populated on first read, updated on every write. */ +let cache: StoreSchema | null = null + /** * Get the config file path in the app's userData directory */ @@ -54,7 +57,7 @@ function getConfigPath(): string { } /** - * Read the config file + * Read the config file (only called when cache is cold). */ function readConfig(): StoreSchema { try { @@ -67,33 +70,39 @@ function readConfig(): StoreSchema { } catch (error) { logger.error('Error reading config:', error) } - return defaultData + return { ...defaultData } } /** - * Write the config file + * Write the config file and keep the cache in sync. */ function writeConfig(data: StoreSchema): void { try { const configPath = getConfigPath() fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') + cache = data } catch (error) { logger.error('Error writing config:', error) } } +function getCache(): StoreSchema { + if (!cache) { + cache = readConfig() + } + return cache +} + /** * Simple store object that mimics electron-store API */ export const store = { get<K extends keyof StoreSchema>(key: K): StoreSchema[K] { - const data = readConfig() - return data[key] + return getCache()[key] }, set<K extends keyof StoreSchema>(key: K, value: StoreSchema[K]): void { - const data = readConfig() - data[key] = value + const data = { ...getCache(), [key]: value } writeConfig(data) } } diff --git a/apps/desktop/src/main/vault/folders.ts b/apps/desktop/src/main/vault/folders.ts index 9083057df..fda2731df 100644 --- a/apps/desktop/src/main/vault/folders.ts +++ b/apps/desktop/src/main/vault/folders.ts @@ -64,6 +64,8 @@ function parseFolderConfig(content: string): FolderConfig { const { data } = matter(content) return { + // Icon + icon: typeof data.icon === 'string' ? data.icon : undefined, // Template configuration template: typeof data.template === 'string' ? data.template : undefined, inherit: data.inherit !== false, // Default to true @@ -92,6 +94,11 @@ function parseFolderConfig(content: string): FolderConfig { function serializeFolderConfig(config: FolderConfig): string { const frontmatter: Record<string, unknown> = {} + // Icon + if (config.icon) { + frontmatter.icon = config.icon + } + // Template configuration if (config.template) { frontmatter.template = config.template @@ -174,6 +181,7 @@ export async function writeFolderConfig(folderPath: string, config: FolderConfig } // Check if config has any meaningful content + const hasIconConfig = !!config.icon const hasTemplateConfig = config.template || config.inherit === false const hasViewConfig = (config.views && config.views.length > 0) || @@ -182,7 +190,7 @@ export async function writeFolderConfig(folderPath: string, config: FolderConfig (config.summaries && Object.keys(config.summaries).length > 0) // If config is empty, delete the file - if (!hasTemplateConfig && !hasViewConfig) { + if (!hasIconConfig && !hasTemplateConfig && !hasViewConfig) { if (existsSync(configPath)) { await fs.unlink(configPath) } diff --git a/apps/desktop/src/main/vault/indexer.ts b/apps/desktop/src/main/vault/indexer.ts index 7437d52ae..75bae9ff9 100644 --- a/apps/desktop/src/main/vault/indexer.ts +++ b/apps/desktop/src/main/vault/indexer.ts @@ -258,10 +258,36 @@ async function indexNonMarkdownFile( } } +// ============================================================================ +// Concurrency Limiter +// ============================================================================ + +/** + * Run tasks with a bounded concurrency limit. + * Avoids exhausting file-descriptors or SQLite write slots on large vaults. + */ +async function withConcurrency<T>(tasks: (() => Promise<T>)[], limit: number): Promise<T[]> { + const results: T[] = new Array(tasks.length) + let next = 0 + + async function worker(): Promise<void> { + while (next < tasks.length) { + const i = next++ + results[i] = await tasks[i]() + } + } + + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker()) + await Promise.all(workers) + return results +} + // ============================================================================ // Main Indexer // ============================================================================ +const INDEX_CONCURRENCY = 8 + /** * Index all files in the vault. * Scans notes and journal folders, populates cache. @@ -309,11 +335,25 @@ export async function indexVault(vaultPath: string): Promise<IndexResult> { return result } - // Index each file - for (let i = 0; i < allFiles.length; i++) { - const file = allFiles[i] + // Track completed count for progress reporting (thread-safe increment via closure) + let completed = 0 + + const tasks = allFiles.map((file, i) => async () => { const status = await indexFile(vaultPath, file) + completed++ + + // Emit progress every 10 completions to reduce IPC overhead + if (completed % 10 === 0 || completed === allFiles.length) { + const progress = Math.round((completed / allFiles.length) * 100) + emitIndexProgress(progress) + } + return { i, status } + }) + + const statuses = await withConcurrency(tasks, INDEX_CONCURRENCY) + + for (const { status } of statuses) { switch (status) { case 'indexed': result.indexed++ @@ -325,12 +365,6 @@ export async function indexVault(vaultPath: string): Promise<IndexResult> { result.errors++ break } - - // Emit progress (batch every 10 files to reduce IPC overhead) - if (i % 10 === 0 || i === allFiles.length - 1) { - const progress = Math.round(((i + 1) / allFiles.length) * 100) - emitIndexProgress(progress) - } } logger.info( diff --git a/apps/desktop/src/main/vault/notes.test.ts b/apps/desktop/src/main/vault/notes.test.ts index f82ed4675..af346201f 100644 --- a/apps/desktop/src/main/vault/notes.test.ts +++ b/apps/desktop/src/main/vault/notes.test.ts @@ -873,10 +873,11 @@ describe('notes operations', () => { await notes.createNote({ title: 'C', content: 'C.', folder: 'folder1/nested' }) const folders = await notes.getFolders() + const folderPaths = folders.map((f) => f.path) - expect(folders).toContain('folder1') - expect(folders).toContain('folder2') - expect(folders).toContain('folder1/nested') + expect(folderPaths).toContain('folder1') + expect(folderPaths).toContain('folder2') + expect(folderPaths).toContain('folder1/nested') }) }) diff --git a/apps/desktop/src/main/vault/notes.ts b/apps/desktop/src/main/vault/notes.ts index fb7a10b2f..d6eee44e9 100644 --- a/apps/desktop/src/main/vault/notes.ts +++ b/apps/desktop/src/main/vault/notes.ts @@ -64,6 +64,8 @@ import { getDatabase, getIndexDatabase } from '../database' import { NoteError, NoteErrorCode, VaultError, VaultErrorCode } from '../lib/errors' import { generateNoteId } from '../lib/id' import { NotesChannels } from '@memry/contracts/notes-api' +import type { FolderInfo } from '@memry/contracts/templates-api' +import { readFolderConfig } from './folders' import { queueEmbeddingUpdate } from '../inbox/embedding-queue' import { createLogger } from '../lib/logger' import { getFileType, getExtension, isBinaryFileType } from '@memry/shared/file-types' @@ -1000,11 +1002,18 @@ export function getNoteLinks(id: string): NoteLinksResponse { // ============================================================================ /** - * Get all folders in the notes directory. + * Get all folders in the notes directory with their icons. */ -export async function getFolders(): Promise<string[]> { +export async function getFolders(): Promise<FolderInfo[]> { const notesDir = getNotesDir() - return listDirectories(notesDir, notesDir) + const paths = await listDirectories(notesDir, notesDir) + + return Promise.all( + paths.map(async (folderPath) => { + const config = await readFolderConfig(folderPath) + return { path: folderPath, icon: config?.icon ?? null } + }) + ) } /** diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index 3c2e27ef5..5ed6bab25 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -211,10 +211,16 @@ export interface TemplateListResponse { } export interface FolderConfig { + icon?: string | null template?: string inherit?: boolean } +export interface FolderInfo { + path: string + icon?: string | null +} + // Export types (T106, T108) export interface ExportNoteInput { noteId: string @@ -850,6 +856,7 @@ export interface VaultClientAPI { switch(vaultPath: string): Promise<SelectVaultResponse> remove(vaultPath: string): Promise<void> reindex(): Promise<void> + reveal(): Promise<void> } // Notes client API interface @@ -866,7 +873,7 @@ export interface NotesClientAPI { list(options?: NoteListOptions): Promise<NoteListResponse> getTags(): Promise<{ tag: string; color: string; count: number }[]> getLinks(id: string): Promise<NoteLinksResponse> - getFolders(): Promise<string[]> + getFolders(): Promise<FolderInfo[]> createFolder(path: string): Promise<{ success: boolean; error?: string }> renameFolder(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> deleteFolder(path: string): Promise<{ success: boolean; error?: string }> @@ -1468,6 +1475,14 @@ export interface InboxProcessingErrorEvent { error: string } +export interface LinkPreviewData { + title: string + domain: string + favicon?: string + image?: string + description?: string +} + // Inbox client API interface export interface InboxClientAPI { // Capture @@ -1579,11 +1594,13 @@ export interface InboxClientAPI { reason?: string }): Promise<InboxBulkResponse> fileAllStale(): Promise<InboxBulkResponse> - bulkArchiveOlderThan(olderThanDays: number): Promise<InboxBulkResponse> // Transcription retryTranscription(itemId: string): Promise<{ success: boolean; error?: string }> + // Preview + previewLink(url: string): Promise<LinkPreviewData> + // Metadata retryMetadata(itemId: string): Promise<{ success: boolean; error?: string }> @@ -1698,6 +1715,8 @@ export interface QuickCaptureClientAPI { close(): void /** Get current clipboard text content */ getClipboard(): Promise<string> + /** Resize the quick capture window height */ + resize(height: number): void } // Native context menu types @@ -2038,7 +2057,7 @@ export interface NoteEditorSettings { export interface GeneralSettingsDTO { theme: 'light' | 'dark' | 'white' | 'system' fontSize: 'small' | 'medium' | 'large' - fontFamily: 'system' | 'serif' | 'sans-serif' | 'monospace' + fontFamily: 'system' | 'serif' | 'sans-serif' | 'monospace' | 'gelasio' | 'geist' | 'inter' accentColor: string startOnBoot: boolean language: string @@ -2157,6 +2176,12 @@ export interface SettingsClientAPI { setGraphSettings( settings: Partial<GraphSettingsDTO> ): Promise<{ success: boolean; error?: string }> + registerGlobalCapture(): Promise<{ + success: boolean + registered: boolean + permissionRequired?: boolean + error?: string + }> } // Sync Auth API @@ -2249,6 +2274,13 @@ interface SyncLinkingClientAPI { }> } +// Account API +interface AccountClientAPI { + getInfo: () => Promise<{ email: string | null; joinedAt: number | null }> + signOut: () => Promise<{ success: boolean; keychainWarning?: string }> + getRecoveryKey: () => Promise<{ success: boolean; key?: string; error?: string }> +} + // Device Management API interface SyncDevicesClientAPI { getDevices: () => Promise<{ @@ -2443,6 +2475,7 @@ interface API extends WindowAPI { syncAuth: SyncAuthClientAPI syncSetup: SyncSetupClientAPI syncLinking: SyncLinkingClientAPI + account: AccountClientAPI syncDevices: SyncDevicesClientAPI syncOps: SyncOpsClientAPI crypto: CryptoClientAPI diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2ee6ebef9..8b055a334 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -17,7 +17,8 @@ import { FolderViewChannels, PropertiesChannels, SearchChannels, - GraphChannels + GraphChannels, + AccountChannels } from '@memry/contracts/ipc-channels' import { SYNC_CHANNELS, SYNC_EVENTS } from '@memry/contracts/ipc-sync' import type { @@ -60,6 +61,17 @@ type StartupTheme = 'light' | 'dark' | 'white' | 'system' const THEME_STORAGE_KEY = 'memry-theme' function getStartupThemeSync(): StartupTheme { + // Fast path: use the theme cached in localStorage from the previous run. + // This avoids a synchronous IPC round-trip on every launch after the first. + try { + const cached = window.localStorage.getItem(THEME_STORAGE_KEY) + if (cached === 'light' || cached === 'dark' || cached === 'white' || cached === 'system') { + return cached + } + } catch { + // localStorage may be unavailable; fall through to IPC + } + // First launch (or corrupted storage): fall back to synchronous IPC. try { return ipcRenderer.sendSync(SettingsChannels.sync.GET_STARTUP_THEME) as StartupTheme } catch { @@ -137,7 +149,8 @@ export const api = { close: () => invoke(VaultChannels.invoke.CLOSE), switch: (vaultPath: string) => invoke(VaultChannels.invoke.SWITCH, vaultPath), remove: (vaultPath: string) => invoke(VaultChannels.invoke.REMOVE, vaultPath), - reindex: () => invoke(VaultChannels.invoke.REINDEX) + reindex: () => invoke(VaultChannels.invoke.REINDEX), + reveal: () => invoke(VaultChannels.invoke.REVEAL) }, // Notes API @@ -229,8 +242,10 @@ export const api = { // Folder config API (T096.5) getFolderConfig: (folderPath: string) => invoke(NotesChannels.invoke.GET_FOLDER_CONFIG, folderPath), - setFolderConfig: (folderPath: string, config: { template?: string; inherit?: boolean }) => - invoke(NotesChannels.invoke.SET_FOLDER_CONFIG, { folderPath, config }), + setFolderConfig: ( + folderPath: string, + config: { icon?: string | null; template?: string; inherit?: boolean } + ) => invoke(NotesChannels.invoke.SET_FOLDER_CONFIG, { folderPath, config }), getFolderTemplate: (folderPath: string) => invoke(NotesChannels.invoke.GET_FOLDER_TEMPLATE, folderPath), @@ -573,7 +588,8 @@ export const api = { getGraphSettings: () => invoke(SettingsChannels.invoke.GET_GRAPH_SETTINGS), setGraphSettings: (settings: Record<string, unknown>) => - invoke(SettingsChannels.invoke.SET_GRAPH_SETTINGS, settings) + invoke(SettingsChannels.invoke.SET_GRAPH_SETTINGS, settings), + registerGlobalCapture: () => invoke(SettingsChannels.invoke.REGISTER_GLOBAL_CAPTURE) }, // Bookmarks API @@ -621,6 +637,7 @@ export const api = { invoke(InboxChannels.invoke.CAPTURE_TEXT, input), captureLink: (input: { url: string; tags?: string[] }) => invoke(InboxChannels.invoke.CAPTURE_LINK, input), + previewLink: (url: string) => invoke(InboxChannels.invoke.PREVIEW_LINK, url), captureImage: (input: { data: ArrayBuffer filename: string @@ -720,8 +737,6 @@ export const api = { bulkTag: (input: { itemIds: string[]; tags: string[] }) => invoke(InboxChannels.invoke.BULK_TAG, input), fileAllStale: () => invoke(InboxChannels.invoke.FILE_ALL_STALE), - bulkArchiveOlderThan: (olderThanDays: number) => - invoke(InboxChannels.invoke.BULK_ARCHIVE_OLDER_THAN, { olderThanDays }), // Transcription retryTranscription: (itemId: string) => @@ -821,7 +836,9 @@ export const api = { /** Close the quick capture window */ close: (): void => ipcRenderer.send('quick-capture:close'), /** Get current clipboard text content */ - getClipboard: (): Promise<string> => invoke('quick-capture:get-clipboard') + getClipboard: (): Promise<string> => invoke('quick-capture:get-clipboard'), + /** Resize the quick capture window height */ + resize: (height: number): void => ipcRenderer.send('quick-capture:resize', height) }, // Native context menu @@ -1492,6 +1509,13 @@ export const api = { invoke(SYNC_CHANNELS.COMPLETE_LINKING_QR, input) }, + // Account API + account: { + getInfo: () => invoke(AccountChannels.invoke.GET_INFO), + signOut: () => invoke(AccountChannels.invoke.SIGN_OUT), + getRecoveryKey: () => invoke(AccountChannels.invoke.GET_RECOVERY_KEY) + }, + // Device Management API syncDevices: { getDevices: () => invoke(SYNC_CHANNELS.GET_DEVICES), diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index ce730e336..98b5877f7 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -6,7 +6,7 @@ <!-- CSP meta fallback — authoritative policy set via session headers in main/index.ts --> <meta http-equiv="Content-Security-Policy" - content="default-src 'self' memry-file:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: memry-file:; media-src 'self' memry-file:; connect-src 'self' memry-file: https://*.memry.app wss://*.memry.app http://127.0.0.1:*; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + content="default-src 'self' memry-file:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: memry-file: https://pbs.twimg.com; media-src 'self' memry-file:; connect-src 'self' memry-file: https://*.memry.app wss://*.memry.app https://cdn.syndication.twimg.com https://react-tweet.vercel.app http://127.0.0.1:*; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" /> </head> diff --git a/apps/desktop/src/renderer/src/assets/base.css b/apps/desktop/src/renderer/src/assets/base.css index 631a19a52..771ceebf8 100644 --- a/apps/desktop/src/renderer/src/assets/base.css +++ b/apps/desktop/src/renderer/src/assets/base.css @@ -20,6 +20,12 @@ color: #ffffff !important; } +/* Inbox detail drawer: editor inherits drawer surface bg, not page bg */ +.inbox-content-editor .bn-editor, +.inbox-content-editor .bn-container { + background-color: transparent !important; +} + /* Make all text content in BlockNote white in dark mode */ .dark .bn-editor *, .dark .bn-container * { @@ -285,28 +291,6 @@ --graph-bg: #f6f5f0; --graph-label-color: #1a1a1a; - /* ===== CALENDAR VIEW ===== */ - --cal-bg: #f6f5f0; - --cal-cell-bg: transparent; - --cal-cell-outside-bg: #efefe9; - --cal-cell-weekend-bg: #f0efe8; - --cal-today-bg: #fffbeb; - --cal-today-border: #f59e0b; - --cal-today-badge: #f59e0b; - --cal-today-label: #d97706; - --cal-date-current: #4a4a4a; - --cal-date-outside: #c4c4be; - --cal-weekday: #8c8c8c; - --cal-task-bg: #f6f5f0; - --cal-task-bg-today: rgba(255, 255, 255, 0.8); - --cal-task-text: #4a4a4a; - --cal-task-text-today: #1a1a1a; - --cal-task-overdue-bg: #fef2f2; - --cal-task-overdue-text: #dc2626; - --cal-overflow: #8c8c8c; - --cal-month-text: #1a1a1a; - --cal-grid-gap: 1px; - /* ===== QUEUE LIST ===== */ --queue-bg: #eae8e1; --queue-number-bg: #d4d1c5; @@ -493,22 +477,18 @@ --font-heading: 'Space Grotesk Variable', system-ui, sans-serif; --font-mono: 'JetBrains Mono Variable', 'SF Mono', 'Fira Code', monospace; - /* ===== BASE COLORS - Dark Mode (Cool Dark Variant) ===== */ - --background: #0e0e10; - /* Deep cool dark */ + /* ===== BASE COLORS - Dark Mode (Neutral Charcoal) ===== */ + --background: #191919; --foreground: #e8e6e1; - /* Light warm text */ - --surface: #161618; - /* Dark panels */ - --surface-active: #1e1e21; - /* Dark hover */ + --surface: #222222; + --surface-active: #2a2a2a; /* ===== SEMANTIC PASTELS - Dark Mode (Muted versions) ===== */ - --card-sage: #1a231a; - --card-rose: #231a1a; - --card-sand: #23211a; - --card-lavender: #1d1a23; - --card-grey: #1a1a1c; + --card-sage: #1e261e; + --card-rose: #261e1e; + --card-sand: #26241e; + --card-lavender: #211e26; + --card-grey: #222222; /* ===== TYPOGRAPHY COLORS - Dark Mode ===== */ --text-primary: #e8e6e1; @@ -522,23 +502,23 @@ --accent-orange: #fb923c; /* ===== UI SEMANTIC COLORS - Dark Mode ===== */ - --muted: #161618; + --muted: #222222; --muted-foreground: #a8a6a1; - --popover: #131315; + --popover: #1e1e1e; --popover-foreground: #e8e6e1; - --border: #2a2a2e; - --input: #2a2a2e; - --card: #161618; + --border: #333333; + --input: #333333; + --card: #222222; --card-foreground: #e8e6e1; --primary: #e8e6e1; - --primary-foreground: #0e0e10; - --secondary: #161618; + --primary-foreground: #191919; + --secondary: #222222; --secondary-foreground: #e8e6e1; - --accent: #1e1e21; + --accent: #2a2a2a; --accent-foreground: #e8e6e1; --destructive: #dc2626; --destructive-foreground: #fafafa; - --ring: #6b6966; + --ring: #6b6b6b; /* ===== BORDER RADIUS (Same in dark mode) ===== */ --radius: 0.5rem; @@ -575,28 +555,6 @@ --graph-bg: #0e0e10; --graph-label-color: #e8e6e1; - /* ===== CALENDAR VIEW - Dark Mode ===== */ - --cal-bg: #0e0e10; - --cal-cell-bg: transparent; - --cal-cell-outside-bg: rgba(255, 255, 255, 0.03); - --cal-cell-weekend-bg: rgba(255, 255, 255, 0.02); - --cal-today-bg: rgba(245, 158, 11, 0.08); - --cal-today-border: #d97706; - --cal-today-badge: #d97706; - --cal-today-label: #fbbf24; - --cal-date-current: #d4d4d4; - --cal-date-outside: #525252; - --cal-weekday: #737373; - --cal-task-bg: rgba(255, 255, 255, 0.04); - --cal-task-bg-today: rgba(255, 255, 255, 0.08); - --cal-task-text: #d4d4d4; - --cal-task-text-today: #f5f5f5; - --cal-task-overdue-bg: rgba(239, 68, 68, 0.1); - --cal-task-overdue-text: #f87171; - --cal-overflow: #737373; - --cal-month-text: #f5f5f5; - --cal-grid-gap: 1px; - /* ===== QUEUE LIST - Dark Mode ===== */ --queue-bg: #161618; --queue-number-bg: #2a2a2e; @@ -1191,23 +1149,38 @@ del.bn-inline-content { .drag-region button, .drag-region a, .drag-region input, +.drag-region textarea, .drag-region [role='button'], .no-drag { -webkit-app-region: no-drag; } +/* ===== USER ACCENT COLOR (tint) ===== */ +:root, +.white, +.dark { + --tint: var(--user-accent-color, #6366f1); + --tint-foreground: #ffffff; + --tint-hover: color-mix(in srgb, var(--tint) 85%, black); + --tint-light: color-mix(in srgb, var(--tint) 15%, transparent); + --tint-lighter: color-mix(in srgb, var(--tint) 10%, transparent); + --tint-muted: color-mix(in srgb, var(--tint) 50%, transparent); + --tint-ring: color-mix(in srgb, var(--tint) 30%, transparent); + --tint-border: color-mix(in srgb, var(--tint) 50%, transparent); +} + :root { /* ===== SIDEBAR THEME - Warm Editorial Palette ===== */ --sidebar: #efefe9; --sidebar-foreground: #8a857a; --sidebar-primary: #1a1917; --sidebar-primary-foreground: #edeae4; - --sidebar-accent: rgba(196, 93, 62, 0.1); - --sidebar-accent-foreground: #c45d3e; + --sidebar-accent: color-mix(in srgb, var(--tint) 10%, transparent); + --sidebar-accent-foreground: var(--tint); --sidebar-border: #d9d5ce; - --sidebar-ring: #c45d3e; + --sidebar-ring: var(--tint); --sidebar-muted: #b5b0a6; - --sidebar-terracotta: #c45d3e; + --sidebar-terracotta: var(--tint); --sidebar-text-folder: #3d3a35; --sidebar-text-child: #5c5850; --sidebar-dot-inactive: #d9d5ce; @@ -1217,15 +1190,15 @@ del.bn-inline-content { .white { /* ===== SIDEBAR THEME - Clean White Palette ===== */ --sidebar: #f9f8f7; - --sidebar-foreground: #9b9a97; + --sidebar-foreground: #5f5e59; --sidebar-primary: #37352f; --sidebar-primary-foreground: #ffffff; - --sidebar-accent: rgba(35, 131, 226, 0.08); - --sidebar-accent-foreground: #2383e2; + --sidebar-accent: color-mix(in srgb, var(--tint) 8%, transparent); + --sidebar-accent-foreground: var(--tint); --sidebar-border: #e9e9e7; - --sidebar-ring: #2383e2; + --sidebar-ring: var(--tint); --sidebar-muted: #b0afab; - --sidebar-terracotta: #2383e2; + --sidebar-terracotta: var(--tint); --sidebar-text-folder: #37352f; --sidebar-text-child: #6b6966; --sidebar-dot-inactive: #e3e2e0; @@ -1233,27 +1206,28 @@ del.bn-inline-content { } .dark { - /* ===== SIDEBAR THEME - Cool Dark Palette ===== */ - --sidebar: #131315; + /* ===== SIDEBAR THEME - Neutral Charcoal ===== */ + --sidebar: #202020; --sidebar-foreground: #b5b3ae; --sidebar-primary: #e8e5df; - --sidebar-primary-foreground: #131315; - --sidebar-accent: rgba(224, 122, 92, 0.15); - --sidebar-accent-foreground: #e07a5c; - --sidebar-border: #2a2a2e; - --sidebar-ring: #e07a5c; + --sidebar-primary-foreground: #202020; + --sidebar-accent: color-mix(in srgb, var(--tint) 15%, transparent); + --sidebar-accent-foreground: var(--tint); + --sidebar-border: #333333; + --sidebar-ring: var(--tint); --sidebar-muted: #6b6b6b; - --sidebar-terracotta: #e07a5c; + --sidebar-terracotta: var(--tint); --sidebar-text-folder: #c5c0b8; --sidebar-text-child: #9a958d; - --sidebar-dot-inactive: #3a3a3e; - --sidebar-surface: #222225; + --sidebar-dot-inactive: #444444; + --sidebar-surface: #2a2a2a; } @theme inline { /* ===== FONT FAMILIES ===== */ --font-sans: var(--font-sans); --font-serif: var(--font-serif); + --font-heading: var(--font-heading); /* ===== BASE COLORS ===== */ --color-background: var(--background); @@ -1294,31 +1268,21 @@ del.bn-inline-content { --color-secondary-foreground: var(--secondary-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); + + /* ===== USER ACCENT (tint) ===== */ + --color-tint: var(--tint); + --color-tint-foreground: var(--tint-foreground); + --color-tint-hover: var(--tint-hover); + --color-tint-light: var(--tint-light); + --color-tint-lighter: var(--tint-lighter); + --color-tint-muted: var(--tint-muted); + --color-tint-ring: var(--tint-ring); + --color-tint-border: var(--tint-border); + --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-ring: var(--ring); - /* ===== CALENDAR VIEW ===== */ - --color-cal-bg: var(--cal-bg); - --color-cal-cell-bg: var(--cal-cell-bg); - --color-cal-cell-outside-bg: var(--cal-cell-outside-bg); - --color-cal-cell-weekend-bg: var(--cal-cell-weekend-bg); - --color-cal-today-bg: var(--cal-today-bg); - --color-cal-today-border: var(--cal-today-border); - --color-cal-today-badge: var(--cal-today-badge); - --color-cal-today-label: var(--cal-today-label); - --color-cal-date-current: var(--cal-date-current); - --color-cal-date-outside: var(--cal-date-outside); - --color-cal-weekday: var(--cal-weekday); - --color-cal-task-bg: var(--cal-task-bg); - --color-cal-task-bg-today: var(--cal-task-bg-today); - --color-cal-task-text: var(--cal-task-text); - --color-cal-task-text-today: var(--cal-task-text-today); - --color-cal-task-overdue-bg: var(--cal-task-overdue-bg); - --color-cal-task-overdue-text: var(--cal-task-overdue-text); - --color-cal-overflow: var(--cal-overflow); - --color-cal-month-text: var(--cal-month-text); - /* ===== QUEUE LIST ===== */ --color-queue-bg: var(--queue-bg); --color-queue-number-bg: var(--queue-number-bg); diff --git a/apps/desktop/src/renderer/src/assets/main.css b/apps/desktop/src/renderer/src/assets/main.css index 1053b8657..63ffb741b 100644 --- a/apps/desktop/src/renderer/src/assets/main.css +++ b/apps/desktop/src/renderer/src/assets/main.css @@ -5,6 +5,10 @@ body { -moz-osx-font-smoothing: grayscale; } +*:focus-visible { + outline: none; +} + /* Find in page — CSS Custom Highlight API */ ::highlight(find-matches) { background-color: #fde68a; diff --git a/apps/desktop/src/renderer/src/components/app-sidebar.tsx b/apps/desktop/src/renderer/src/components/app-sidebar.tsx index 2bac34e09..08f5868c0 100644 --- a/apps/desktop/src/renderer/src/components/app-sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/app-sidebar.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { useMemo, useState, useCallback, useRef } from 'react' -import { CloudOff, Plus, Search, Upload } from '@/lib/icons' +import { CloudOff, FilePlus, FolderPlus, Plus, Search, Upload } from '@/lib/icons' import { SidebarInbox, SidebarHome, @@ -38,7 +38,6 @@ import { notesService } from '@/services/notes-service' import { useSidebarDrillDown } from '@/contexts/sidebar-drill-down' import { useAuth } from '@/contexts/auth-context' import { SyncStatus } from '@/components/sync/sync-status' -import { SidebarUserProfile } from '@/components/sidebar/sidebar-user-profile' import { useInboxList } from '@/hooks/use-inbox' import type { SidebarItem, TabType } from '@/contexts/tabs/types' import type { AppPage } from '@/App' @@ -69,17 +68,18 @@ function SidebarHeaderContent() { const isCollapsed = state === 'collapsed' return ( - <SidebarHeader className="pt-3 pb-0 px-2 gap-1"> - {/* Drag region + Traffic lights for macOS */} + <SidebarHeader className="pt-3 pb-0 px-2 gap-0"> <div className={cn( 'drag-region flex items-center shrink-0', - isCollapsed ? 'justify-center' : 'justify-start px-2.5' + isCollapsed ? 'justify-center' : 'px-1' )} > <TrafficLights compact={isCollapsed} /> + <div className="group-data-[collapsible=icon]:hidden"> + <VaultSwitcher /> + </div> </div> - <VaultSwitcher /> </SidebarHeader> ) } @@ -97,9 +97,8 @@ export function AppSidebar({ currentPage, viewCounts, ...props }: AppSidebarProp * Inner sidebar component that has access to the drill-down context. */ function AppSidebarInner({ currentPage, viewCounts, ...props }: AppSidebarProps) { - // State to hold action buttons from NotesTree and TagList - const [notesActions, setNotesActions] = useState<React.ReactNode>(null) const [tagsActions, setTagsActions] = useState<React.ReactNode>(null) + const notesActionsRef = useRef<{ createNote: () => void; createFolder: () => void } | null>(null) const sidebarScrollRef = useRef<HTMLDivElement>(null) const targetFolderRef = useRef('') @@ -172,6 +171,7 @@ function AppSidebarInner({ currentPage, viewCounts, ...props }: AppSidebarProps) } } catch (error) { log.error('Failed to create new note', error) + toast.error(extractErrorMessage(error, 'Failed to create note')) } }, [openTab]) @@ -294,10 +294,8 @@ function AppSidebarInner({ currentPage, viewCounts, ...props }: AppSidebarProps) /> <span className={cn( - 'text-[13px] leading-4', - active - ? 'text-sidebar-accent-foreground font-medium' - : 'text-sidebar-foreground' + 'text-[13px] leading-4 font-medium', + active ? 'text-sidebar-accent-foreground' : 'text-sidebar-foreground' )} > {item.title} @@ -308,15 +306,10 @@ function AppSidebarInner({ currentPage, viewCounts, ...props }: AppSidebarProps) </span> )} {item.page === 'tasks' && todayTasksCount > 0 && ( - <span className="ml-auto size-[18px] flex items-center justify-center rounded-full bg-sidebar-terracotta/15 text-sidebar-terracotta text-[10px] font-semibold leading-none"> + <span className="ml-auto text-sidebar-muted font-medium text-[11px]"> {todayTasksCount} </span> )} - {item.shortcut && item.page !== 'inbox' && item.page !== 'tasks' && ( - <span className="ml-auto font-mono text-[9px] text-sidebar-muted/50"> - {item.shortcut} - </span> - )} </SidebarMenuButton> </SidebarMenuItem> ) @@ -339,10 +332,31 @@ function AppSidebarInner({ currentPage, viewCounts, ...props }: AppSidebarProps) id="collections" label="Collections" defaultExpanded={false} - actions={notesActions} + actions={ + <> + <button + type="button" + onClick={() => notesActionsRef.current?.createNote()} + className="p-0.5 rounded cursor-pointer hover:bg-sidebar-accent transition-colors" + aria-label="New note" + > + <FilePlus className="size-3.5 text-sidebar-muted hover:text-sidebar-foreground" /> + </button> + <button + type="button" + onClick={() => notesActionsRef.current?.createFolder()} + className="p-0.5 rounded cursor-pointer hover:bg-sidebar-accent transition-colors" + aria-label="New folder" + > + <FolderPlus className="size-3.5 text-sidebar-muted hover:text-sidebar-foreground" /> + </button> + </> + } > <NotesTree - onActionsReady={setNotesActions} + onActionsReady={(actions) => { + notesActionsRef.current = actions + }} onTargetFolderChange={handleTargetFolderChange} /> </SidebarSection> @@ -368,7 +382,7 @@ function AppSidebarInner({ currentPage, viewCounts, ...props }: AppSidebarProps) isDraggingFiles ? 'opacity-100' : 'opacity-0 invisible pointer-events-none' )} > - <div className="flex flex-col items-center gap-2 rounded-lg border-2 border-dashed border-primary/50 px-6 py-4"> + <div className="flex flex-col items-center gap-2 rounded-md border-2 border-dashed border-primary/50 px-6 py-4"> <Upload className="size-6 text-primary" /> <span className="text-sm font-medium">Drop files to import</span> <span className="text-xs text-muted-foreground"> @@ -383,9 +397,9 @@ function AppSidebarInner({ currentPage, viewCounts, ...props }: AppSidebarProps) const { state: authState } = useAuth() const handleSyncClick = useCallback(() => { - localStorage.setItem('memry_settings_section', 'sync') + localStorage.setItem('memry_settings_section', 'account') window.dispatchEvent( - new StorageEvent('storage', { key: 'memry_settings_section', newValue: 'sync' }) + new StorageEvent('storage', { key: 'memry_settings_section', newValue: 'account' }) ) openTab({ type: 'settings', @@ -409,21 +423,14 @@ function AppSidebarInner({ currentPage, viewCounts, ...props }: AppSidebarProps) <SidebarMenu> <SidebarMenuItem> {authState.status === 'authenticated' ? ( - <SyncStatus onOpenSettings={handleSyncClick} /> + <SyncStatus onOpenSettings={handleSyncClick} iconOnly /> ) : authState.status === 'checking' ? null : ( <SidebarMenuButton tooltip="Sync disabled" onClick={handleSyncClick}> <CloudOff className="size-4 text-muted-foreground" /> - <span className="text-muted-foreground">Sync disabled</span> </SidebarMenuButton> )} </SidebarMenuItem> </SidebarMenu> - {authState.status === 'authenticated' && ( - <> - <div className="h-px bg-sidebar-border mx-2 my-1" /> - <SidebarUserProfile /> - </> - )} </SidebarFooter> <SidebarRail /> </Sidebar> diff --git a/apps/desktop/src/renderer/src/components/bulk/bulk-action-bar.tsx b/apps/desktop/src/renderer/src/components/bulk/bulk-action-bar.tsx index ac3af7f4e..f9fd2bbcd 100644 --- a/apps/desktop/src/renderer/src/components/bulk/bulk-action-bar.tsx +++ b/apps/desktop/src/renderer/src/components/bulk/bulk-action-bar.tsx @@ -1,7 +1,5 @@ -import { Folder, Tag, Archive, Clock } from '@/lib/icons' +import { Folder, Tag, Archive, Clock, Star, Plus, X } from '@/lib/icons' -import { Button } from '@/components/ui/button' -import { AIClusterSuggestion } from '@/components/bulk/ai-cluster-suggestion' import { SnoozePicker } from '@/components/snooze' import { cn } from '@/lib/utils' import type { InboxItemListItem } from '@/types' @@ -22,6 +20,14 @@ interface BulkActionBarProps { onDismissSuggestion: () => void } +const KEYBOARD_HINTS = [ + { key: 'F', label: 'file' }, + { key: 'T', label: 'tag' }, + { key: 'S', label: 'snooze' }, + { key: 'E', label: 'archive' }, + { key: 'Esc', label: 'deselect' } +] as const + const BulkActionBar = ({ selectedCount, onFileAll, @@ -34,63 +40,178 @@ const BulkActionBar = ({ }: BulkActionBarProps): React.JSX.Element | null => { if (selectedCount === 0) return null + const hasSuggestion = aiSuggestion && aiSuggestion.items.length > 0 + return ( <div className={cn( - 'fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border shadow-lg', + 'fixed bottom-8 left-1/2 -translate-x-1/2 z-40', + 'flex flex-col items-center', + 'w-[520px] rounded-2xl', + 'bg-popover/95 backdrop-blur-md', + 'border border-border/60', + 'shadow-[0_24px_48px_rgba(0,0,0,0.3),0_0px_0px_1px_var(--border)]', + 'dark:shadow-[0_24px_48px_rgba(0,0,0,0.5),0_0px_0px_1px_rgba(255,255,255,0.04)]', 'slide-up-enter motion-reduce:animate-none' )} role="toolbar" - aria-label="Bulk actions" + aria-label={`Bulk actions for ${selectedCount} selected items`} > - <div className="max-w-4xl mx-auto px-6 py-4"> - {/* Action Buttons Row */} - <div className="flex items-center justify-center gap-4"> - <Button variant="secondary" onClick={onFileAll} className="gap-2"> - <Folder className="size-4" aria-hidden="true" /> - File all - </Button> - - <Button variant="outline" onClick={onTagAll} className="gap-2"> - <Tag className="size-4" aria-hidden="true" /> - Tag all - </Button> - - {/* Snooze all - with dropdown picker */} - {onSnoozeAll && ( - <SnoozePicker - onSnooze={onSnoozeAll} - size="default" - variant="outline" - trigger={ - <Button variant="outline" className="gap-2"> - <Clock className="size-4" aria-hidden="true" /> - Snooze all - </Button> - } - /> - )} - - <Button variant="outline" onClick={onArchiveAll} className="gap-2"> - <Archive className="size-4" aria-hidden="true" /> - Archive all - </Button> - </div> - - {/* AI Suggestion Section */} - {aiSuggestion && aiSuggestion.items.length > 0 && ( - <> - <div className="h-px bg-[var(--border)] my-4" aria-hidden="true" /> - <AIClusterSuggestion - suggestion={aiSuggestion} - onAddToSelection={onAddSuggestionToSelection} - onDismiss={onDismissSuggestion} - /> - </> + {/* Count badge — floating on bar edge */} + <div + className={cn( + 'absolute -top-3 left-1/2 -translate-x-1/2', + 'flex items-center gap-1 px-3 py-0.5', + 'rounded-full bg-amber-500', + 'shadow-[0_2px_8px_rgba(217,160,55,0.3)]' + )} + > + <span className="text-[11px]/3.5 font-bold text-white dark:text-background"> + {selectedCount} selected + </span> + </div> + + {/* Action buttons row */} + <div className="flex items-center w-full gap-0.5 p-2"> + <ActionButton onClick={onFileAll} active> + <Folder className="size-[15px]" aria-hidden="true" /> + File + </ActionButton> + + <ActionButton onClick={onTagAll}> + <Tag className="size-[15px]" aria-hidden="true" /> + Tag + </ActionButton> + + {onSnoozeAll ? ( + <SnoozePicker + onSnooze={onSnoozeAll} + size="default" + variant="ghost" + trigger={ + <button + type="button" + className={cn( + 'flex flex-1 items-center justify-center gap-1.5 py-2 px-4 rounded-[10px]', + 'text-[13px]/4 font-medium cursor-pointer', + 'text-muted-foreground hover:text-foreground/75 hover:bg-foreground/[0.05]', + 'transition-colors' + )} + > + <Clock className="size-[15px]" aria-hidden="true" /> + Snooze + </button> + } + /> + ) : ( + <ActionButton disabled> + <Clock className="size-[15px]" aria-hidden="true" /> + Snooze + </ActionButton> )} + + {/* Divider */} + <div className="w-px h-6 shrink-0 bg-border/60" aria-hidden="true" /> + + <ActionButton onClick={onArchiveAll} variant="destructive"> + <Archive className="size-[15px]" aria-hidden="true" /> + Archive + </ActionButton> + </div> + + {/* AI cluster suggestion */} + {hasSuggestion && ( + <> + <div className="w-[calc(100%-24px)] h-px bg-border/40 mx-3" aria-hidden="true" /> + <div className="flex items-center w-full gap-2 px-4 py-2.5"> + <Star className="size-3.5 shrink-0 text-[var(--accent-purple)]" aria-hidden="true" /> + <span className="flex-1 truncate text-xs text-muted-foreground/60"> + {aiSuggestion.reason} + </span> + <button + type="button" + onClick={onAddSuggestionToSelection} + className={cn( + 'flex items-center gap-1 px-2.5 py-0.5 rounded-md', + 'border border-[var(--accent-purple)]/20', + 'text-[var(--accent-purple)]', + 'hover:bg-[var(--accent-purple)]/10', + 'transition-colors' + )} + > + <Plus className="size-2.5" aria-hidden="true" /> + <span className="text-[11px]/3.5 font-medium">Add</span> + </button> + <button + type="button" + onClick={onDismissSuggestion} + className="p-1 text-muted-foreground/20 hover:text-muted-foreground/50 transition-colors" + aria-label="Dismiss suggestion" + > + <X className="size-3" aria-hidden="true" /> + </button> + </div> + </> + )} + + {/* Keyboard hints */} + <div className="flex items-center justify-center w-full gap-4 px-4 pt-1 pb-2"> + {KEYBOARD_HINTS.map(({ key, label }) => ( + <div key={key} className="flex items-center gap-1"> + <kbd + className={cn( + 'px-1.5 py-px rounded-[3px]', + 'bg-foreground/[0.06] border border-foreground/[0.08]', + 'font-mono text-[9px]/3 font-medium text-muted-foreground/30' + )} + > + {key} + </kbd> + <span className="text-[9px]/3 text-muted-foreground/25">{label}</span> + </div> + ))} </div> </div> ) } +interface ActionButtonProps { + children: React.ReactNode + onClick?: () => void + active?: boolean + disabled?: boolean + variant?: 'default' | 'destructive' +} + +function ActionButton({ + children, + onClick, + active = false, + disabled = false, + variant = 'default' +}: ActionButtonProps): React.JSX.Element { + const isDestructive = variant === 'destructive' + + return ( + <button + type="button" + onClick={onClick} + disabled={disabled} + className={cn( + 'flex flex-1 items-center justify-center gap-1.5 py-2 px-4 rounded-[10px]', + 'text-[13px]/4 font-medium', + 'transition-colors cursor-pointer', + isDestructive + ? 'text-destructive/70 hover:text-destructive hover:bg-destructive/10 shrink-0 grow-0' + : active + ? 'bg-foreground/[0.07] text-foreground/75 hover:bg-foreground/[0.12]' + : 'text-muted-foreground hover:text-foreground/75 hover:bg-foreground/[0.05]', + disabled && 'opacity-50 cursor-not-allowed' + )} + > + {children} + </button> + ) +} + export { BulkActionBar, type ClusterSuggestion } diff --git a/apps/desktop/src/renderer/src/components/bulk/bulk-file-panel.tsx b/apps/desktop/src/renderer/src/components/bulk/bulk-file-panel.tsx index c9ce3a1ed..75df9bf30 100644 --- a/apps/desktop/src/renderer/src/components/bulk/bulk-file-panel.tsx +++ b/apps/desktop/src/renderer/src/components/bulk/bulk-file-panel.tsx @@ -67,16 +67,15 @@ const BulkFilePanel = ({ const { data: vaultFolders = [] } = useQuery({ queryKey: ['vault', 'folders'], queryFn: async () => { - const paths = await window.api.notes.getFolders() - // Add root folder option and convert paths to Folder objects + const folderInfos = await window.api.notes.getFolders() const folders: Folder[] = [{ id: '', name: 'Notes (root)', path: '' }] - for (const path of paths) { - if (path) { + for (const fi of folderInfos) { + if (fi.path) { folders.push({ - id: path, - name: path.split('/').pop() || path, - path: path, - parent: path.includes('/') ? path.split('/').slice(0, -1).join('/') : undefined + id: fi.path, + name: fi.path.split('/').pop() || fi.path, + path: fi.path, + parent: fi.path.includes('/') ? fi.path.split('/').slice(0, -1).join('/') : undefined }) } } @@ -182,7 +181,7 @@ const BulkFilePanel = ({ <h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)]"> Items to file </h3> - <div className="rounded-lg border border-[var(--border)] bg-[var(--muted)]/20"> + <div className="rounded-md border border-[var(--border)] bg-[var(--muted)]/20"> <ScrollArea className="max-h-[160px]"> <div className="p-3 space-y-1"> {items.map((item) => ( diff --git a/apps/desktop/src/renderer/src/components/capture-input.tsx b/apps/desktop/src/renderer/src/components/capture-input.tsx index 2721741cd..7ec998a9e 100644 --- a/apps/desktop/src/renderer/src/components/capture-input.tsx +++ b/apps/desktop/src/renderer/src/components/capture-input.tsx @@ -12,7 +12,7 @@ import { Send, Loader2, Link, FileText, Mic, Paperclip, Copy } from '@/lib/icons import { cn } from '@/lib/utils' import { extractErrorMessage } from '@/lib/ipc-error' import { useCaptureText, useCaptureLink, useCaptureVoice, useCaptureImage } from '@/hooks/use-inbox' -import { type DisplayDensity, DENSITY_CONFIG } from '@/hooks/use-display-density' +import type { DisplayDensity } from '@/hooks/use-display-density' import { VoiceRecorder } from './voice-recorder' /** @@ -52,6 +52,7 @@ interface CaptureInputProps { onCaptureSuccess?: () => void onCaptureError?: (error: string) => void density?: DisplayDensity + compact?: boolean className?: string } @@ -91,6 +92,7 @@ export function CaptureInput({ onCaptureSuccess, onCaptureError, density = 'comfortable', + compact = false, className }: CaptureInputProps): React.JSX.Element { const [value, setValue] = useState('') @@ -104,8 +106,6 @@ export function CaptureInput({ const textareaRef = useRef<HTMLTextAreaElement>(null) const fileInputRef = useRef<HTMLInputElement>(null) - const densityConfig = DENSITY_CONFIG[density] - const captureText = useCaptureText() const captureLink = useCaptureLink() const captureVoice = useCaptureVoice() @@ -272,57 +272,41 @@ export function CaptureInput({ [captureImage, onCaptureSuccess, onCaptureError] ) - // Show voice recorder when recording - if (isRecording) { - return ( - <div className={cn('relative group', 'transition-all duration-300', className)}> - <VoiceRecorder - onRecordingComplete={handleRecordingComplete} - onCancel={handleRecordingCancel} - maxDuration={300} - autoStart - className="w-full" - /> - </div> - ) - } - return ( - <div className={cn('relative group', 'transition-all duration-300', className)}> - {/* Input container with editorial styling */} + <div + className={cn( + 'relative group flex flex-col gap-2', + compact && 'grow shrink basis-0 min-w-0', + 'transition-all duration-300', + className + )} + > <div className={cn( 'relative flex items-center', - densityConfig.captureGap, - densityConfig.capturePadding, - // Enhanced foundation - soft gradient with depth - 'bg-linear-to-r from-muted/30 via-muted/40 to-muted/30', - 'hover:from-muted/35 hover:via-muted/45 hover:to-muted/35', - 'border border-border/60', - 'shadow-[inset_0_1px_2px_rgba(0,0,0,0.04)]', - densityConfig.captureRadius, - 'transition-all duration-300', - // Focused state with warm amber glow - isFocused && 'bg-muted/50 border-border shadow-sm ring-1 ring-amber-500/20' + compact ? 'gap-1.5 px-2.5 py-1 rounded-md' : 'gap-2.5 px-3.5 py-2.5 rounded-[10px]', + 'border-[1.5px] border-dashed transition-all duration-150', + !isFocused && + (compact ? 'border-border hover:border-text-tertiary' : 'border-border/30 bg-muted/20'), + isFocused && + (compact + ? 'border-amber-500/60 bg-muted/10' + : 'bg-muted/30 border-border/50 ring-1 ring-amber-500/20') )} > - {/* Type indicator icon */} <div className={cn( - 'shrink-0', - 'text-muted-foreground/70', // More visible - 'transition-colors duration-200', - isFocused && 'text-amber-600 dark:text-amber-400' // Amber on focus + 'shrink-0 text-muted-foreground/50 transition-colors duration-200', + isFocused && 'text-amber-600 dark:text-amber-400' )} > {isUrl ? ( - <Link className="size-4" aria-hidden="true" /> + <Link className={compact ? 'size-3.5' : 'size-4'} aria-hidden="true" /> ) : ( - <FileText className="size-4" aria-hidden="true" /> + <FileText className={compact ? 'size-3.5' : 'size-4'} aria-hidden="true" /> )} </div> - {/* Textarea */} <textarea ref={textareaRef} value={value} @@ -333,130 +317,96 @@ export function CaptureInput({ onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} onKeyDown={handleKeyDown} - placeholder="What's on your mind?" + placeholder={ + compact + ? 'Capture a link or thought...' + : 'Quick capture — paste a link, jot a thought...' + } disabled={isCapturing} rows={1} className={cn( - 'flex-1 min-h-[24px] max-h-[200px]', - 'bg-transparent', - 'text-sm text-foreground/90 leading-6', - // Editorial placeholder - serif, italic, more visible - 'placeholder:font-serif placeholder:italic', - 'placeholder:text-muted-foreground/60', - 'resize-none', - 'focus:outline-none', + 'flex-1 bg-transparent resize-none focus:outline-none', 'disabled:opacity-50 disabled:cursor-not-allowed', - 'tracking-wide' + compact + ? 'min-h-[18px] max-h-[18px] text-[12px] leading-[18px] placeholder:text-text-tertiary' + : 'min-h-[24px] max-h-[200px] text-sm text-foreground/90 leading-6 placeholder:text-muted-foreground/40' )} aria-label="Capture input" /> - {/* Attachment button */} - <button - onClick={handleAttachClick} - disabled={isCapturing} - className={cn( - 'shrink-0', - 'p-1.5 rounded-lg', - 'text-muted-foreground/60', // More visible - 'transition-all duration-200', - 'hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400', // Amber hover - 'disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent' - )} - aria-label="Attach file" - title="Attach file (Images, Audio, Video, PDF)" - > - <Paperclip className="size-4" aria-hidden="true" /> - </button> + <div className={cn('flex shrink-0 items-center', compact ? 'gap-0.5' : 'gap-1')}> + <button + onClick={handleAttachClick} + disabled={isCapturing} + className={cn( + 'flex items-center justify-center rounded-md', + 'text-muted-foreground/50 transition-colors duration-200', + 'hover:text-muted-foreground', + 'disabled:opacity-30 disabled:cursor-not-allowed', + compact ? 'size-5' : 'size-7' + )} + aria-label="Attach file" + title="Attach file (Images, Audio, Video, PDF)" + > + <Paperclip className={compact ? 'size-3' : 'size-[15px]'} aria-hidden="true" /> + </button> - {/* Microphone button */} - <button - onClick={handleMicClick} - disabled={isCapturing} - className={cn( - 'shrink-0', - 'p-1.5 rounded-lg', - 'text-muted-foreground/60', // More visible - 'transition-all duration-200', - 'hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400', // Amber hover - 'disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent' - )} - aria-label="Record voice memo" - title="Record voice memo" - > - <Mic className="size-4" aria-hidden="true" /> - </button> - - {/* Hidden file input */} - <input - ref={fileInputRef} - type="file" - accept={ALLOWED_ATTACHMENT_TYPES.join(',')} - onChange={handleFileSelect} - className="hidden" - aria-hidden="true" - /> + <button + onClick={handleMicClick} + disabled={isCapturing} + className={cn( + 'flex items-center justify-center rounded-md', + 'text-muted-foreground/50 transition-colors duration-200', + 'hover:text-muted-foreground', + 'disabled:opacity-30 disabled:cursor-not-allowed', + compact ? 'size-5' : 'size-7' + )} + aria-label="Record voice memo" + title="Record voice memo" + > + <Mic className={compact ? 'size-3' : 'size-[15px]'} aria-hidden="true" /> + </button> - {/* Submit button */} - <button - onClick={() => handleSubmit()} - disabled={!value.trim() || isCapturing} - className={cn( - 'shrink-0', - 'p-1.5 rounded-lg', - 'text-muted-foreground/60', // More visible - 'transition-all duration-200', - 'hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400', // Amber hover - 'disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent', - // Active state when there's content - value.trim() && !isCapturing && 'text-amber-600 dark:text-amber-400 bg-amber-500/10' - )} - aria-label={isUrl ? 'Capture link' : 'Capture note'} - > - {isCapturing ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <Send className="size-4" aria-hidden="true" /> - )} - </button> - </div> + <input + ref={fileInputRef} + type="file" + accept={ALLOWED_ATTACHMENT_TYPES.join(',')} + onChange={handleFileSelect} + className="hidden" + aria-hidden="true" + /> - {/* Hint text */} - <div - className={cn( - 'mt-2 px-4', - 'text-xs text-muted-foreground/60', // More visible - 'transition-all duration-200', - !isFocused && 'opacity-0 translate-y-1', - isFocused && 'opacity-100 translate-y-0' - )} - > - {isUrl ? ( - <span> - Press{' '} - <kbd className="px-1.5 py-0.5 bg-muted/70 rounded border border-border/50 text-[10px] font-medium"> - Enter - </kbd>{' '} - to capture link - </span> - ) : ( - <span> - Press{' '} - <kbd className="px-1.5 py-0.5 bg-muted/70 rounded border border-border/50 text-[10px] font-medium"> - Enter - </kbd>{' '} - to capture,{' '} - <kbd className="px-1.5 py-0.5 bg-muted/70 rounded border border-border/50 text-[10px] font-medium"> - Shift+Enter - </kbd>{' '} - for new line - </span> - )} + <button + onClick={() => handleSubmit()} + disabled={!value.trim() || isCapturing} + className={cn( + 'flex items-center justify-center rounded-md', + 'transition-all duration-200', + value.trim() && !isCapturing + ? 'bg-amber-500 text-background dark:text-black' + : compact + ? 'text-muted-foreground/30' + : 'bg-muted/40 text-muted-foreground/30', + 'disabled:cursor-not-allowed', + compact ? 'size-5' : 'size-7' + )} + aria-label={isUrl ? 'Capture link' : 'Capture note'} + > + {isCapturing ? ( + <Loader2 + className={cn('animate-spin', compact ? 'size-3' : 'size-3.5')} + aria-hidden="true" + /> + ) : ( + <Send className={compact ? 'size-3' : 'size-3.5'} aria-hidden="true" /> + )} + </button> + </div> </div> {/* Duplicate notice */} {duplicateMatch && ( - <div className="mt-2 flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2"> + <div className="mt-2 flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2"> <Copy className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" /> <p className="flex-1 text-xs text-muted-foreground"> Already captured: “{duplicateMatch.title.slice(0, 50)} @@ -470,6 +420,16 @@ export function CaptureInput({ </button> </div> )} + + {isRecording && ( + <VoiceRecorder + onRecordingComplete={handleRecordingComplete} + onCancel={handleRecordingCancel} + maxDuration={300} + autoStart + className="w-full" + /> + )} </div> ) } diff --git a/apps/desktop/src/renderer/src/components/empty-state/capture-methods-grid.tsx b/apps/desktop/src/renderer/src/components/empty-state/capture-methods-grid.tsx deleted file mode 100644 index 306e758e5..000000000 --- a/apps/desktop/src/renderer/src/components/empty-state/capture-methods-grid.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Globe, FileText, Mic } from '@/lib/icons' -import { cn } from '@/lib/utils' - -interface CaptureMethod { - icon: React.ReactNode - label: string - sublabel?: string -} - -const captureMethods: CaptureMethod[] = [ - { - icon: <Globe className="size-6" aria-hidden="true" />, - label: 'Browser', - sublabel: 'Extension' - }, - { - icon: <FileText className="size-6" aria-hidden="true" />, - label: 'Quick Note' - }, - { - icon: <Mic className="size-6" aria-hidden="true" />, - label: 'Voice Memo' - } -] - -interface CaptureMethodCardProps { - method: CaptureMethod - delay: number -} - -const CaptureMethodCard = ({ method, delay }: CaptureMethodCardProps): React.JSX.Element => { - return ( - <div - className={cn( - 'flex flex-col items-center gap-2 p-4', - 'empty-state-entrance', - 'motion-reduce:animate-none' - )} - style={{ animationDelay: `${delay}ms` }} - > - <div className="text-muted-foreground">{method.icon}</div> - <div className="text-center"> - <p className="text-sm font-medium text-foreground">{method.label}</p> - {method.sublabel && <p className="text-xs text-muted-foreground">{method.sublabel}</p>} - </div> - </div> - ) -} - -/** - * Horizontal grid showing the different ways to capture content - * Browser Extension, Quick Note, Voice Memo - */ -const CaptureMethodsGrid = (): React.JSX.Element => { - return ( - <div className="w-full max-w-md"> - <p - className={cn( - 'text-xs font-medium text-muted-foreground text-center mb-4', - 'empty-state-entrance stagger-delay-3', - 'motion-reduce:animate-none' - )} - > - Ways to capture: - </p> - <div className="flex items-start justify-center gap-8"> - {captureMethods.map((method, index) => ( - <CaptureMethodCard key={method.label} method={method} delay={350 + index * 100} /> - ))} - </div> - </div> - ) -} - -export { CaptureMethodsGrid } diff --git a/apps/desktop/src/renderer/src/components/empty-state/empty-state.tsx b/apps/desktop/src/renderer/src/components/empty-state/empty-state.tsx index 285765f51..b0a3516b4 100644 --- a/apps/desktop/src/renderer/src/components/empty-state/empty-state.tsx +++ b/apps/desktop/src/renderer/src/components/empty-state/empty-state.tsx @@ -1,46 +1,26 @@ import { InboxZeroState } from '@/components/empty-state/inbox-zero-state' -import { GettingStartedState } from '@/components/empty-state/getting-started-state' import { cn } from '@/lib/utils' -export type EmptyStateVariant = 'inboxZero' | 'gettingStarted' - interface EmptyStateProps { itemsProcessedToday: number - hasFilingHistory: boolean + processedThisWeek: number + currentStreak: number isExiting?: boolean className?: string } -/** - * Determines which empty state variant to show based on user history - */ -const getVariant = (hasFilingHistory: boolean, itemsProcessedToday: number): EmptyStateVariant => { - // If user has processed items this session or has filing history, show celebration - if (hasFilingHistory || itemsProcessedToday > 0) { - return 'inboxZero' - } - // Otherwise, show onboarding - return 'gettingStarted' -} - -/** - * Empty State container component that selects the appropriate variant - * based on user history and displays the corresponding UI. - */ const EmptyState = ({ itemsProcessedToday, - hasFilingHistory, + processedThisWeek, + currentStreak, isExiting = false, className }: EmptyStateProps): React.JSX.Element => { - const variant = getVariant(hasFilingHistory, itemsProcessedToday) - return ( <div className={cn( 'flex flex-col items-center justify-center h-full w-full px-4', 'transition-all duration-150 ease-out', - // Entrance/exit animations isExiting ? 'opacity-0 scale-95 motion-reduce:opacity-0 motion-reduce:scale-100' : 'opacity-100 scale-100 animate-in fade-in duration-300 motion-reduce:animate-none', @@ -49,11 +29,11 @@ const EmptyState = ({ role="status" aria-live="polite" > - {variant === 'inboxZero' ? ( - <InboxZeroState itemsProcessedToday={itemsProcessedToday} /> - ) : ( - <GettingStartedState /> - )} + <InboxZeroState + itemsProcessedToday={itemsProcessedToday} + processedThisWeek={processedThisWeek} + currentStreak={currentStreak} + /> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/empty-state/getting-started-state.tsx b/apps/desktop/src/renderer/src/components/empty-state/getting-started-state.tsx deleted file mode 100644 index 6057aaa8c..000000000 --- a/apps/desktop/src/renderer/src/components/empty-state/getting-started-state.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Inbox } from '@/lib/icons' -import { Button } from '@/components/ui/button' -import { CaptureMethodsGrid } from '@/components/empty-state/capture-methods-grid' -import { cn } from '@/lib/utils' - -interface GettingStartedStateProps { - onInstallExtension?: () => void -} - -/** - * Getting Started state - shown for new users or when no captures exist. - * Displays onboarding guidance with capture methods and CTA. - */ -const GettingStartedState = ({ - onInstallExtension -}: GettingStartedStateProps): React.JSX.Element => { - const handleInstallClick = (): void => { - if (onInstallExtension) { - onInstallExtension() - } else { - // Default behavior: open extension store (placeholder) - window.open('https://chrome.google.com/webstore', '_blank') - } - } - - return ( - <div className="flex flex-col items-center text-center max-w-md space-y-8"> - {/* Inbox Icon in box */} - <div - className={cn( - 'flex items-center justify-center', - 'size-20 rounded-xl', - 'border border-border bg-muted/30', - 'empty-state-entrance stagger-delay-1', - 'motion-reduce:animate-none' - )} - aria-hidden="true" - > - <Inbox className="size-10 text-muted-foreground" strokeWidth={1.5} /> - </div> - - {/* Title */} - <h2 - className={cn( - 'text-2xl font-medium text-foreground', - 'empty-state-entrance stagger-delay-2', - 'motion-reduce:animate-none' - )} - > - Your inbox is empty - </h2> - - {/* Description */} - <p - className={cn( - 'text-sm text-muted-foreground leading-relaxed', - 'empty-state-entrance stagger-delay-3', - 'motion-reduce:animate-none' - )} - > - Capture links, notes, images, and voice memos to process them later - </p> - - {/* Capture Methods Grid */} - <CaptureMethodsGrid /> - - {/* Primary CTA */} - <Button - onClick={handleInstallClick} - size="lg" - className={cn('empty-state-entrance stagger-delay-5', 'motion-reduce:animate-none')} - > - Install browser extension - </Button> - </div> - ) -} - -export { GettingStartedState } diff --git a/apps/desktop/src/renderer/src/components/empty-state/inbox-zero-state.tsx b/apps/desktop/src/renderer/src/components/empty-state/inbox-zero-state.tsx index fb61e2c9d..53e2a9a50 100644 --- a/apps/desktop/src/renderer/src/components/empty-state/inbox-zero-state.tsx +++ b/apps/desktop/src/renderer/src/components/empty-state/inbox-zero-state.tsx @@ -1,88 +1,114 @@ -import { CheckCircle2 } from '@/lib/icons' -import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' interface InboxZeroStateProps { itemsProcessedToday: number + processedThisWeek: number + currentStreak: number onViewRecentActivity?: () => void } -/** - * Formats the processing stats message based on items processed today - */ -const getStatsMessage = (count: number): string => { - if (count === 0) { - return 'All caught up' - } - if (count === 1) { - return 'You processed 1 item today' - } - return `You processed ${count} items today` -} - -/** - * Inbox Zero state - shown when user has cleared their inbox. - * Displays a calm, understated celebration with processing stats. - */ const InboxZeroState = ({ - itemsProcessedToday, - onViewRecentActivity + processedThisWeek, + currentStreak }: InboxZeroStateProps): React.JSX.Element => { - const statsMessage = getStatsMessage(itemsProcessedToday) + const showStats = processedThisWeek > 0 || currentStreak > 0 return ( - <div className="flex flex-col items-center text-center max-w-md space-y-6"> - {/* Checkmark Icon - calm, muted color */} + <div className="flex flex-col items-center max-w-90 gap-5 text-xs/4"> + {/* Radial glow → bordered inner circle → checkmark */} <div className={cn( - 'flex items-center justify-center', - 'size-16 rounded-full', - 'bg-primary/10', - 'empty-state-entrance stagger-delay-1', - 'motion-reduce:animate-none' + 'flex items-center justify-center rounded-[40px] shrink-0 size-20', + 'empty-state-entrance stagger-delay-1 motion-reduce:animate-none' )} + style={{ + backgroundImage: + 'radial-gradient(circle farthest-corner at 50% 50%, color-mix(in srgb, var(--accent-orange) 12%, transparent) 0%, color-mix(in srgb, var(--accent-orange) 0%, transparent) 70%)' + }} aria-label="Success, inbox is empty" > - <CheckCircle2 className="size-8 text-primary" strokeWidth={1.5} aria-hidden="true" /> + <div className="flex items-center justify-center rounded-[28px] bg-accent-orange/5 border border-accent-orange/20 shrink-0 size-14"> + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"> + <path + d="M9 12l2 2 4-4" + className="stroke-accent-orange" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <circle cx="12" cy="12" r="9" className="stroke-accent-orange" strokeWidth="1.5" /> + </svg> + </div> </div> {/* Title */} <h2 className={cn( - 'text-2xl font-medium text-foreground', - 'empty-state-entrance stagger-delay-2', - 'motion-reduce:animate-none' + 'text-center text-foreground font-medium text-lg/6', + 'empty-state-entrance stagger-delay-2 motion-reduce:animate-none' )} > - Inbox zero + Inbox Zero </h2> - {/* Processing Stats */} + {/* Subtitle */} <p className={cn( - 'text-sm text-muted-foreground', - 'empty-state-entrance stagger-delay-3', - 'motion-reduce:animate-none' + 'text-center text-muted-foreground text-[13px]/5', + 'empty-state-entrance stagger-delay-3 motion-reduce:animate-none' )} > - {statsMessage} + Everything's processed. Capture something new with the input above, or paste a link to + get started. </p> - {/* Optional Action */} - {onViewRecentActivity && ( - <Button - variant="link" - onClick={onViewRecentActivity} + {/* Stats: filed this week | streak */} + {showStats && ( + <div className={cn( - 'text-muted-foreground hover:text-foreground', - 'empty-state-entrance stagger-delay-4', - 'transition-colors duration-[var(--duration-fast)]', - 'motion-reduce:animate-none' + 'flex items-center pt-2 gap-4', + 'empty-state-entrance stagger-delay-4 motion-reduce:animate-none' )} > - View recent activity - </Button> + {processedThisWeek > 0 && ( + <div className="flex items-center gap-1.5"> + <span className="text-accent-green font-medium text-xs/4 tabular-nums"> + {processedThisWeek} + </span> + <span className="text-muted-foreground/60 text-xs/4">filed this week</span> + </div> + )} + + {processedThisWeek > 0 && currentStreak > 0 && ( + <div className="w-px h-3 bg-foreground/[6%] shrink-0" /> + )} + + {currentStreak > 0 && ( + <div className="flex items-center gap-1.5"> + <span className="text-accent-orange font-medium text-xs/4 tabular-nums"> + {currentStreak} + </span> + <span className="text-muted-foreground/60 text-xs/4">day streak</span> + </div> + )} + </div> )} + + {/* Keyboard tip */} + <div + className={cn( + 'flex items-center mt-1 rounded-md py-1.5 px-3 gap-1.5 bg-foreground/[3%]', + 'empty-state-entrance stagger-delay-5 motion-reduce:animate-none' + )} + > + <span className="text-muted-foreground/60 text-[11px]/3.5">Tip: use</span> + <kbd className="inline-flex items-center rounded-sm py-px px-1.5 bg-foreground/[4%] border border-foreground/10 text-muted-foreground font-medium text-[10px]/3.5"> + ⌘V + </kbd> + <span className="text-muted-foreground/60 text-[11px]/3.5"> + to quick-capture from clipboard + </span> + </div> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/empty-state/index.ts b/apps/desktop/src/renderer/src/components/empty-state/index.ts index 1a4722cb5..d92c30849 100644 --- a/apps/desktop/src/renderer/src/components/empty-state/index.ts +++ b/apps/desktop/src/renderer/src/components/empty-state/index.ts @@ -1,4 +1,2 @@ -export { EmptyState, type EmptyStateVariant } from './empty-state' +export { EmptyState } from './empty-state' export { InboxZeroState } from './inbox-zero-state' -export { GettingStartedState } from './getting-started-state' -export { CaptureMethodsGrid } from './capture-methods-grid' diff --git a/apps/desktop/src/renderer/src/components/filing/tag-autocomplete.tsx b/apps/desktop/src/renderer/src/components/filing/tag-autocomplete.tsx index 01d3d378d..3815ef6e6 100644 --- a/apps/desktop/src/renderer/src/components/filing/tag-autocomplete.tsx +++ b/apps/desktop/src/renderer/src/components/filing/tag-autocomplete.tsx @@ -1,176 +1,58 @@ /** * TagAutocomplete Component - * Enhanced tag input with autocomplete dropdown, recent tags, and popular tags. - * Used across FilingPanel, BulkFilePanel, and BulkTagPopover. + * Inline tag input with dropdown showing AI suggestions, matching tags, and create option. */ -import { useState, useRef, useEffect, useCallback } from 'react' -import { X, Plus, Clock, TrendingUp } from '@/lib/icons' +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { Plus } from '@/lib/icons' -import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -import { useAllTags, type TagWithMeta } from '@/hooks/use-all-tags' +import { useAllTags } from '@/hooks/use-all-tags' import { COLOR_NAMES, getTagColors } from '@/components/note/tags-row/tag-colors' -// Hash function to get consistent color for a tag name function getColorForTag(tagName: string): string { let hash = 0 for (let i = 0; i < tagName.length; i++) { hash = tagName.charCodeAt(i) + ((hash << 5) - hash) } - const index = Math.abs(hash) % COLOR_NAMES.length - return COLOR_NAMES[index] + return COLOR_NAMES[Math.abs(hash) % COLOR_NAMES.length] } // ============================================================================= -// TagPill Component +// TagPill — inline pill for selected tags // ============================================================================= -interface TagPillProps { - tag: string - onRemove: (tag: string) => void -} - -const TagPill = ({ tag, onRemove }: TagPillProps): React.JSX.Element => { - const colorName = getColorForTag(tag) - const colors = getTagColors(colorName) - - const handleRemove = (e: React.MouseEvent): void => { - e.stopPropagation() - onRemove(tag) - } - - const handleKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter' || e.key === ' ' || e.key === 'Backspace') { - e.preventDefault() - onRemove(tag) - } - } +const TagPill = ({ tag }: { tag: string }): React.JSX.Element => { + const colors = getTagColors(getColorForTag(tag)) return ( <span - className={cn( - 'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium', - 'tag-pill-enter motion-reduce:animate-none' - )} - style={{ - backgroundColor: colors.background, - color: colors.text - }} + role="listitem" + className="inline-flex items-center rounded-[10px] py-0.5 px-2 text-[11px] leading-3.5 tag-pill-enter motion-reduce:animate-none" + style={{ backgroundColor: `${colors.text}15`, color: colors.text }} > {tag} - <button - type="button" - onClick={handleRemove} - onKeyDown={handleKeyDown} - className="rounded-full p-0.5 transition-opacity hover:opacity-70" - aria-label={`Remove tag ${tag}`} - > - <X className="size-3" aria-hidden="true" /> - </button> </span> ) } -// ============================================================================= -// SuggestionItem Component -// ============================================================================= - -interface SuggestionItemProps { - tag: TagWithMeta - isHighlighted: boolean - onSelect: (tag: string) => void - onMouseEnter: () => void -} - -const SuggestionItem = ({ - tag, - isHighlighted, - onSelect, - onMouseEnter -}: SuggestionItemProps): React.JSX.Element => { - return ( - <button - type="button" - onClick={() => onSelect(tag.name)} - onMouseEnter={onMouseEnter} - className={cn( - 'w-full flex items-center justify-between px-3 py-1.5 text-sm text-left', - 'transition-colors duration-75', - isHighlighted ? 'bg-accent text-accent-foreground' : 'hover:bg-muted' - )} - > - <span className="flex items-center gap-2"> - {tag.color && ( - <span - className="size-2 rounded-full" - style={{ backgroundColor: tag.color }} - aria-hidden="true" - /> - )} - <span>{tag.name}</span> - </span> - <span className="text-xs text-muted-foreground">{tag.count}</span> - </button> - ) -} - -// ============================================================================= -// QuickTagButton Component -// ============================================================================= - -interface QuickTagButtonProps { - tag: string - onAdd: (tag: string) => void - disabled?: boolean -} - -const QuickTagButton = ({ tag, onAdd, disabled }: QuickTagButtonProps): React.JSX.Element => { - const colorName = getColorForTag(tag) - const colors = getTagColors(colorName) - - return ( - <button - type="button" - onClick={() => onAdd(tag)} - disabled={disabled} - className={cn( - 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', - 'transition-opacity duration-[var(--duration-instant)]', - disabled ? 'opacity-40 cursor-not-allowed' : 'hover:opacity-80 cursor-pointer' - )} - style={{ - backgroundColor: disabled ? undefined : `${colors.background}80`, // 50% opacity - color: disabled ? undefined : colors.text - }} - > - <Plus className="size-3" aria-hidden="true" /> - {tag} - </button> - ) -} - // ============================================================================= // TagAutocomplete Component // ============================================================================= interface TagAutocompleteProps { - /** Currently selected tags */ tags: string[] - /** Callback when tags change */ onTagsChange: (tags: string[]) => void - /** Placeholder text for input */ placeholder?: string - /** Show section labels (Recent, Popular) */ showSections?: boolean - /** Maximum suggestions to show in dropdown */ maxSuggestions?: number - /** Auto focus input on mount */ autoFocus?: boolean - /** Class name for container */ + aiSuggestedTags?: string[] className?: string } +const LISTBOX_ID = 'tag-autocomplete-listbox' + export const TagAutocomplete = ({ tags, onTagsChange, @@ -178,64 +60,105 @@ export const TagAutocomplete = ({ showSections = true, maxSuggestions = 8, autoFocus = false, + aiSuggestedTags = [], className }: TagAutocompleteProps): React.JSX.Element => { const [inputValue, setInputValue] = useState('') const [isDropdownOpen, setIsDropdownOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) + const [isFocused, setIsFocused] = useState(false) const inputRef = useRef<HTMLInputElement>(null) const dropdownRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null) - const { searchTags, getPopularTags, getRecentTags, isLoading } = useAllTags() + const { searchTags, getPopularTags } = useAllTags() - // Get filtered suggestions based on input - const suggestions = inputValue.trim() - ? searchTags(inputValue).filter((t) => !tags.includes(t.name)) - : [] + const trimmedInput = inputValue.trim() - // Get quick suggestions (recent + popular) when no input - const recentTags = getRecentTags(5).filter((t) => !tags.includes(t.name)) - const popularTags = getPopularTags(8).filter((t) => !tags.includes(t.name)) + // AI suggested tags not yet added + const availableAiTags = useMemo( + () => aiSuggestedTags.filter((t) => !tags.includes(t)), + [aiSuggestedTags, tags] + ) - useEffect(() => { - if (inputValue.trim() && suggestions.length > 0) { - setHighlightedIndex(0) - } else if (!inputValue.trim() && isDropdownOpen && popularTags.length > 0) { - setHighlightedIndex(0) + // Search results when typing + const matchingTags = useMemo( + () => (trimmedInput ? searchTags(trimmedInput).filter((t) => !tags.includes(t.name)) : []), + [trimmedInput, searchTags, tags] + ) + + // Popular tags for idle state + const popularTags = useMemo( + () => getPopularTags(maxSuggestions).filter((t) => !tags.includes(t.name)), + [getPopularTags, maxSuggestions, tags] + ) + + // Check if exact match exists (to decide whether to show "Create") + const exactMatchExists = useMemo( + () => + trimmedInput + ? tags.includes(trimmedInput.toLowerCase()) || + matchingTags.some((t) => t.name.toLowerCase() === trimmedInput.toLowerCase()) + : true, + [trimmedInput, tags, matchingTags] + ) + + // Build flat list for keyboard navigation + const flatItems = useMemo(() => { + const items: Array<{ + type: 'ai' | 'match' | 'popular' | 'create' + value: string + count?: number + }> = [] + + if (!trimmedInput && availableAiTags.length > 0) { + availableAiTags.slice(0, 2).forEach((t) => items.push({ type: 'ai', value: t })) + } + + if (trimmedInput) { + matchingTags + .slice(0, maxSuggestions) + .forEach((t) => items.push({ type: 'match', value: t.name, count: t.count })) } else { - setHighlightedIndex(-1) + popularTags + .slice(0, 5) + .forEach((t) => items.push({ type: 'popular', value: t.name, count: t.count })) + } + + if (trimmedInput && !exactMatchExists) { + items.push({ type: 'create', value: trimmedInput.toLowerCase() }) } - }, [inputValue, suggestions.length, isDropdownOpen, popularTags.length]) - // Close dropdown on click outside + return items + }, [trimmedInput, availableAiTags, matchingTags, popularTags, maxSuggestions, exactMatchExists]) + + useEffect(() => { + setHighlightedIndex(flatItems.length > 0 ? 0 : -1) + }, [flatItems.length, trimmedInput]) + + // Close on click outside useEffect(() => { - const handleClickOutside = (e: MouseEvent): void => { + const handler = (e: MouseEvent): void => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setIsDropdownOpen(false) } } - - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) }, []) - // Auto focus useEffect(() => { - if (autoFocus) { - setTimeout(() => inputRef.current?.focus(), 100) - } + if (autoFocus) setTimeout(() => inputRef.current?.focus(), 100) }, [autoFocus]) const addTag = useCallback( (tag: string): void => { - const normalizedTag = tag.trim().toLowerCase() - if (normalizedTag && !tags.includes(normalizedTag)) { - onTagsChange([...tags, normalizedTag]) + const normalized = tag.trim().toLowerCase() + if (normalized && !tags.includes(normalized)) { + onTagsChange([...tags, normalized]) } setInputValue('') - setIsDropdownOpen(false) setHighlightedIndex(-1) inputRef.current?.focus() }, @@ -251,181 +174,244 @@ export const TagAutocomplete = ({ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => { const value = e.target.value - // Check for comma to add tag - if (value.includes(',')) { - const parts = value.split(',') - const tagToAdd = parts[0] - if (tagToAdd.trim()) { + + // Space or comma creates tag + if (value.endsWith(' ') || value.includes(',')) { + const tagToAdd = value.replace(',', '').trim() + if (tagToAdd) { addTag(tagToAdd) } - setInputValue(parts.slice(1).join(',')) - } else { - setInputValue(value) + return } + + setInputValue(value) + if (!isDropdownOpen) setIsDropdownOpen(true) } const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { - const hasSearchResults = inputValue.trim() && suggestions.length > 0 - const hasPopularTags = !inputValue.trim() && popularTags.length > 0 - const activeList = hasSearchResults ? suggestions : popularTags.slice(0, 5) - const hasDropdownItems = isDropdownOpen && (hasSearchResults || hasPopularTags) + // Escape closes dropdown first, then lets panel handle it + if (e.key === 'Escape') { + if (isDropdownOpen) { + e.preventDefault() + e.stopPropagation() + setIsDropdownOpen(false) + return + } + return + } - if (hasDropdownItems) { + if (isDropdownOpen && flatItems.length > 0) { switch (e.key) { case 'ArrowDown': e.preventDefault() - setHighlightedIndex((prev) => (prev < activeList.length - 1 ? prev + 1 : 0)) - break + setHighlightedIndex((prev) => (prev < flatItems.length - 1 ? prev + 1 : 0)) + return case 'ArrowUp': e.preventDefault() - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : activeList.length - 1)) - break + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : flatItems.length - 1)) + return case 'Enter': e.preventDefault() - if (highlightedIndex >= 0 && highlightedIndex < activeList.length) { - addTag(activeList[highlightedIndex].name) - } else if (inputValue.trim()) { - addTag(inputValue) + if (highlightedIndex >= 0 && highlightedIndex < flatItems.length) { + addTag(flatItems[highlightedIndex].value) + } else if (trimmedInput) { + addTag(trimmedInput) } - break - case 'Escape': - e.preventDefault() - setIsDropdownOpen(false) - setHighlightedIndex(-1) - break + return case 'Tab': - if (highlightedIndex >= 0 && highlightedIndex < activeList.length) { + if (highlightedIndex >= 0 && highlightedIndex < flatItems.length) { e.preventDefault() - addTag(activeList[highlightedIndex].name) + addTag(flatItems[highlightedIndex].value) } - break - } - } else { - if (e.key === 'Enter' && inputValue.trim()) { - e.preventDefault() - addTag(inputValue) - } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) { - removeTag(tags[tags.length - 1]) + return } } - } - const handleInputFocus = (): void => { - setIsDropdownOpen(true) + if (e.key === 'Enter' && trimmedInput) { + e.preventDefault() + addTag(trimmedInput) + } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) { + removeTag(tags[tags.length - 1]) + } } - return ( - <div ref={containerRef} className={cn('space-y-3', className)}> - {/* Section Label */} - <div className="flex items-center gap-2"> - <h3 className="text-sm font-medium text-foreground">Tags</h3> - <span className="text-xs text-muted-foreground">(optional)</span> + // Render helpers for dropdown sections + const renderAiSection = (): React.JSX.Element | null => { + if (trimmedInput || availableAiTags.length === 0) return null + const aiItems = availableAiTags.slice(0, 2) + const startIdx = 0 + + return ( + <div className="flex flex-col py-1"> + <div className="flex items-center py-0.5 px-2"> + <span className="text-[11px] [letter-spacing:0.05em] uppercase text-text-tertiary font-medium leading-3.5"> + Suggested + </span> + </div> + {aiItems.map((tag, i) => { + const idx = startIdx + i + return ( + <button + key={tag} + type="button" + onClick={() => addTag(tag)} + onMouseEnter={() => setHighlightedIndex(idx)} + className={cn( + 'flex items-center gap-2 rounded-sm py-2 px-3 mx-1 my-0.5 text-left transition-colors', + highlightedIndex === idx ? 'bg-[var(--tint)]/[0.03]' : 'hover:bg-foreground/[0.03]' + )} + > + <span className="inline-flex items-center rounded-[10px] py-0.5 px-2 bg-[var(--tint)]/[0.08] text-[var(--tint)] text-[11px] leading-3.5"> + {tag} + </span> + <span className="text-[10px] leading-3 text-[var(--tint)]/40">AI</span> + </button> + ) + })} </div> + ) + } - {/* Input with autocomplete dropdown */} - <div className="relative"> - <Input - ref={inputRef} - type="text" - placeholder={placeholder} - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - aria-label="Add tags" - aria-expanded={isDropdownOpen} - aria-haspopup="listbox" - aria-autocomplete="list" - autoComplete="off" - /> - - {/* Autocomplete Dropdown */} - {isDropdownOpen && - (suggestions.length > 0 || (!inputValue.trim() && popularTags.length > 0)) && ( - <div - ref={dropdownRef} + const renderMatchingSection = (): React.JSX.Element | null => { + const aiCount = !trimmedInput ? availableAiTags.slice(0, 2).length : 0 + const itemsToShow = trimmedInput + ? matchingTags.slice(0, maxSuggestions) + : popularTags.slice(0, 5) + if (itemsToShow.length === 0) return null + + const sectionLabel = trimmedInput ? 'Matching' : 'Popular' + + return ( + <div className={cn('flex flex-col py-1', aiCount > 0 && 'border-t border-border/40')}> + <div className="flex items-center py-0.5 px-2"> + <span className="text-[11px] [letter-spacing:0.05em] uppercase text-text-tertiary font-medium leading-3.5"> + {sectionLabel} + </span> + </div> + {itemsToShow.map((tag, i) => { + const idx = aiCount + i + const colors = getTagColors(getColorForTag(tag.name)) + return ( + <button + key={tag.name} + type="button" + onClick={() => addTag(tag.name)} + onMouseEnter={() => setHighlightedIndex(idx)} className={cn( - 'absolute z-50 w-full mt-1 py-1 rounded-md border border-border', - 'bg-popover shadow-md max-h-48 overflow-y-auto' + 'flex items-center gap-2 rounded-sm py-2 px-3 mx-1 my-0.5 text-left transition-colors', + highlightedIndex === idx ? 'bg-foreground/[0.03]' : 'hover:bg-foreground/[0.03]' )} - role="listbox" > - {inputValue.trim() ? ( - suggestions - .slice(0, maxSuggestions) - .map((tag, index) => ( - <SuggestionItem - key={tag.name} - tag={tag} - isHighlighted={index === highlightedIndex} - onSelect={addTag} - onMouseEnter={() => setHighlightedIndex(index)} - /> - )) - ) : ( - <> - <p className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider text-muted-foreground/70 border-b border-border/50"> - <TrendingUp className="size-3" aria-hidden="true" /> - Popular - </p> - {popularTags.slice(0, 5).map((tag, index) => ( - <SuggestionItem - key={tag.name} - tag={tag} - isHighlighted={index === highlightedIndex} - onSelect={addTag} - onMouseEnter={() => setHighlightedIndex(index)} - /> - ))} - </> + <span + className="inline-flex items-center rounded-[10px] py-0.5 px-2 text-[11px] leading-3.5" + style={{ backgroundColor: `${colors.text}15`, color: colors.text }} + > + {tag.name} + </span> + {tag.count > 0 && ( + <span className="text-[10px] leading-3 text-muted-foreground/30"> + used {tag.count} times + </span> )} - </div> - )} + </button> + ) + })} </div> + ) + } + + const renderCreateFooter = (): React.JSX.Element | null => { + if (!trimmedInput || exactMatchExists) return null + const normalized = trimmedInput.toLowerCase() + const colors = getTagColors(getColorForTag(normalized)) + const idx = flatItems.length - 1 + + return ( + <button + type="button" + onClick={() => addTag(normalized)} + onMouseEnter={() => setHighlightedIndex(idx)} + className={cn( + 'flex items-center w-full py-2 px-3 gap-1.5 border-t border-border/40 text-left transition-colors', + highlightedIndex === idx ? 'bg-foreground/[0.03]' : 'hover:bg-foreground/[0.03]' + )} + > + <Plus className="size-3 text-muted-foreground/30" aria-hidden="true" /> + <span className="text-[11px] leading-3.5 text-muted-foreground/30">Create</span> + <span + className="inline-flex items-center rounded-md py-px px-1.5 text-[11px] leading-3.5" + style={{ backgroundColor: `${colors.text}15`, color: colors.text }} + > + {normalized} + </span> + </button> + ) + } + + const showDropdown = isDropdownOpen && flatItems.length > 0 - {/* Current Tags */} - {tags.length > 0 && ( - <div className="flex flex-wrap gap-1.5" role="list" aria-label="Selected tags"> + return ( + <div + ref={containerRef} + className={cn('flex flex-col gap-2 py-4 px-5 border-b border-border', className)} + > + <span className="text-[11px] [letter-spacing:0.05em] uppercase text-text-tertiary font-medium leading-3.5"> + Tags + </span> + + <div className="relative"> + <div + className={cn( + 'flex items-center flex-wrap rounded-md py-2 px-3 gap-1.5 bg-foreground/[0.02] border transition-colors cursor-text', + isFocused ? 'border-[var(--accent-purple)]/30' : 'border-border' + )} + onClick={() => inputRef.current?.focus()} + role="list" + aria-label="Selected tags" + > {tags.map((tag) => ( - <TagPill key={tag} tag={tag} onRemove={removeTag} /> + <TagPill key={tag} tag={tag} /> ))} + <input + ref={inputRef} + type="text" + placeholder={tags.length === 0 ? placeholder : ''} + value={inputValue} + onChange={handleInputChange} + onKeyDown={handleKeyDown} + onFocus={() => { + setIsDropdownOpen(true) + setIsFocused(true) + }} + onBlur={() => { + setIsFocused(false) + setTimeout(() => setIsDropdownOpen(false), 150) + }} + role="combobox" + aria-label="Add tags" + aria-expanded={isDropdownOpen} + aria-haspopup="listbox" + aria-controls={LISTBOX_ID} + aria-autocomplete="list" + autoComplete="off" + className="flex-1 min-w-[60px] bg-transparent border-0 p-0 text-xs text-foreground placeholder:text-muted-foreground/30 outline-none focus:outline-none" + /> </div> - )} - - {/* Quick Suggestions (when not typing) */} - {showSections && !inputValue.trim() && !isLoading && ( - <div className="space-y-3"> - {/* Recent Tags */} - {recentTags.length > 0 && ( - <div className="space-y-1.5"> - <p className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-muted-foreground/70"> - <Clock className="size-3" aria-hidden="true" /> - Recent - </p> - <div className="flex flex-wrap gap-1.5"> - {recentTags.slice(0, 5).map((tag) => ( - <QuickTagButton key={tag.name} tag={tag.name} onAdd={addTag} /> - ))} - </div> - </div> - )} - {/* Popular Tags */} - {popularTags.length > 0 && ( - <div className="space-y-1.5"> - <p className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-muted-foreground/70"> - <TrendingUp className="size-3" aria-hidden="true" /> - Popular - </p> - <div className="flex flex-wrap gap-1.5"> - {popularTags.slice(0, 8).map((tag) => ( - <QuickTagButton key={tag.name} tag={tag.name} onAdd={addTag} /> - ))} - </div> - </div> - )} - </div> - )} + {showDropdown && ( + <div + ref={dropdownRef} + id={LISTBOX_ID} + className="absolute z-50 w-full mt-1 p-0 rounded-md border border-border bg-popover shadow-[0_8px_24px_rgba(0,0,0,0.25)] overflow-hidden" + role="listbox" + aria-label="Tag suggestions" + > + {renderAiSection()} + {renderMatchingSection()} + {renderCreateFooter()} + </div> + )} + </div> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/find-bar/find-bar.tsx b/apps/desktop/src/renderer/src/components/find-bar/find-bar.tsx index e58ff7a0f..088730935 100644 --- a/apps/desktop/src/renderer/src/components/find-bar/find-bar.tsx +++ b/apps/desktop/src/renderer/src/components/find-bar/find-bar.tsx @@ -54,7 +54,7 @@ export const FindBar = memo(function FindBar({ <div className={cn( 'flex items-center gap-2', - 'bg-background border border-border/60 rounded-lg shadow-sm', + 'bg-background border border-border/60 rounded-md shadow-sm', 'px-3 py-1.5' )} > diff --git a/apps/desktop/src/renderer/src/components/first-run-onboarding.tsx b/apps/desktop/src/renderer/src/components/first-run-onboarding.tsx index 3a517fc3e..7f58f2969 100644 --- a/apps/desktop/src/renderer/src/components/first-run-onboarding.tsx +++ b/apps/desktop/src/renderer/src/components/first-run-onboarding.tsx @@ -51,7 +51,18 @@ export function FirstRunOnboarding({ onComplete }: FirstRunOnboardingProps): Rea const title = taskTitle.trim() || 'My first task' setIsSubmitting(true) try { - await tasksService.create({ projectId: 'personal', title }) + // Resolve a real project ID — use the first existing project, or create a default one + const listResult = await tasksService.listProjects() + let projectId: string + if (listResult.projects.length > 0) { + projectId = listResult.projects[0].id + } else { + const created = await tasksService.createProject({ name: 'Personal', color: '#6366f1' }) + if (!created.success || !created.project) + throw new Error('Failed to create default project') + projectId = created.project.id + } + await tasksService.create({ projectId, title }) } catch (err) { log.warn('Failed to create onboarding task:', err) } finally { @@ -158,7 +169,7 @@ function NoteStep({ return ( <div className="space-y-6"> <div className="flex items-center gap-3"> - <div className="flex items-center justify-center w-10 h-10 rounded-xl bg-blue-500/10 text-blue-500 shrink-0"> + <div className="flex items-center justify-center w-10 h-10 rounded-xl bg-tint-lighter text-tint shrink-0"> <FileText className="w-5 h-5" /> </div> <div> diff --git a/apps/desktop/src/renderer/src/components/folder-icon-button.tsx b/apps/desktop/src/renderer/src/components/folder-icon-button.tsx new file mode 100644 index 000000000..bf8933eee --- /dev/null +++ b/apps/desktop/src/renderer/src/components/folder-icon-button.tsx @@ -0,0 +1,151 @@ +import { useCallback, useRef, useState, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { Folder, FolderOpen, ArrowRight } from '@/lib/icons' +import { NoteIconDisplay } from '@/lib/render-note-icon' +import { EmojiPicker } from '@/components/note/note-title/EmojiPicker' +import { cn } from '@/lib/utils' + +interface FolderIconButtonProps { + icon: string | null + isExpanded: boolean + hasChildren?: boolean + onIconChange: (icon: string | null) => void + onToggleExpand?: () => void + pickerOpen?: boolean + onPickerOpenChange?: (open: boolean) => void +} + +export function FolderIconButton({ + icon, + isExpanded, + hasChildren = false, + onIconChange, + onToggleExpand, + pickerOpen, + onPickerOpenChange +}: FolderIconButtonProps) { + const [internalOpen, setInternalOpen] = useState(false) + const buttonRef = useRef<HTMLButtonElement>(null) + const [portalPosition, setPortalPosition] = useState<{ top: number; left: number } | null>(null) + + const isControlled = pickerOpen !== undefined + const isPickerOpen = isControlled ? pickerOpen : internalOpen + + const setPickerOpen = useCallback( + (open: boolean) => { + if (isControlled) { + onPickerOpenChange?.(open) + } else { + setInternalOpen(open) + } + }, + [isControlled, onPickerOpenChange] + ) + + useEffect(() => { + if (isControlled) { + setInternalOpen(pickerOpen) + } + }, [isControlled, pickerOpen]) + + useEffect(() => { + if (isPickerOpen && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect() + setPortalPosition({ top: rect.bottom + 4, left: rect.left }) + } + }, [isPickerOpen]) + + const handleIconClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + e.preventDefault() + setPickerOpen(!isPickerOpen) + }, + [setPickerOpen, isPickerOpen] + ) + + const handleExpandClick = useCallback((_e: React.MouseEvent) => { + // Let the click bubble to TreeNodeTrigger which handles expand/collapse + }, []) + + const handleSelect = useCallback( + (value: string) => { + onIconChange(value) + setPickerOpen(false) + }, + [onIconChange, setPickerOpen] + ) + + const handleRemove = useCallback(() => { + onIconChange(null) + setPickerOpen(false) + }, [onIconChange, setPickerOpen]) + + const handleClose = useCallback(() => { + setPickerOpen(false) + }, [setPickerOpen]) + + const folderIcon = icon ? ( + <NoteIconDisplay value={icon} className="text-sm leading-none" /> + ) : isExpanded ? ( + <FolderOpen className="h-4 w-4 text-muted-foreground" /> + ) : ( + <Folder className="h-4 w-4 text-muted-foreground" /> + ) + + return ( + <div className="relative shrink-0 flex items-center justify-center h-5 w-5"> + {/* Folder icon — visible by default, hidden on row hover when has children */} + <button + ref={buttonRef} + type="button" + onClick={handleIconClick} + className={cn( + 'flex h-5 w-5 items-center justify-center rounded', + hasChildren && 'group-hover/folderrow:hidden' + )} + aria-label="Set folder icon" + > + {folderIcon} + </button> + + {/* Arrow — hidden by default, shown on row hover, rotates when expanded */} + {hasChildren && ( + <button + type="button" + onClick={handleExpandClick} + className={cn( + 'hidden h-5 w-5 items-center justify-center cursor-pointer', + 'group-hover/folderrow:flex' + )} + aria-label={isExpanded ? 'Collapse folder' : 'Expand folder'} + > + <ArrowRight + className={cn( + 'h-3.5 w-3.5 text-muted-foreground transition-transform duration-150', + isExpanded && 'rotate-90' + )} + /> + </button> + )} + + {isPickerOpen && + portalPosition && + createPortal( + <div + className="fixed z-[100]" + style={{ top: portalPosition.top, left: portalPosition.left }} + > + <EmojiPicker + isOpen + onClose={handleClose} + onSelect={handleSelect} + onRemove={handleRemove} + hasEmoji={!!icon} + /> + </div>, + document.body + )} + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/folder-view/column-header.tsx b/apps/desktop/src/renderer/src/components/folder-view/column-header.tsx index 490f6de43..b75681581 100644 --- a/apps/desktop/src/renderer/src/components/folder-view/column-header.tsx +++ b/apps/desktop/src/renderer/src/components/folder-view/column-header.tsx @@ -308,7 +308,7 @@ export function ColumnHeader({ className={cn( 'flex-1 min-w-0 px-1 py-0.5 -mx-1 -my-0.5', 'bg-background border border-primary rounded text-sm', - 'focus:outline-none focus:ring-1 focus:ring-primary' + 'focus:outline-none' )} /> ) : ( diff --git a/apps/desktop/src/renderer/src/components/folder-view/formula-editor-modal.tsx b/apps/desktop/src/renderer/src/components/folder-view/formula-editor-modal.tsx index ab977ea1d..18ea4bf9c 100644 --- a/apps/desktop/src/renderer/src/components/folder-view/formula-editor-modal.tsx +++ b/apps/desktop/src/renderer/src/components/folder-view/formula-editor-modal.tsx @@ -29,6 +29,8 @@ import { evaluateFormula, getBuiltInFunctions } from '@/lib/expression-evaluator import { useAutocomplete, type AutocompleteSuggestion } from '@/hooks/use-autocomplete' import type { NoteWithProperties } from '@memry/contracts/folder-view-api' import { createLogger } from '@/lib/logger' +import { toast } from 'sonner' +import { extractErrorMessage } from '@/lib/ipc-error' const log = createLogger('Component:FormulaEditorModal') @@ -326,6 +328,7 @@ export function FormulaEditorModal({ handleOpenChange(false) } catch (err) { log.error('Failed to save formula', err) + toast.error(extractErrorMessage(err, 'Failed to save formula')) } finally { setIsSubmitting(false) } diff --git a/apps/desktop/src/renderer/src/components/folder-view/move-to-folder-dialog.tsx b/apps/desktop/src/renderer/src/components/folder-view/move-to-folder-dialog.tsx index f226bd9a5..d9bd524e8 100644 --- a/apps/desktop/src/renderer/src/components/folder-view/move-to-folder-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/folder-view/move-to-folder-dialog.tsx @@ -151,8 +151,8 @@ export function MoveToFolderDialog({ // Filter and add folders const filteredFolders = allFolders + .map((f) => f.path) .filter((path) => { - // Filter by search query if (query && !path.toLowerCase().includes(query)) return false return true }) @@ -188,7 +188,7 @@ export function MoveToFolderDialog({ const canCreateFolder = useMemo(() => { if (!searchQuery.trim()) return false // Check if the exact folder already exists - const exists = allFolders.some((f) => f.toLowerCase() === searchQuery.toLowerCase()) + const exists = allFolders.some((f) => f.path.toLowerCase() === searchQuery.toLowerCase()) return !exists }, [searchQuery, allFolders]) diff --git a/apps/desktop/src/renderer/src/components/folder-view/property-cell.tsx b/apps/desktop/src/renderer/src/components/folder-view/property-cell.tsx index 0c8997555..01da4bd53 100644 --- a/apps/desktop/src/renderer/src/components/folder-view/property-cell.tsx +++ b/apps/desktop/src/renderer/src/components/folder-view/property-cell.tsx @@ -23,6 +23,7 @@ import { UrlEditor } from '@/components/note/info-section/editors' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { NoteIconDisplay } from '@/lib/render-note-icon' // ============================================================================ // Types @@ -353,10 +354,7 @@ export const EditablePropertyCell = memo(function EditablePropertyCell({ return ( <div role="presentation" - className={cn( - 'w-full focus-within:ring-1 focus-within:ring-amber-400/60 dark:focus-within:ring-amber-600/60', - className - )} + className={cn('w-full', className)} onClick={stopPropagation} onDoubleClick={stopPropagation} onKeyDown={stopPropagation} @@ -389,10 +387,7 @@ export const EditablePropertyCell = memo(function EditablePropertyCell({ return ( <div role="presentation" - className={cn( - 'w-full focus-within:ring-1 focus-within:ring-amber-400/60 dark:focus-within:ring-amber-600/60', - className - )} + className={cn('w-full', className)} onMouseDown={stopPropagation} onClick={stopPropagation} onDoubleClick={stopPropagation} @@ -686,7 +681,7 @@ export const TitleCell = memo(function TitleCell({ title={title} > {emoji ? ( - <span className="flex-shrink-0 text-base">{emoji}</span> + <NoteIconDisplay value={emoji} className="flex-shrink-0 text-base" /> ) : ( <FileText className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> )} @@ -765,10 +760,7 @@ export const TagsCell = memo(function TagsCell({ e.stopPropagation() onTagClick?.(tag) }} - className={cn( - 'px-1.5 py-0.5', - 'focus:outline-none focus:ring-1 focus:ring-primary/50 rounded' - )} + className={cn('px-1.5 py-0.5', 'focus:outline-none rounded')} > #{shouldHighlight ? highlightText(tag, highlightQuery) : tag} </button> @@ -783,7 +775,7 @@ export const TagsCell = memo(function TagsCell({ className={cn( 'pr-1.5 pl-0.5 py-0.5', 'opacity-70 hover:opacity-100', - 'focus:outline-none focus:ring-1 focus:ring-primary/50 rounded' + 'focus:outline-none rounded' )} > <X className="h-3 w-3" /> diff --git a/apps/desktop/src/renderer/src/components/folder-view/row-context-menu.tsx b/apps/desktop/src/renderer/src/components/folder-view/row-context-menu.tsx index 781379b57..8e62a5a3d 100644 --- a/apps/desktop/src/renderer/src/components/folder-view/row-context-menu.tsx +++ b/apps/desktop/src/renderer/src/components/folder-view/row-context-menu.tsx @@ -25,6 +25,8 @@ import { import type { NoteWithProperties } from '@memry/contracts/folder-view-api' import { notesService } from '@/services/notes-service' import { createLogger } from '@/lib/logger' +import { toast } from 'sonner' +import { extractErrorMessage } from '@/lib/ipc-error' const log = createLogger('Component:RowContextMenu') @@ -80,6 +82,7 @@ export function RowContextMenu({ await notesService.openExternal(note.id) } catch (err) { log.error('Failed to open in external editor', err) + toast.error(extractErrorMessage(err, 'Failed to open in external editor')) } } @@ -88,6 +91,7 @@ export function RowContextMenu({ await notesService.revealInFinder(note.id) } catch (err) { log.error('Failed to reveal in Finder', err) + toast.error(extractErrorMessage(err, 'Failed to reveal in Finder')) } } @@ -105,11 +109,11 @@ export function RowContextMenu({ const handleCopyLink = async (): Promise<void> => { try { - // Copy memry:// link to clipboard const link = `memry://note/${note.id}` await navigator.clipboard.writeText(link) } catch (err) { log.error('Failed to copy link', err) + toast.error(extractErrorMessage(err, 'Failed to copy link')) } } diff --git a/apps/desktop/src/renderer/src/components/folder-view/sortable-column-header.tsx b/apps/desktop/src/renderer/src/components/folder-view/sortable-column-header.tsx index 77c53374d..a4dca73b9 100644 --- a/apps/desktop/src/renderer/src/components/folder-view/sortable-column-header.tsx +++ b/apps/desktop/src/renderer/src/components/folder-view/sortable-column-header.tsx @@ -293,7 +293,7 @@ export function SortableColumnHeader({ className={cn( 'flex-1 min-w-0 px-1 py-0.5 -mx-1 -my-0.5', 'bg-background border border-primary rounded text-sm', - 'focus:outline-none focus:ring-1 focus:ring-primary' + 'focus:outline-none' )} /> ) : ( diff --git a/apps/desktop/src/renderer/src/components/folder-view/view-switcher.tsx b/apps/desktop/src/renderer/src/components/folder-view/view-switcher.tsx index 2e0cc8a3d..a93e90da5 100644 --- a/apps/desktop/src/renderer/src/components/folder-view/view-switcher.tsx +++ b/apps/desktop/src/renderer/src/components/folder-view/view-switcher.tsx @@ -43,6 +43,8 @@ import { cn } from '@/lib/utils' import { DEFAULT_COLUMNS } from '@memry/contracts/folder-view-api' import type { ViewConfig } from '@/hooks/use-folder-view' import { createLogger } from '@/lib/logger' +import { toast } from 'sonner' +import { extractErrorMessage } from '@/lib/ipc-error' const log = createLogger('Component:ViewSwitcher') @@ -144,7 +146,7 @@ export function ViewSwitcher({ setCopyFromCurrent(true) } catch (err) { log.error('Failed to create view', err) - // Keep dialog open on error so user can retry + toast.error(extractErrorMessage(err, 'Failed to create view')) } finally { setIsSubmitting(false) } diff --git a/apps/desktop/src/renderer/src/components/graph/graph-context-menu.tsx b/apps/desktop/src/renderer/src/components/graph/graph-context-menu.tsx index 8562d721f..31830f875 100644 --- a/apps/desktop/src/renderer/src/components/graph/graph-context-menu.tsx +++ b/apps/desktop/src/renderer/src/components/graph/graph-context-menu.tsx @@ -53,7 +53,7 @@ export function GraphContextMenu({ return ( <div ref={menuRef} - className="absolute z-50 min-w-[160px] rounded-lg border border-border bg-popover p-1 shadow-card animate-in fade-in-0 zoom-in-95" + className="absolute z-50 min-w-[160px] rounded-md border border-border bg-popover p-1 shadow-card animate-in fade-in-0 zoom-in-95" style={{ left: menu.x, top: menu.y }} > <div className="px-2 py-1.5 mb-0.5"> diff --git a/apps/desktop/src/renderer/src/components/graph/graph-filters.tsx b/apps/desktop/src/renderer/src/components/graph/graph-filters.tsx index 87064b30f..b36361784 100644 --- a/apps/desktop/src/renderer/src/components/graph/graph-filters.tsx +++ b/apps/desktop/src/renderer/src/components/graph/graph-filters.tsx @@ -70,7 +70,7 @@ export function GraphFilters({ }: GraphFiltersProps): React.JSX.Element { return ( <div className="absolute left-3 top-3 z-40 flex flex-col gap-2"> - <div className="rounded-lg border border-border bg-popover/95 backdrop-blur-sm p-2 shadow-card"> + <div className="rounded-md border border-border bg-popover/95 backdrop-blur-sm p-2 shadow-card"> <div className="flex items-center gap-1"> {ENTITY_TOGGLES.map(({ type, icon: Icon, label, colorClass }) => ( <Toggle @@ -116,7 +116,7 @@ export function GraphFilters({ </div> {filterState.focusNodeId && focusLabel && ( - <div className="rounded-lg border border-border bg-popover/95 backdrop-blur-sm px-2.5 py-1.5 shadow-card flex items-center gap-2"> + <div className="rounded-md border border-border bg-popover/95 backdrop-blur-sm px-2.5 py-1.5 shadow-card flex items-center gap-2"> <Focus className="size-3.5 text-accent-cyan shrink-0" /> <span className="text-xs text-foreground truncate max-w-[140px]">{focusLabel}</span> <span className="text-[10px] text-muted-foreground">depth {filterState.focusDepth}</span> diff --git a/apps/desktop/src/renderer/src/components/graph/graph-page.tsx b/apps/desktop/src/renderer/src/components/graph/graph-page.tsx index 7d1888b2f..32f909d79 100644 --- a/apps/desktop/src/renderer/src/components/graph/graph-page.tsx +++ b/apps/desktop/src/renderer/src/components/graph/graph-page.tsx @@ -26,6 +26,17 @@ export function GraphPage(): React.JSX.Element { [dispatch] ) + const nodeSummary = useMemo(() => { + if (!data?.nodes) return '' + const counts: Record<string, number> = {} + data.nodes.forEach((n) => { + counts[n.type] = (counts[n.type] ?? 0) + 1 + }) + return Object.entries(counts) + .map(([type, count]) => `${count} ${type}${count !== 1 ? 's' : ''}`) + .join(', ') + }, [data?.nodes]) + if (isLoading) { return ( <div className="flex h-full flex-col items-center justify-center gap-4"> @@ -51,14 +62,28 @@ export function GraphPage(): React.JSX.Element { return <GraphEmptyState /> } + const nodeCount = data.nodes.length + const edgeCount = data.edges.length + const graphAriaLabel = `Knowledge graph with ${nodeCount} node${nodeCount !== 1 ? 's' : ''} and ${edgeCount} connection${edgeCount !== 1 ? 's' : ''}${nodeSummary ? `: ${nodeSummary}` : ''}.` + return ( <div className="relative h-full w-full"> - <GraphCanvas - data={data} - filterState={filterState} - graphSettings={graphSettings} - onFocusNode={handleFocusNode} - /> + <div role="img" aria-label={graphAriaLabel} className="h-full w-full"> + <GraphCanvas + data={data} + filterState={filterState} + graphSettings={graphSettings} + onFocusNode={handleFocusNode} + /> + {/* Visually-hidden node list for screen readers */} + <ul className="sr-only" aria-label="Graph nodes"> + {data.nodes.map((node) => ( + <li key={node.id}> + {node.label} ({node.type}) + </li> + ))} + </ul> + </div> <GraphControlPanel filterState={filterState} dispatch={dispatch} @@ -92,7 +117,7 @@ function GraphEmptyState(): React.JSX.Element { </div> <div className="space-y-3 text-left"> - <div className="flex items-start gap-3 rounded-lg border border-border/50 p-3"> + <div className="flex items-start gap-3 rounded-md border border-border/50 p-3"> <Link2 className="size-4 mt-0.5 text-accent-cyan shrink-0" /> <div> <p className="text-xs font-medium text-foreground">Link your notes</p> @@ -101,7 +126,7 @@ function GraphEmptyState(): React.JSX.Element { </p> </div> </div> - <div className="flex items-start gap-3 rounded-lg border border-border/50 p-3"> + <div className="flex items-start gap-3 rounded-md border border-border/50 p-3"> <Lightbulb className="size-4 mt-0.5 text-accent-orange shrink-0" /> <div> <p className="text-xs font-medium text-foreground">Discover patterns</p> diff --git a/apps/desktop/src/renderer/src/components/graph/graph-tooltip.tsx b/apps/desktop/src/renderer/src/components/graph/graph-tooltip.tsx index 8e1820f04..4a43c8449 100644 --- a/apps/desktop/src/renderer/src/components/graph/graph-tooltip.tsx +++ b/apps/desktop/src/renderer/src/components/graph/graph-tooltip.tsx @@ -1,4 +1,5 @@ import type Graph from 'graphology' +import { NoteIconDisplay } from '@/lib/render-note-icon' const TYPE_COLORS: Record<string, string> = { note: 'bg-accent-cyan/15 text-accent-cyan', @@ -28,14 +29,14 @@ export function GraphTooltip({ nodeId, graph, x, y }: GraphTooltipProps): React. return ( <div - className="pointer-events-none absolute z-50 max-w-[240px] rounded-lg border border-border bg-popover p-2.5 shadow-card" + className="pointer-events-none absolute z-50 max-w-[240px] rounded-md border border-border bg-popover p-2.5 shadow-card" style={{ left: x + 12, top: y + 12 }} > <div className="flex items-center gap-1.5 mb-1"> - {emoji && <span className="text-sm">{emoji}</span>} + {emoji && <NoteIconDisplay value={emoji} className="text-sm" />} <span className="text-sm font-medium text-foreground truncate">{label}</span> </div> diff --git a/apps/desktop/src/renderer/src/components/graph/local-graph-panel.tsx b/apps/desktop/src/renderer/src/components/graph/local-graph-panel.tsx index 89ded0871..1de6e2339 100644 --- a/apps/desktop/src/renderer/src/components/graph/local-graph-panel.tsx +++ b/apps/desktop/src/renderer/src/components/graph/local-graph-panel.tsx @@ -139,7 +139,7 @@ export function LocalGraphPanel({ if (isLoading || !graph) { return ( - <div className="relative h-[250px] rounded-lg border border-border bg-muted/30"> + <div className="relative h-[250px] rounded-md border border-border bg-muted/30"> <div className="flex h-full items-center justify-center"> <span className="text-xs text-muted-foreground">Loading graph...</span> </div> @@ -150,7 +150,7 @@ export function LocalGraphPanel({ if (graph.order === 0) { return ( - <div className="relative h-[250px] rounded-lg border border-border bg-muted/30"> + <div className="relative h-[250px] rounded-md border border-border bg-muted/30"> <div className="flex h-full items-center justify-center"> <span className="text-xs text-muted-foreground">No connections found</span> </div> @@ -160,7 +160,7 @@ export function LocalGraphPanel({ } return ( - <div className="relative h-[250px] rounded-lg border border-border bg-muted/30 overflow-hidden"> + <div className="relative h-[250px] rounded-md border border-border bg-muted/30 overflow-hidden"> <PanelHeader onClose={onClose} onOpenFullGraph={onOpenFullGraph} /> <SigmaContainer graph={graph} settings={sigmaSettings} className="h-full w-full"> diff --git a/apps/desktop/src/renderer/src/components/icon-picker.tsx b/apps/desktop/src/renderer/src/components/icon-picker.tsx index 709f752f6..620b7d5af 100644 --- a/apps/desktop/src/renderer/src/components/icon-picker.tsx +++ b/apps/desktop/src/renderer/src/components/icon-picker.tsx @@ -512,7 +512,7 @@ export const IconPicker = ({ <div ref={popoverRef} className={cn( - 'fixed z-[100] w-80 rounded-lg border bg-popover text-popover-foreground shadow-xl', + 'fixed z-[100] w-80 rounded-md border bg-popover text-popover-foreground shadow-xl', 'animate-in fade-in-0 zoom-in-95 duration-200' )} style={positionStyle} diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/content-section.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/content-section.tsx index a39304125..6b0b045f4 100644 --- a/apps/desktop/src/renderer/src/components/inbox-detail/content-section.tsx +++ b/apps/desktop/src/renderer/src/components/inbox-detail/content-section.tsx @@ -3,17 +3,17 @@ * Displays type-specific content previews (link, image, voice, text) */ -import { useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { TweetCard } from './tweet-card' import { extractErrorMessage } from '@/lib/ipc-error' import { - Globe, Image, Mic, FileText, Calendar, Clock, User, - ExternalLink, + Globe, Play, Pause, Copy, @@ -22,18 +22,25 @@ import { AlertCircle, RefreshCw, FileType, - Video + FilePdf, + Video, + Link2, + Bell } from '@/lib/icons' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { extractDomain } from '@/lib/inbox-utils' import { InboxContentEditor } from './inbox-content-editor' +import { LinkPreview } from './link-preview' +import { ReminderDetail } from './reminder-detail' +import { getTypeAccentClass } from './type-accents' import type { InboxItem, InboxItemListItem, InboxItemType, LinkMetadata, ImageMetadata, + PdfMetadata, VoiceMetadata } from '@/types' import { createLogger } from '@/lib/logger' @@ -89,6 +96,10 @@ const formatDate = (date: Date | string): string => { ) } +const PLAYBACK_BAR_COUNT = 60 +const PLAYBACK_MIN_HEIGHT = 3 +const PLAYBACK_MAX_HEIGHT = 32 + // ============================================================================= // Type Icon Component // ============================================================================= @@ -99,11 +110,12 @@ interface TypeIconProps { } export const TypeIcon = ({ type, className = 'size-5' }: TypeIconProps): React.JSX.Element => { - const iconClass = `${className} text-[var(--muted-foreground)]` + const accentClass = getTypeAccentClass(type) + const iconClass = `${className} ${accentClass}` switch (type) { case 'link': - return <Globe className={iconClass} aria-hidden="true" /> + return <Link2 className={iconClass} aria-hidden="true" /> case 'note': return <FileText className={iconClass} aria-hidden="true" /> case 'image': @@ -114,6 +126,8 @@ export const TypeIcon = ({ type, className = 'size-5' }: TypeIconProps): React.J return <FileType className={iconClass} aria-hidden="true" /> case 'video': return <Video className={iconClass} aria-hidden="true" /> + case 'reminder': + return <Bell className={iconClass} aria-hidden="true" /> case 'clip': case 'social': default: @@ -127,7 +141,7 @@ export const TypeIcon = ({ type, className = 'size-5' }: TypeIconProps): React.J export const ContentSkeleton = (): React.JSX.Element => ( <div className="space-y-4 p-6"> - <Skeleton className="h-[200px] w-full rounded-lg" /> + <Skeleton className="h-[200px] w-full rounded-md" /> <Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-1/2" /> <div className="space-y-2 mt-6"> @@ -212,91 +226,6 @@ export const ContentMetadata = ({ item }: ContentMetadataProps): React.JSX.Eleme ) } -// ============================================================================= -// Link Preview Content -// ============================================================================= - -interface LinkPreviewProps { - item: InboxItem | InboxItemListItem -} - -const LinkPreview = ({ item }: LinkPreviewProps): React.JSX.Element => { - const metadata = 'metadata' in item ? (item.metadata as LinkMetadata | null) : null - const heroImage = metadata?.heroImage || item.thumbnailUrl - - return ( - <div className="space-y-4"> - {/* Hero image - full size */} - {heroImage && ( - <div className="relative overflow-hidden rounded-lg bg-[var(--muted)]"> - <img - src={heroImage} - alt="" - className="w-full object-cover max-h-[280px]" - onError={(e) => { - e.currentTarget.style.display = 'none' - }} - /> - </div> - )} - - {/* Site name badge */} - {metadata?.siteName && ( - <div className="flex items-center gap-2"> - {metadata.favicon && ( - <img - src={metadata.favicon} - alt="" - className="size-4 rounded" - onError={(e) => { - e.currentTarget.style.display = 'none' - }} - /> - )} - <span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide"> - {metadata.siteName} - </span> - </div> - )} - - {/* Description/Excerpt */} - <div className="prose prose-sm dark:prose-invert max-w-none"> - <p className="text-[var(--foreground)] leading-relaxed"> - {metadata?.description || - metadata?.excerpt || - item.content || - 'No description available.'} - </p> - </div> - - {/* Published date if available */} - {metadata?.publishedDate && ( - <p className="text-xs text-[var(--muted-foreground)]"> - Published:{' '} - {new Date(metadata.publishedDate).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric' - })} - </p> - )} - - {/* Open in browser button */} - {item.sourceUrl && ( - <a - href={item.sourceUrl} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-[var(--primary)] bg-[var(--primary)]/10 rounded-lg hover:bg-[var(--primary)]/20 transition-colors" - > - <ExternalLink className="size-4" /> - Open in browser - </a> - )} - </div> - ) -} - // ============================================================================= // Image Preview Content // ============================================================================= @@ -310,36 +239,51 @@ const ImagePreview = ({ item }: ImagePreviewProps): React.JSX.Element => { const imageUrl = ('attachmentUrl' in item && item.attachmentUrl) || item.thumbnailUrl return ( - <div className="space-y-4"> + <div className="flex flex-col gap-3.5"> {imageUrl ? ( - <div className="relative overflow-hidden rounded-lg bg-[var(--muted)]"> - <img - src={imageUrl} - alt={item.title} - className="w-full object-contain max-h-[400px] mx-auto" - /> + <div className="overflow-hidden rounded-lg bg-muted"> + <img src={imageUrl} alt={item.title} className="w-full object-contain max-h-[400px]" /> </div> ) : ( - <div className="flex items-center justify-center h-[200px] bg-[var(--muted)] rounded-lg"> - <Image className="size-12 text-[var(--muted-foreground)]" /> + <div className="flex items-center justify-center aspect-[34/22] rounded-lg bg-muted"> + <Image className="size-8 text-muted-foreground/25" /> </div> )} - {/* Image metadata */} + {metadata?.originalFilename && ( + <span + className="text-[11px] leading-3.5 text-muted-foreground truncate" + title={metadata.originalFilename} + > + {metadata.originalFilename} + </span> + )} + {metadata && ( - <div className="flex flex-wrap gap-x-4 gap-y-2 text-sm text-[var(--muted-foreground)] px-1"> + <div className="flex items-center gap-4"> {metadata.width && metadata.height && ( - <span className="flex items-center gap-1"> - <span className="font-medium">{metadata.width}</span> x{' '} - <span className="font-medium">{metadata.height}</span> px - </span> + <div className="flex items-center gap-1"> + <span className="text-[11px] leading-3.5 text-text-tertiary">Dimensions</span> + <span className="text-[11px] leading-3.5 text-muted-foreground"> + {metadata.width} x {metadata.height} + </span> + </div> )} - {metadata.format && <span className="uppercase font-medium">{metadata.format}</span>} - {metadata.fileSize && <span>{formatFileSize(metadata.fileSize)}</span>} - {metadata.originalFilename && ( - <span className="truncate max-w-[200px]" title={metadata.originalFilename}> - {metadata.originalFilename} - </span> + {metadata.format && ( + <div className="flex items-center gap-1"> + <span className="text-[11px] leading-3.5 text-text-tertiary">Format</span> + <span className="text-[11px] leading-3.5 text-muted-foreground uppercase"> + {metadata.format} + </span> + </div> + )} + {metadata.fileSize && ( + <div className="flex items-center gap-1"> + <span className="text-[11px] leading-3.5 text-text-tertiary">Size</span> + <span className="text-[11px] leading-3.5 text-muted-foreground"> + {formatFileSize(metadata.fileSize)} + </span> + </div> )} </div> )} @@ -363,21 +307,75 @@ const VoicePreview = ({ isRetrying }: VoicePreviewProps): React.JSX.Element => { const audioRef = useRef<HTMLAudioElement>(null) + const waveformRef = useRef<HTMLDivElement>(null) const [isPlaying, setIsPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) const [copied, setCopied] = useState(false) const [audioError, setAudioError] = useState<string | null>(null) + const [waveformBars, setWaveformBars] = useState<number[]>(() => + Array.from({ length: PLAYBACK_BAR_COUNT }, () => PLAYBACK_MIN_HEIGHT) + ) const metadata = 'metadata' in item ? (item.metadata as VoiceMetadata | null) : null const audioUrl = 'attachmentUrl' in item ? item.attachmentUrl : null const transcription = 'transcription' in item ? item.transcription : null const transcriptionStatus = 'transcriptionStatus' in item ? item.transcriptionStatus : null - // Get duration from metadata or audio element const displayDuration = duration || metadata?.duration || ('duration' in item ? item.duration : 0) || 0 + useEffect(() => { + if (!audioUrl) return + + let cancelled = false + const decodeWaveform = async (): Promise<void> => { + try { + const response = await fetch(audioUrl) + const arrayBuffer = await response.arrayBuffer() + const audioContext = new AudioContext() + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer) + + if (cancelled) { + void audioContext.close() + return + } + + const rawData = audioBuffer.getChannelData(0) + const samplesPerBar = Math.floor(rawData.length / PLAYBACK_BAR_COUNT) + + const bars: number[] = [] + for (let i = 0; i < PLAYBACK_BAR_COUNT; i++) { + let sum = 0 + const start = i * samplesPerBar + const end = Math.min(start + samplesPerBar, rawData.length) + for (let j = start; j < end; j++) { + sum += rawData[j] * rawData[j] + } + bars.push(Math.sqrt(sum / samplesPerBar)) + } + + const maxRms = Math.max(...bars, 0.001) + const normalized = bars.map( + (b) => PLAYBACK_MIN_HEIGHT + (b / maxRms) * (PLAYBACK_MAX_HEIGHT - PLAYBACK_MIN_HEIGHT) + ) + + if (!cancelled) { + setWaveformBars(normalized) + } + + void audioContext.close() + } catch (err) { + log.error('Failed to decode waveform', err) + } + } + + void decodeWaveform() + return () => { + cancelled = true + } + }, [audioUrl]) + const handlePlayPause = async (): Promise<void> => { if (!audioRef.current) return setAudioError(null) @@ -413,13 +411,17 @@ const VoicePreview = ({ } } - const handleSeek = (e: React.ChangeEvent<HTMLInputElement>): void => { - const time = parseFloat(e.target.value) - if (audioRef.current) { - audioRef.current.currentTime = time - setCurrentTime(time) - } - } + const handleWaveformClick = useCallback( + (e: React.MouseEvent<HTMLDivElement>) => { + if (!audioRef.current || !waveformRef.current || !displayDuration) return + const rect = waveformRef.current.getBoundingClientRect() + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + const seekTime = ratio * displayDuration + audioRef.current.currentTime = seekTime + setCurrentTime(seekTime) + }, + [displayDuration] + ) const handleCopyTranscription = async (): Promise<void> => { if (transcription) { @@ -429,158 +431,161 @@ const VoicePreview = ({ } } + const progress = displayDuration > 0 ? currentTime / displayDuration : 0 + + const metaParts: string[] = [] + if (metadata?.format) metaParts.push(metadata.format.toUpperCase()) + if (metadata?.sampleRate) metaParts.push(`${(metadata.sampleRate / 1000).toFixed(0)}kHz`) + if (metadata?.fileSize) metaParts.push(formatFileSize(metadata.fileSize)) + return ( - <div className="space-y-4"> - {/* Audio Player */} - {audioUrl ? ( - <div className="bg-[var(--muted)] rounded-lg p-4 space-y-3"> - <audio - ref={audioRef} - src={audioUrl} - preload="metadata" - onPlay={() => setIsPlaying(true)} - onPause={() => setIsPlaying(false)} - onEnded={() => setIsPlaying(false)} - onTimeUpdate={handleTimeUpdate} - onLoadedMetadata={handleLoadedMetadata} - onError={handleAudioError} - /> - - {/* Audio error display */} - {audioError && ( - <div className="flex items-center gap-2 p-3 bg-red-500/10 rounded-lg text-sm text-red-600 dark:text-red-400"> - <AlertCircle className="size-4 shrink-0" /> - <span>Audio error: {audioError}</span> - </div> - )} + <div className="flex flex-col gap-4"> + <audio + ref={audioRef} + src={audioUrl ?? undefined} + preload="metadata" + onPlay={() => setIsPlaying(true)} + onPause={() => setIsPlaying(false)} + onEnded={() => setIsPlaying(false)} + onTimeUpdate={handleTimeUpdate} + onLoadedMetadata={handleLoadedMetadata} + onError={handleAudioError} + /> + + {audioError && ( + <div className="flex items-center gap-2 p-3 bg-destructive/10 rounded-md text-sm text-destructive"> + <AlertCircle className="size-4 shrink-0" /> + <span>{audioError}</span> + </div> + )} - {/* Play button and waveform area */} - <div className="flex items-center gap-4"> - <Button - size="icon" - variant="secondary" - onClick={() => void handlePlayPause()} - className="size-12 rounded-full bg-[var(--primary)] hover:bg-[var(--primary)]/90" - > - {isPlaying ? ( - <Pause className="size-5 text-[var(--primary-foreground)]" /> - ) : ( - <Play className="size-5 text-[var(--primary-foreground)] ml-0.5" /> - )} - </Button> - - <div className="flex-1 space-y-1"> - {/* Progress bar */} - <input - type="range" - min={0} - max={displayDuration || 100} - value={currentTime} - onChange={handleSeek} - className="w-full h-2 bg-[var(--border)] rounded-lg appearance-none cursor-pointer accent-[var(--primary)]" - /> - {/* Time display */} - <div className="flex justify-between text-xs text-[var(--muted-foreground)]"> - <span>{formatDuration(currentTime)}</span> - <span>{formatDuration(displayDuration)}</span> - </div> - </div> + {audioUrl ? ( + <div className="flex items-center rounded-[10px] gap-2.5 bg-muted-foreground/[0.04] border border-muted-foreground/10 py-2.5 px-3.5"> + <button + onClick={() => void handlePlayPause()} + className="flex items-center justify-center rounded-full bg-muted-foreground shrink-0 size-8 hover:opacity-90 transition-opacity" + aria-label={isPlaying ? 'Pause' : 'Play'} + > + {isPlaying ? ( + <Pause className="size-3.5 text-background" /> + ) : ( + <Play className="size-3.5 text-background ml-0.5" /> + )} + </button> + + <div + ref={waveformRef} + className="flex items-center grow h-8 gap-0.5 cursor-pointer" + onClick={handleWaveformClick} + role="slider" + aria-label="Audio position" + aria-valuemin={0} + aria-valuemax={displayDuration || 100} + aria-valuenow={currentTime} + tabIndex={0} + > + {waveformBars.map((height, i) => { + const isPlayed = i / PLAYBACK_BAR_COUNT < progress + return ( + <div + key={i} + className="flex-1 min-w-0 rounded-xs transition-colors duration-150" + style={{ + height: `${height}px`, + backgroundColor: isPlayed + ? 'color-mix(in srgb, var(--muted-foreground) 80%, transparent)' + : 'color-mix(in srgb, var(--muted-foreground) 30%, transparent)' + }} + /> + ) + })} </div> - {/* Metadata */} - {metadata && ( - <div className="flex gap-4 text-xs text-[var(--muted-foreground)] pt-2 border-t border-[var(--border)]"> - {metadata.format && <span className="uppercase">{metadata.format}</span>} - {metadata.fileSize && <span>{formatFileSize(metadata.fileSize)}</span>} - {metadata.sampleRate && <span>{(metadata.sampleRate / 1000).toFixed(1)}kHz</span>} - </div> - )} + <div className="flex items-center shrink-0 gap-0.5 text-xs tabular-nums"> + <span className="text-foreground font-medium">{formatDuration(currentTime)}</span> + <span className="text-text-tertiary">/ {formatDuration(displayDuration)}</span> + </div> </div> ) : ( - <div className="flex items-center gap-4 p-4 bg-[var(--muted)] rounded-lg"> - <div className="size-12 rounded-full bg-[var(--primary)] flex items-center justify-center"> - <Mic className="size-6 text-[var(--primary-foreground)]" /> + <div className="flex items-center gap-3 p-3.5 bg-muted rounded-[10px]"> + <div className="size-8 rounded-full bg-muted-foreground flex items-center justify-center"> + <Mic className="size-4 text-background" /> </div> <div className="flex-1"> - <p className="font-medium">{item.title}</p> - <p className="text-sm text-[var(--muted-foreground)]"> + <p className="font-medium text-sm">{item.title}</p> + <p className="text-xs text-muted-foreground"> {displayDuration > 0 ? formatDuration(displayDuration) : 'Voice memo'} </p> </div> </div> )} - {/* Transcription Section */} - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide"> + <div className="flex flex-col gap-1.5"> + <div className="flex items-center gap-1.5"> + <span className="uppercase tracking-[0.04em] text-text-tertiary font-medium text-[11px]/3.5"> Transcription </span> + {transcriptionStatus === 'processing' && ( + <div className="flex items-center gap-1 rounded-[10px] py-px px-1.5 bg-muted-foreground/10"> + <Loader2 className="size-2.5 animate-spin text-muted-foreground" /> + <span className="text-muted-foreground text-[10px]/3.5">processing</span> + </div> + )} + {transcriptionStatus === 'pending' && ( + <div className="flex items-center rounded-[10px] py-px px-1.5 bg-muted-foreground/10"> + <span className="text-muted-foreground text-[10px]/3.5">pending</span> + </div> + )} + {transcriptionStatus === 'failed' && ( + <div className="flex items-center rounded-[10px] py-px px-1.5 bg-destructive/10"> + <span className="text-destructive text-[10px]/3.5">failed</span> + </div> + )} {transcription && ( - <Button - size="sm" - variant="ghost" + <button onClick={handleCopyTranscription} - className="h-7 px-2 text-xs" + className="ml-auto text-muted-foreground hover:text-foreground transition-colors" + aria-label="Copy transcription" > - {copied ? ( - <> - <Check className="size-3 mr-1" /> - Copied - </> - ) : ( - <> - <Copy className="size-3 mr-1" /> - Copy - </> - )} - </Button> + {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />} + </button> )} </div> - {transcriptionStatus === 'complete' && transcription ? ( - <div className="p-4 bg-[var(--muted)]/50 rounded-lg"> - <p className="text-sm whitespace-pre-wrap leading-relaxed">{transcription}</p> - </div> - ) : transcriptionStatus === 'pending' ? ( - <div className="flex items-center gap-2 p-4 bg-[var(--muted)]/30 rounded-lg text-sm text-[var(--muted-foreground)]"> - <Loader2 className="size-4 animate-spin" /> - <span>Transcription pending...</span> - </div> - ) : transcriptionStatus === 'processing' ? ( - <div className="flex items-center gap-2 p-4 bg-[var(--muted)]/30 rounded-lg text-sm text-[var(--muted-foreground)]"> - <Loader2 className="size-4 animate-spin" /> - <span>Transcribing audio...</span> - </div> + {transcription ? ( + <p className="text-muted-foreground text-xs/[18px]">{transcription}</p> + ) : transcriptionStatus === 'processing' || transcriptionStatus === 'pending' ? ( + <p className="text-muted-foreground text-xs italic"> + {transcriptionStatus === 'processing' + ? 'Transcribing audio...' + : 'Awaiting transcription...'} + </p> ) : transcriptionStatus === 'failed' ? ( - <div className="flex items-center justify-between p-4 bg-red-500/10 rounded-lg"> - <div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400"> - <AlertCircle className="size-4" /> - <span>Transcription failed</span> - </div> + <div className="flex items-center gap-2"> + <span className="text-destructive text-xs">Transcription failed</span> {onRetryTranscription && ( - <Button - size="sm" - variant="outline" + <button onClick={onRetryTranscription} disabled={isRetrying} - className="h-7" + className="text-muted-foreground hover:text-foreground text-xs flex items-center gap-1 transition-colors disabled:opacity-50" > {isRetrying ? ( - <Loader2 className="size-3 animate-spin mr-1" /> + <Loader2 className="size-3 animate-spin" /> ) : ( - <RefreshCw className="size-3 mr-1" /> + <RefreshCw className="size-3" /> )} Retry - </Button> + </button> )} </div> ) : ( - <div className="p-4 bg-[var(--muted)]/30 rounded-lg text-sm text-[var(--muted-foreground)] italic"> - No transcription available - </div> + <p className="text-muted-foreground text-xs italic">No transcription available</p> )} </div> + + {metaParts.length > 0 && ( + <div className="text-text-tertiary text-[11px]/3.5">{metaParts.join(' · ')}</div> + )} </div> ) } @@ -594,42 +599,31 @@ interface PdfPreviewProps { } const PdfPreview = ({ item }: PdfPreviewProps): React.JSX.Element => { - const pdfUrl = 'attachmentUrl' in item ? item.attachmentUrl : null - const metadata = 'metadata' in item ? (item.metadata as Record<string, unknown> | null) : null + const metadata = 'metadata' in item ? (item.metadata as PdfMetadata | null) : null + + const metaParts: string[] = [] + if (metadata?.pageCount) metaParts.push(`${metadata.pageCount} pages`) + if (metadata?.fileSize) metaParts.push(formatFileSize(metadata.fileSize)) return ( - <div className="space-y-4"> - {pdfUrl ? ( - <div className="relative overflow-hidden rounded-lg bg-[var(--muted)] border border-[var(--border)]"> - <iframe - src={pdfUrl} - title={item.title} - className="w-full h-[400px]" - style={{ border: 'none' }} - /> - </div> - ) : ( - <div className="flex items-center justify-center h-[200px] bg-[var(--muted)] rounded-lg"> - <FileText className="size-12 text-[var(--muted-foreground)]" /> - </div> - )} + <div className="flex flex-col gap-3.5"> + <div className="flex flex-col items-center justify-center gap-2.5 aspect-[34/18] rounded-lg bg-muted border border-border"> + <FilePdf className="size-9 text-destructive" /> + {metaParts.length > 0 && ( + <span className="text-[11px] leading-3.5 text-text-tertiary"> + {metaParts.join(' · ')} + </span> + )} + </div> - {/* PDF metadata */} - {(() => { - const fileSize = metadata?.fileSize - const originalFilename = metadata?.originalFilename - return ( - <div className="flex flex-wrap gap-x-4 gap-y-2 text-sm text-[var(--muted-foreground)] px-1"> - <span className="uppercase font-medium">PDF</span> - {typeof fileSize === 'number' && <span>{formatFileSize(fileSize)}</span>} - {typeof originalFilename === 'string' && ( - <span className="truncate max-w-[200px]" title={originalFilename}> - {originalFilename} - </span> - )} - </div> - ) - })()} + {metadata?.originalFilename && ( + <span + className="text-[11px] leading-3.5 text-muted-foreground truncate" + title={metadata.originalFilename} + > + {metadata.originalFilename} + </span> + )} </div> ) } @@ -649,13 +643,13 @@ const VideoPreview = ({ item }: VideoPreviewProps): React.JSX.Element => { return ( <div className="space-y-4"> {videoUrl ? ( - <div className="relative overflow-hidden rounded-lg bg-black"> + <div className="relative overflow-hidden rounded-md bg-black"> <video src={videoUrl} controls className="w-full max-h-[400px]" preload="metadata"> Your browser does not support the video tag. </video> </div> ) : ( - <div className="flex items-center justify-center h-[200px] bg-[var(--muted)] rounded-lg"> + <div className="flex items-center justify-center h-[200px] bg-[var(--muted)] rounded-md"> <FileText className="size-12 text-[var(--muted-foreground)]" /> </div> )} @@ -721,8 +715,6 @@ export const ContentSection = ({ switch (item.type) { case 'link': return <LinkPreview item={item} /> - case 'note': - return <SimpleContent item={item} onContentChange={onContentChange} /> case 'image': return <ImagePreview item={item} /> case 'voice': @@ -737,8 +729,11 @@ export const ContentSection = ({ return <PdfPreview item={item} /> case 'video': return <VideoPreview item={item} /> - case 'clip': case 'social': + return <TweetCard item={item as Parameters<typeof TweetCard>[0]['item']} /> + case 'reminder': + return <ReminderDetail item={item} /> + case 'clip': default: return <SimpleContent item={item} onContentChange={onContentChange} /> } diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/detail-header.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/detail-header.tsx new file mode 100644 index 000000000..d0b4a5c64 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/inbox-detail/detail-header.tsx @@ -0,0 +1,35 @@ +import { X } from '@/lib/icons' +import { formatCompactDate } from '@/services/inbox-service' + +import { TypeIcon } from './content-section' +import { getTypeLabel } from './type-accents' +import type { InboxItemType } from '@/types' + +interface DetailHeaderProps { + type: InboxItemType + createdAt: Date | string + onClose: () => void +} + +export const DetailHeader = ({ + type, + createdAt, + onClose +}: DetailHeaderProps): React.JSX.Element => ( + <div className="flex items-center justify-between py-4 px-5 h-[47px] border-b border-border shrink-0"> + <div className="flex items-center gap-1.5"> + <TypeIcon type={type} className="size-3.5" /> + <span className="text-[11px] leading-3.5 text-muted-foreground">{getTypeLabel(type)}</span> + <span className="text-[11px] leading-3.5 text-muted-foreground/60"> + · {formatCompactDate(createdAt)} + </span> + </div> + <button + onClick={onClose} + className="p-1 rounded-md text-muted-foreground/50 hover:text-foreground transition-colors" + aria-label="Close panel" + > + <X className="size-3.5" /> + </button> + </div> +) diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/filing-section.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/filing-section.tsx index 36af27b0e..fcd45a39e 100644 --- a/apps/desktop/src/renderer/src/components/inbox-detail/filing-section.tsx +++ b/apps/desktop/src/renderer/src/components/inbox-detail/filing-section.tsx @@ -3,8 +3,8 @@ * Provides folder selection, tags, and note linking in a compact layout */ -import { useState, useEffect, useCallback, useMemo } from 'react' -import { Folder, Sparkles, Loader2, ChevronDown, Check, FileText, Link2 } from '@/lib/icons' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { Folder, Sparkles, Loader2, ChevronDown, Check, FileText, Link2, Search } from '@/lib/icons' import { useQuery } from '@tanstack/react-query' import { Button } from '@/components/ui/button' @@ -12,6 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { TagAutocomplete } from '@/components/filing/tag-autocomplete' +import { NoteIconDisplay } from '@/lib/render-note-icon' import { LinkInput } from './link-input' import { cn } from '@/lib/utils' import type { InboxItem, InboxItemListItem, Folder as FolderType, LinkedNote } from '@/types' @@ -40,39 +41,6 @@ interface FilingSectionProps { className?: string } -// ============================================================================= -// Compact Folder Chip Component -// ============================================================================= - -interface FolderChipProps { - folder: SuggestedFolder - index: number - isSelected: boolean - onClick: () => void -} - -const FolderChip = ({ folder, index, isSelected, onClick }: FolderChipProps): React.JSX.Element => { - const confidence = folder.aiConfidence ? Math.round(folder.aiConfidence * 100) : null - - return ( - <button - onClick={onClick} - className={cn( - 'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors', - 'border hover:bg-accent', - isSelected - ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90' - : 'bg-background border-border text-foreground' - )} - > - <span className="text-[10px] font-bold opacity-60">{index + 1}</span> - <span className="truncate max-w-[100px]">{folder.name || 'Notes'}</span> - {confidence && !isSelected && <span className="text-[10px] opacity-50">{confidence}%</span>} - {isSelected && <Check className="size-3" />} - </button> - ) -} - // ============================================================================= // Filing Section Component // ============================================================================= @@ -94,15 +62,15 @@ export const FilingSection = ({ const { data: vaultFolders = [] } = useQuery({ queryKey: ['vault', 'folders'], queryFn: async () => { - const paths = await window.api.notes.getFolders() + const folderInfos = await window.api.notes.getFolders() const folders: FolderType[] = [{ id: '', name: 'Notes (root)', path: '' }] - for (const path of paths) { - if (path) { + for (const fi of folderInfos) { + if (fi.path) { folders.push({ - id: path, - name: path.split('/').pop() || path, - path: path, - parent: path.includes('/') ? path.split('/').slice(0, -1).join('/') : undefined + id: fi.path, + name: fi.path.split('/').pop() || fi.path, + path: fi.path, + parent: fi.path.includes('/') ? fi.path.split('/').slice(0, -1).join('/') : undefined }) } } @@ -160,13 +128,49 @@ export const FilingSection = ({ })) }, [aiSuggestions]) + const aiSuggestedTags = useMemo(() => { + if (aiSuggestions.length === 0) return [] + return aiSuggestions.flatMap((s) => s.suggestedTags || []).filter(Boolean) + }, [aiSuggestions]) + const hasAISuggestions = aiSuggestions.length > 0 + // Track whether auto-selection already fired for this item + const didAutoSelectFolder = useRef(false) + // Reset flags when item changes + useEffect(() => { + didAutoSelectFolder.current = false + }, [item?.id]) + + // Auto-select top AI-suggested folder (once per item) + useEffect(() => { + if (!didAutoSelectFolder.current && suggestedFolders.length > 0 && !selectedFolder) { + didAutoSelectFolder.current = true + onFolderSelect(suggestedFolders[0]) + } + }, [suggestedFolders, selectedFolder, onFolderSelect]) + + // Derive display info for the folder dropdown trigger + const displayFolder = selectedFolder + ? (suggestedFolders.find((f) => f.id === selectedFolder.id) ?? { + ...selectedFolder, + aiConfidence: undefined + }) + : (suggestedFolders[0] ?? null) + const displayConfidence = (displayFolder as SuggestedFolder | null)?.aiConfidence + ? Math.round((displayFolder as SuggestedFolder).aiConfidence! * 100) + : null + const displayPath = displayFolder?.path + ? displayFolder.path.replace(/\//g, ' / ') + : displayFolder?.name || 'Select folder' + const handleLinkSuggestedNote = useCallback( (note: { id: string; title: string }) => { const alreadyLinked = linkedNotes.some((ln) => ln.id === note.id) - if (alreadyLinked) return - + if (alreadyLinked) { + onLinkedNotesChange(linkedNotes.filter((ln) => ln.id !== note.id)) + return + } onLinkedNotesChange([...linkedNotes, { id: note.id, title: note.title, type: 'note' }]) }, [linkedNotes, onLinkedNotesChange] @@ -181,43 +185,27 @@ export const FilingSection = ({ ) }, [vaultFolders, folderSearch]) - // Check if selected folder is from "Other" dropdown (not a suggested chip) - const isSelectedFromOther = - selectedFolder && !suggestedFolders.some((f) => f.id === selectedFolder.id) - return ( - <div className={cn('space-y-3', className)}> - {/* Header Row */} - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider"> - <Folder className="size-3.5" /> - <span>File to</span> + <div className={cn(className)}> + {/* File To — Header + Dropdown */} + <div className="flex flex-col gap-2 py-4 px-5 border-b border-border"> + <div className="flex items-center justify-between"> + <span className="text-[11px] [letter-spacing:0.05em] uppercase text-text-tertiary font-medium leading-3.5"> + File to + </span> + {isLoadingAISuggestions ? ( + <div className="flex items-center gap-1 text-[11px] text-muted-foreground"> + <Loader2 className="size-3 animate-spin" /> + </div> + ) : hasAISuggestions ? ( + <div className="flex items-center gap-1 text-[11px] text-[var(--tint)]"> + <Sparkles className="size-3" /> + <span>AI</span> + </div> + ) : null} </div> - {isLoadingAISuggestions ? ( - <div className="flex items-center gap-1 text-xs text-muted-foreground"> - <Loader2 className="size-3 animate-spin" /> - </div> - ) : hasAISuggestions ? ( - <div className="flex items-center gap-1 text-xs text-yellow-600 dark:text-yellow-500"> - <Sparkles className="size-3" /> - <span>AI</span> - </div> - ) : null} - </div> - {/* Folder Suggestions as Chips */} - <div className="flex flex-wrap items-center gap-2"> - {suggestedFolders.map((folder, index) => ( - <FolderChip - key={folder.id || `folder-${index}`} - folder={folder} - index={index} - isSelected={selectedFolder?.id === folder.id} - onClick={() => onFolderSelect(folder)} - /> - ))} - - {/* Other Folder Dropdown */} + {/* Folder Dropdown */} <Popover open={showAllFolders} onOpenChange={(open) => { @@ -226,118 +214,225 @@ export const FilingSection = ({ }} > <PopoverTrigger asChild> - <Button - variant={isSelectedFromOther ? 'default' : 'outline'} - size="sm" - className="h-7 px-2 text-xs" - > - {isSelectedFromOther ? ( - <> - <Folder className="size-3 mr-1" /> - <span className="truncate max-w-[100px]">{selectedFolder.name}</span> - </> - ) : ( - 'Other' + <button + className={cn( + 'flex items-center w-full rounded-md py-2.5 px-3.5 transition-colors', + hasAISuggestions + ? 'bg-[var(--tint)]/[0.03] border border-[var(--tint)]/12' + : 'bg-foreground/[0.02] border border-border' )} - <ChevronDown className="size-3 ml-1" /> - </Button> + > + <div className="flex items-center grow gap-2 min-w-0"> + <Folder + className={cn( + 'size-4 shrink-0', + hasAISuggestions ? 'text-[var(--tint)]' : 'text-muted-foreground' + )} + /> + <span className="text-[13px] leading-4 font-medium text-foreground truncate"> + {displayPath} + </span> + </div> + <div className="flex items-center gap-2 shrink-0"> + {displayConfidence && ( + <span className="text-[11px] leading-3.5 text-[var(--tint)]/50"> + {displayConfidence}% + </span> + )} + <ChevronDown className="size-3 text-muted-foreground/50" /> + </div> + </button> </PopoverTrigger> - <PopoverContent className="w-56 p-2" align="start"> - <Input - placeholder="Search folders..." - value={folderSearch} - onChange={(e) => setFolderSearch(e.target.value)} - className="h-8 text-xs mb-2" - autoFocus - /> - <ScrollArea className="max-h-48"> - {filteredFolders.length === 0 ? ( - <p className="text-xs text-muted-foreground text-center py-2">No folders found</p> - ) : ( - <div className="space-y-1"> - {filteredFolders.map((folder) => ( - <button - key={folder.id} - onClick={() => { - onFolderSelect(folder) - setShowAllFolders(false) - }} - className={cn( - 'w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded text-left', - selectedFolder?.id === folder.id - ? 'bg-primary/10 text-primary' - : 'hover:bg-accent' - )} - > - <Folder className="size-3 shrink-0" /> - <span className="truncate flex-1">{folder.name}</span> - {selectedFolder?.id === folder.id && <Check className="size-3 shrink-0" />} - </button> - ))} + <PopoverContent + className="w-[var(--radix-popover-trigger-width)] p-0 rounded-md bg-[var(--popover)] border-border shadow-[0_8px_24px_rgba(0,0,0,0.25)]" + align="start" + sideOffset={4} + > + {/* Search */} + <div className="flex items-center py-2.5 px-3 gap-2 border-b border-border/40"> + <Search className="size-3.5 text-muted-foreground/40 shrink-0" /> + <Input + placeholder="Search folders..." + value={folderSearch} + onChange={(e) => setFolderSearch(e.target.value)} + className="h-auto p-0 border-0 bg-transparent text-[13px] leading-4 text-foreground placeholder:text-muted-foreground/30 focus-visible:border-transparent shadow-none" + autoFocus + /> + </div> + + <ScrollArea className="max-h-56"> + {/* Suggested section */} + {suggestedFolders.length > 0 && !folderSearch.trim() && ( + <div className="flex flex-col py-1"> + <div className="flex items-center py-0.5 px-2"> + <span className="text-[11px] [letter-spacing:0.05em] uppercase text-text-tertiary font-medium leading-3.5"> + Suggested + </span> + </div> + {suggestedFolders.map((folder) => { + const confidence = folder.aiConfidence + ? Math.round(folder.aiConfidence * 100) + : null + const isSelected = selectedFolder?.id === folder.id + return ( + <button + key={folder.id || 'root-suggested'} + onClick={() => { + onFolderSelect(folder) + setShowAllFolders(false) + }} + className={cn( + 'flex items-center gap-2 rounded-sm py-2 px-3 mx-1 my-0.5 text-left transition-colors', + isSelected ? 'bg-[var(--tint)]/[0.03]' : 'hover:bg-foreground/[0.03]' + )} + > + <Folder className="size-3.5 shrink-0 text-[var(--tint)]" /> + <div className="flex flex-col grow gap-px min-w-0"> + <span className="text-[13px] leading-4 font-medium text-foreground truncate"> + {folder.name || 'Notes'} + </span> + {folder.path && ( + <span className="text-[11px] leading-3.5 text-muted-foreground/60 truncate"> + {folder.path.replace(/\//g, ' / ')} + </span> + )} + </div> + {confidence && ( + <span className="text-[10px] leading-3 text-[var(--tint)]/50 shrink-0"> + {confidence}% + </span> + )} + </button> + ) + })} </div> )} + + {/* All folders section */} + <div + className={cn( + 'flex flex-col py-1', + suggestedFolders.length > 0 && !folderSearch.trim() && 'border-t border-border/40' + )} + > + {!folderSearch.trim() && ( + <div className="flex items-center py-0.5 px-2"> + <span className="text-[11px] [letter-spacing:0.05em] uppercase text-text-tertiary font-medium leading-3.5"> + All folders + </span> + </div> + )} + {filteredFolders.length === 0 ? ( + <p className="text-xs text-muted-foreground text-center py-3">No folders found</p> + ) : ( + filteredFolders.map((folder) => { + const isSelected = selectedFolder?.id === folder.id + const parentPath = folder.path?.includes('/') + ? folder.path.split('/').slice(0, -1).join(' / ') + : null + return ( + <button + key={folder.id} + onClick={() => { + onFolderSelect(folder) + setShowAllFolders(false) + }} + className={cn( + 'flex items-center gap-2 rounded-sm py-2 px-3 mx-1 my-0.5 text-left transition-colors', + isSelected ? 'bg-foreground/[0.03]' : 'hover:bg-foreground/[0.03]' + )} + > + <Folder className="size-3.5 shrink-0 text-muted-foreground" /> + <span className="grow text-[13px] leading-4 text-foreground truncate"> + {folder.name} + </span> + {parentPath && ( + <span className="text-[10px] leading-3 text-muted-foreground/30 shrink-0"> + {parentPath} + </span> + )} + </button> + ) + }) + )} + </div> </ScrollArea> + + {/* Footer hints */} + <div className="flex items-center py-2 px-3 border-t border-border/40"> + <span className="text-[10px] leading-3 text-muted-foreground/30"> + ↑↓ navigate · ⏎ select · esc close + </span> + </div> </PopoverContent> </Popover> </div> - {/* AI Note Suggestions */} - {noteSuggestions.length > 0 && ( - <div className="space-y-2"> - <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider"> - <Link2 className="size-3.5" /> - <span>Link to note</span> - </div> + {/* Tags */} + <TagAutocomplete + tags={tags} + onTagsChange={onTagsChange} + placeholder="Add tags..." + showSections={false} + maxSuggestions={5} + aiSuggestedTags={aiSuggestedTags} + /> + + {/* Link to note */} + <div className="flex flex-col gap-2 py-4 px-5 border-b border-border"> + <div className="flex items-center justify-between"> + <span className="text-[11px] [letter-spacing:0.05em] uppercase text-text-tertiary font-medium leading-3.5"> + Link to note + </span> + {noteSuggestions.length > 0 && ( + <div className="flex items-center gap-1 text-[11px] text-[var(--tint)]"> + <Sparkles className="size-3" /> + <span>AI</span> + </div> + )} + </div> + + {/* AI Note Suggestions */} + {noteSuggestions.length > 0 && ( <div className="space-y-1.5"> - {noteSuggestions.map((suggestion) => { + {noteSuggestions.map((suggestion, index) => { const isLinked = linkedNotes.some((ln) => ln.id === suggestion.note.id) + const bgOpacity = [0.05, 0.02, 0.01][index] ?? 0.01 + const borderOpacity = [0.12, 0.06, 0.03][index] ?? 0.03 return ( <button key={suggestion.note.id} onClick={() => handleLinkSuggestedNote(suggestion.note)} - className={cn( - 'w-full flex items-start gap-2.5 rounded-md border px-3 py-2 text-left transition-colors', - isLinked - ? 'bg-primary/10 border-primary/30' - : 'bg-background border-border hover:bg-accent' - )} + className="w-full flex items-center gap-2 rounded-md px-3 py-2.5 text-left transition-colors border border-dashed" + style={{ + backgroundColor: `color-mix(in srgb, var(--tint) ${Math.round(bgOpacity * 100)}%, transparent)`, + borderColor: isLinked + ? `color-mix(in srgb, var(--tint) 50%, transparent)` + : `color-mix(in srgb, var(--tint) ${Math.round(borderOpacity * 100)}%, transparent)` + }} > - <FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> - <div className="flex min-w-0 flex-1 flex-col gap-0.5"> - <div className="flex items-center gap-2"> - <span className="truncate text-xs font-medium">{suggestion.note.title}</span> - <span className="text-[10px] text-muted-foreground/60"> - {Math.round(suggestion.confidence * 100)}% - </span> - {isLinked && <Check className="size-3 shrink-0 text-primary" />} - </div> - {suggestion.note.snippet && ( - <p className="line-clamp-2 text-[11px] leading-relaxed text-muted-foreground"> - {suggestion.note.snippet} - </p> - )} - </div> + {linkedNotes.find((ln) => ln.id === suggestion.note.id)?.emoji ? ( + <NoteIconDisplay + value={linkedNotes.find((ln) => ln.id === suggestion.note.id)!.emoji!} + className="size-3.5 shrink-0" + /> + ) : ( + <FileText className="size-3.5 shrink-0 text-muted-foreground" /> + )} + <span className="truncate text-[13px] leading-4 font-medium text-foreground flex-1 min-w-0"> + {suggestion.note.title} + </span> + {isLinked && <Check className="size-3 shrink-0 text-[var(--tint)]" />} + <span className="text-[10px] leading-3 text-muted-foreground/40 shrink-0"> + {Math.round(suggestion.confidence * 100)}% + </span> </button> ) })} </div> - </div> - )} - - {/* Tags Section - Full Width */} - <div className="pt-2"> - <TagAutocomplete - tags={tags} - onTagsChange={onTagsChange} - placeholder="Add tags..." - showSections={false} - maxSuggestions={5} - className="[&>div:first-child]:hidden [&>div]:space-y-1.5" - /> - </div> + )} - {/* Links Section - Full Width with Card-based Design */} - <div className="pt-2"> + {/* Link notes search input */} <LinkInput linkedNotes={linkedNotes} onLinkedNotesChange={onLinkedNotesChange} /> </div> </div> diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/inbox-content-editor.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/inbox-content-editor.tsx index 9a09ed07e..68140a61f 100644 --- a/apps/desktop/src/renderer/src/components/inbox-detail/inbox-content-editor.tsx +++ b/apps/desktop/src/renderer/src/components/inbox-detail/inbox-content-editor.tsx @@ -15,6 +15,7 @@ import '@blocknote/shadcn/style.css' import { cn } from '@/lib/utils' import { createLogger } from '@/lib/logger' +import { extractTitleFromBlocks } from '@/lib/blocknote-title' const log = createLogger('Component:InboxContentEditor') @@ -23,6 +24,8 @@ interface InboxContentEditorProps { initialContent: string | null /** Called when content changes */ onContentChange?: (content: string) => void + /** Called when the first line (title) changes */ + onTitleChange?: (title: string) => void /** Whether the editor is editable */ editable?: boolean /** Optional placeholder text */ @@ -43,6 +46,7 @@ interface InboxContentEditorProps { export const InboxContentEditor = memo(function InboxContentEditor({ initialContent, onContentChange, + onTitleChange, editable = true, placeholder = 'Edit your captured text...', className @@ -109,16 +113,19 @@ export const InboxContentEditor = memo(function InboxContentEditor({ // Handle content changes - convert to HTML and notify parent const handleChange = useCallback(async () => { - if (!onContentChange || !isContentReadyRef.current) return + if (!isContentReadyRef.current) return try { - // Convert blocks to HTML for storage - const html = await editor.blocksToHTMLLossy(editor.document) - onContentChange(html) + onTitleChange?.(extractTitleFromBlocks(editor.document)) + + if (onContentChange) { + const html = await editor.blocksToHTMLLossy(editor.document) + onContentChange(html) + } } catch (error) { log.error('Failed to convert content', error) } - }, [editor, onContentChange]) + }, [editor, onContentChange, onTitleChange]) const handleContainerMouseDown = useCallback( (e: React.MouseEvent<HTMLDivElement>) => { @@ -155,7 +162,7 @@ export const InboxContentEditor = memo(function InboxContentEditor({ 'inbox-content-editor prose prose-sm dark:prose-invert max-w-none', 'min-h-[300px] flex flex-col', '[&_.bn-editor]:min-h-[280px] [&_.bn-editor]:flex-1', - '[&_.bn-container]:bg-transparent [&_.bn-container]:flex-1', + '[&_.bn-container]:flex-1', editable && 'cursor-text', className )} diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/inbox-detail-panel.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/inbox-detail-panel.tsx index ad9b81d5c..c3ec75c29 100644 --- a/apps/desktop/src/renderer/src/components/inbox-detail/inbox-detail-panel.tsx +++ b/apps/desktop/src/renderer/src/components/inbox-detail/inbox-detail-panel.tsx @@ -11,23 +11,16 @@ */ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { Archive, Check, Loader2, GripHorizontal } from '@/lib/icons' -import { useQuery } from '@tanstack/react-query' +import { Archive, Check, Loader2, GripHorizontal, RotateCcw, Trash2 } from '@/lib/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { cn } from '@/lib/utils' -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetFooter, - SheetDescription -} from '@/components/ui/sheet' -import * as VisuallyHidden from '@radix-ui/react-visually-hidden' import { Button } from '@/components/ui/button' -import { ContentSection, ContentMetadata, ContentSkeleton, TypeIcon } from './content-section' +import { ContentSection, ContentSkeleton } from './content-section' +import { DetailHeader } from './detail-header' +import { NoteDetail } from './note-detail' import { FilingSection, useFilingState } from './filing-section' import { useRetryTranscription, useUpdateInboxItem } from '@/hooks/use-inbox' import { isMac, isInputFocused } from '@/hooks/use-keyboard-shortcuts' @@ -47,9 +40,12 @@ interface InboxDetailPanelProps { isOpen: boolean item: DetailItem | null isLoading?: boolean + readOnly?: boolean onClose: () => void onFile: (itemId: string, folderId: string, tags: string[], linkedNoteIds: string[]) => void onArchive: (id: string) => void + onRestore?: (id: string) => void + onDelete?: (id: string) => void } // ============================================================================= @@ -60,10 +56,15 @@ export const InboxDetailPanel = ({ isOpen, item, isLoading = false, + readOnly = false, onClose, onFile, - onArchive + onArchive, + onRestore, + onDelete }: InboxDetailPanelProps): React.JSX.Element => { + const queryClient = useQueryClient() + // Retry transcription mutation const retryTranscriptionMutation = useRetryTranscription() @@ -111,47 +112,47 @@ export const InboxDetailPanel = ({ // Loading state for filing const [isFilingLoading, setIsFilingLoading] = useState(false) - // Resizable filing section state (percentage of total height for filing section) - const [filingSectionRatio, setFilingSectionRatio] = useState(0.35) + // Resizable content area: null = auto-height (handle sits right after content) + const [manualContentHeight, setManualContentHeight] = useState<number | null>(null) const [isResizing, setIsResizing] = useState(false) const containerRef = useRef<HTMLDivElement>(null) + const contentRef = useRef<HTMLDivElement>(null) - // Handle resize drag - const handleResizeStart = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - setIsResizing(true) - - const startY = e.clientY - const startRatio = filingSectionRatio - - const handleMouseMove = (moveEvent: MouseEvent): void => { - if (!containerRef.current) return - - const containerRect = containerRef.current.getBoundingClientRect() - const containerHeight = containerRect.height - const deltaY = startY - moveEvent.clientY - const deltaRatio = deltaY / containerHeight - - const newRatio = Math.min(0.7, Math.max(0.2, startRatio + deltaRatio)) - setFilingSectionRatio(newRatio) - } + useEffect(() => { + setManualContentHeight(null) + }, [item?.id]) + + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setIsResizing(true) + + const startY = e.clientY + const startHeight = contentRef.current?.getBoundingClientRect().height ?? 0 + const containerHeight = containerRef.current?.getBoundingClientRect().height ?? 0 + const MIN_CONTENT = 60 + const MIN_FILING = 120 + const HANDLE_HEIGHT = 8 + const maxContent = containerHeight - MIN_FILING - HANDLE_HEIGHT + + const handleMouseMove = (moveEvent: MouseEvent): void => { + const deltaY = moveEvent.clientY - startY + const newHeight = Math.min(maxContent, Math.max(MIN_CONTENT, startHeight + deltaY)) + setManualContentHeight(newHeight) + } - const handleMouseUp = (): void => { - setIsResizing(false) - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' - } + const handleMouseUp = (): void => { + setIsResizing(false) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - document.body.style.cursor = 'row-resize' - document.body.style.userSelect = 'none' - }, - [filingSectionRatio] - ) + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + }, []) // Handle keyboard shortcuts useEffect(() => { @@ -255,23 +256,47 @@ export const InboxDetailPanel = ({ // Debounce timer for content changes const contentChangeTimerRef = useRef<NodeJS.Timeout | null>(null) + const pendingTitleRef = useRef<string | null>(null) + + const handleTitleChange = useCallback((title: string): void => { + pendingTitleRef.current = title + }, []) + + const handleVoiceTitleSave = useCallback( + (title: string): void => { + if (!item) return + const trimmed = title.trim() + if (trimmed && trimmed !== item.title) { + updateItemMutation.mutate({ id: item.id, title: trimmed }) + } + }, + [item, updateItemMutation] + ) - // Handle content change (debounced save - 500ms delay) const handleContentChange = useCallback( (content: string): void => { if (!item) return - // Clear any pending save if (contentChangeTimerRef.current) { clearTimeout(contentChangeTimerRef.current) } - // Debounce the save contentChangeTimerRef.current = setTimeout(() => { - updateItemMutation.mutate({ id: item.id, content }) - }, 500) + const update: { id: string; content: string; title?: string } = { id: item.id, content } + if (pendingTitleRef.current !== null) { + update.title = pendingTitleRef.current + pendingTitleRef.current = null + } + updateItemMutation.mutate(update, { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ['inbox', 'suggestions', item.id] + }) + } + }) + }, 1500) }, - [item, updateItemMutation] + [item, updateItemMutation, queryClient] ) // Cleanup timer on unmount @@ -291,152 +316,197 @@ export const InboxDetailPanel = ({ [setSelectedFolder] ) - // Handle sheet open change - const handleOpenChange = (open: boolean): void => { - if (!open) { - onClose() - } - } - const modifierKeyDisplay = isMac ? '⌘' : 'Ctrl+' const keyboardHint = `${modifierKeyDisplay}⏎ file · 1-5 folder · Esc close` return ( - <Sheet open={isOpen} onOpenChange={handleOpenChange}> - <SheetContent - side="right" - className="w-[600px] sm:max-w-[600px] flex flex-col p-0 h-full overflow-hidden" - aria-describedby={item ? undefined : 'detail-panel-description'} - > + <div + role="complementary" + aria-label="Item details" + aria-hidden={!isOpen} + className={cn( + 'shrink-0 h-full border-l bg-surface overflow-hidden', + 'transition-[width,opacity] duration-200 ease-out', + isOpen ? 'w-[380px] opacity-100 border-border' : 'w-0 opacity-0 border-transparent' + )} + > + <div className="w-[380px] h-full flex flex-col overflow-hidden [font-synthesis:none] text-[12px] leading-4 antialiased"> {isLoading ? ( - <> - {/* Hidden title/description for accessibility when loading */} - <VisuallyHidden.Root> - <SheetTitle>Loading preview</SheetTitle> - <SheetDescription id="detail-panel-description"> - Loading item details... - </SheetDescription> - </VisuallyHidden.Root> - <ContentSkeleton /> - </> + <ContentSkeleton /> ) : item ? ( <> - {/* Header */} - <SheetHeader className="px-6 py-4 border-b border-[var(--border)] shrink-0"> - <div className="flex items-start gap-3"> - <TypeIcon type={item.type} /> - <SheetTitle className="text-lg font-semibold flex-1 line-clamp-2 pr-8"> - {item.title} - </SheetTitle> - </div> - </SheetHeader> + <DetailHeader type={item.type} createdAt={item.createdAt} onClose={onClose} /> - {/* Metadata Bar */} - <ContentMetadata item={item} /> - - {/* Main Content Area - Resizable Split */} + {/* Main Content Area */} <div ref={containerRef} className="flex-1 min-h-0 flex flex-col"> - {/* Scrollable Content Area */} <div - className="min-h-0 overflow-y-auto" - style={{ flex: `${1 - filingSectionRatio} 1 0%` }} + ref={contentRef} + className={cn( + 'overflow-y-auto', + readOnly || item.type === 'reminder' ? 'flex-1 min-h-0' : 'shrink-0' + )} + style={ + readOnly || item.type === 'reminder' + ? undefined + : manualContentHeight !== null + ? { height: manualContentHeight } + : { maxHeight: '60%' } + } > - <div className="px-6 py-4"> - <ContentSection + {item.type === 'note' ? ( + <NoteDetail item={item} - onRetryTranscription={handleRetryTranscription} - isRetrying={retryTranscriptionMutation.isPending} - onContentChange={handleContentChange} + onContentChange={readOnly ? undefined : handleContentChange} + onTitleChange={readOnly ? undefined : handleTitleChange} /> - </div> + ) : ( + <div + className={ + item.type === 'reminder' || item.type === 'social' ? '' : 'px-5 py-4' + } + > + {item.type === 'voice' ? ( + <input + type="text" + defaultValue={item.title} + key={item.id + item.title} + onBlur={(e) => handleVoiceTitleSave(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + className="text-[15px] leading-5 font-medium text-foreground mb-3.5 w-full bg-transparent focus:outline-none border-b border-transparent focus:border-muted-foreground/20 transition-colors" + placeholder="Name this voice memo..." + /> + ) : ( + item.type !== 'link' && + item.type !== 'image' && + item.type !== 'pdf' && + item.type !== 'reminder' && + item.type !== 'social' && ( + <h3 className="text-[15px] leading-5 font-medium text-foreground mb-3.5"> + {item.title} + </h3> + ) + )} + <ContentSection + item={item} + onRetryTranscription={handleRetryTranscription} + isRetrying={retryTranscriptionMutation.isPending} + onContentChange={readOnly ? undefined : handleContentChange} + /> + </div> + )} </div> - {/* Resize Handle */} - <div - onMouseDown={handleResizeStart} - className={cn( - 'relative h-2 shrink-0 cursor-row-resize group', - 'border-y border-border bg-muted/30', - 'hover:bg-muted/60 transition-colors', - isResizing && 'bg-primary/20' - )} - role="separator" - aria-orientation="horizontal" - aria-label="Resize filing section" - tabIndex={0} - > - <div className="absolute inset-0 flex items-center justify-center"> - <GripHorizontal + {!readOnly && item.type !== 'reminder' && ( + <> + {/* Resize Handle */} + <div + onMouseDown={handleResizeStart} className={cn( - 'size-4 text-muted-foreground/50', - 'group-hover:text-muted-foreground transition-colors', - isResizing && 'text-primary' + 'relative h-2 shrink-0 cursor-row-resize group', + 'border-y border-border bg-muted/30', + 'hover:bg-muted/60 transition-colors', + isResizing && 'bg-primary/20' )} - /> - </div> - </div> - - {/* Filing Section - Resizable */} - <div - className="min-h-0 overflow-y-auto bg-muted/30" - style={{ flex: `${filingSectionRatio} 1 0%` }} - > - <div className="px-6 py-4"> - <FilingSection - item={item} - selectedFolder={selectedFolder} - tags={tags} - linkedNotes={linkedNotes} - onFolderSelect={handleFolderSelect} - onTagsChange={setTags} - onLinkedNotesChange={setLinkedNotes} - /> - </div> - </div> + role="separator" + aria-orientation="horizontal" + aria-label="Resize filing section" + tabIndex={0} + > + <div className="absolute inset-0 flex items-center justify-center"> + <GripHorizontal + className={cn( + 'size-4 text-muted-foreground/50', + 'group-hover:text-muted-foreground transition-colors', + isResizing && 'text-primary' + )} + /> + </div> + </div> + + {/* Filing Section — fills remaining space */} + <div className="flex-1 min-h-0 overflow-y-auto"> + <FilingSection + item={item} + selectedFolder={selectedFolder} + tags={tags} + linkedNotes={linkedNotes} + onFolderSelect={handleFolderSelect} + onTagsChange={setTags} + onLinkedNotesChange={setLinkedNotes} + /> + </div> + </> + )} </div> - {/* Footer with Actions */} - <SheetFooter className="shrink-0 px-6 py-3 border-t border-[var(--border)] flex-col gap-2"> - <div className="flex items-center justify-between w-full gap-3"> + {/* Footer */} + <div className="shrink-0 px-5 py-3 border-t border-border flex flex-col gap-1.5"> + {readOnly ? ( + <div className="flex items-center w-full gap-2"> + <Button + variant="outline" + onClick={() => item && onRestore?.(item.id)} + className="flex-1 text-muted-foreground border-border" + > + <RotateCcw className="size-4 mr-1.5" aria-hidden="true" /> + Restore + </Button> + <Button + variant="outline" + onClick={() => item && onDelete?.(item.id)} + className="flex-1 text-destructive border-destructive/30 hover:bg-destructive/10" + > + <Trash2 className="size-4 mr-1.5" aria-hidden="true" /> + Delete + </Button> + </div> + ) : item?.type === 'reminder' ? ( <Button - variant="ghost" + variant="outline" onClick={handleArchive} - className="text-[var(--muted-foreground)]" + className="w-full text-muted-foreground border-border" > - <Archive className="size-4 mr-2" aria-hidden="true" /> + <Archive className="size-4 mr-1.5" aria-hidden="true" /> Archive </Button> - <Button - onClick={handleFileItem} - disabled={!canFile || isFilingLoading} - className="min-w-[120px]" - > - {isFilingLoading ? ( - <> - <Loader2 className="size-4 animate-spin mr-2" aria-hidden="true" /> - Filing... - </> - ) : ( - <> - {canFile && <Check className="size-4 mr-2" aria-hidden="true" />} - File item - </> - )} - </Button> - </div> - <p className="text-xs text-[var(--muted-foreground)] text-center w-full"> - {keyboardHint} - </p> - </SheetFooter> + ) : ( + <> + <div className="flex items-center w-full gap-2"> + <Button + variant="outline" + onClick={handleArchive} + className="flex-1 text-muted-foreground border-border" + > + <Archive className="size-4 mr-1.5" aria-hidden="true" /> + Archive + </Button> + <Button + onClick={handleFileItem} + disabled={!canFile || isFilingLoading} + className="flex-1 bg-tint hover:bg-tint-hover text-tint-foreground border-0" + > + {isFilingLoading ? ( + <Loader2 className="size-4 animate-spin mr-1.5" aria-hidden="true" /> + ) : ( + <Check className="size-4 mr-1.5" aria-hidden="true" /> + )} + File + <kbd className="ml-2 text-[11px] opacity-60">{modifierKeyDisplay}⏎</kbd> + </Button> + </div> + <p className="text-[10px] text-muted-foreground/40 text-center w-full"> + {keyboardHint} + </p> + </> + )} + </div> </> - ) : ( - // Hidden title/description for accessibility when no item - <VisuallyHidden.Root> - <SheetTitle>Detail panel</SheetTitle> - <SheetDescription>No item selected</SheetDescription> - </VisuallyHidden.Root> - )} - </SheetContent> - </Sheet> + ) : null} + </div> + </div> ) } diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/index.ts b/apps/desktop/src/renderer/src/components/inbox-detail/index.ts index 597e2455c..2f05b5503 100644 --- a/apps/desktop/src/renderer/src/components/inbox-detail/index.ts +++ b/apps/desktop/src/renderer/src/components/inbox-detail/index.ts @@ -4,4 +4,9 @@ export { InboxDetailPanel } from './inbox-detail-panel' export { ContentSection, ContentMetadata, ContentSkeleton, TypeIcon } from './content-section' +export { DetailHeader } from './detail-header' +export { NoteDetail } from './note-detail' export { FilingSection, useFilingState } from './filing-section' +export { LinkPreview } from './link-preview' +export { ReminderDetail } from './reminder-detail' +export { getTypeLabel, getTypeAccentClass, getTypeAccentHex } from './type-accents' diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/link-input.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/link-input.tsx index c1891771c..bd89eb7c9 100644 --- a/apps/desktop/src/renderer/src/components/inbox-detail/link-input.tsx +++ b/apps/desktop/src/renderer/src/components/inbox-detail/link-input.tsx @@ -8,8 +8,8 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { Link2, FileText, X, Loader2, Folder } from '@/lib/icons' import { useQuery } from '@tanstack/react-query' -import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' +import { NoteIconDisplay } from '@/lib/render-note-icon' import type { LinkedNote } from '@/types' // ============================================================================= @@ -45,24 +45,22 @@ const LinkedNoteCard = ({ note, onRemove }: LinkedNoteCardProps): React.JSX.Elem return ( <div className={cn( - 'group flex items-center gap-3 px-3 py-2.5 rounded-lg', + 'group flex items-center gap-3 px-3 py-2.5 rounded-md', 'bg-muted/40 border border-border/50', 'transition-colors hover:bg-muted/60' )} > - <div className="flex items-center justify-center size-8 rounded-md bg-background border border-border/50 shrink-0"> + <div className="flex items-center justify-center size-7 rounded-md bg-foreground/[0.03] border border-border/50 shrink-0"> {note.emoji ? ( - <span className="text-base" aria-hidden="true"> - {note.emoji} - </span> + <NoteIconDisplay value={note.emoji} className="size-3.5" /> ) : ( - <Icon className="size-4 text-muted-foreground" aria-hidden="true" /> + <Icon className="size-3.5 text-muted-foreground" aria-hidden="true" /> )} </div> <div className="flex-1 min-w-0"> - <p className="text-sm font-medium truncate">{note.title}</p> + <p className="text-[13px] leading-4 font-medium truncate text-foreground">{note.title}</p> {note.type === 'note' && ( - <p className="text-xs text-muted-foreground truncate opacity-70">Note</p> + <p className="text-[11px] leading-3.5 text-muted-foreground/60 truncate">Note</p> )} </div> <button @@ -105,21 +103,19 @@ const SearchResultItem = ({ onClick={() => onSelect(note)} onMouseEnter={onMouseEnter} className={cn( - 'w-full flex items-center gap-2 px-3 py-2 text-left', + 'w-full flex items-center gap-2 px-3 py-2 mx-1 my-0.5 rounded-sm text-left', 'transition-colors duration-75', - isHighlighted ? 'bg-accent text-accent-foreground' : 'hover:bg-muted' + isHighlighted ? 'bg-foreground/[0.03]' : 'hover:bg-foreground/[0.03]' )} role="option" aria-selected={isHighlighted} > {note.emoji ? ( - <span className="size-4 text-center shrink-0" aria-hidden="true"> - {note.emoji} - </span> + <NoteIconDisplay value={note.emoji} className="size-3.5 shrink-0" /> ) : ( - <Icon className="size-4 text-muted-foreground shrink-0" aria-hidden="true" /> + <Icon className="size-3.5 text-muted-foreground shrink-0" aria-hidden="true" /> )} - <span className="text-sm truncate flex-1">{note.title}</span> + <span className="text-[13px] leading-4 truncate flex-1 text-foreground">{note.title}</span> </button> ) } @@ -276,43 +272,39 @@ export const LinkInput = ({ <div ref={containerRef} className={cn('space-y-3', className)}> {/* Search Input */} <div className="relative"> - <Link2 - className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" - aria-hidden="true" - /> - <Input - ref={inputRef} - type="text" - placeholder="Link notes..." - value={searchQuery} - onChange={handleInputChange} - onFocus={handleInputFocus} - onKeyDown={handleKeyDown} - className="pl-9" - aria-label="Search notes to link" - aria-expanded={isDropdownOpen} - aria-haspopup="listbox" - aria-autocomplete="list" - autoComplete="off" - /> + <div className="flex items-center rounded-md py-2 px-3 gap-2 bg-foreground/[0.02] border border-border"> + <Link2 className="size-3.5 text-muted-foreground/30 shrink-0" aria-hidden="true" /> + <input + ref={inputRef} + type="text" + placeholder="Link notes..." + value={searchQuery} + onChange={handleInputChange} + onFocus={handleInputFocus} + onKeyDown={handleKeyDown} + aria-label="Search notes to link" + aria-expanded={isDropdownOpen} + aria-haspopup="listbox" + aria-autocomplete="list" + autoComplete="off" + className="flex-1 min-w-0 bg-transparent border-0 p-0 text-[13px] leading-4 text-foreground placeholder:text-muted-foreground/30 outline-none focus:outline-none" + /> + </div> {/* Dropdown Results */} {isDropdownOpen && ( <div ref={dropdownRef} - className={cn( - 'absolute z-50 w-full mt-1 py-1 rounded-md border border-border', - 'bg-popover shadow-md max-h-48 overflow-y-auto' - )} + className="absolute z-50 w-full mt-1 p-0 rounded-md border border-border bg-popover shadow-[0_8px_24px_rgba(0,0,0,0.25)] max-h-48 overflow-y-auto" role="listbox" > {isSearching ? ( <div className="flex items-center gap-2 px-3 py-2"> - <Loader2 className="size-4 animate-spin text-muted-foreground" /> - <span className="text-sm text-muted-foreground">Searching...</span> + <Loader2 className="size-3.5 animate-spin text-muted-foreground" /> + <span className="text-xs text-muted-foreground">Searching...</span> </div> ) : availableResults.length === 0 ? ( - <p className="text-sm text-muted-foreground text-center py-2"> + <p className="text-xs text-muted-foreground text-center py-3"> {searchResults.length > 0 ? 'All matches already linked' : 'No notes found'} </p> ) : ( diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/link-preview.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/link-preview.tsx new file mode 100644 index 000000000..bee2a9899 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/inbox-detail/link-preview.tsx @@ -0,0 +1,130 @@ +import { memo, useState } from 'react' +import { ExternalLink, Globe } from '@/lib/icons' +import { extractDomain, formatCompactRelativeTime } from '@/lib/inbox-utils' +import { cn } from '@/lib/utils' +import type { InboxItem, InboxItemListItem, LinkMetadata } from '@/types' + +const getInitials = (name: string): string => { + const parts = name.replace(/\.[a-z]+$/, '').split(/[\s._-]+/) + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase() + } + return name.slice(0, 2).toUpperCase() +} + +const formatPublishedDate = (date: string): string => { + const d = new Date(date) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +interface LinkPreviewProps { + item: InboxItem | InboxItemListItem +} + +export const LinkPreview = memo(({ item }: LinkPreviewProps): React.JSX.Element => { + const [imgError, setImgError] = useState(false) + const [faviconError, setFaviconError] = useState(false) + + const metadata = 'metadata' in item ? (item.metadata as LinkMetadata | null) : null + const raw = metadata as Record<string, unknown> | null + const heroImageUrl = item.thumbnailUrl || metadata?.heroImage || (raw?.image as string) || null + const domain = item.sourceUrl ? extractDomain(item.sourceUrl) : null + const siteName = metadata?.siteName || (raw?.publisher as string) || domain + const faviconUrl = metadata?.favicon || (raw?.logo as string) || null + const author = metadata?.author || null + const publishedDate = + metadata?.publishedDate || (raw?.date as string) + ? formatPublishedDate((metadata?.publishedDate || raw?.date) as string) + : null + const excerpt = metadata?.description || metadata?.excerpt || item.content + const capturedAgo = formatCompactRelativeTime(item.createdAt) + + const showHeroImage = heroImageUrl && !imgError + + return ( + <div className="flex flex-col gap-4"> + {/* Hero image */} + <div className="relative h-[200px] overflow-hidden rounded-[10px] bg-muted"> + {showHeroImage ? ( + <img + src={heroImageUrl} + alt="" + className="size-full object-cover" + onError={() => setImgError(true)} + loading="lazy" + /> + ) : ( + <div className="flex items-center justify-center size-full bg-gradient-to-br from-muted to-surface"> + {faviconUrl && !faviconError ? ( + <img + src={faviconUrl} + alt="" + className="size-12 rounded-lg object-contain" + onError={() => setFaviconError(true)} + /> + ) : ( + <Globe className="size-8 text-muted-foreground/20" /> + )} + </div> + )} + </div> + + {/* Title */} + <h3 className="text-[17px] leading-6 font-semibold text-foreground">{item.title}</h3> + + {/* Excerpt */} + {excerpt && ( + <p className="text-[13px] leading-5 text-muted-foreground line-clamp-3">{excerpt}</p> + )} + + {/* Domain bar */} + <div className="flex items-center gap-2"> + {faviconUrl ? ( + <img + src={faviconUrl} + alt="" + className="size-5 shrink-0 rounded bg-muted object-contain" + onError={(e) => { + e.currentTarget.style.display = 'none' + }} + /> + ) : siteName ? ( + <div className="flex items-center justify-center shrink-0 size-5 rounded bg-muted"> + <span className="text-[10px] font-semibold leading-none text-muted-foreground"> + {getInitials(siteName)} + </span> + </div> + ) : null} + <span className="text-[11px] leading-3.5 text-muted-foreground/70"> + {[domain, author, publishedDate].filter(Boolean).join(' · ')} + </span> + </div> + + {/* Open in browser */} + {item.sourceUrl && ( + <a + href={item.sourceUrl} + target="_blank" + rel="noopener noreferrer" + className={cn( + 'flex items-center justify-center gap-1.5', + 'py-2 rounded-lg', + 'border border-border', + 'text-xs font-medium text-muted-foreground', + 'hover:text-foreground hover:border-foreground/20 transition-colors' + )} + > + <ExternalLink className="size-3.5" /> + Open in browser + </a> + )} + + {/* Capture timestamp */} + <span className="text-[10px] leading-3.5 text-muted-foreground/40"> + Captured {capturedAgo} + </span> + </div> + ) +}) + +LinkPreview.displayName = 'LinkPreview' diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/note-detail.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/note-detail.tsx new file mode 100644 index 000000000..cedcfaab6 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/inbox-detail/note-detail.tsx @@ -0,0 +1,27 @@ +import { InboxContentEditor } from './inbox-content-editor' +import type { InboxItem, InboxItemListItem } from '@/types' + +type NoteItem = InboxItem | InboxItemListItem + +interface NoteDetailProps { + item: NoteItem + onContentChange?: (content: string) => void + onTitleChange?: (title: string) => void +} + +export const NoteDetail = ({ + item, + onContentChange, + onTitleChange +}: NoteDetailProps): React.JSX.Element => ( + <div className="flex flex-col p-5 border-b border-border"> + <InboxContentEditor + initialContent={item.content} + onContentChange={onContentChange} + onTitleChange={onTitleChange} + editable={!!onContentChange} + placeholder="Write your note..." + className="!min-h-0 [&_.bn-editor]:!min-h-0" + /> + </div> +) diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/reminder-detail.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/reminder-detail.tsx new file mode 100644 index 000000000..227a59152 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/inbox-detail/reminder-detail.tsx @@ -0,0 +1,274 @@ +import { useState, useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' + +import { cn } from '@/lib/utils' +import { BellRing, FileText, Calendar, Clock, ChevronRight } from '@/lib/icons' +import { Button } from '@/components/ui/button' +import { SnoozePicker } from '@/components/snooze/snooze-picker' +import { inOneHour, tomorrow, nextWeek, formatSnoozeTime } from '@/components/snooze/snooze-presets' +import { inboxService } from '@/services/inbox-service' +import { inboxKeys } from '@/hooks/use-inbox' +import { useTabs } from '@/contexts/tabs' +import { createLogger } from '@/lib/logger' +import type { InboxItem, InboxItemListItem } from '@/types' +import type { ReminderMetadata } from '@memry/contracts/inbox-api' + +const log = createLogger('Component:ReminderDetail') + +type ReminderItem = InboxItem | InboxItemListItem + +interface ReminderDetailProps { + item: ReminderItem +} + +const SNOOZE_PRESETS = [ + { id: 'in-1-hour', label: '1 hour', getTime: inOneHour }, + { id: 'tomorrow', label: 'Tomorrow', getTime: tomorrow }, + { id: 'next-week', label: 'Next week', getTime: nextWeek } +] as const + +function formatTriggerDate(isoString: string): string { + const date = new Date(isoString) + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }) +} + +function getTargetIcon(targetType: string) { + switch (targetType) { + case 'journal': + return Calendar + default: + return FileText + } +} + +export function ReminderDetail({ item }: ReminderDetailProps): React.JSX.Element { + const metadata = item.metadata as ReminderMetadata | undefined + const queryClient = useQueryClient() + const { openTab } = useTabs() + const [isSnoozing, setIsSnoozing] = useState(false) + const [isMarkingViewed, setIsMarkingViewed] = useState(false) + const [localViewedAt, setLocalViewedAt] = useState<Date | null>(item.viewedAt ?? null) + + const invalidateInbox = useCallback(() => { + queryClient.invalidateQueries({ queryKey: inboxKeys.lists() }) + queryClient.invalidateQueries({ queryKey: inboxKeys.stats() }) + }, [queryClient]) + + const handleSnooze = useCallback( + async (snoozeUntil: string) => { + setIsSnoozing(true) + try { + await inboxService.snooze({ itemId: item.id, snoozeUntil }) + invalidateInbox() + } catch (err) { + log.error('Failed to snooze reminder', err) + } finally { + setIsSnoozing(false) + } + }, + [item.id, invalidateInbox] + ) + + const handlePresetSnooze = useCallback( + (getTime: () => Date) => { + handleSnooze(getTime().toISOString()) + }, + [handleSnooze] + ) + + const handleMarkViewed = useCallback(async () => { + setIsMarkingViewed(true) + try { + await inboxService.markViewed(item.id) + setLocalViewedAt(new Date()) + invalidateInbox() + } catch (err) { + log.error('Failed to mark reminder as viewed', err) + } finally { + setIsMarkingViewed(false) + } + }, [item.id, invalidateInbox]) + + const handleNavigateToSource = useCallback(() => { + if (!metadata) return + + inboxService.markViewed(item.id).catch(() => {}) + + switch (metadata.targetType) { + case 'note': + case 'highlight': + openTab({ + type: 'note', + title: metadata.targetTitle || 'Note', + icon: 'file-text', + path: `/notes/${metadata.targetId}`, + entityId: metadata.targetId, + isPinned: false, + isModified: false, + isPreview: true, + isDeleted: false, + viewState: + metadata.targetType === 'highlight' + ? { + highlightStart: metadata.highlightStart, + highlightEnd: metadata.highlightEnd, + highlightText: metadata.highlightText + } + : undefined + }) + break + case 'journal': + openTab({ + type: 'journal', + title: 'Journal', + icon: 'book-open', + path: '/journal', + isPinned: false, + isModified: false, + isPreview: false, + isDeleted: false, + viewState: { date: metadata.targetId } + }) + break + } + }, [metadata, item.id, openTab]) + + if (!metadata) { + return <div className="p-5 text-muted-foreground text-sm">Reminder data unavailable.</div> + } + + const TargetIcon = getTargetIcon(metadata.targetType) + const isViewed = localViewedAt !== null + + return ( + <div className="flex flex-col gap-3.5 p-5 text-xs/4"> + {/* Triggered banner */} + <div + className={cn( + 'flex items-center rounded-lg py-2 px-3 gap-1.5', + 'bg-[var(--accent-orange)]/5 border border-[var(--accent-orange)]/15' + )} + > + <BellRing className="size-4 text-[var(--accent-orange)]" aria-hidden="true" /> + <span className="text-[var(--accent-orange)] font-medium text-xs">Reminder triggered</span> + <span className="ml-auto text-text-tertiary text-[11px]"> + {formatTriggerDate(metadata.remindAt)} + </span> + </div> + + {/* Reminder note */} + {metadata.reminderNote && ( + <div className="flex flex-col gap-1"> + <span className="uppercase tracking-[0.04em] text-text-tertiary font-medium text-[11px]"> + Reminder Note + </span> + <p className="text-muted-foreground text-[13px] leading-5">{metadata.reminderNote}</p> + </div> + )} + + {/* Source card */} + <div className="flex flex-col gap-1"> + <span className="uppercase tracking-[0.04em] text-text-tertiary font-medium text-[11px]"> + Source + </span> + <button + type="button" + onClick={handleNavigateToSource} + className={cn( + 'flex items-center rounded-lg py-2.5 px-3 gap-2.5 w-full text-left', + 'bg-muted/30 border border-border', + 'hover:bg-muted/50 transition-colors cursor-pointer' + )} + > + <TargetIcon className="size-3.5 text-muted-foreground shrink-0" aria-hidden="true" /> + <div className="flex flex-col gap-0.5 min-w-0 flex-1"> + <span className="text-foreground text-xs truncate"> + {metadata.targetType === 'journal' + ? `Journal \u2014 ${new Date(metadata.targetId).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` + : metadata.targetTitle || 'Note'} + </span> + {metadata.highlightText && ( + <span className="text-text-tertiary text-[11px] truncate"> + Highlighted: “{metadata.highlightText}” + </span> + )} + </div> + <ChevronRight className="size-2.5 text-text-tertiary shrink-0" aria-hidden="true" /> + </button> + </div> + + {/* Mark as viewed */} + <div className="flex items-center gap-2"> + {isViewed ? ( + <span className="text-text-tertiary text-[11px]">Viewed</span> + ) : ( + <Button + variant="outline" + size="sm" + onClick={handleMarkViewed} + disabled={isMarkingViewed} + className="h-auto py-0.5 px-2 text-[11px] text-muted-foreground border-border" + > + Mark as viewed + </Button> + )} + {!isViewed && ( + <span className="text-text-tertiary text-[11px]">· Not yet viewed</span> + )} + </div> + + {/* Snooze section */} + <div className="flex flex-col gap-2.5 pt-3.5 border-t border-border"> + <div className="flex items-center gap-1.5"> + <Clock className="size-3.5 text-muted-foreground/60" aria-hidden="true" /> + <span className="uppercase tracking-[0.04em] text-muted-foreground/60 text-xs font-medium"> + Snooze + </span> + </div> + <div className="flex flex-wrap gap-1.5"> + {SNOOZE_PRESETS.map((preset) => ( + <button + key={preset.id} + type="button" + onClick={() => handlePresetSnooze(preset.getTime)} + disabled={isSnoozing} + className={cn( + 'rounded-md py-1 px-2.5 text-[13px]', + 'bg-muted/50 text-muted-foreground', + 'hover:bg-muted/80 transition-colors', + 'disabled:opacity-50 disabled:cursor-not-allowed' + )} + > + {preset.label} + </button> + ))} + <SnoozePicker + onSnooze={handleSnooze} + disabled={isSnoozing} + trigger={ + <button + type="button" + disabled={isSnoozing} + className={cn( + 'flex items-center gap-1.5 rounded-md py-1 px-2.5 text-[13px]', + 'border border-border text-muted-foreground/60', + 'hover:bg-muted/30 transition-colors', + 'disabled:opacity-50 disabled:cursor-not-allowed' + )} + > + <Calendar className="size-3" aria-hidden="true" /> + Custom... + </button> + } + /> + </div> + </div> + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/tweet-card.test.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/tweet-card.test.tsx new file mode 100644 index 000000000..1541a829e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/inbox-detail/tweet-card.test.tsx @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' + +vi.mock('react-tweet', () => ({ + useTweet: vi.fn() +})) + +import { useTweet } from 'react-tweet' +import { TweetCard } from './tweet-card' + +const mockUseTweet = vi.mocked(useTweet) + +const baseItem = { + id: 'item-1', + type: 'social' as const, + title: 'Tweet by @rauchg', + content: null, + sourceUrl: 'https://twitter.com/rauchg/status/1234567890', + processingStatus: 'complete', + metadata: { + platform: 'twitter' as const, + tweetId: '1234567890', + postUrl: 'https://twitter.com/rauchg/status/1234567890', + authorName: '', + authorHandle: '@rauchg', + postContent: '', + mediaUrls: [], + extractionStatus: 'partial' as const + } +} + +describe('TweetCard', () => { + beforeEach(() => { + mockUseTweet.mockReset() + }) + + it('should show loading skeleton while fetching', () => { + mockUseTweet.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: true + } as ReturnType<typeof useTweet>) + + render(<TweetCard item={baseItem} />) + + expect(screen.getByTestId('tweet-skeleton')).toBeInTheDocument() + }) + + it('should show error state when tweet not found', () => { + mockUseTweet.mockReturnValue({ + data: undefined, + error: new Error('Not found'), + isLoading: false + } as ReturnType<typeof useTweet>) + + render(<TweetCard item={baseItem} />) + + expect(screen.getByText(/unavailable/i)).toBeInTheDocument() + }) + + it('should render tweet content on success', () => { + mockUseTweet.mockReturnValue({ + data: { + __typename: 'Tweet', + lang: 'en', + favorite_count: 4200, + possibly_sensitive: false, + created_at: '2025-12-28T12:00:00.000Z', + display_text_range: [0, 140], + id_str: '1234567890', + text: 'The future is offline-capable by default', + full_text: 'The future is offline-capable by default', + user: { + id_str: '123', + name: 'Guillermo Rauch', + screen_name: 'rauchg', + verified: true, + profile_image_url_https: 'https://pbs.twimg.com/profile_images/avatar.jpg', + is_blue_verified: true, + profile_image_shape: 'Circle' + }, + edit_control: { + edit_tweet_ids: ['1234567890'], + editable_until_msecs: '0', + is_edit_eligible: false, + edits_remaining: '0' + }, + isEdited: false, + isStaleEdit: false + }, + error: undefined, + isLoading: false + } as unknown as ReturnType<typeof useTweet>) + + render(<TweetCard item={baseItem} />) + + expect(screen.getByText('Guillermo Rauch')).toBeInTheDocument() + expect(screen.getByText('@rauchg')).toBeInTheDocument() + expect(screen.getByText(/offline-capable/)).toBeInTheDocument() + }) + + it('should show x.com badge', () => { + mockUseTweet.mockReturnValue({ + data: { + __typename: 'Tweet', + lang: 'en', + favorite_count: 100, + possibly_sensitive: false, + created_at: '2025-12-28T12:00:00.000Z', + display_text_range: [0, 10], + id_str: '123', + text: 'Hello', + full_text: 'Hello', + user: { + id_str: '1', + name: 'Test', + screen_name: 'test', + verified: false, + profile_image_url_https: '', + is_blue_verified: false, + profile_image_shape: 'Circle' + }, + edit_control: { + edit_tweet_ids: ['123'], + editable_until_msecs: '0', + is_edit_eligible: false, + edits_remaining: '0' + }, + isEdited: false, + isStaleEdit: false + }, + error: undefined, + isLoading: false + } as unknown as ReturnType<typeof useTweet>) + + render(<TweetCard item={baseItem} />) + + expect(screen.getByText('x.com')).toBeInTheDocument() + }) + + it('should call useTweet with tweetId from metadata', () => { + mockUseTweet.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: true + } as ReturnType<typeof useTweet>) + + render(<TweetCard item={baseItem} />) + + expect(mockUseTweet).toHaveBeenCalledWith('1234567890') + }) + + it('should extract tweetId from sourceUrl if not in metadata', () => { + mockUseTweet.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: true + } as ReturnType<typeof useTweet>) + + const itemWithoutTweetId = { + ...baseItem, + metadata: { ...baseItem.metadata, tweetId: undefined } + } + + render(<TweetCard item={itemWithoutTweetId} />) + + expect(mockUseTweet).toHaveBeenCalledWith('1234567890') + }) + + it('should show error when no tweetId can be extracted', () => { + mockUseTweet.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: false + } as ReturnType<typeof useTweet>) + + const itemNoId = { + ...baseItem, + sourceUrl: 'https://twitter.com/user', + metadata: { ...baseItem.metadata, tweetId: undefined } + } + + render(<TweetCard item={itemNoId} />) + + expect(screen.getByText(/unavailable/i)).toBeInTheDocument() + }) +}) diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/tweet-card.tsx b/apps/desktop/src/renderer/src/components/inbox-detail/tweet-card.tsx new file mode 100644 index 000000000..f46be1880 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/inbox-detail/tweet-card.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react' +import { useTweet } from 'react-tweet' +import { ExternalLink } from '@/lib/icons' +import { cn } from '@/lib/utils' +import type { SocialMetadata } from '@/types' + +interface TweetCardItem { + id: string + type: string + title: string + content: string | null + sourceUrl: string | null + processingStatus: string + metadata: SocialMetadata | Record<string, unknown> | null +} + +interface TweetCardProps { + item: TweetCardItem +} + +function extractTweetId(url: string | null): string | null { + if (!url) return null + const match = url.match(/\/status\/(\d+)/) + return match ? match[1] : null +} + +function formatCount(n: number | undefined): string { + if (!n) return '0' + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return String(n) +} + +// ============================================================================ +// Skeleton +// ============================================================================ + +function TweetSkeleton(): React.JSX.Element { + return ( + <div data-testid="tweet-skeleton" className="flex flex-col gap-3.5 p-5 animate-pulse"> + <div className="flex items-center gap-2.5"> + <div className="rounded-[18px] size-9 bg-[var(--muted)]" /> + <div className="flex flex-col gap-1.5"> + <div className="h-3.5 w-28 rounded bg-[var(--muted)]" /> + <div className="h-3 w-16 rounded bg-[var(--muted)]" /> + </div> + </div> + <div className="rounded-lg p-3.5 bg-[var(--surface)]"> + <div className="space-y-2"> + <div className="h-4 w-full rounded bg-[var(--muted)]" /> + <div className="h-4 w-3/4 rounded bg-[var(--muted)]" /> + <div className="h-4 w-1/2 rounded bg-[var(--muted)]" /> + </div> + </div> + <div className="flex gap-5"> + <div className="h-3 w-10 rounded bg-[var(--muted)]" /> + <div className="h-3 w-10 rounded bg-[var(--muted)]" /> + <div className="h-3 w-16 rounded bg-[var(--muted)]" /> + </div> + </div> + ) +} + +// ============================================================================ +// Error State +// ============================================================================ + +function TweetUnavailable({ url }: { url: string | null }): React.JSX.Element { + return ( + <div className="flex flex-col items-center justify-center gap-3 p-8 text-center"> + <div className="text-sm font-medium text-[var(--foreground)]">Tweet unavailable</div> + <div className="text-xs text-[var(--muted-foreground)]"> + This tweet may have been deleted or is from a private account. + </div> + {url && ( + <a + href={url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 text-xs font-medium text-[var(--accent-cyan)] hover:underline" + onClick={(e) => e.stopPropagation()} + > + <ExternalLink className="size-3" /> + Open on x.com + </a> + )} + </div> + ) +} + +// ============================================================================ +// Avatar +// ============================================================================ + +function TweetAvatar({ imageUrl, name }: { imageUrl?: string; name: string }): React.JSX.Element { + const [imgError, setImgError] = useState(false) + const initial = (name[0] || '?').toUpperCase() + + if (imageUrl && !imgError) { + return ( + <img + src={imageUrl} + alt={name} + className="shrink-0 rounded-[18px] size-9 object-cover" + onError={() => setImgError(true)} + loading="lazy" + /> + ) + } + + return ( + <div className="flex items-center justify-center shrink-0 rounded-[18px] size-9 bg-[var(--muted)]"> + <span className="text-sm/4.5 font-semibold text-[var(--muted-foreground)]">{initial}</span> + </div> + ) +} + +// ============================================================================ +// Metrics +// ============================================================================ + +function TweetMetrics({ + likes, + retweets, + views +}: { + likes?: number + retweets?: number + views?: number +}): React.JSX.Element { + return ( + <div className="flex items-center gap-5 text-[11px]/3.5 text-[var(--muted-foreground)]"> + <div className="flex items-center gap-1.5"> + <svg width="12" height="12" viewBox="0 0 12 12" fill="none"> + <path + d="M6 10.5s-4.5-3-4.5-5.5a2.5 2.5 0 015 0 2.5 2.5 0 015 0c0 2.5-4.5 5.5-4.5 5.5z" + stroke="currentColor" + strokeWidth="1" + className="text-red-500/70" + fill="none" + /> + </svg> + <span>{formatCount(likes)}</span> + </div> + <div className="flex items-center gap-1.5"> + <svg width="12" height="12" viewBox="0 0 12 12" fill="none"> + <path + d="M1 4.5h3L6 2l2 2.5h3L9 7l1 3.5L6 8.5l-4 2L3 7 1 4.5z" + stroke="currentColor" + strokeWidth="1" + className="text-emerald-500/70" + fill="none" + /> + </svg> + <span>{formatCount(retweets)}</span> + </div> + {views !== undefined && ( + <div className="flex items-center gap-1.5"> + <svg width="12" height="12" viewBox="0 0 12 12" fill="none"> + <path + d="M1 6s2-3.5 5-3.5S11 6 11 6s-2 3.5-5 3.5S1 6 1 6z" + stroke="currentColor" + strokeWidth="1" + fill="none" + /> + <circle cx="6" cy="6" r="1.5" stroke="currentColor" strokeWidth="1" fill="none" /> + </svg> + <span>{formatCount(views)} views</span> + </div> + )} + </div> + ) +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function TweetCard({ item }: TweetCardProps): React.JSX.Element { + const meta = item.metadata as SocialMetadata | null + const tweetId = meta?.tweetId || extractTweetId(item.sourceUrl) + + const { data: tweet, error, isLoading } = useTweet(tweetId ?? undefined) + + if (!tweetId) { + return <TweetUnavailable url={item.sourceUrl} /> + } + + if (isLoading) { + return <TweetSkeleton /> + } + + if (error || !tweet) { + return <TweetUnavailable url={item.sourceUrl} /> + } + + const user = tweet.user + const text = tweet.text || '' + + return ( + <div + className={cn( + 'flex flex-col shrink-0 gap-3.5', + 'border-b border-[var(--border)]', + 'text-xs/4 p-5' + )} + > + {/* Header: avatar + author + badge */} + <div className="flex items-center gap-2.5"> + <TweetAvatar imageUrl={user.profile_image_url_https} name={user.name} /> + <div className="flex flex-col gap-px"> + <span className="text-[13px]/4 font-medium text-[var(--foreground)]">{user.name}</span> + <span className="text-[11px]/3.5 text-[var(--muted-foreground)]"> + @{user.screen_name} + </span> + </div> + <div className="flex items-center ml-auto rounded-[10px] py-0.5 px-2 bg-[var(--accent-cyan)]/10"> + <span className="text-[10px]/3.5 font-medium text-[var(--accent-cyan)]">x.com</span> + </div> + </div> + + {/* Content */} + <div className="rounded-lg bg-[var(--surface)] border border-[var(--border)] p-3.5"> + <p className="text-sm/5.5 text-[var(--foreground)] whitespace-pre-wrap">{text}</p> + </div> + + {/* Media */} + {tweet.mediaDetails && tweet.mediaDetails.length > 0 && ( + <div className="grid grid-cols-2 gap-1.5 rounded-lg overflow-hidden"> + {tweet.mediaDetails.slice(0, 4).map((media, i) => ( + <img + key={i} + src={media.media_url_https} + alt={`Tweet media ${i + 1}`} + className={cn( + 'w-full object-cover', + tweet.mediaDetails!.length === 1 ? 'col-span-2 max-h-72' : 'aspect-square' + )} + loading="lazy" + /> + ))} + </div> + )} + + {/* Metrics */} + <TweetMetrics + likes={tweet.favorite_count} + retweets={(tweet as unknown as Record<string, unknown>).retweet_count as number | undefined} + views={ + (tweet as unknown as Record<string, unknown>).views_count + ? Number((tweet as unknown as Record<string, unknown>).views_count) + : undefined + } + /> + + {/* Open link */} + {item.sourceUrl && ( + <a + href={item.sourceUrl} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 text-xs font-medium text-[var(--accent-cyan)] hover:underline" + onClick={(e) => e.stopPropagation()} + > + <ExternalLink className="size-3" /> + View on X + </a> + )} + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/inbox-detail/type-accents.ts b/apps/desktop/src/renderer/src/components/inbox-detail/type-accents.ts new file mode 100644 index 000000000..a1c742a5f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/inbox-detail/type-accents.ts @@ -0,0 +1,34 @@ +import type { InboxItemType } from '@/types' + +interface TypeAccent { + label: string + accentClass: string + hex: string +} + +const TYPE_ACCENTS: Record<string, TypeAccent> = { + link: { label: 'link', accentClass: 'text-[var(--accent-purple)]', hex: '#8b5cf6' }, + voice: { label: 'voice', accentClass: 'text-[var(--accent-orange)]', hex: '#f59e0b' }, + image: { label: 'image', accentClass: 'text-[var(--accent-green)]', hex: '#10b981' }, + note: { label: 'note', accentClass: 'text-[var(--accent-cyan)]', hex: '#3b82f6' }, + video: { label: 'video', accentClass: 'text-[var(--accent-orange)]', hex: '#ef4444' }, + pdf: { label: 'pdf', accentClass: 'text-[var(--accent-orange)]', hex: '#f97316' }, + clip: { label: 'clip', accentClass: 'text-[var(--accent-purple)]', hex: '#8b5cf6' }, + social: { label: 'social', accentClass: 'text-[var(--accent-cyan)]', hex: '#06b6d4' }, + reminder: { label: 'reminder', accentClass: 'text-[var(--accent-orange)]', hex: '#ec4899' } +} + +const DEFAULT_ACCENT: TypeAccent = { + label: 'item', + accentClass: 'text-[var(--muted-foreground)]', + hex: '#8c8c8c' +} + +export const getTypeLabel = (type: InboxItemType): string => + (TYPE_ACCENTS[type] ?? DEFAULT_ACCENT).label + +export const getTypeAccentClass = (type: InboxItemType): string => + (TYPE_ACCENTS[type] ?? DEFAULT_ACCENT).accentClass + +export const getTypeAccentHex = (type: InboxItemType): string => + (TYPE_ACCENTS[type] ?? DEFAULT_ACCENT).hex diff --git a/apps/desktop/src/renderer/src/components/inbox/inbox-archived-item-row.tsx b/apps/desktop/src/renderer/src/components/inbox/inbox-archived-item-row.tsx deleted file mode 100644 index a24c149c9..000000000 --- a/apps/desktop/src/renderer/src/components/inbox/inbox-archived-item-row.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useState } from 'react' -import { formatDistanceToNow } from 'date-fns' -import { - FileText, - Link, - Mic, - Image, - Paperclip, - FileIcon, - Share2, - Bell, - StickyNote, - RotateCcw, - Trash2, - X, - Check, - Loader2 -} from '@/lib/icons' -import { cn } from '@/lib/utils' -import type { InboxItemListItem } from '../../../../preload/index.d' - -interface ArchivedInboxItem extends InboxItemListItem { - archivedAt?: Date | string -} - -export interface InboxArchivedItemRowProps { - item: ArchivedInboxItem - onUnarchive: (id: string) => void - onDelete: (id: string) => void - isDeleting?: boolean - isUnarchiving?: boolean -} - -export function InboxArchivedItemRow({ - item, - onUnarchive, - onDelete, - isDeleting = false, - isUnarchiving = false -}: InboxArchivedItemRowProps): React.JSX.Element { - const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) - - const getIcon = (): React.JSX.Element => { - const iconClass = 'w-4 h-4 text-muted-foreground/60' - - switch (item.type) { - case 'link': - return <Link className={iconClass} aria-hidden="true" /> - case 'note': - return <FileText className={iconClass} aria-hidden="true" /> - case 'image': - return <Image className={iconClass} aria-hidden="true" /> - case 'voice': - return <Mic className={iconClass} aria-hidden="true" /> - case 'clip': - return <Paperclip className={iconClass} aria-hidden="true" /> - case 'pdf': - return <FileIcon className={iconClass} aria-hidden="true" /> - case 'social': - return <Share2 className={iconClass} aria-hidden="true" /> - case 'reminder': - return <Bell className="w-4 h-4 text-amber-500" aria-hidden="true" /> - default: - return <StickyNote className={iconClass} aria-hidden="true" /> - } - } - - const handleRestore = (e: React.MouseEvent): void => { - e.stopPropagation() - onUnarchive(item.id) - } - - const handleDeleteClick = (e: React.MouseEvent): void => { - e.stopPropagation() - setIsConfirmingDelete(true) - } - - const handleConfirmDelete = (e: React.MouseEvent): void => { - e.stopPropagation() - onDelete(item.id) - setIsConfirmingDelete(false) - } - - const handleCancelDelete = (e: React.MouseEvent): void => { - e.stopPropagation() - setIsConfirmingDelete(false) - } - - const dateToUse = item.archivedAt ? new Date(item.archivedAt) : new Date(item.createdAt) - const relativeDate = formatDistanceToNow(dateToUse, { addSuffix: true }) - - const previewText = item.excerpt ?? item.content ?? item.sourceUrl ?? '' - - return ( - <div - className={cn( - 'group relative w-full', - 'flex items-center gap-3 px-3 py-2.5 rounded-lg', - 'transition-all duration-150 ease-out', - 'hover:bg-muted/50' - )} - role="listitem" - aria-label={`${item.type}: ${item.title}`} - > - <div - className={cn( - 'flex-shrink-0 flex items-center justify-center', - 'w-9 h-9 rounded-lg', - 'bg-muted/60 dark:bg-muted/40', - 'transition-colors duration-150', - 'group-hover:bg-muted dark:group-hover:bg-muted/60' - )} - > - {getIcon()} - </div> - - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - <span className="font-medium text-sm truncate text-foreground/90"> - {item.title || 'Untitled Item'} - </span> - {item.isStale && ( - <span className="shrink-0 inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"> - Stale - </span> - )} - </div> - {previewText && ( - <p className="text-xs text-muted-foreground/60 truncate mt-0.5 max-w-md">{previewText}</p> - )} - </div> - - {/* Timestamp */} - <span className="shrink-0 text-xs text-muted-foreground/60 tabular-nums group-hover:opacity-0 transition-opacity"> - {relativeDate} - </span> - - {/* Actions - show on hover */} - <div - className={cn( - 'absolute right-3 flex items-center gap-1', - 'opacity-0 group-hover:opacity-100 transition-opacity duration-150', - (isConfirmingDelete || isDeleting || isUnarchiving) && 'opacity-100' - )} - > - {isConfirmingDelete ? ( - <div className="flex items-center bg-muted rounded-md p-0.5 border border-border animate-in fade-in zoom-in-95 duration-150"> - <span className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground px-2 mr-1"> - Delete? - </span> - <button - onClick={handleConfirmDelete} - className="p-1.5 hover:bg-destructive/10 hover:text-destructive rounded transition-colors text-muted-foreground" - title="Yes, delete permanently" - > - <Check className="w-3.5 h-3.5" /> - </button> - <button - onClick={handleCancelDelete} - className="p-1.5 hover:bg-accent hover:text-foreground rounded transition-colors text-muted-foreground" - title="Cancel" - > - <X className="w-3.5 h-3.5" /> - </button> - </div> - ) : ( - <> - <button - onClick={handleRestore} - disabled={isUnarchiving || isDeleting} - className={cn( - 'p-2 rounded-md transition-all text-muted-foreground', - 'hover:bg-background hover:text-amber-600 dark:hover:text-amber-400 hover:shadow-sm', - isUnarchiving && 'animate-pulse cursor-wait' - )} - title="Restore to Inbox" - > - {isUnarchiving ? ( - <Loader2 className="w-4 h-4 animate-spin" /> - ) : ( - <RotateCcw className="w-4 h-4" /> - )} - </button> - - <button - onClick={handleDeleteClick} - disabled={isUnarchiving || isDeleting} - className={cn( - 'p-2 rounded-md transition-all text-muted-foreground', - 'hover:bg-background hover:text-destructive hover:shadow-sm', - isDeleting && 'animate-pulse cursor-wait' - )} - title="Delete Permanently" - > - {isDeleting ? ( - <Loader2 className="w-4 h-4 animate-spin" /> - ) : ( - <Trash2 className="w-4 h-4" /> - )} - </button> - </> - )} - </div> - </div> - ) -} diff --git a/apps/desktop/src/renderer/src/components/inbox/inbox-archived-view.tsx b/apps/desktop/src/renderer/src/components/inbox/inbox-archived-view.tsx index a1d09eb4d..b8aaec816 100644 --- a/apps/desktop/src/renderer/src/components/inbox/inbox-archived-view.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/inbox-archived-view.tsx @@ -1,14 +1,17 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { format } from 'date-fns' -import { Archive, Loader2, ChevronRight } from '@/lib/icons' +import { useEffect, useMemo, useRef, useState, useCallback } from 'react' +import { Archive, ArrowTurnBackward, Loader2, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { useInboxArchived, + useInboxItem, useUnarchiveInboxItem, useDeletePermanentInboxItem } from '@/hooks/use-inbox' -import { InboxArchivedItemRow } from './inbox-archived-item-row' -import type { InboxItemListItem } from '../../../../preload/index.d' +import { InboxListSection, ListTypeIcon } from '@/components/inbox' +import { InboxDetailPanel } from '@/components/inbox-detail' +import { groupItemsByTimePeriod, formatCompactRelativeTime, extractDomain } from '@/lib/inbox-utils' +import { DENSITY_CONFIG } from '@/hooks/use-display-density' +import type { InboxItemListItem } from '@/types' interface ArchivedInboxItem extends InboxItemListItem { archivedAt?: Date | string @@ -16,53 +19,167 @@ interface ArchivedInboxItem extends InboxItemListItem { export interface InboxArchivedViewProps { className?: string + searchQuery?: string } -export function InboxArchivedView({ className }: InboxArchivedViewProps): React.JSX.Element { - const { items, hasMore, isLoading, loadMore, isLoadingMore } = useInboxArchived() - const [collapsedMonths, setCollapsedMonths] = useState<Set<string>>(new Set()) +const densityConfig = DENSITY_CONFIG.compact - const toggleMonth = (month: string): void => { - setCollapsedMonths((prev) => { - const next = new Set(prev) - if (next.has(month)) { - next.delete(month) - } else { - next.add(month) - } - return next - }) - } +function ArchivedListItem({ + item, + onPreview, + onUnarchive, + onDelete, + isFocused, + isUnarchiving, + isDeleting +}: { + item: ArchivedInboxItem + onPreview: (id: string) => void + onUnarchive: (id: string) => void + onDelete: (id: string) => void + isFocused: boolean + isUnarchiving: boolean + isDeleting: boolean +}): React.JSX.Element { + return ( + <div + className={cn( + 'group relative w-full', + 'flex items-center', + 'gap-2.5', + densityConfig.itemPadding, + densityConfig.itemRadius, + 'transition-all duration-150 ease-out', + 'cursor-pointer', + 'hover:bg-muted/50', + isFocused && 'bg-muted' + )} + role="listitem" + aria-label={`${item.type}: ${item.title}`} + onClick={() => onPreview(item.id)} + data-item-id={item.id} + > + <div className="flex-shrink-0"> + <ListTypeIcon type={item.type} /> + </div> + + <span + className={cn( + 'grow shrink min-w-0 truncate font-medium', + densityConfig.titleSize, + 'text-foreground/90' + )} + > + {item.title || 'Untitled'} + </span> + + {item.sourceUrl && + (item.type === 'link' || item.type === 'social' || item.type === 'clip') && ( + <span className={cn('shrink-0', densityConfig.metaSize, 'text-muted-foreground/60')}> + {extractDomain(item.sourceUrl)} + </span> + )} + + <span + className={cn( + 'shrink-0 w-9 text-right tabular-nums', + densityConfig.metaSize, + 'text-muted-foreground/60' + )} + > + {formatCompactRelativeTime( + item.archivedAt + ? new Date(item.archivedAt) + : item.createdAt instanceof Date + ? item.createdAt + : new Date(item.createdAt) + )} + </span> + + <div className="shrink-0 quick-actions-reveal flex items-center gap-0.5"> + <button + type="button" + onClick={(e) => { + e.stopPropagation() + onUnarchive(item.id) + }} + disabled={isUnarchiving || isDeleting} + className={cn( + 'p-1.5 rounded-md transition-colors', + 'text-muted-foreground/50 hover:text-foreground hover:bg-muted', + isUnarchiving && 'animate-spin' + )} + title="Restore to inbox" + aria-label="Restore to inbox" + > + {isUnarchiving ? ( + <Loader2 className="size-3.5" /> + ) : ( + <ArrowTurnBackward className="size-3.5" /> + )} + </button> + <button + type="button" + onClick={(e) => { + e.stopPropagation() + onDelete(item.id) + }} + disabled={isUnarchiving || isDeleting} + className={cn( + 'p-1.5 rounded-md transition-colors', + 'text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10', + isDeleting && 'animate-spin' + )} + title="Delete permanently" + aria-label="Delete permanently" + > + {isDeleting ? <Loader2 className="size-3.5" /> : <Trash2 className="size-3.5" />} + </button> + </div> + </div> + ) +} + +export function InboxArchivedView({ + className, + searchQuery = '' +}: InboxArchivedViewProps): React.JSX.Element { + const [debouncedSearch, setDebouncedSearch] = useState('') + const [activeDetailItemId, setActiveDetailItemId] = useState<string | null>(null) + const [focusedItemId, setFocusedItemId] = useState<string | null>(null) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchQuery), 250) + return () => clearTimeout(timer) + }, [searchQuery]) + + const { items, hasMore, isLoading, loadMore, isLoadingMore } = useInboxArchived({ + search: debouncedSearch || undefined + }) const unarchiveMutation = useUnarchiveInboxItem() const deleteMutation = useDeletePermanentInboxItem() const observerTarget = useRef<HTMLDivElement>(null) - const groupedItems = useMemo(() => { - if (!items || items.length === 0) return {} + const sortedItems = useMemo(() => { + if (!items || items.length === 0) return [] - const groups: Record<string, ArchivedInboxItem[]> = {} const archivedItems = items as ArchivedInboxItem[] - - const sortedItems = [...archivedItems].sort((a, b) => { + return [...archivedItems].sort((a, b) => { const dateA = a.archivedAt ? new Date(a.archivedAt) : new Date(a.createdAt) const dateB = b.archivedAt ? new Date(b.archivedAt) : new Date(b.createdAt) return dateB.getTime() - dateA.getTime() }) - - sortedItems.forEach((item) => { - const date = item.archivedAt ? new Date(item.archivedAt) : new Date(item.createdAt) - const key = format(date, 'MMMM yyyy') - - if (!groups[key]) { - groups[key] = [] - } - groups[key].push(item) - }) - - return groups }, [items]) + const groupedItems = useMemo( + () => + groupItemsByTimePeriod(sortedItems, (item: ArchivedInboxItem) => + item.archivedAt ? new Date(item.archivedAt) : new Date(item.createdAt) + ), + [sortedItems] + ) + useEffect(() => { const currentTarget = observerTarget.current const observer = new IntersectionObserver( @@ -85,108 +202,114 @@ export function InboxArchivedView({ className }: InboxArchivedViewProps): React. } }, [hasMore, isLoadingMore, loadMore]) - const handleUnarchive = (id: string): void => { - unarchiveMutation.mutate(id) - } + const handleUnarchive = useCallback( + (id: string): void => { + unarchiveMutation.mutate(id) + if (activeDetailItemId === id) setActiveDetailItemId(null) + }, + [unarchiveMutation, activeDetailItemId] + ) - const handleDelete = (id: string): void => { - deleteMutation.mutate(id) - } + const handleDelete = useCallback( + (id: string): void => { + deleteMutation.mutate(id) + if (activeDetailItemId === id) setActiveDetailItemId(null) + }, + [deleteMutation, activeDetailItemId] + ) + + const handlePreview = useCallback( + (id: string): void => { + if (activeDetailItemId === id) { + setActiveDetailItemId(null) + } else { + setActiveDetailItemId(id) + setFocusedItemId(id) + } + }, + [activeDetailItemId] + ) - if (isLoading && items.length === 0) { + const { item: fullDetailItem, isLoading: isDetailLoading } = useInboxItem(activeDetailItemId) + const activeDetailItem = useMemo(() => { + if (!activeDetailItemId) return null + if (fullDetailItem) return fullDetailItem + return sortedItems.find((item) => item.id === activeDetailItemId) || null + }, [activeDetailItemId, fullDetailItem, sortedItems]) + + const isDetailPanelOpen = activeDetailItemId !== null + + const noopFile = useCallback((): void => {}, []) + + if (isLoading && items.length === 0 && !searchQuery) { return ( <div className={cn('flex flex-col items-center justify-center h-64 gap-4', className)}> <Loader2 className="size-8 text-muted-foreground/50 animate-spin" /> - <p className="text-sm text-muted-foreground/60 font-serif">Loading archives...</p> </div> ) } - if (!isLoading && items.length === 0) { - return ( - <div - className={cn( - 'flex flex-col items-center justify-center h-full w-full p-8 text-center', - className + return ( + <div className={cn('flex h-full overflow-hidden', className)}> + <div className="flex flex-col flex-1 min-w-0 h-full px-4 lg:px-6 pt-3 pb-4 lg:pb-6 overflow-y-auto"> + {sortedItems.length === 0 && !isLoading ? ( + <div className="flex flex-col items-center justify-center py-16 text-center"> + <Archive className="size-6 text-muted-foreground/30 mb-3" strokeWidth={1.5} /> + <p className="text-sm text-muted-foreground/50"> + {searchQuery ? 'No matching archived items' : 'No archived items'} + </p> + </div> + ) : ( + <div className="space-y-1" role="list" aria-label="Archived items"> + {groupedItems.map((group) => ( + <InboxListSection + key={group.period} + title={group.period} + count={group.items.length} + collapsible + selectedIds={new Set<string>()} + focusedId={focusedItemId} + density="compact" + onSelect={() => {}} + onFocus={setFocusedItemId} + > + {group.items.map((item) => ( + <ArchivedListItem + key={item.id} + item={item} + onPreview={handlePreview} + onUnarchive={handleUnarchive} + onDelete={handleDelete} + isFocused={focusedItemId === item.id} + isUnarchiving={ + unarchiveMutation.isPending && unarchiveMutation.variables === item.id + } + isDeleting={deleteMutation.isPending && deleteMutation.variables === item.id} + /> + ))} + </InboxListSection> + ))} + </div> + )} + + {hasMore && ( + <div ref={observerTarget} className="py-6 flex justify-center"> + {isLoadingMore && <Loader2 className="size-5 text-muted-foreground/40 animate-spin" />} + </div> )} - > - <div className="size-16 rounded-full bg-primary/10 flex items-center justify-center mb-4"> - <Archive className="size-8 text-primary" strokeWidth={1.5} /> - </div> - <h3 className="text-2xl font-medium text-foreground mb-2">No archived items</h3> - <p className="text-sm text-muted-foreground max-w-xs leading-relaxed"> - Items you archive from your inbox will appear here for safekeeping. - </p> </div> - ) - } - return ( - <div className={cn('space-y-6', className)}> - {Object.entries(groupedItems).map(([month, monthItems]) => { - const isCollapsed = collapsedMonths.has(month) - - return ( - <section key={month}> - <button - type="button" - onClick={() => toggleMonth(month)} - className={cn( - 'flex items-center gap-2 w-full px-1 py-1.5 -mx-1 rounded-md', - 'hover:bg-muted/50 transition-colors duration-150', - 'cursor-pointer select-none' - )} - aria-expanded={!isCollapsed} - aria-controls={`month-${month}`} - > - <ChevronRight - className={cn( - 'size-4 text-muted-foreground/50 transition-transform duration-200', - !isCollapsed && 'rotate-90' - )} - /> - <h3 className="text-sm font-medium text-muted-foreground/70 tracking-wide uppercase"> - {month} - </h3> - <span className="text-xs text-muted-foreground/50"> - ({monthItems.length} item{monthItems.length !== 1 ? 's' : ''}) - </span> - </button> - - <div - id={`month-${month}`} - className={cn( - 'space-y-0.5 overflow-hidden transition-all duration-200', - isCollapsed ? 'max-h-0 opacity-0' : 'max-h-[5000px] opacity-100' - )} - role="list" - > - {monthItems.map((item) => ( - <InboxArchivedItemRow - key={item.id} - item={item} - onUnarchive={handleUnarchive} - onDelete={handleDelete} - isUnarchiving={ - unarchiveMutation.isPending && unarchiveMutation.variables === item.id - } - isDeleting={deleteMutation.isPending && deleteMutation.variables === item.id} - /> - ))} - </div> - </section> - ) - })} - - {hasMore && ( - <div ref={observerTarget} className="py-8 flex justify-center"> - {isLoadingMore ? ( - <Loader2 className="size-6 text-muted-foreground animate-spin" /> - ) : ( - <div className="h-4" /> - )} - </div> - )} + <InboxDetailPanel + isOpen={isDetailPanelOpen} + item={activeDetailItem} + isLoading={isDetailLoading} + readOnly + onClose={() => setActiveDetailItemId(null)} + onFile={noopFile} + onArchive={noopFile} + onRestore={handleUnarchive} + onDelete={handleDelete} + /> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/inbox/inbox-capture-heatmap.tsx b/apps/desktop/src/renderer/src/components/inbox/inbox-capture-heatmap.tsx index 2155e59ea..f657b7315 100644 --- a/apps/desktop/src/renderer/src/components/inbox/inbox-capture-heatmap.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/inbox-capture-heatmap.tsx @@ -1,84 +1,75 @@ import type { InboxCapturePattern } from '../../../../preload/index.d' -import { cn } from '@/lib/utils' export interface InboxCaptureHeatmapProps { patterns: InboxCapturePattern | undefined } -const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] -const HOURS = Array.from({ length: 24 }, (_, i) => i) +const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const +const HEATMAP_HOURS = [6, 8, 10, 12, 14, 16, 18, 20, 22] as const -function getIntensityClass(count: number, max: number): string { - if (count === 0) return 'bg-muted/30' - - const ratio = max > 0 ? count / max : 0 - - if (ratio < 0.25) return 'bg-primary/20' - if (ratio < 0.5) return 'bg-primary/40' - if (ratio < 0.75) return 'bg-primary/60' - return 'bg-primary/80' -} - -function formatHour(hour: number): string { - if (hour === 0) return '12am' - if (hour === 12) return '12pm' - return hour > 12 ? `${hour - 12}pm` : `${hour}am` +function intensityToAlpha(intensity: number): string { + if (intensity <= 0) return '0D' + if (intensity < 0.1) return '1A' + if (intensity < 0.2) return '26' + if (intensity < 0.3) return '40' + if (intensity < 0.4) return '59' + if (intensity < 0.5) return '73' + if (intensity < 0.6) return '8C' + if (intensity < 0.7) return '99' + if (intensity < 0.8) return 'B3' + if (intensity < 0.9) return 'CC' + return 'E6' } export function InboxCaptureHeatmap({ patterns }: InboxCaptureHeatmapProps): React.JSX.Element { - if (!patterns?.timeHeatmap) { - return ( - <div className="p-6 rounded-xl border border-border/50 bg-card h-full min-h-[400px] flex items-center justify-center"> - <span className="text-muted-foreground font-serif italic">No capture data available</span> - </div> - ) - } + const heatmap = patterns?.timeHeatmap let maxCount = 0 - patterns.timeHeatmap.forEach((row) => { - row.forEach((count) => { - if (count > maxCount) maxCount = count - }) - }) - - const grid = patterns.timeHeatmap + if (Array.isArray(heatmap) && heatmap.length > 0) { + for (let day = 0; day < 7; day++) { + for (const hour of HEATMAP_HOURS) { + const val = (heatmap[hour]?.[day] ?? 0) + (heatmap[hour + 1]?.[day] ?? 0) + if (val > maxCount) maxCount = val + } + } + } return ( - <div className="p-6 rounded-xl border border-border/50 bg-card flex flex-col h-full"> - <div className="mb-6"> - <h3 className="text-lg font-serif font-medium text-foreground">Capture Patterns</h3> - <p className="text-sm text-muted-foreground mt-1">When you add items to your inbox</p> - </div> - - <div className="flex-1 flex flex-col items-center justify-center"> - <div className="grid grid-cols-[auto_repeat(7,1fr)] gap-x-2 gap-y-1 w-full max-w-md"> - <div className="h-6 w-8" /> + <div className="flex flex-col rounded-[10px] gap-3.5 border border-border/50 p-4"> + <div className="text-muted-foreground font-sans font-medium text-xs/4">Capture Activity</div> + <div className="[font-synthesis:none] flex gap-1.5 antialiased text-xs/4"> + <div className="flex flex-col pt-4 gap-0.75"> {DAYS.map((day) => ( - <div key={day} className="h-6 flex items-center justify-center"> - <span className="text-xs font-medium text-muted-foreground">{day}</span> + <div + key={day} + className="h-3 inline-block text-[#50505A] font-sans shrink-0 text-[9px]/3" + > + {day} </div> ))} - - {HOURS.map((hour) => ( - <div key={`row-${hour}`} className="contents"> - <div className="h-6 w-8 flex items-center justify-end pr-2"> - {hour % 4 === 0 && ( - <span className="text-[10px] text-muted-foreground font-medium"> - {formatHour(hour)} - </span> - )} + </div> + <div className="flex flex-col gap-0.75"> + <div className="flex h-3 gap-0.75 shrink-0"> + {HEATMAP_HOURS.map((hour) => ( + <div + key={hour} + className="w-3 text-center inline-block text-[#50505A] font-sans shrink-0 text-[9px]/3" + > + {hour} </div> - - {DAYS.map((_, dayIndex) => { - const count = grid[hour]?.[dayIndex] || 0 + ))} + </div> + {DAYS.map((_, dayIdx) => ( + <div key={dayIdx} className="flex gap-0.75"> + {HEATMAP_HOURS.map((hour) => { + const val = (heatmap?.[hour]?.[dayIdx] ?? 0) + (heatmap?.[hour + 1]?.[dayIdx] ?? 0) + const intensity = maxCount > 0 ? val / maxCount : 0 return ( <div - key={`${hour}-${dayIndex}`} - className={cn( - 'h-6 w-full rounded-sm transition-colors duration-200', - getIntensityClass(count, maxCount) - )} - title={`${count} captures on ${DAYS[dayIndex]} at ${formatHour(hour)}`} + key={hour} + className="rounded-xs shrink-0 size-3" + style={{ backgroundColor: `#E8A44A${intensityToAlpha(intensity)}` }} + title={`${val} captures`} /> ) })} diff --git a/apps/desktop/src/renderer/src/components/inbox/inbox-filing-history.tsx b/apps/desktop/src/renderer/src/components/inbox/inbox-filing-history.tsx index 9d2f12c94..83f19216c 100644 --- a/apps/desktop/src/renderer/src/components/inbox/inbox-filing-history.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/inbox-filing-history.tsx @@ -13,7 +13,7 @@ import { Bell, HelpCircle } from '@/lib/icons' -import { formatDistanceToNow } from 'date-fns' +import { formatCompactDate } from '@/services/inbox-service' import type { InboxFilingHistoryEntry } from '../../../../preload/index.d' export interface InboxFilingHistoryListProps { @@ -64,7 +64,7 @@ export function InboxFilingHistoryList({ items }: InboxFilingHistoryListProps): key={item.id} className="group flex items-center gap-3 py-2 border-b border-border/30 last:border-0" > - <div className="p-2 rounded-lg bg-muted/50 text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary transition-colors"> + <div className="p-2 rounded-md bg-muted/50 text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary transition-colors"> <Icon className="size-4" /> </div> @@ -88,7 +88,7 @@ export function InboxFilingHistoryList({ items }: InboxFilingHistoryListProps): <div className="flex items-center justify-between"> <span className="text-xs text-muted-foreground/70"> - {formatDistanceToNow(new Date(item.filedAt), { addSuffix: true })} + {formatCompactDate(item.filedAt)} </span> {item.filedAction === 'linked' && ( <span className="text-[10px] uppercase tracking-wider font-bold text-primary bg-primary/10 px-1.5 py-0.5 rounded-sm"> diff --git a/apps/desktop/src/renderer/src/components/inbox/inbox-list.test.tsx b/apps/desktop/src/renderer/src/components/inbox/inbox-list.test.tsx index 21e0dad52..aef7accb5 100644 --- a/apps/desktop/src/renderer/src/components/inbox/inbox-list.test.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/inbox-list.test.tsx @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { InboxListSection, InboxListItem, TypeIcon, TranscriptionStatus } from './inbox-list' +import { InboxListSection, InboxListItem, TypeIcon } from './inbox-list' import type { InboxItemListItem, InboxItemType } from '@/types' // ============================================================================ @@ -112,14 +112,14 @@ describe('T519: InboxListItem - item type display', () => { expect(document.querySelector('[aria-hidden="true"]')).toBeInTheDocument() }) - it('should render bell icon for unviewed reminder', () => { - render(<TypeIcon type="reminder" isViewed={false} />) + it('should render bell icon for reminder', () => { + render(<TypeIcon type="reminder" />) expect(document.querySelector('[aria-hidden="true"]')).toBeInTheDocument() }) - it('should render check icon for viewed reminder', () => { - render(<TypeIcon type="reminder" isViewed={true} />) + it('should render spinner icon for voice with processing transcription', () => { + render(<TypeIcon type="voice" transcriptionStatus="processing" />) expect(document.querySelector('[aria-hidden="true"]')).toBeInTheDocument() }) @@ -144,7 +144,7 @@ describe('T519: InboxListItem - item type display', () => { }) describe('image item', () => { - it('should display image item with thumbnail', () => { + it('should display image item with title', () => { const item = createInboxItem('image', { title: 'Screenshot', thumbnailUrl: 'https://example.com/thumb.jpg' @@ -152,8 +152,6 @@ describe('T519: InboxListItem - item type display', () => { renderWithContext(item) expect(screen.getByText('Screenshot')).toBeInTheDocument() - // Image has alt="" making it presentational, so we query by tag - expect(document.querySelector('img')).toBeInTheDocument() }) it('should handle missing thumbnail gracefully', () => { @@ -178,17 +176,18 @@ describe('T519: InboxListItem - item type display', () => { expect(screen.getByText('Voice memo')).toBeInTheDocument() }) - it('should show transcription status when transcribing', () => { + it('should render voice item with processing transcription status', () => { const item = createInboxItem('voice', { title: 'Voice memo', transcriptionStatus: 'processing' }) renderWithContext(item) - expect(screen.getByText(/transcribing/i)).toBeInTheDocument() + expect(screen.getByText('Voice memo')).toBeInTheDocument() + expect(document.querySelector('svg[aria-hidden="true"]')).toBeInTheDocument() }) - it('should show transcription when complete', () => { + it('should render voice item with complete transcription', () => { const item = createInboxItem('voice', { title: 'Voice memo', transcriptionStatus: 'complete', @@ -196,10 +195,10 @@ describe('T519: InboxListItem - item type display', () => { }) renderWithContext(item) - expect(screen.getByText(/This is the transcribed text/)).toBeInTheDocument() + expect(screen.getByText('Voice memo')).toBeInTheDocument() }) - it('should show retry button on transcription failure', () => { + it('should render voice item with failed transcription', () => { const onRetry = vi.fn() const item = createInboxItem('voice', { id: 'voice-1', @@ -208,8 +207,8 @@ describe('T519: InboxListItem - item type display', () => { }) renderWithContext(item, { onRetryTranscription: onRetry }) - expect(screen.getByText(/transcription failed/i)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument() + expect(screen.getByText('Voice memo')).toBeInTheDocument() + expect(document.querySelector('svg[aria-hidden="true"]')).toBeInTheDocument() }) }) @@ -251,17 +250,17 @@ describe('T519: InboxListItem - item type display', () => { expect(screen.getByText('Meeting Notes')).toBeInTheDocument() }) - it('should show reminder badge', () => { + it('should display reminder type icon', () => { const item = createInboxItem('reminder', { title: 'Reminder', metadata: { targetTitle: 'Meeting Notes', targetId: 'note-1', targetType: 'note' } }) renderWithContext(item) - expect(screen.getByText('Reminder')).toBeInTheDocument() + expect(document.querySelector('svg[aria-hidden="true"]')).toBeInTheDocument() }) - it('should show viewed badge for viewed reminders', () => { + it('should display viewed reminder with target title', () => { const item = createInboxItem('reminder', { title: 'Reminder', viewedAt: new Date(), @@ -269,7 +268,7 @@ describe('T519: InboxListItem - item type display', () => { }) renderWithContext(item) - expect(screen.getByText('Viewed')).toBeInTheDocument() + expect(screen.getByText('Meeting Notes')).toBeInTheDocument() }) }) }) diff --git a/apps/desktop/src/renderer/src/components/inbox/inbox-list.tsx b/apps/desktop/src/renderer/src/components/inbox/inbox-list.tsx index 40b366ca4..5707a34f0 100644 --- a/apps/desktop/src/renderer/src/components/inbox/inbox-list.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/inbox-list.tsx @@ -9,27 +9,32 @@ import { useState, useEffect, createContext, useContext } from 'react' import { ChevronRight, - Link, + Link2, FileText, Image, Mic, Scissors, - FileIcon, + FilePdf, Share2, Loader2, AlertCircle, RotateCcw, Bell, - CheckCircle2 + Video } from '@/lib/icons' import type { ReminderMetadata } from '@memry/contracts/inbox-api' import { Checkbox } from '@/components/ui/checkbox' import { Button } from '@/components/ui/button' +import { Pill } from '@/components/ui/pill' import { QuickActions } from '@/components/quick-actions' import { InlineQuickFile } from '@/components/inline-quick-file' import { QuickFileDropdown, getFilteredFolders } from '@/components/quick-file-dropdown' -import { SnoozeCountdown } from '@/components/snooze' -import { formatTimestamp, formatDuration, type TimePeriod } from '@/lib/inbox-utils' +import { + formatDuration, + formatCompactRelativeTime, + extractDomain, + type TimePeriod +} from '@/lib/inbox-utils' import { cn } from '@/lib/utils' import { type DisplayDensity, @@ -64,40 +69,76 @@ function useInboxList() { } // ============================================================================ -// Type Icon - matches the inbox item type +// Type Icon Colors — distinct color per capture type +// ============================================================================ + +const TYPE_ICON_COLORS: Record<string, string> = { + link: 'text-indigo-500 dark:text-indigo-400', + voice: 'text-amber-500 dark:text-amber-400', + image: 'text-emerald-500 dark:text-emerald-400', + clip: 'text-purple-400 dark:text-purple-300', + note: 'text-muted-foreground/60', + pdf: 'text-red-500 dark:text-red-400', + social: 'text-sky-400 dark:text-sky-300', + video: 'text-sky-500 dark:text-sky-400', + reminder: 'text-amber-500 dark:text-amber-400' +} + +// ============================================================================ +// Type Icon - colored per capture type with transcription awareness // ============================================================================ interface TypeIconProps { type: InboxItemType - isViewed?: boolean + transcriptionStatus?: string | null } -const TypeIcon = ({ type, isViewed }: TypeIconProps): React.JSX.Element => { - const iconClass = 'w-4 h-4 text-muted-foreground/60' +const TypeIcon = ({ type, transcriptionStatus }: TypeIconProps): React.JSX.Element => { + const iconSize = 'w-3.5 h-3.5' + + if (type === 'voice') { + if (transcriptionStatus === 'pending' || transcriptionStatus === 'processing') { + return ( + <Loader2 + className={cn(iconSize, 'text-amber-500 dark:text-amber-400 animate-spin')} + aria-hidden="true" + /> + ) + } + if (transcriptionStatus === 'failed') { + return ( + <AlertCircle + className={cn(iconSize, 'text-red-500 dark:text-red-400')} + aria-hidden="true" + /> + ) + } + return <Mic className={cn(iconSize, TYPE_ICON_COLORS.voice)} aria-hidden="true" /> + } + + if (type === 'reminder') { + return <Bell className={cn(iconSize, TYPE_ICON_COLORS.reminder)} aria-hidden="true" /> + } + + const color = TYPE_ICON_COLORS[type] || TYPE_ICON_COLORS.note switch (type) { case 'link': - return <Link className={iconClass} aria-hidden="true" /> + return <Link2 className={cn(iconSize, color)} aria-hidden="true" /> case 'note': - return <FileText className={iconClass} aria-hidden="true" /> + return <FileText className={cn(iconSize, color)} aria-hidden="true" /> case 'image': - return <Image className={iconClass} aria-hidden="true" /> - case 'voice': - return <Mic className={iconClass} aria-hidden="true" /> + return <Image className={cn(iconSize, color)} aria-hidden="true" /> case 'clip': - return <Scissors className={iconClass} aria-hidden="true" /> + return <Scissors className={cn(iconSize, color)} aria-hidden="true" /> case 'pdf': - return <FileIcon className={iconClass} aria-hidden="true" /> + return <FilePdf className={cn(iconSize, color)} aria-hidden="true" /> case 'social': - return <Share2 className={iconClass} aria-hidden="true" /> - case 'reminder': - return isViewed ? ( - <CheckCircle2 className="w-4 h-4 text-green-500/70" aria-hidden="true" /> - ) : ( - <Bell className="w-4 h-4 text-amber-500" aria-hidden="true" /> - ) + return <Share2 className={cn(iconSize, color)} aria-hidden="true" /> + case 'video': + return <Video className={cn(iconSize, color)} aria-hidden="true" /> default: - return <FileText className={iconClass} aria-hidden="true" /> + return <FileText className={cn(iconSize, TYPE_ICON_COLORS.note)} aria-hidden="true" /> } } @@ -181,7 +222,7 @@ const ItemThumbnail = ({ item }: { item: InboxItem }): React.JSX.Element | null } return ( - <div className="w-9 h-9 rounded-lg overflow-hidden bg-muted shrink-0 ring-1 ring-border/50"> + <div className="w-9 h-9 rounded-md overflow-hidden bg-muted shrink-0 ring-1 ring-border/50"> <img src={item.thumbnailUrl} alt="" @@ -242,6 +283,8 @@ export function InboxListSection({ const isInBulkMode = selectedIds.size > 0 const densityConfig = DENSITY_CONFIG[density] + const formattedTitle = title.charAt(0).toUpperCase() + title.slice(1).toLowerCase() + return ( <InboxListContext.Provider value={{ selectedIds, focusedId, isInBulkMode, densityConfig, onSelect, onFocus }} @@ -254,8 +297,7 @@ export function InboxListSection({ type="button" onClick={() => collapsible && setIsCollapsed(!isCollapsed)} className={cn( - 'flex items-center gap-2 w-full text-left', - densityConfig.sectionHeaderMargin, + 'flex items-center gap-1.5 w-full text-left py-2 px-2', collapsible && 'cursor-pointer group' )} disabled={!collapsible} @@ -264,32 +306,28 @@ export function InboxListSection({ {collapsible && ( <ChevronRight className={cn( - 'w-3.5 h-3.5 text-muted-foreground/40 transition-transform duration-200', - 'group-hover:text-amber-600 dark:group-hover:text-amber-500', + 'w-2.5 h-2.5 text-muted-foreground/40 transition-transform duration-200', !isCollapsed && 'rotate-90' )} /> )} - {icon && <span className="text-amber-600 dark:text-amber-500">{icon}</span>} - <h3 + {icon && <span>{icon}</span>} + <span className={cn( - densityConfig.sectionTitleSize, - 'font-semibold uppercase tracking-wider text-muted-foreground/60', - collapsible && 'group-hover:text-amber-600 dark:group-hover:text-amber-500', + 'text-xs font-semibold tracking-[0.02em] text-muted-foreground', 'transition-colors' )} > - {title} - </h3> + {formattedTitle} + </span> {count !== undefined && ( - <span className={cn(densityConfig.metaSize, 'text-muted-foreground/40 tabular-nums')}> + <span className="text-[11px] leading-[14px] text-muted-foreground/40 tabular-nums"> {count} </span> )} - <div className="flex-1 h-px bg-gradient-to-r from-amber-200/30 dark:from-amber-800/30 to-transparent" /> </button> - {!isCollapsed && <div className="space-y-0.5">{children}</div>} + {!isCollapsed && <div className="space-y-px">{children}</div>} </section> </InboxListContext.Provider> ) @@ -379,24 +417,21 @@ export function InboxListItem({ className={cn( 'group relative w-full', 'flex items-center', - densityConfig.itemGap, + 'gap-2.5', densityConfig.itemPadding, densityConfig.itemRadius, 'transition-all duration-150 ease-out', 'cursor-pointer', - // Exit animation isExiting && 'item-removing', - // Base hover state 'hover:bg-muted/50', - // Selected state - warm amber isSelected && [ - 'bg-amber-50 dark:bg-amber-950/30', - 'hover:bg-amber-50 dark:hover:bg-amber-950/30', - 'ring-1 ring-inset ring-amber-200 dark:ring-amber-800/50' + 'bg-[var(--user-accent-color)]/[0.04]', + 'hover:bg-[var(--user-accent-color)]/[0.06]', + 'ring-1 ring-inset ring-[var(--user-accent-color)]/25' ], - // Focused state (not selected) !isSelected && - isFocused && ['bg-muted', 'ring-2 ring-inset ring-amber-400/50 dark:ring-amber-600/50'], + isFocused && ['bg-muted', 'ring-1 ring-inset ring-[var(--user-accent-color)]/40'], + item.isStale && 'opacity-60', className )} role="listitem" @@ -406,7 +441,7 @@ export function InboxListItem({ onClick={handleClick} data-item-id={item.id} > - {/* Checkbox with amber styling */} + {/* Checkbox */} <div className={cn( 'flex-shrink-0 transition-opacity duration-150', @@ -431,25 +466,11 @@ export function InboxListItem({ /> </div> - {/* Icon or Thumbnail */} - <div - className={cn( - 'flex-shrink-0 flex items-center justify-center', - densityConfig.iconSize, - densityConfig.itemRadius, - 'transition-colors duration-150', - isSelected ? 'bg-amber-100 dark:bg-amber-900/40' : 'bg-muted/60 dark:bg-muted/40', - 'group-hover:bg-muted dark:group-hover:bg-muted/60' - )} - > - {item.type === 'image' && item.thumbnailUrl ? ( - <ItemThumbnail item={item} /> - ) : ( - <TypeIcon type={item.type} isViewed={isReminderViewed} /> - )} + {/* Type icon — bare, colored per type */} + <div className="flex-shrink-0"> + <TypeIcon type={item.type} transcriptionStatus={item.transcriptionStatus} /> </div> - {/* Content area - shows title or Quick-File input */} {isQuickFileActive && onQuickFileFolderSelect ? ( <> <span @@ -479,61 +500,92 @@ export function InboxListItem({ </> ) : ( <> - {/* Title and metadata */} - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> + {/* Title — single line, truncated */} + <span + className={cn( + 'grow shrink min-w-0 truncate font-medium', + densityConfig.titleSize, + isReminderViewed + ? 'text-muted-foreground/60' + : item.snoozedUntil + ? 'text-muted-foreground' + : 'text-foreground/90' + )} + > + {displayTitle} + </span> + + {/* Voice duration pill — hidden on hover when actions show */} + {item.type === 'voice' && item.duration != null && ( + <div className={cn('shrink-0', !isInBulkMode && 'group-hover:opacity-0')}> + <Pill variant="bordered" color="amber"> + {formatDuration(item.duration)} + </Pill> + </div> + )} + + {/* PDF page count pill */} + {item.type === 'pdf' && item.pageCount != null && ( + <Pill variant="bordered" color="red"> + {item.pageCount} page{item.pageCount !== 1 ? 's' : ''} + </Pill> + )} + + {/* Snooze pill */} + {item.snoozedUntil && ( + <Pill variant="filled" color="gray"> + snoozed til{' '} + {new Date(item.snoozedUntil).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + })} + </Pill> + )} + + {/* Right slot: metadata swaps to actions on hover */} + <div className="relative shrink-0 flex items-center"> + {/* Metadata — visible by default, hidden on hover */} + <div + className={cn('flex items-center gap-2', !isInBulkMode && 'group-hover:opacity-0')} + > + {item.sourceUrl && + (item.type === 'link' || item.type === 'social' || item.type === 'clip') && ( + <span + className={cn('shrink-0', densityConfig.metaSize, 'text-muted-foreground/60')} + > + {extractDomain(item.sourceUrl)} + </span> + )} <span className={cn( - 'font-medium truncate', - densityConfig.titleSize, - isReminderViewed ? 'text-muted-foreground/60' : 'text-foreground/90' + 'shrink-0 w-9 text-right tabular-nums', + densityConfig.metaSize, + item.isStale ? 'text-red-500 dark:text-red-400' : 'text-muted-foreground/60' )} > - {displayTitle} + {formatCompactRelativeTime( + item.createdAt instanceof Date ? item.createdAt : new Date(item.createdAt) + )} </span> - {/* Reminder badge for reminder items */} - {item.type === 'reminder' && reminderMetadata && ( - <span - className={cn( - 'shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full', - isReminderViewed - ? 'bg-muted text-muted-foreground' - : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' - )} - > - {isReminderViewed ? 'Viewed' : 'Reminder'} - </span> - )} - {/* Snooze badge with warm styling - live countdown */} - <SnoozeCountdown - snoozedUntil={item.snoozedUntil} - className="shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" - /> </div> - <TranscriptionStatus item={item} onRetry={onRetryTranscription} /> - </div> - {/* Timestamp - fades out on hover when actions show */} - <span - className={cn('shrink-0 text-muted-foreground/60 tabular-nums', densityConfig.metaSize)} - > - {formatTimestamp( - item.createdAt instanceof Date ? item.createdAt : new Date(item.createdAt), - period + {/* Actions — overlaid, visible on hover */} + {!isInBulkMode && ( + <div + className={cn( + 'absolute inset-0 flex items-center justify-end', + 'opacity-0 group-hover:opacity-100' + )} + > + <QuickActions + itemId={item.id} + onArchive={onArchive} + onSnooze={onSnooze} + variant="row" + /> + </div> )} - </span> - - {/* Quick Actions - slide in from right on hover (hidden in bulk mode) */} - {!isInBulkMode && ( - <div className="shrink-0 quick-actions-reveal"> - <QuickActions - itemId={item.id} - onArchive={onArchive} - onSnooze={onSnooze} - variant="row" - /> - </div> - )} + </div> </> )} </div> diff --git a/apps/desktop/src/renderer/src/components/inbox/inbox-segment-control.tsx b/apps/desktop/src/renderer/src/components/inbox/inbox-segment-control.tsx index 8868b016c..bd39b9e08 100644 --- a/apps/desktop/src/renderer/src/components/inbox/inbox-segment-control.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/inbox-segment-control.tsx @@ -1,7 +1,13 @@ -import { cn } from '@/lib/utils' +import { ToolbarSegment, ToolbarSegmentTab } from '@/components/ui/page-toolbar' export type InboxView = 'inbox' | 'archived' | 'insights' +const TABS: { id: InboxView; label: string }[] = [ + { id: 'inbox', label: 'Inbox' }, + { id: 'archived', label: 'Archived' }, + { id: 'insights', label: 'Insights' } +] + export interface InboxSegmentControlProps { value: InboxView onChange: (view: InboxView) => void @@ -14,51 +20,17 @@ export function InboxSegmentControl({ className }: InboxSegmentControlProps): React.JSX.Element { return ( - <div - className={cn( - 'inline-flex h-10 items-center justify-center rounded-lg bg-muted/40 p-1 text-muted-foreground', - className - )} - role="tablist" - aria-label="Inbox View Selection" - > - <SegmentButton label="Inbox" isActive={value === 'inbox'} onClick={() => onChange('inbox')} /> - <SegmentButton - label="Archived" - isActive={value === 'archived'} - onClick={() => onChange('archived')} - /> - <SegmentButton - label="Insights" - isActive={value === 'insights'} - onClick={() => onChange('insights')} - /> - </div> - ) -} - -interface SegmentButtonProps { - label: string - isActive: boolean - onClick: () => void -} - -function SegmentButton({ label, isActive, onClick }: SegmentButtonProps): React.JSX.Element { - return ( - <button - type="button" - role="tab" - aria-selected={isActive} - onClick={onClick} - className={cn( - 'group relative flex items-center justify-center gap-2 rounded-sm px-4 py-1.5 text-sm font-medium transition-all duration-300 ease-out focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2', - 'font-serif tracking-wide', - isActive - ? 'bg-background text-foreground shadow-sm ring-0' - : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground' - )} - > - <span>{label}</span> - </button> + <ToolbarSegment label="Inbox View Selection" className={className}> + {TABS.map((tab, index) => ( + <ToolbarSegmentTab + key={tab.id} + isActive={value === tab.id} + showBorder={index > 0} + onClick={() => onChange(tab.id)} + > + <span className="text-[12px] leading-4">{tab.label}</span> + </ToolbarSegmentTab> + ))} + </ToolbarSegment> ) } diff --git a/apps/desktop/src/renderer/src/components/inbox/streak-badge.tsx b/apps/desktop/src/renderer/src/components/inbox/streak-badge.tsx index 6b6ce1da3..0e5e4f8e3 100644 --- a/apps/desktop/src/renderer/src/components/inbox/streak-badge.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/streak-badge.tsx @@ -1,4 +1,4 @@ -import { Flame } from '@/lib/icons' +import { Star } from '@/lib/icons' import { cn } from '@/lib/utils' interface StreakBadgeProps { @@ -14,32 +14,19 @@ export function StreakBadge({ }: StreakBadgeProps): React.JSX.Element | null { if (streak <= 0) return null - const isHot = streak >= 7 - const isBurning = streak >= 14 - return ( <div className={cn( - 'inline-flex items-center gap-1 rounded-full font-medium tabular-nums', - size === 'sm' && 'px-2 py-0.5 text-xs', + 'inline-flex items-center gap-1.5 rounded-xl font-medium tabular-nums', + size === 'sm' && 'px-2.5 py-0.5 text-[11px]/3.5', size === 'md' && 'px-3 py-1 text-sm', - isBurning - ? 'bg-orange-500/15 text-orange-600 dark:text-orange-400' - : isHot - ? 'bg-amber-500/15 text-amber-600 dark:text-amber-400' - : 'bg-muted text-muted-foreground', + 'bg-tint/10 text-tint', className )} title={`${streak} day processing streak`} > - <Flame - className={cn( - size === 'sm' ? 'size-3' : 'size-3.5', - isBurning && 'animate-pulse', - isBurning ? 'text-orange-500' : isHot ? 'text-amber-500' : 'text-muted-foreground/70' - )} - /> - <span>{streak}d</span> + <Star className={cn(size === 'sm' ? 'size-3' : 'size-3.5')} /> + <span>{streak} streak</span> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/inbox/triage-action-bar.tsx b/apps/desktop/src/renderer/src/components/inbox/triage-action-bar.tsx index 5485bea34..ce57ee46e 100644 --- a/apps/desktop/src/renderer/src/components/inbox/triage-action-bar.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/triage-action-bar.tsx @@ -1,4 +1,5 @@ -import { useState, useCallback, useEffect, useMemo } from 'react' +import { useEffect, useMemo } from 'react' +import { isInputFocused } from '@/hooks/use-keyboard-shortcuts' import { Trash2, CheckSquare, @@ -6,25 +7,20 @@ import { FolderOpen, Clock, ExternalLink, - BellOff + Archive03 } from '@/lib/icons' import { cn } from '@/lib/utils' -import { TriageFilePicker } from './triage-file-picker' -import { TriageSnoozePicker } from './triage-snooze-picker' -import type { FileItemInput, SnoozeInput } from '@/services/inbox-service' import type { InboxItemType } from '@memry/contracts/inbox-api' -type ActivePicker = 'file' | 'snooze' | null +export type ActivePicker = 'file' | 'snooze' | null interface TriageActionBarProps { - itemId: string itemType?: InboxItemType + activePicker: ActivePicker + onPickerChange: (picker: ActivePicker) => void onDiscard: () => void onConvertToTask: () => void onExpandToNote: () => void - onFile: (input: FileItemInput) => void - onDefer: (input: SnoozeInput) => void - onDismissReminder?: () => void onOpenTarget?: () => void disabled?: boolean } @@ -33,27 +29,31 @@ interface ActionDef { key: string label: string icon: React.ReactNode + colorVar: string picker?: ActivePicker action?: () => void - variant?: 'destructive' } +const ACTION_STYLES = { + discard: 'var(--destructive)', + task: 'var(--accent-purple)', + note: 'var(--accent-green)', + file: 'var(--accent-orange)', + snooze: 'var(--accent-cyan)', + archive: 'var(--muted-foreground)', + open: 'var(--accent-purple)' +} as const + export function TriageActionBar({ - itemId, itemType, + activePicker, + onPickerChange, onDiscard, onConvertToTask, onExpandToNote, - onFile, - onDefer, - onDismissReminder, onOpenTarget, disabled = false }: TriageActionBarProps): React.JSX.Element { - const [activePicker, setActivePicker] = useState<ActivePicker>(null) - - const closePicker = useCallback(() => setActivePicker(null), []) - const isReminder = itemType === 'reminder' const actions: ActionDef[] = useMemo(() => { @@ -61,21 +61,17 @@ export function TriageActionBar({ return [ { key: 'D', - label: 'Dismiss', - icon: <BellOff className="size-4" />, - action: onDismissReminder ?? onDiscard + label: 'Archive', + icon: <Archive03 className="size-5" />, + colorVar: ACTION_STYLES.archive, + action: onDiscard }, { key: 'O', label: 'Open', - icon: <ExternalLink className="size-4" />, + icon: <ExternalLink className="size-5" />, + colorVar: ACTION_STYLES.open, action: onOpenTarget - }, - { - key: 'S', - label: 'Snooze', - icon: <Clock className="size-4" />, - picker: 'snooze' as ActivePicker } ] } @@ -84,31 +80,40 @@ export function TriageActionBar({ { key: 'D', label: 'Discard', - icon: <Trash2 className="size-4" />, - action: onDiscard, - variant: 'destructive' as const + icon: <Trash2 className="size-5" />, + colorVar: ACTION_STYLES.discard, + action: onDiscard }, { key: 'T', - label: 'Task', - icon: <CheckSquare className="size-4" />, + label: 'To Task', + icon: <CheckSquare className="size-5" />, + colorVar: ACTION_STYLES.task, action: onConvertToTask }, - { key: 'N', label: 'Note', icon: <FileText className="size-4" />, action: onExpandToNote }, + { + key: 'N', + label: 'To Note', + icon: <FileText className="size-5" />, + colorVar: ACTION_STYLES.note, + action: onExpandToNote + }, { key: 'F', label: 'File', - icon: <FolderOpen className="size-4" />, + icon: <FolderOpen className="size-5" />, + colorVar: ACTION_STYLES.file, picker: 'file' as ActivePicker }, { key: 'S', label: 'Snooze', - icon: <Clock className="size-4" />, + icon: <Clock className="size-5" />, + colorVar: ACTION_STYLES.snooze, picker: 'snooze' as ActivePicker } ] - }, [isReminder, onDiscard, onConvertToTask, onExpandToNote, onDismissReminder, onOpenTarget]) + }, [isReminder, onDiscard, onConvertToTask, onExpandToNote, onOpenTarget]) useEffect(() => { if (disabled) return @@ -116,14 +121,13 @@ export function TriageActionBar({ const handler = (e: KeyboardEvent): void => { if (e.metaKey || e.ctrlKey || e.altKey) return - const target = e.target as HTMLElement - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return + if (isInputFocused()) return const key = e.key.toUpperCase() if (key === 'ESCAPE' && activePicker) { e.preventDefault() - closePicker() + onPickerChange(null) return } @@ -132,7 +136,7 @@ export function TriageActionBar({ e.preventDefault() if (action.picker) { - setActivePicker(action.picker === activePicker ? null : action.picker) + onPickerChange(action.picker === activePicker ? null : action.picker) } else if (action.action) { action.action() } @@ -140,79 +144,50 @@ export function TriageActionBar({ window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [disabled, activePicker, actions, closePicker]) - - const handleFolderSelect = (folder: { path?: string }): void => { - onFile({ - itemId, - destination: { type: 'folder', path: folder.path || '' } - }) - closePicker() - } - - const handleNoteLink = (noteId: string): void => { - onFile({ - itemId, - destination: { type: 'note', noteId } - }) - closePicker() - } - - const handleSnoozeSelect = (snoozeUntil: string): void => { - onDefer({ itemId, snoozeUntil }) - closePicker() - } + }, [disabled, activePicker, actions, onPickerChange]) return ( - <div className="border-t px-6 py-4"> - {activePicker === 'file' && ( - <div className="mb-4"> - <TriageFilePicker - itemId={itemId} - onSelect={handleFolderSelect} - onLinkToNote={handleNoteLink} - onCancel={closePicker} - /> - </div> - )} - - {activePicker === 'snooze' && ( - <div className="mb-4"> - <TriageSnoozePicker onSelect={handleSnoozeSelect} onCancel={closePicker} /> - </div> - )} - - <div className="flex items-center justify-center gap-2"> - {actions.map((action) => ( + <div className="flex shrink-0 items-center justify-center gap-4 px-8 pt-6 pb-10"> + {actions.map((action) => { + const isPickerActive = action.picker && action.picker === activePicker + return ( <button key={action.key} type="button" disabled={disabled} onClick={() => { if (action.picker) { - setActivePicker(action.picker === activePicker ? null : action.picker) + onPickerChange(action.picker === activePicker ? null : action.picker) } else if (action.action) { action.action() } }} className={cn( - 'inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors', - 'disabled:pointer-events-none disabled:opacity-50', - action.variant === 'destructive' - ? 'hover:bg-destructive/10 hover:text-destructive text-muted-foreground' - : action.picker === activePicker - ? 'bg-accent text-foreground' - : 'hover:bg-accent text-muted-foreground hover:text-foreground' + 'flex w-[100px] flex-col items-center gap-2 transition-opacity', + 'disabled:pointer-events-none disabled:opacity-50' )} > - {action.icon} - <span>{action.label}</span> - <kbd className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-mono"> - {action.key} - </kbd> + <div + className={cn( + 'flex size-[52px] items-center justify-center rounded-[14px] border transition-colors', + isPickerActive && 'ring-2 ring-offset-2 ring-offset-background' + )} + style={{ + color: action.colorVar, + backgroundColor: `color-mix(in srgb, ${action.colorVar} 6%, transparent)`, + borderColor: `color-mix(in srgb, ${action.colorVar} 25%, transparent)`, + ...(isPickerActive ? { ringColor: action.colorVar } : {}) + }} + > + {action.icon} + </div> + <span className="text-[11px]/3.5 font-medium" style={{ color: action.colorVar }}> + {action.label} + </span> + <span className="text-[10px]/3 text-text-tertiary">{action.key}</span> </button> - ))} - </div> + ) + })} </div> ) } diff --git a/apps/desktop/src/renderer/src/components/inbox/triage-complete.tsx b/apps/desktop/src/renderer/src/components/inbox/triage-complete.tsx index 8dceff5f4..5cd0b219b 100644 --- a/apps/desktop/src/renderer/src/components/inbox/triage-complete.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/triage-complete.tsx @@ -42,27 +42,27 @@ export function TriageComplete({ }, []) return ( - <div className="flex flex-1 flex-col items-center justify-center gap-8 p-12"> + <div className="flex flex-1 flex-col items-center justify-center gap-8 bg-background p-12"> <div className={cn( - 'flex h-24 w-24 items-center justify-center rounded-full', - 'bg-emerald-500/10 dark:bg-emerald-500/15', + 'flex size-24 items-center justify-center rounded-full', + 'bg-accent-green/10', 'transition-all duration-700 ease-out', showCheck ? 'scale-100 opacity-100' : 'scale-50 opacity-0' )} > <div className={cn( - 'flex h-16 w-16 items-center justify-center rounded-full', - 'bg-emerald-500/20 dark:bg-emerald-500/25', - 'transition-all duration-500 delay-200 ease-out', + 'flex size-16 items-center justify-center rounded-full', + 'bg-accent-green/20', + 'transition-all delay-200 duration-500 ease-out', showCheck ? 'scale-100' : 'scale-75' )} > <Check className={cn( - 'h-8 w-8 text-emerald-600 dark:text-emerald-400', - 'transition-all duration-300 delay-400', + 'size-8 text-accent-green', + 'transition-all delay-400 duration-300', showCheck ? 'opacity-100' : 'opacity-0' )} strokeWidth={3} @@ -76,12 +76,14 @@ export function TriageComplete({ showStats ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0' )} > - <h2 className="text-3xl font-semibold tracking-tight">Inbox Zero</h2> + <h2 className="text-3xl font-semibold tracking-tight text-foreground">Inbox Zero</h2> <div className="mt-4 flex items-center justify-center gap-4"> <div className="text-center"> - <div className="text-2xl font-semibold tabular-nums">{processedCount}</div> - <div className="text-xs text-muted-foreground"> + <div className="text-2xl font-semibold tabular-nums text-foreground"> + {processedCount} + </div> + <div className="text-[11px] text-muted-foreground"> {processedCount === 1 ? 'item' : 'items'} processed </div> </div> @@ -91,13 +93,13 @@ export function TriageComplete({ <div className="h-8 w-px bg-border" /> <div className="flex flex-col items-center gap-1"> <StreakBadge streak={streak} size="md" /> - <div className="text-xs text-muted-foreground">streak</div> + <div className="text-[11px] text-muted-foreground">streak</div> </div> </> )} </div> - <p className="text-muted-foreground mt-4 text-sm italic"> + <p className="mt-4 text-sm italic text-muted-foreground"> {pickMotivation(processedCount)} </p> </div> @@ -106,8 +108,8 @@ export function TriageComplete({ type="button" onClick={onReturnToInbox} className={cn( - 'inline-flex items-center gap-2 rounded-lg px-6 py-2.5 text-sm font-medium', - 'bg-foreground/5 hover:bg-foreground/10 text-foreground', + 'inline-flex items-center gap-2 rounded-xl px-6 py-2.5 text-sm font-medium', + 'bg-foreground/5 text-foreground hover:bg-foreground/10', 'transition-all duration-500 ease-out', showButton ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0' )} diff --git a/apps/desktop/src/renderer/src/components/inbox/triage-file-picker.tsx b/apps/desktop/src/renderer/src/components/inbox/triage-file-picker.tsx deleted file mode 100644 index 1f02d3b75..000000000 --- a/apps/desktop/src/renderer/src/components/inbox/triage-file-picker.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useState, useMemo } from 'react' -import { Folder, Check, ChevronDown, Sparkles, Loader2, FileText, Link2 } from '@/lib/icons' -import { useQuery } from '@tanstack/react-query' -import { cn } from '@/lib/utils' -import type { Folder as FolderType } from '@/types' - -type SuggestedFolder = FolderType & { aiConfidence?: number; aiReason?: string } - -interface NoteSuggestion { - note: { id: string; title: string; snippet: string } - confidence: number - reason: string -} - -interface TriageFilePickerProps { - itemId: string - onSelect: (folder: FolderType) => void - onLinkToNote?: (noteId: string) => void - onCancel: () => void -} - -export function TriageFilePicker({ - itemId, - onSelect, - onLinkToNote, - onCancel -}: TriageFilePickerProps): React.JSX.Element { - const [showAll, setShowAll] = useState(false) - const [search, setSearch] = useState('') - const [selectedPath, setSelectedPath] = useState<string | null>(null) - const [linkedNoteId, setLinkedNoteId] = useState<string | null>(null) - - const { data: vaultFolders = [] } = useQuery({ - queryKey: ['vault', 'folders'], - queryFn: async () => { - const paths = await window.api.notes.getFolders() - const folders: FolderType[] = [{ id: '', name: 'Notes (root)', path: '' }] - for (const p of paths) { - if (p) { - folders.push({ - id: p, - name: p.split('/').pop() || p, - path: p, - parent: p.includes('/') ? p.split('/').slice(0, -1).join('/') : undefined - }) - } - } - return folders - } - }) - - const { data: rawSuggestions = [], isLoading: isLoadingAI } = useQuery({ - queryKey: ['inbox', 'suggestions', itemId], - queryFn: async () => { - const response = await window.api.inbox.getSuggestions(itemId) - return response.suggestions || [] - }, - staleTime: 60_000 - }) - - const aiSuggestions = useMemo<SuggestedFolder[]>( - () => - rawSuggestions - .filter((s) => s.destination.type === 'folder' && s.destination.path !== undefined) - .slice(0, 3) - .map((s) => { - const folderPath = s.destination.path || '' - return { - id: folderPath, - name: folderPath.split('/').pop() || folderPath || 'Notes (root)', - path: folderPath, - aiConfidence: s.confidence, - aiReason: s.reason - } satisfies SuggestedFolder - }), - [rawSuggestions] - ) - - const noteSuggestions = useMemo<NoteSuggestion[]>( - () => - rawSuggestions - .filter((s) => s.destination.type === 'note' && s.suggestedNote) - .slice(0, 3) - .map((s) => ({ - note: s.suggestedNote!, - confidence: s.confidence, - reason: s.reason - })), - [rawSuggestions] - ) - - const filteredFolders = search - ? vaultFolders.filter((f) => f.name.toLowerCase().includes(search.toLowerCase())) - : vaultFolders - - const handleSelect = (folder: FolderType): void => { - setSelectedPath(folder.path ?? null) - setLinkedNoteId(null) - onSelect(folder) - } - - const handleLinkNote = (noteId: string): void => { - setLinkedNoteId(noteId) - setSelectedPath(null) - onLinkToNote?.(noteId) - } - - return ( - <div className="flex flex-col gap-3"> - <div className="text-muted-foreground flex items-center gap-2 text-xs font-medium"> - <Folder className="size-3.5" /> - <span>File to folder</span> - {isLoadingAI && <Loader2 className="size-3 animate-spin" />} - </div> - - {aiSuggestions.length > 0 && ( - <div className="flex flex-wrap gap-2"> - {aiSuggestions.map((folder, i) => { - const isSelected = selectedPath === folder.path - const confidence = folder.aiConfidence ? Math.round(folder.aiConfidence * 100) : null - return ( - <button - key={folder.id} - type="button" - onClick={() => handleSelect(folder)} - className={cn( - 'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors', - isSelected - ? 'bg-primary text-primary-foreground border-primary' - : 'bg-background border-border hover:bg-accent' - )} - > - <Sparkles className="size-3 opacity-60" /> - <span className="text-[10px] font-bold opacity-60">{i + 1}</span> - <span className="max-w-[100px] truncate">{folder.name}</span> - {confidence && !isSelected && ( - <span className="text-[10px] opacity-50">{confidence}%</span> - )} - {isSelected && <Check className="size-3" />} - </button> - ) - })} - </div> - )} - - {!showAll ? ( - <button - type="button" - onClick={() => setShowAll(true)} - className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs transition-colors" - > - <ChevronDown className="size-3" /> - Other folders - </button> - ) : ( - <div className="flex flex-col gap-2"> - <input - type="text" - value={search} - onChange={(e) => setSearch(e.target.value)} - placeholder="Search folders…" - className="border-border bg-background placeholder:text-muted-foreground rounded-md border px-2.5 py-1.5 text-xs" - autoFocus - /> - <div className="max-h-40 overflow-y-auto"> - {filteredFolders.map((folder) => ( - <button - key={folder.id || 'root'} - type="button" - onClick={() => handleSelect(folder)} - className={cn( - 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs transition-colors', - selectedPath === folder.path - ? 'bg-primary/10 text-primary' - : 'hover:bg-accent text-foreground' - )} - > - <Folder className="size-3 shrink-0" /> - <span className="truncate">{folder.name}</span> - </button> - ))} - </div> - </div> - )} - - {noteSuggestions.length > 0 && ( - <> - <div className="text-muted-foreground flex items-center gap-2 text-xs font-medium"> - <Link2 className="size-3.5" /> - <span>Link to note</span> - </div> - - <div className="flex flex-col gap-1.5"> - {noteSuggestions.map((suggestion) => { - const isLinked = linkedNoteId === suggestion.note.id - return ( - <button - key={suggestion.note.id} - type="button" - onClick={() => handleLinkNote(suggestion.note.id)} - className={cn( - 'flex items-start gap-2.5 rounded-md border px-3 py-2 text-left transition-colors', - isLinked - ? 'bg-primary/10 border-primary/30' - : 'bg-background border-border hover:bg-accent' - )} - > - <FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> - <div className="flex min-w-0 flex-1 flex-col gap-0.5"> - <div className="flex items-center gap-2"> - <span className="truncate text-xs font-medium">{suggestion.note.title}</span> - <span className="text-[10px] text-muted-foreground/60"> - {Math.round(suggestion.confidence * 100)}% - </span> - {isLinked && <Check className="size-3 shrink-0 text-primary" />} - </div> - {suggestion.note.snippet && ( - <p className="line-clamp-2 text-[11px] leading-relaxed text-muted-foreground"> - {suggestion.note.snippet} - </p> - )} - </div> - </button> - ) - })} - </div> - </> - )} - - <button - type="button" - onClick={onCancel} - className="text-muted-foreground hover:text-foreground text-xs transition-colors" - > - Cancel - </button> - </div> - ) -} diff --git a/apps/desktop/src/renderer/src/components/inbox/triage-item-card.tsx b/apps/desktop/src/renderer/src/components/inbox/triage-item-card.tsx index 982ca7c3c..660b8342f 100644 --- a/apps/desktop/src/renderer/src/components/inbox/triage-item-card.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/triage-item-card.tsx @@ -1,74 +1,51 @@ import { memo } from 'react' -import { ExternalLink } from '@/lib/icons' -import { TypeIcon, ContentMetadata } from '@/components/inbox-detail/content-section' -import { extractDomain } from '@/lib/inbox-utils' -import { formatRelativeTime } from '@/services/inbox-service' +import { ContentSection } from '@/components/inbox-detail/content-section' +import { formatTimeAgo } from '@/services/inbox-service' import type { InboxItemListItem } from '@/types' interface TriageItemCardProps { item: InboxItemListItem } +const TYPES_WITH_OWN_TITLE = new Set(['link', 'image', 'pdf', 'reminder', 'social']) +const TYPES_WITH_OWN_TIMESTAMP = new Set(['link']) +const TYPES_WITHOUT_PADDING = new Set(['reminder', 'social']) + export const TriageItemCard = memo(function TriageItemCard({ item }: TriageItemCardProps): React.JSX.Element { + const showTitle = !TYPES_WITH_OWN_TITLE.has(item.type) + const showTimestamp = !TYPES_WITH_OWN_TIMESTAMP.has(item.type) + const hasPadding = !TYPES_WITHOUT_PADDING.has(item.type) + return ( - <div className="mx-auto flex w-full max-w-2xl flex-col gap-4 px-6 py-8"> - <div className="flex items-start gap-3"> - <TypeIcon type={item.type} className="mt-1 size-5 shrink-0" /> - <div className="min-w-0 flex-1"> - <h2 className="text-lg font-semibold leading-tight">{item.title}</h2> - <div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs"> - <span className="capitalize">{item.type}</span> - <span className="opacity-40">·</span> - <span>{formatRelativeTime(item.createdAt)}</span> - </div> - </div> + <div className="flex w-full max-w-[520px] flex-col overflow-hidden rounded-xl border border-foreground/[0.08] bg-card"> + <div className={hasPadding ? 'px-5 py-4' : ''}> + {showTitle && ( + <h3 className="mb-3.5 text-[15px] font-medium leading-5 text-foreground">{item.title}</h3> + )} + <ContentSection item={item} /> </div> - {item.thumbnailUrl && ( - <div className="overflow-hidden rounded-lg"> - <img - src={item.thumbnailUrl} - alt="" - loading="lazy" - decoding="async" - className="h-auto max-h-64 w-full object-cover" - /> - </div> - )} - - {item.content && ( - <div className="text-foreground/90 whitespace-pre-wrap text-sm leading-relaxed"> - {item.content} - </div> - )} - - {item.excerpt && !item.content && ( - <blockquote className="text-muted-foreground border-l-2 pl-4 text-sm italic"> - {item.excerpt} - </blockquote> - )} - - {item.sourceUrl && ( - <a - href={item.sourceUrl} - target="_blank" - rel="noopener noreferrer" - className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors" - > - <ExternalLink className="size-3" /> - {extractDomain(item.sourceUrl)} - </a> - )} - - {item.tags.length > 0 && ( - <div className="flex flex-wrap gap-1.5"> - {item.tags.map((tag) => ( - <span key={tag} className="bg-muted rounded-md px-2 py-0.5 text-xs"> - {tag} + {(item.tags.length > 0 || showTimestamp) && ( + <div className="flex flex-col gap-2 border-t border-foreground/[0.06] px-5 py-3"> + {item.tags.length > 0 && ( + <div className="flex flex-wrap gap-1.5"> + {item.tags.map((tag) => ( + <span + key={tag} + className="rounded-md bg-foreground/[0.06] px-2 py-0.5 text-[11px] text-muted-foreground" + > + {tag} + </span> + ))} + </div> + )} + {showTimestamp && ( + <span className="text-[11px]/3.5 text-text-tertiary"> + Captured {formatTimeAgo(item.createdAt)} </span> - ))} + )} </div> )} </div> diff --git a/apps/desktop/src/renderer/src/components/inbox/triage-progress.tsx b/apps/desktop/src/renderer/src/components/inbox/triage-progress.tsx index 2e5198e76..dae4fb974 100644 --- a/apps/desktop/src/renderer/src/components/inbox/triage-progress.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/triage-progress.tsx @@ -1,33 +1,17 @@ interface TriageProgressProps { - current: number - total: number completed: number + total: number } -export function TriageProgress({ - current, - total, - completed -}: TriageProgressProps): React.JSX.Element { +export function TriageProgress({ completed, total }: TriageProgressProps): React.JSX.Element { const percentage = total > 0 ? Math.round((completed / total) * 100) : 0 return ( - <div className="flex items-center gap-4 px-6 py-3"> - <div className="flex-1"> - <div className="bg-muted h-2 overflow-hidden rounded-full"> - <div - className="bg-primary h-full rounded-full transition-all duration-500 ease-out" - style={{ width: `${percentage}%` }} - /> - </div> - </div> - <div className="text-muted-foreground flex items-center gap-2 text-sm tabular-nums"> - <span className="font-medium"> - {current + 1} of {total} - </span> - <span className="text-muted-foreground/60">·</span> - <span>{percentage}%</span> - </div> + <div className="flex h-[3px] w-full overflow-hidden rounded-sm bg-foreground/[0.06]"> + <div + className="h-full rounded-sm bg-tint transition-all duration-500 ease-out" + style={{ width: `${percentage}%` }} + /> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/inbox/triage-snooze-picker.tsx b/apps/desktop/src/renderer/src/components/inbox/triage-snooze-picker.tsx index 7de8c3d64..c52cad461 100644 --- a/apps/desktop/src/renderer/src/components/inbox/triage-snooze-picker.tsx +++ b/apps/desktop/src/renderer/src/components/inbox/triage-snooze-picker.tsx @@ -17,8 +17,8 @@ export function TriageSnoozePicker({ } return ( - <div className="flex flex-col gap-2"> - <div className="text-muted-foreground flex items-center gap-2 text-xs font-medium"> + <div className="flex flex-col gap-2 rounded-xl border border-foreground/[0.08] bg-card p-4"> + <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"> <Clock className="size-3.5" /> <span>Snooze until…</span> </div> @@ -29,7 +29,7 @@ export function TriageSnoozePicker({ key={preset.id} type="button" onClick={() => handlePresetClick(preset)} - className="border-border bg-background hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors" + className="inline-flex items-center gap-1.5 rounded-lg border border-foreground/[0.08] bg-card px-3 py-1.5 text-xs font-medium transition-colors hover:bg-surface-active" > <Calendar className="size-3" /> {preset.label} @@ -40,7 +40,7 @@ export function TriageSnoozePicker({ <button type="button" onClick={onCancel} - className="text-muted-foreground hover:text-foreground text-xs transition-colors" + className="text-xs text-muted-foreground transition-colors hover:text-foreground" > Cancel </button> diff --git a/apps/desktop/src/renderer/src/components/journal/ai-connections-panel.tsx b/apps/desktop/src/renderer/src/components/journal/ai-connections-panel.tsx index 05284ae52..215c1d2c7 100644 --- a/apps/desktop/src/renderer/src/components/journal/ai-connections-panel.tsx +++ b/apps/desktop/src/renderer/src/components/journal/ai-connections-panel.tsx @@ -78,7 +78,7 @@ export const AIConnectionsPanel = memo(function AIConnectionsPanel({ role="region" aria-label="AI Connections" aria-live="polite" - className="rounded-lg border border-border/40 bg-card overflow-hidden" + className="rounded-md border border-border/40 bg-card overflow-hidden" > {/* Header */} <PanelHeader @@ -217,7 +217,7 @@ function ConnectionItem({ connection, onClick }: ConnectionItemProps): React.JSX <button onClick={onClick} className={cn( - 'w-full text-left p-3 rounded-lg', + 'w-full text-left p-3 rounded-md', 'bg-muted/30 hover:bg-muted/60', 'border border-transparent hover:border-border/40', 'transition-all duration-150', diff --git a/apps/desktop/src/renderer/src/components/journal/collapsible-section.tsx b/apps/desktop/src/renderer/src/components/journal/collapsible-section.tsx index 599040b10..6a1c65296 100644 --- a/apps/desktop/src/renderer/src/components/journal/collapsible-section.tsx +++ b/apps/desktop/src/renderer/src/components/journal/collapsible-section.tsx @@ -56,7 +56,7 @@ export const CollapsibleSection = memo(function CollapsibleSection({ return ( <div - className={cn('rounded-lg border border-border/40 bg-muted/20 overflow-hidden', className)} + className={cn('rounded-md border border-border/40 bg-muted/20 overflow-hidden', className)} > {/* Header - always visible */} <button @@ -68,7 +68,7 @@ export const CollapsibleSection = memo(function CollapsibleSection({ className={cn( 'w-full flex items-center justify-between px-4 py-3', 'hover:bg-muted/40 transition-colors duration-150', - 'text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset' + 'text-left focus-visible:outline-none' )} > <div className="flex items-center gap-2"> @@ -136,7 +136,7 @@ export const JournalSection = memo(function JournalSection({ onContentChange }: JournalSectionProps): React.JSX.Element { return ( - <div className="rounded-lg border border-border/40 bg-muted/20"> + <div className="rounded-md border border-border/40 bg-muted/20"> {/* Header */} <div className="px-4 py-3 border-b border-border/30"> <div className="flex items-center gap-2"> diff --git a/apps/desktop/src/renderer/src/components/journal/date-breadcrumb.tsx b/apps/desktop/src/renderer/src/components/journal/date-breadcrumb.tsx index 22d5cf8d5..535c7f4a0 100644 --- a/apps/desktop/src/renderer/src/components/journal/date-breadcrumb.tsx +++ b/apps/desktop/src/renderer/src/components/journal/date-breadcrumb.tsx @@ -72,7 +72,7 @@ function BreadcrumbSegment({ ], isActive && 'text-foreground font-medium', !isClickable && 'text-muted-foreground/50 cursor-default', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:rounded-md', + 'focus-visible:outline-none focus-visible:rounded-md', className )} > @@ -111,7 +111,7 @@ function DayNavArrow({ direction, onClick }: DayNavArrowProps) { 'text-muted-foreground/50 hover:text-foreground', 'hover:bg-muted/50', 'transition-all duration-200', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50' + 'focus-visible:outline-none' )} > <Icon className="size-3.5" /> @@ -139,7 +139,7 @@ function BackButton({ onClick, className }: BackButtonProps) { 'text-muted-foreground/60 hover:text-foreground', 'hover:bg-muted/50', 'cursor-pointer transition-all duration-200', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50', + 'focus-visible:outline-none', className )} > diff --git a/apps/desktop/src/renderer/src/components/journal/day-context-sidebar.tsx b/apps/desktop/src/renderer/src/components/journal/day-context-sidebar.tsx index 4b22845bc..246d86927 100644 --- a/apps/desktop/src/renderer/src/components/journal/day-context-sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/journal/day-context-sidebar.tsx @@ -211,7 +211,7 @@ function ContextSection({ const contentId = `${sectionId}-content` return ( - <div className="rounded-lg border border-border/40 bg-card/50 overflow-hidden"> + <div className="rounded-md border border-border/40 bg-card/50 overflow-hidden"> {/* Header */} <button onClick={onToggle} diff --git a/apps/desktop/src/renderer/src/components/journal/default-template-indicator.tsx b/apps/desktop/src/renderer/src/components/journal/default-template-indicator.tsx index b41f54382..9e9582ecf 100644 --- a/apps/desktop/src/renderer/src/components/journal/default-template-indicator.tsx +++ b/apps/desktop/src/renderer/src/components/journal/default-template-indicator.tsx @@ -73,7 +73,7 @@ export function DefaultTemplateIndicator({ > <div className={cn( - 'flex items-center gap-3 px-4 py-3 rounded-lg', + 'flex items-center gap-3 px-4 py-3 rounded-md', 'border border-dashed', 'border-amber-300/60 dark:border-amber-700/50', 'bg-gradient-to-r from-amber-50/60 to-orange-50/40', @@ -82,7 +82,7 @@ export function DefaultTemplateIndicator({ )} > {/* Template icon */} - <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-gradient-to-br from-amber-100 to-orange-100 dark:from-amber-900/50 dark:to-orange-900/40 flex items-center justify-center border border-amber-200/50 dark:border-amber-800/30 shadow-sm"> + <div className="flex-shrink-0 w-8 h-8 rounded-md bg-gradient-to-br from-amber-100 to-orange-100 dark:from-amber-900/50 dark:to-orange-900/40 flex items-center justify-center border border-amber-200/50 dark:border-amber-800/30 shadow-sm"> {templateIcon ? ( <span className="text-base">{templateIcon}</span> ) : ( diff --git a/apps/desktop/src/renderer/src/components/journal/floating-day-context.tsx b/apps/desktop/src/renderer/src/components/journal/floating-day-context.tsx index d4525d6ee..df77d4828 100644 --- a/apps/desktop/src/renderer/src/components/journal/floating-day-context.tsx +++ b/apps/desktop/src/renderer/src/components/journal/floating-day-context.tsx @@ -66,7 +66,7 @@ export const FloatingDayContext = memo(function FloatingDayContext({ onClick={() => setIsExpanded(true)} className={cn( 'flex flex-col items-center gap-2 p-2', - 'rounded-lg border border-border/60 bg-background/95 backdrop-blur-sm', + 'rounded-md border border-border/60 bg-background/95 backdrop-blur-sm', 'shadow-sm hover:shadow-md hover:border-border', 'transition-all duration-150' )} @@ -98,7 +98,7 @@ export const FloatingDayContext = memo(function FloatingDayContext({ {isExpanded && ( <div className={cn( - 'w-64 rounded-lg border border-border/60 bg-background/95 backdrop-blur-sm', + 'w-64 rounded-md border border-border/60 bg-background/95 backdrop-blur-sm', 'shadow-lg overflow-hidden', 'animate-in slide-in-from-right-2 fade-in duration-200' )} diff --git a/apps/desktop/src/renderer/src/components/journal/journal-editor.tsx b/apps/desktop/src/renderer/src/components/journal/journal-editor.tsx index 999fc4738..e16f94503 100644 --- a/apps/desktop/src/renderer/src/components/journal/journal-editor.tsx +++ b/apps/desktop/src/renderer/src/components/journal/journal-editor.tsx @@ -217,7 +217,7 @@ export const JournalEditor = memo(function JournalEditor({ 'prose-blockquote:border-l-2 prose-blockquote:border-primary/40 prose-blockquote:pl-4 prose-blockquote:italic', // Code 'prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm', - 'prose-pre:bg-muted prose-pre:p-4 prose-pre:rounded-lg' + 'prose-pre:bg-muted prose-pre:p-4 prose-pre:rounded-md' ) } } @@ -248,7 +248,7 @@ export const JournalEditor = memo(function JournalEditor({ <div className={cn( - 'rounded-lg border bg-background overflow-hidden', + 'rounded-md border bg-background overflow-hidden', isActive ? 'border-border ring-1 ring-primary/20' : 'border-border/50', className )} diff --git a/apps/desktop/src/renderer/src/components/journal/journal-entry-list-item.tsx b/apps/desktop/src/renderer/src/components/journal/journal-entry-list-item.tsx index 9151249de..c87a74762 100644 --- a/apps/desktop/src/renderer/src/components/journal/journal-entry-list-item.tsx +++ b/apps/desktop/src/renderer/src/components/journal/journal-entry-list-item.tsx @@ -65,11 +65,11 @@ export function JournalEntryListItem({ className={cn( // Base styling 'w-full flex items-center gap-3 px-3 py-2.5 text-left', - 'rounded-lg transition-all duration-150', + 'rounded-md transition-all duration-150', // Hover state 'hover:bg-muted/60', // Focus state - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent-purple/50', + 'focus-visible:outline-none', // Today highlight isToday && 'bg-accent-purple/5 ring-1 ring-accent-purple/20', // Future styling diff --git a/apps/desktop/src/renderer/src/components/journal/journal-error-boundary.tsx b/apps/desktop/src/renderer/src/components/journal/journal-error-boundary.tsx index dc664372b..e372ac933 100644 --- a/apps/desktop/src/renderer/src/components/journal/journal-error-boundary.tsx +++ b/apps/desktop/src/renderer/src/components/journal/journal-error-boundary.tsx @@ -95,7 +95,7 @@ export class JournalErrorBoundary extends Component< {/* Pending content recovery */} {this.props.pendingContent && ( - <div className="w-full p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800/50"> + <div className="w-full p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md border border-amber-200 dark:border-amber-800/50"> <p className="text-xs text-amber-700 dark:text-amber-400 mb-2"> Unsaved content detected ({this.props.pendingContent.length} characters) </p> diff --git a/apps/desktop/src/renderer/src/components/journal/journal-navigation-row.tsx b/apps/desktop/src/renderer/src/components/journal/journal-navigation-row.tsx index 283d88f02..abd40636c 100644 --- a/apps/desktop/src/renderer/src/components/journal/journal-navigation-row.tsx +++ b/apps/desktop/src/renderer/src/components/journal/journal-navigation-row.tsx @@ -81,7 +81,7 @@ function NavArrow({ direction, onClick, label }: NavArrowProps) { onClick={onClick} aria-label={label} className={cn( - 'size-8 rounded-lg', + 'size-8 rounded-md', 'text-foreground/60 hover:text-foreground', 'hover:bg-foreground/10', 'transition-all duration-200' @@ -107,7 +107,7 @@ function TodayButton({ onClick }: TodayButtonProps) { size="sm" onClick={onClick} className={cn( - 'h-8 px-4 rounded-lg', + 'h-8 px-4 rounded-md', 'text-xs font-semibold', 'border-foreground/10 bg-background/90 shadow-sm backdrop-blur-md', 'hover:bg-background hover:border-foreground/20', @@ -184,7 +184,7 @@ export function JournalNavigationRow({ variant="ghost" size="icon" className={cn( - 'size-8 rounded-lg', + 'size-8 rounded-md', 'text-foreground/60 hover:text-foreground', 'hover:bg-foreground/10', 'transition-all duration-200' @@ -205,7 +205,7 @@ export function JournalNavigationRow({ variant="ghost" size="icon" className={cn( - 'size-8 rounded-lg', + 'size-8 rounded-md', 'text-foreground/60 hover:text-foreground', 'hover:bg-foreground/10', 'transition-all duration-200' diff --git a/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.tsx b/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.tsx index 04ef6793c..af9039dd5 100644 --- a/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.tsx +++ b/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.tsx @@ -67,7 +67,7 @@ export function JournalReminderButton({ variant="ghost" size="icon" className={cn( - 'size-8 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-foreground/5 transition-all duration-200', + 'size-8 rounded-md text-muted-foreground/60 hover:text-foreground hover:bg-foreground/5 transition-all duration-200', className )} disabled={disabled} diff --git a/apps/desktop/src/renderer/src/components/journal/journal-year-view.tsx b/apps/desktop/src/renderer/src/components/journal/journal-year-view.tsx index 7963f07c6..3302cf29b 100644 --- a/apps/desktop/src/renderer/src/components/journal/journal-year-view.tsx +++ b/apps/desktop/src/renderer/src/components/journal/journal-year-view.tsx @@ -58,7 +58,7 @@ function MonthCard({ stat, isCurrent, onClick }: MonthCardProps) { // Hover state 'hover:border-accent-purple/40 hover:bg-accent-purple/5 hover:shadow-sm', // Focus state - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent-purple/50', + 'focus-visible:outline-none', // Active state 'active:scale-[0.98]', // Current month highlight diff --git a/apps/desktop/src/renderer/src/components/journal/todays-notes.tsx b/apps/desktop/src/renderer/src/components/journal/todays-notes.tsx index 931b48550..306c73ff0 100644 --- a/apps/desktop/src/renderer/src/components/journal/todays-notes.tsx +++ b/apps/desktop/src/renderer/src/components/journal/todays-notes.tsx @@ -59,7 +59,7 @@ export const TodaysNotesSection = memo(function TodaysNotesSection({ role="region" aria-label="Today's Notes" aria-live="polite" - className="rounded-lg border border-border/40 bg-card overflow-hidden" + className="rounded-md border border-border/40 bg-card overflow-hidden" > {/* Header */} <NotesSectionHeader count={notes.length} onCreate={onCreate ? handleCreateNote : undefined} /> @@ -181,7 +181,7 @@ function NoteItem({ note, isActive, onClick }: NoteItemProps): React.JSX.Element <button onClick={onClick} className={cn( - 'w-full text-left p-3 rounded-lg', + 'w-full text-left p-3 rounded-md', 'border transition-all duration-150', 'group cursor-pointer', isActive diff --git a/apps/desktop/src/renderer/src/components/keyboard-shortcuts-modal.tsx b/apps/desktop/src/renderer/src/components/keyboard-shortcuts-modal.tsx index 99f7fe71a..8318f23c5 100644 --- a/apps/desktop/src/renderer/src/components/keyboard-shortcuts-modal.tsx +++ b/apps/desktop/src/renderer/src/components/keyboard-shortcuts-modal.tsx @@ -44,7 +44,7 @@ const KeyboardShortcutsModal = ({ 'size-8 rounded-md flex items-center justify-center', 'text-muted-foreground hover:text-foreground hover:bg-muted', 'transition-colors duration-[var(--duration-instant)]', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'focus-visible:outline-none' )} aria-label="Close shortcuts help" > diff --git a/apps/desktop/src/renderer/src/components/keyboard/chord-indicator.tsx b/apps/desktop/src/renderer/src/components/keyboard/chord-indicator.tsx index 3b3576c77..5f9fa92fe 100644 --- a/apps/desktop/src/renderer/src/components/keyboard/chord-indicator.tsx +++ b/apps/desktop/src/renderer/src/components/keyboard/chord-indicator.tsx @@ -30,7 +30,7 @@ export const ChordIndicator = ({ 'fixed bottom-4 right-4 z-50', 'bg-primary', 'text-primary-foreground', - 'px-4 py-2 rounded-lg shadow-lg', + 'px-4 py-2 rounded-md shadow-lg', 'animate-in fade-in slide-in-from-bottom-2 duration-200', className )} diff --git a/apps/desktop/src/renderer/src/components/kibo-ui/tree/index.tsx b/apps/desktop/src/renderer/src/components/kibo-ui/tree/index.tsx index 037c18d95..0be7857d2 100644 --- a/apps/desktop/src/renderer/src/components/kibo-ui/tree/index.tsx +++ b/apps/desktop/src/renderer/src/components/kibo-ui/tree/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { ChevronRight, File, Folder, FolderOpen, Palette } from '@/lib/icons' +import { ChevronRight, ChevronDown, File, Folder, FolderOpen, Palette } from '@/lib/icons' import { AnimatePresence, LazyMotion, domAnimation, m } from 'motion/react' import { type ComponentProps, @@ -106,6 +106,7 @@ type TreeNodeContextType = { hasChildren: boolean setHasChildren: (value: boolean) => void acceptsDropInside: boolean + hideLines: boolean customIcon?: string inheritedIcon?: string setCustomIcon: (iconName: string | undefined) => void @@ -424,7 +425,7 @@ export const TreeProvider = ({ export type TreeViewProps = HTMLAttributes<HTMLDivElement> export const TreeView = ({ className, children, ...props }: TreeViewProps) => ( - <div className={cn('py-2 h-full', className)} data-tree-view {...props}> + <div className={cn('h-full', className)} data-tree-view {...props}> {children} </div> ) @@ -436,6 +437,7 @@ export type TreeNodeProps = HTMLAttributes<HTMLDivElement> & { parentPath?: boolean[] children?: ReactNode acceptsDropInside?: boolean + hideLines?: boolean customIcon?: string inheritedIcon?: string } @@ -449,6 +451,7 @@ export const TreeNode = ({ className, onClick, acceptsDropInside = false, + hideLines: hideLinesProp = false, customIcon: initialCustomIcon, inheritedIcon: initialInheritedIcon, ...props @@ -500,13 +503,14 @@ export const TreeNode = ({ hasChildren, setHasChildren, acceptsDropInside, + hideLines: hideLinesProp, customIcon, inheritedIcon, setCustomIcon, setInheritedIcon }} > - <div className={cn('select-none', className)} {...props}> + <div className={cn('select-none pb-px', className)} {...props}> {children} </div> </TreeNodeContext.Provider> @@ -749,10 +753,10 @@ export const TreeNodeTrigger = ({ data-tree-node-id={nodeId} draggable={draggable} className={cn( - 'group relative flex cursor-pointer items-center rounded-md px-3 py-1 outline-none', + 'group relative flex cursor-pointer items-center rounded-[5px] h-7 pr-2.5 ml-(--tree-indent) pl-1 gap-1.5 outline-none text-sidebar-foreground', 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', 'focus:bg-sidebar-accent focus:text-sidebar-accent-foreground', - isSelected && 'bg-sidebar-accent text-sidebar-accent-foreground', + isSelected && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium', isDragging && 'opacity-50', draggable && 'cursor-default', className @@ -775,7 +779,7 @@ export const TreeNodeTrigger = ({ onDragOverCapture={handleDragOver as unknown as React.DragEventHandler} onDragLeaveCapture={handleDragLeave as unknown as React.DragEventHandler} onDropCapture={handleDropEvent as unknown as React.DragEventHandler} - style={{ paddingLeft: level * (indent ?? 0) + 8 }} + style={{ '--tree-indent': `${level * (indent ?? 0) + 4}px` } as React.CSSProperties} {...props} > {/* Drop indicator - before */} @@ -847,53 +851,24 @@ export const TreeNodeTrigger = ({ export const TreeLines = () => { const { showLines, indent } = useTree() - const { level, isLast, parentPath } = useTreeNode() + const { level, hideLines } = useTreeNode() - if (!showLines || level === 0) { + if (!showLines || level === 0 || hideLines) { return null } - return ( - <div className="pointer-events-none absolute top-0 bottom-0 left-0"> - {/* Render vertical lines for all parent levels */} - {Array.from({ length: level }, (_, index) => { - const shouldHideLine = parentPath[index] === true - if (shouldHideLine && index === level - 1) { - return null - } - - return ( - <div - className="absolute top-0 bottom-0 border-border border-l" - key={`indent-${index}`} - style={{ - left: index * (indent ?? 0) + 12, - display: shouldHideLine ? 'none' : 'block' - }} - /> - ) - })} - - {/* Horizontal connector line */} - <div - className="absolute top-1/2 border-border border-t" - style={{ - left: (level - 1) * (indent ?? 0) + 12, - width: (indent ?? 0) - 4, - transform: 'translateY(-1px)' - }} - /> + const indentPx = indent ?? 0 + const marginOffset = level * indentPx + 4 - {/* Vertical line to midpoint for last items */} - {isLast && ( + return ( + <div className="pointer-events-none absolute top-0 bottom-0" style={{ left: -marginOffset }}> + {Array.from({ length: level }, (_, index) => ( <div - className="absolute top-0 border-border border-l" - style={{ - left: (level - 1) * (indent ?? 0) + 12, - height: '50%' - }} + className="absolute top-0 bottom-0 border-sidebar-border/50 border-l" + key={`indent-${index}`} + style={{ left: index * indentPx + 12 }} /> - )} + ))} </div> ) } @@ -921,7 +896,7 @@ export const TreeNodeContent = ({ {hasChildrenProp && isExpanded && ( <m.div animate={{ height: 'auto', opacity: 1 }} - className="overflow-hidden" + className="overflow-clip" exit={{ height: 0, opacity: 0 }} initial={{ height: 0, opacity: 0 }} transition={{ @@ -967,22 +942,22 @@ export const TreeExpander = ({ } if (!hasChildrenProp) { - return <div className="mr-1 h-4 w-4" /> + return null } + const Icon = isExpanded ? ChevronDown : ChevronRight + return ( <m.div - animate={{ rotate: isExpanded ? 90 : 0 }} - className={cn('mr-1 flex h-4 w-4 cursor-pointer items-center justify-center', className)} + className={cn('flex h-4 w-4 cursor-pointer items-center justify-center shrink-0', className)} onClick={(e) => { e.stopPropagation() toggleExpanded(nodeId) onClick?.(e) }} - transition={{ duration: 0.2, ease: 'easeInOut' }} {...props} > - <ChevronRight className="h-3 w-3 text-muted-foreground" /> + <Icon className="size-[10px] text-sidebar-muted" /> </m.div> ) } @@ -1040,7 +1015,7 @@ export const TreeIcon = ({ return ( <m.div className={cn( - 'mr-2 flex h-4 w-4 items-center justify-center text-muted-foreground', + 'flex h-4 w-4 items-center justify-center text-muted-foreground shrink-0', className )} transition={{ duration: 0.15 }} @@ -1055,5 +1030,5 @@ export const TreeIcon = ({ export type TreeLabelProps = HTMLAttributes<HTMLSpanElement> export const TreeLabel = ({ className, ...props }: TreeLabelProps) => ( - <span className={cn('font flex-1 truncate text-sm', className)} {...props} /> + <span className={cn('flex-1 truncate text-[13px] leading-4 font-medium', className)} {...props} /> ) diff --git a/apps/desktop/src/renderer/src/components/list-view.tsx b/apps/desktop/src/renderer/src/components/list-view.tsx index fba5fa446..e663efb12 100644 --- a/apps/desktop/src/renderer/src/components/list-view.tsx +++ b/apps/desktop/src/renderer/src/components/list-view.tsx @@ -10,7 +10,6 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useQuery } from '@tanstack/react-query' import { InboxListSection, InboxListItem } from '@/components/inbox' -import { StaleSection } from '@/components/stale/stale-section' import { getFilteredFolders } from '@/components/quick-file-dropdown' import { groupItemsByTimePeriod } from '@/lib/inbox-utils' import { useRetryTranscription } from '@/hooks/use-inbox' @@ -22,7 +21,6 @@ type InboxItem = InboxItemListItem interface ListViewProps { items: InboxItem[] - staleItems?: InboxItem[] selectedItemIds: Set<string> exitingItemIds?: Set<string> density?: DisplayDensity @@ -31,8 +29,6 @@ interface ListViewProps { onSnooze?: (id: string, snoozeUntil: string) => void onQuickFile: (itemId: string, folderId: string) => void onSelectionChange: (selectedIds: Set<string>) => void - onFileAllStale?: () => void - onReviewStale?: () => void focusedItemId?: string | null onFocusedItemChange?: (id: string | null) => void isPreviewOpen?: boolean @@ -40,7 +36,6 @@ interface ListViewProps { const ListView = ({ items, - staleItems = [], selectedItemIds, exitingItemIds = new Set(), density = 'comfortable', @@ -49,8 +44,6 @@ const ListView = ({ onSnooze, onQuickFile, onSelectionChange, - onFileAllStale, - onReviewStale, focusedItemId: controlledFocusedItemId, onFocusedItemChange, isPreviewOpen = false @@ -69,8 +62,7 @@ const ListView = ({ [retryTranscription] ) - // Flatten all items (stale + regular) for keyboard navigation - const flatItems = [...staleItems, ...groupedItems.flatMap((group) => group.items)] + const flatItems = groupedItems.flatMap((group) => group.items) // Track last selected item for shift-click range selection const lastSelectedIdRef = useRef<string | null>(null) @@ -79,15 +71,15 @@ const ListView = ({ const { data: vaultFolders = [] } = useQuery({ queryKey: ['vault', 'folders'], queryFn: async () => { - const paths = await window.api.notes.getFolders() + const folderInfos = await window.api.notes.getFolders() const folders: Folder[] = [{ id: '', name: 'Notes (root)', path: '' }] - for (const path of paths) { - if (path) { + for (const fi of folderInfos) { + if (fi.path) { folders.push({ - id: path, - name: path.split('/').pop() || path, - path: path, - parent: path.includes('/') ? path.split('/').slice(0, -1).join('/') : undefined + id: fi.path, + name: fi.path.split('/').pop() || fi.path, + path: fi.path, + parent: fi.path.includes('/') ? fi.path.split('/').slice(0, -1).join('/') : undefined }) } } @@ -434,25 +426,7 @@ const ListView = ({ role="list" aria-label="Inbox items" > - {/* Stale Items Section - appears at top when there are stale items */} - {staleItems.length > 0 && onFileAllStale && onReviewStale && ( - <StaleSection - items={staleItems} - selectedItemIds={selectedItemIds} - exitingItemIds={exitingItemIds} - focusedItemId={focusedItemId} - density={density} - onArchive={onArchive} - onSnooze={onSnooze} - onFocus={handleItemFocus} - onPreview={onPreview} - onSelectionToggle={handleSelectionToggle} - onFileAllToUnsorted={onFileAllStale} - onReviewOneByOne={onReviewStale} - /> - )} - - {/* Regular time-grouped items using InboxListSection */} + {/* Time-grouped items */} {groupedItems.map((group, groupIndex) => ( <InboxListSection key={group.period} @@ -487,14 +461,6 @@ const ListView = ({ onRetryTranscription={handleRetryTranscription} /> ))} - - {/* Visual separator between sections (except last) */} - {groupIndex < groupedItems.length - 1 && ( - <div - className="h-px bg-gradient-to-r from-border/30 to-transparent mt-4" - aria-hidden="true" - /> - )} </InboxListSection> ))} </div> diff --git a/apps/desktop/src/renderer/src/components/nav-projects.tsx b/apps/desktop/src/renderer/src/components/nav-projects.tsx index 0b710ca79..b3af49c63 100644 --- a/apps/desktop/src/renderer/src/components/nav-projects.tsx +++ b/apps/desktop/src/renderer/src/components/nav-projects.tsx @@ -50,7 +50,7 @@ export function NavProjects({ </SidebarMenuAction> </DropdownMenuTrigger> <DropdownMenuContent - className="w-48 rounded-lg" + className="w-48 rounded-md" side={isMobile ? 'bottom' : 'right'} align={isMobile ? 'end' : 'start'} > diff --git a/apps/desktop/src/renderer/src/components/nav-user.tsx b/apps/desktop/src/renderer/src/components/nav-user.tsx index d333f8d47..fe56b99b2 100644 --- a/apps/desktop/src/renderer/src/components/nav-user.tsx +++ b/apps/desktop/src/renderer/src/components/nav-user.tsx @@ -49,16 +49,16 @@ export function NavUser({ </SidebarMenuButton> </DropdownMenuTrigger> <DropdownMenuContent - className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" + className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-md" side={isMobile ? 'bottom' : 'right'} align="end" sideOffset={4} > <DropdownMenuLabel className="p-0 font-normal"> <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> - <Avatar className="h-8 w-8 rounded-lg"> + <Avatar className="h-8 w-8 rounded-md"> <AvatarImage src={user.avatar} alt={user.name} /> - <AvatarFallback className="rounded-lg">CN</AvatarFallback> + <AvatarFallback className="rounded-md">CN</AvatarFallback> </Avatar> <div className="grid flex-1 text-left text-sm leading-tight"> <span className="truncate font-semibold">{user.name}</span> diff --git a/apps/desktop/src/renderer/src/components/note/ai-agent/AIAgentComposer.tsx b/apps/desktop/src/renderer/src/components/note/ai-agent/AIAgentComposer.tsx index 1efb45a29..918ca6cd1 100644 --- a/apps/desktop/src/renderer/src/components/note/ai-agent/AIAgentComposer.tsx +++ b/apps/desktop/src/renderer/src/components/note/ai-agent/AIAgentComposer.tsx @@ -110,11 +110,7 @@ export function AIAgentComposer({ return ( <div - className={cn( - 'bg-white border border-stone-200 rounded-2xl', - 'transition-all duration-200', - 'focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/10' - )} + className={cn('bg-white border border-stone-200 rounded-2xl', 'transition-all duration-200')} > {/* Attachment Chips Area */} {attachments.length > 0 && ( @@ -158,7 +154,7 @@ export function AIAgentComposer({ 'h-8 w-8 rounded-full flex items-center justify-center', 'bg-stone-100 hover:bg-stone-200', 'transition-colors duration-150', - 'focus:outline-none focus:ring-1 focus:ring-stone-400 focus:ring-offset-1' + 'focus:outline-none' )} aria-label="Add attachment" > @@ -213,7 +209,7 @@ export function AIAgentComposer({ 'hover:bg-stone-700', 'transition-all duration-150', 'hover:scale-105', - 'focus:outline-none focus:ring-1 focus:ring-stone-400 focus:ring-offset-1' + 'focus:outline-none' )} aria-label="Stop generation" > @@ -227,9 +223,9 @@ export function AIAgentComposer({ className={cn( 'h-9 w-9 rounded-full flex items-center justify-center', 'transition-all duration-150', - 'focus:outline-none focus:ring-1 focus:ring-blue-400 focus:ring-offset-1', + 'focus:outline-none', canSend - ? 'bg-blue-600 text-white hover:bg-blue-700 hover:scale-105' + ? 'bg-tint text-tint-foreground hover:bg-tint-hover hover:scale-105' : 'bg-stone-200 text-stone-400 cursor-not-allowed' )} aria-label="Send message" diff --git a/apps/desktop/src/renderer/src/components/note/ai-agent/AIAgentTab.tsx b/apps/desktop/src/renderer/src/components/note/ai-agent/AIAgentTab.tsx index 038719607..9a6ece6f9 100644 --- a/apps/desktop/src/renderer/src/components/note/ai-agent/AIAgentTab.tsx +++ b/apps/desktop/src/renderer/src/components/note/ai-agent/AIAgentTab.tsx @@ -78,7 +78,9 @@ export function AIAgentTab() { > <div className={`max-w-[85%] rounded-2xl px-4 py-2.5 ${ - msg.role === 'user' ? 'bg-blue-600 text-white' : 'bg-stone-100 text-stone-900' + msg.role === 'user' + ? 'bg-tint text-tint-foreground' + : 'bg-stone-100 text-stone-900' }`} > {msg.attachments && msg.attachments.length > 0 && ( diff --git a/apps/desktop/src/renderer/src/components/note/ai-agent/AttachmentChip.tsx b/apps/desktop/src/renderer/src/components/note/ai-agent/AttachmentChip.tsx index b4a3a5ff2..82aaa6c60 100644 --- a/apps/desktop/src/renderer/src/components/note/ai-agent/AttachmentChip.tsx +++ b/apps/desktop/src/renderer/src/components/note/ai-agent/AttachmentChip.tsx @@ -47,7 +47,7 @@ export function AttachmentChip({ attachment, onRemove }: AttachmentChipProps) { className={cn( 'text-stone-400 hover:text-stone-600', 'transition-colors duration-150', - 'focus:outline-none focus:ring-1 focus:ring-stone-400 focus:ring-offset-1 rounded-full' + 'focus:outline-none rounded-full' )} aria-label={`Remove ${displayName}`} > diff --git a/apps/desktop/src/renderer/src/components/note/ai-agent/ModeToggle.tsx b/apps/desktop/src/renderer/src/components/note/ai-agent/ModeToggle.tsx index 2a08fa2b8..2d51089e9 100644 --- a/apps/desktop/src/renderer/src/components/note/ai-agent/ModeToggle.tsx +++ b/apps/desktop/src/renderer/src/components/note/ai-agent/ModeToggle.tsx @@ -12,7 +12,7 @@ export function ModeToggle({ mode, enabled, onToggle }: ModeToggleProps) { const config = { web: { icon: Globe, - activeClass: 'bg-blue-100 text-blue-600', + activeClass: 'bg-tint-light text-tint', tooltipEnabled: 'Web search enabled', tooltipDisabled: 'Web search' }, @@ -38,7 +38,7 @@ export function ModeToggle({ mode, enabled, onToggle }: ModeToggleProps) { className={cn( 'h-8 w-8 rounded-full flex items-center justify-center', 'transition-all duration-150', - 'focus:outline-none focus:ring-1 focus:ring-stone-400 focus:ring-offset-1', + 'focus:outline-none', enabled ? activeClass : 'text-stone-400 hover:bg-stone-100 hover:text-stone-600', enabled && 'animate-pulse-once' )} diff --git a/apps/desktop/src/renderer/src/components/note/ai-agent/ModelSelector.tsx b/apps/desktop/src/renderer/src/components/note/ai-agent/ModelSelector.tsx index e28bfeec8..cb253fde4 100644 --- a/apps/desktop/src/renderer/src/components/note/ai-agent/ModelSelector.tsx +++ b/apps/desktop/src/renderer/src/components/note/ai-agent/ModelSelector.tsx @@ -43,7 +43,7 @@ export function ModelSelector({ selectedModel, onModelChange }: ModelSelectorPro 'border border-stone-200 rounded-full', 'px-3 py-1.5', 'transition-colors duration-150', - 'focus:outline-none focus:ring-1 focus:ring-stone-400 focus:ring-offset-1' + 'focus:outline-none' )} > <Sparkles className="h-3.5 w-3.5 text-stone-500" /> @@ -71,7 +71,7 @@ export function ModelSelector({ selectedModel, onModelChange }: ModelSelectorPro <p className="text-sm font-medium text-stone-900 truncate">{model.name}</p> <p className="text-xs text-stone-500 truncate">{model.description}</p> </div> - {selectedModel === model.id && <Check className="h-4 w-4 text-blue-600 shrink-0" />} + {selectedModel === model.id && <Check className="h-4 w-4 text-tint shrink-0" />} </button> ))} </div> diff --git a/apps/desktop/src/renderer/src/components/note/backlinks/BacklinkCard.tsx b/apps/desktop/src/renderer/src/components/note/backlinks/BacklinkCard.tsx index d5d807943..39c619ed4 100644 --- a/apps/desktop/src/renderer/src/components/note/backlinks/BacklinkCard.tsx +++ b/apps/desktop/src/renderer/src/components/note/backlinks/BacklinkCard.tsx @@ -44,7 +44,7 @@ export function BacklinkCard({ backlink, onClick }: BacklinkCardProps) { 'transition-all duration-150', 'cursor-pointer', 'group', - 'focus:outline-none focus:ring-1 focus:ring-sidebar-terracotta/20 focus:ring-offset-1' + 'focus:outline-none' )} aria-label={`Link from ${noteTitle}`} > diff --git a/apps/desktop/src/renderer/src/components/note/backlinks/BacklinksLoadingState.tsx b/apps/desktop/src/renderer/src/components/note/backlinks/BacklinksLoadingState.tsx index a20f840ae..b6864d80f 100644 --- a/apps/desktop/src/renderer/src/components/note/backlinks/BacklinksLoadingState.tsx +++ b/apps/desktop/src/renderer/src/components/note/backlinks/BacklinksLoadingState.tsx @@ -5,7 +5,7 @@ export function BacklinksLoadingState() { return ( <div className={cn( - 'bg-stone-50 border border-stone-200 rounded-lg', + 'bg-stone-50 border border-stone-200 rounded-md', 'p-6 flex items-center justify-center gap-2' )} > diff --git a/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx b/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx index 650cd97db..433d72d54 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx +++ b/apps/desktop/src/renderer/src/components/note/content-area/ContentArea.tsx @@ -398,6 +398,7 @@ const ContentAreaEditor = memo(function ContentAreaEditor({ placeholder = "Start writing, or press '/' for commands...", editable = true, stickyToolbar = false, + spellCheck, onContentChange, onMarkdownChange, onHeadingsChange, @@ -455,6 +456,21 @@ const ContentAreaEditor = memo(function ContentAreaEditor({ } }, []) + // Apply spellCheck setting imperatively to BlockNote's contenteditable element + useEffect(() => { + if (spellCheck === undefined) return + const container = editorContainerRef.current + if (!container) return + const applySpellCheck = (): void => { + const ce = container.querySelector<HTMLElement>('[contenteditable="true"]') + if (ce) ce.spellcheck = spellCheck + } + applySpellCheck() + // Short delay to ensure editor DOM is mounted on first render + const t = setTimeout(applySpellCheck, 100) + return () => clearTimeout(t) + }, [spellCheck]) + // Global event listeners to reset drag state when drag is cancelled or tab loses focus // This fixes the bug where the overlay gets stuck when user cancels the drag useEffect(() => { diff --git a/apps/desktop/src/renderer/src/components/note/content-area/file-block.tsx b/apps/desktop/src/renderer/src/components/note/content-area/file-block.tsx index 92d5ab1b1..24efe2768 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/file-block.tsx +++ b/apps/desktop/src/renderer/src/components/note/content-area/file-block.tsx @@ -107,7 +107,7 @@ function PdfPreview({ url, name }: PdfPreviewProps) { if (error) { return ( - <div className="pdf-preview-error rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950"> + <div className="pdf-preview-error rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950"> <div className="flex items-center gap-2 text-red-600 dark:text-red-400"> <FileText className="h-5 w-5" /> <span className="font-medium">{name}</span> @@ -118,7 +118,7 @@ function PdfPreview({ url, name }: PdfPreviewProps) { } return ( - <div className="pdf-preview rounded-lg border border-border bg-muted/30 overflow-hidden"> + <div className="pdf-preview rounded-md border border-border bg-muted/30 overflow-hidden"> {/* Header */} <div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border bg-muted/50"> <div className="flex items-center gap-2 text-sm text-muted-foreground"> @@ -319,7 +319,7 @@ function FilePreview({ url, name, size, mimeType }: FilePreviewProps) { const transferDirection: 'upload' | 'download' = uploadEntry ? 'upload' : 'download' return ( - <div className="file-attachment relative flex items-center gap-3 rounded-lg border border-border bg-muted/30 p-3"> + <div className="file-attachment relative flex items-center gap-3 rounded-md border border-border bg-muted/30 p-3"> {getFileIcon(mimeType)} <div className="flex-1 min-w-0"> <p className="truncate font-medium text-sm">{name}</p> diff --git a/apps/desktop/src/renderer/src/components/note/content-area/types.ts b/apps/desktop/src/renderer/src/components/note/content-area/types.ts index 3ff2548fc..2cc90b16d 100644 --- a/apps/desktop/src/renderer/src/components/note/content-area/types.ts +++ b/apps/desktop/src/renderer/src/components/note/content-area/types.ts @@ -79,6 +79,8 @@ export interface ContentAreaProps { autoFocus?: boolean /** Whether to show sticky formatting toolbar (always visible above editor) */ stickyToolbar?: boolean + /** Whether to enable browser spell checking in the editor */ + spellCheck?: boolean /** Callback when content changes (returns blocks) */ onContentChange?: (blocks: Block[]) => void /** Callback when content changes (returns markdown string) */ diff --git a/apps/desktop/src/renderer/src/components/note/export-dialog.tsx b/apps/desktop/src/renderer/src/components/note/export-dialog.tsx index 87f5ff456..d0559232e 100644 --- a/apps/desktop/src/renderer/src/components/note/export-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/note/export-dialog.tsx @@ -192,7 +192,7 @@ export function ExportDialog({ <Label htmlFor="format-pdf" className={cn( - 'flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors', + 'flex items-center gap-3 p-3 rounded-md border cursor-pointer transition-colors', format === 'pdf' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50' @@ -214,7 +214,7 @@ export function ExportDialog({ <Label htmlFor="format-html" className={cn( - 'flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors', + 'flex items-center gap-3 p-3 rounded-md border cursor-pointer transition-colors', format === 'html' ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50' diff --git a/apps/desktop/src/renderer/src/components/note/info-section/AddPropertyPopup.tsx b/apps/desktop/src/renderer/src/components/note/info-section/AddPropertyPopup.tsx index 5426f6346..9d59eda9f 100644 --- a/apps/desktop/src/renderer/src/components/note/info-section/AddPropertyPopup.tsx +++ b/apps/desktop/src/renderer/src/components/note/info-section/AddPropertyPopup.tsx @@ -74,7 +74,7 @@ export function AddPropertyPopup({ className={cn( 'fixed z-[9999]', 'w-[240px] max-h-[400px] overflow-y-auto', - 'rounded-lg border border-stone-200 bg-white dark:border-stone-700 dark:bg-stone-900', + 'rounded-md border border-stone-200 bg-white dark:border-stone-700 dark:bg-stone-900', 'shadow-lg', 'py-1', 'animate-in fade-in-0 zoom-in-95 duration-150' @@ -95,7 +95,7 @@ export function AddPropertyPopup({ 'bg-stone-50 dark:bg-stone-800', 'border border-stone-200 dark:border-stone-700 rounded', 'placeholder:text-stone-400 dark:placeholder:text-stone-500', - 'focus:outline-none focus:ring-1 focus:ring-stone-400 dark:focus:ring-stone-500' + 'focus:outline-none' )} aria-label="Property name" /> diff --git a/apps/desktop/src/renderer/src/components/note/info-section/PropertyRow.tsx b/apps/desktop/src/renderer/src/components/note/info-section/PropertyRow.tsx index 05c8dacbb..e841e0aeb 100644 --- a/apps/desktop/src/renderer/src/components/note/info-section/PropertyRow.tsx +++ b/apps/desktop/src/renderer/src/components/note/info-section/PropertyRow.tsx @@ -31,7 +31,7 @@ function PropertyValueDisplay({ property }: { property: Property }) { case 'url': return ( - <span className="text-[13px] text-blue-500 font-sans leading-4 truncate max-w-[200px] hover:underline"> + <span className="text-[13px] text-tint font-sans leading-4 truncate max-w-[200px] hover:underline"> {String(value)} </span> ) diff --git a/apps/desktop/src/renderer/src/components/note/info-section/editors/RatingEditor.tsx b/apps/desktop/src/renderer/src/components/note/info-section/editors/RatingEditor.tsx index 196207613..17ebc44cf 100644 --- a/apps/desktop/src/renderer/src/components/note/info-section/editors/RatingEditor.tsx +++ b/apps/desktop/src/renderer/src/components/note/info-section/editors/RatingEditor.tsx @@ -63,7 +63,7 @@ export function RatingEditor({ value, onChange, maxRating = 5 }: RatingEditorPro className={cn( 'p-0 transition-opacity duration-100', 'hover:opacity-80', - 'focus:outline-none focus-visible:ring-1 focus-visible:ring-border/40 focus-visible:rounded' + 'focus:outline-none focus-visible:rounded' )} > <Star diff --git a/apps/desktop/src/renderer/src/components/note/linked-tasks/index.tsx b/apps/desktop/src/renderer/src/components/note/linked-tasks/index.tsx index d883dcda0..bd447f51a 100644 --- a/apps/desktop/src/renderer/src/components/note/linked-tasks/index.tsx +++ b/apps/desktop/src/renderer/src/components/note/linked-tasks/index.tsx @@ -34,7 +34,7 @@ const LinkedTaskItem = ({ task, onClick }: LinkedTaskItemProps): React.JSX.Eleme className={cn( 'flex items-center gap-2 w-full p-2 rounded-md text-left', 'hover:bg-stone-100 dark:hover:bg-stone-800 transition-colors', - 'focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1', + 'focus:outline-none', isCompleted && 'opacity-60' )} > @@ -108,7 +108,7 @@ export const LinkedTasksSection = ({ ) : ( <ChevronDown className="size-4 text-stone-400" aria-hidden="true" /> )} - <CheckSquare className="size-4 text-blue-500" aria-hidden="true" /> + <CheckSquare className="size-4 text-tint" aria-hidden="true" /> <span className="text-xs font-semibold uppercase tracking-wide text-stone-500"> Linked Tasks </span> diff --git a/apps/desktop/src/renderer/src/components/note/note-title/EmojiButton.tsx b/apps/desktop/src/renderer/src/components/note/note-title/EmojiButton.tsx index 36de95ddc..ea92e25b7 100644 --- a/apps/desktop/src/renderer/src/components/note/note-title/EmojiButton.tsx +++ b/apps/desktop/src/renderer/src/components/note/note-title/EmojiButton.tsx @@ -1,5 +1,7 @@ import { cn } from '@/lib/utils' import { Smile } from '@/lib/icons' +import { isIconValue, parseIconName } from './emoji-icon-utils' +import { HugeIconByName } from '@/lib/hugeicon-renderer' interface EmojiButtonProps { emoji: string | null @@ -19,11 +21,13 @@ export function EmojiButton({ emoji, onClick, disabled }: EmojiButtonProps) { 'rounded-xl bg-sidebar-terracotta/8', 'transition-colors duration-150', 'hover:bg-sidebar-terracotta/12', - 'focus:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2', + 'focus:outline-none', 'disabled:pointer-events-none disabled:opacity-50' )} > - {emoji ? ( + {emoji && isIconValue(emoji) ? ( + <HugeIconByName name={parseIconName(emoji)} className="h-5 w-5 text-text-tertiary" /> + ) : emoji ? ( <span className="text-[22px] leading-7">{emoji}</span> ) : ( <Smile className="h-5 w-5 text-text-tertiary" /> diff --git a/apps/desktop/src/renderer/src/components/note/note-title/EmojiPicker.tsx b/apps/desktop/src/renderer/src/components/note/note-title/EmojiPicker.tsx index ccf4e7b8d..f3eda6c0f 100644 --- a/apps/desktop/src/renderer/src/components/note/note-title/EmojiPicker.tsx +++ b/apps/desktop/src/renderer/src/components/note/note-title/EmojiPicker.tsx @@ -1,9 +1,14 @@ -import { useRef, useCallback } from 'react' +import { useRef, useCallback, useState, useEffect } from 'react' import Picker from '@emoji-mart/react' import data from '@emoji-mart/data' +import { useTheme } from 'next-themes' import { cn } from '@/lib/utils' import { useClickOutside } from './use-click-outside' import { X } from '@/lib/icons' +import { HugeIconGrid } from './HugeIconGrid' +import { toIconValue } from './emoji-icon-utils' + +type PickerTab = 'emoji' | 'icons' interface EmojiPickerProps { isOpen: boolean @@ -24,6 +29,26 @@ interface EmojiData { export function EmojiPicker({ isOpen, onClose, onSelect, onRemove, hasEmoji }: EmojiPickerProps) { const pickerRef = useRef<HTMLDivElement>(null) + const contentRef = useRef<HTMLDivElement>(null) + const [activeTab, setActiveTab] = useState<PickerTab>('emoji') + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null) + const { resolvedTheme } = useTheme() + + useEffect(() => { + const el = contentRef.current + if (!el || !isOpen) return + + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry || activeTab !== 'emoji') return + const { width, height } = entry.contentRect + if (width > 0 && height > 0) { + setContentSize({ width, height }) + } + }) + observer.observe(el) + return () => observer.disconnect() + }, [isOpen, activeTab]) useClickOutside(pickerRef, onClose, isOpen) @@ -35,6 +60,14 @@ export function EmojiPicker({ isOpen, onClose, onSelect, onRemove, hasEmoji }: E [onSelect, onClose] ) + const handleIconSelect = useCallback( + (iconName: string) => { + onSelect(toIconValue(iconName)) + onClose() + }, + [onSelect, onClose] + ) + const handleRemove = useCallback(() => { onRemove() onClose() @@ -57,53 +90,93 @@ export function EmojiPicker({ isOpen, onClose, onSelect, onRemove, hasEmoji }: E ref={pickerRef} role="dialog" aria-modal="true" - aria-label="Emoji picker" + aria-label="Emoji and icon picker" onKeyDown={handleKeyDown} className={cn( 'absolute left-0 top-full z-50 mt-2', - 'rounded-xl border border-stone-200 bg-white shadow-lg', + 'rounded-xl border border-border bg-popover shadow-lg', 'animate-in fade-in-0 zoom-in-95 duration-150' )} > - <Picker - data={data} - onEmojiSelect={handleEmojiSelect} - theme="light" - previewPosition="none" - skinTonePosition="none" - maxFrequentRows={2} - perLine={8} - navPosition="bottom" - searchPosition="sticky" - emojiSize={28} - emojiButtonSize={36} - categories={[ - 'frequent', - 'people', - 'nature', - 'foods', - 'activity', - 'places', - 'objects', - 'symbols', - 'flags' - ]} - /> + <div className="flex border-b border-border"> + <button + type="button" + onClick={() => setActiveTab('emoji')} + className={cn( + 'flex-1 px-4 py-2 text-sm font-medium transition-colors', + activeTab === 'emoji' + ? 'text-foreground border-b-2 border-foreground' + : 'text-muted-foreground hover:text-foreground' + )} + > + Emoji + </button> + <button + type="button" + onClick={() => setActiveTab('icons')} + className={cn( + 'flex-1 px-4 py-2 text-sm font-medium transition-colors', + activeTab === 'icons' + ? 'text-foreground border-b-2 border-foreground' + : 'text-muted-foreground hover:text-foreground' + )} + > + Icons + </button> + </div> + + <div + ref={contentRef} + style={ + activeTab === 'icons' && contentSize + ? { width: contentSize.width, height: contentSize.height, overflow: 'hidden' } + : undefined + } + > + {activeTab === 'emoji' ? ( + <Picker + data={data} + onEmojiSelect={handleEmojiSelect} + theme={resolvedTheme === 'dark' ? 'dark' : 'light'} + previewPosition="none" + skinTonePosition="none" + maxFrequentRows={2} + perLine={8} + navPosition="bottom" + searchPosition="sticky" + emojiSize={28} + emojiButtonSize={36} + categories={[ + 'frequent', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags' + ]} + /> + ) : ( + <HugeIconGrid onSelect={handleIconSelect} /> + )} + </div> {hasEmoji && ( - <div className="border-t border-stone-200 p-2"> + <div className="border-t border-border p-2"> <button type="button" onClick={handleRemove} className={cn( - 'flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2', - 'text-sm text-stone-600', + 'flex w-full items-center justify-center gap-2 rounded-md px-3 py-2', + 'text-sm text-muted-foreground', 'transition-colors duration-150', - 'hover:bg-stone-100 hover:text-stone-900' + 'hover:bg-muted hover:text-foreground' )} > <X className="h-4 w-4" /> - Remove emoji + Remove </button> </div> )} diff --git a/apps/desktop/src/renderer/src/components/note/note-title/HugeIconGrid.tsx b/apps/desktop/src/renderer/src/components/note/note-title/HugeIconGrid.tsx new file mode 100644 index 000000000..a3d41e2e7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/note-title/HugeIconGrid.tsx @@ -0,0 +1,175 @@ +import { useCallback, useRef, useEffect, useMemo } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { HugeiconsIcon } from '@hugeicons/react' +import { useHugeIconPicker } from './use-hugeicon-picker' +import { cn } from '@/lib/utils' +import { Loader } from '@/lib/icons' + +const COLS = 8 +const ROW_HEIGHT = 36 +const ROW_GAP = 2 + +interface HugeIconGridProps { + onSelect: (iconName: string) => void +} + +export function HugeIconGrid({ onSelect }: HugeIconGridProps): React.JSX.Element { + const { icons, search, setSearch, isLoading } = useHugeIconPicker() + const inputRef = useRef<HTMLInputElement>(null) + const scrollRef = useRef<HTMLDivElement>(null) + + const rows = useMemo(() => { + const result: (typeof icons)[] = [] + for (let i = 0; i < icons.length; i += COLS) { + result.push(icons.slice(i, i + COLS)) + } + return result + }, [icons]) + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT + ROW_GAP, + overscan: 5 + }) + + useEffect(() => { + virtualizer.scrollToIndex(0) + }, [search, virtualizer]) + + const handleClearSearch = useCallback(() => { + setSearch('') + inputRef.current?.focus() + }, [setSearch]) + + return ( + <div + className="flex flex-col w-full h-full relative" + style={ + { + '--padding': '12px', + '--sidebar-width': '16px', + '--font-size': '15px', + '--duration': '225ms', + '--easing': 'cubic-bezier(.4, 0, .2, 1)' + } as React.CSSProperties + } + > + {/* Search — mirrors emoji-mart: .padding-lr > div > .spacer + .flex.flex-middle > .search.relative.flex-grow */} + <div className="shrink-0 px-[var(--padding)]"> + <div> + <div style={{ height: 10 }} /> + <div className="flex items-center"> + <div className="relative z-[2] flex-auto [&_input,&_button]:text-[calc(var(--font-size)-1px)]"> + <input + ref={inputRef} + type="search" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search" + autoComplete="off" + className={cn( + 'block w-full border-0 outline-none appearance-none', + 'text-inherit placeholder:text-inherit placeholder:opacity-60', + 'bg-[var(--em-color-border,rgba(0,0,0,.05))]', + 'dark:bg-[var(--em-color-border,rgba(255,255,255,.1))]', + 'focus:bg-[rgb(var(--em-rgb-input,255,255,255))]', + 'dark:focus:bg-[rgb(var(--em-rgb-input,0,0,0))]', + 'focus:shadow-[inset_0_0_0_1px_rgb(var(--em-rgb-accent,34,102,237)),0_1px_3px_rgba(65,69,73,0.2)]', + 'dark:focus:shadow-[inset_0_0_0_1px_rgb(var(--em-rgb-accent,58,130,247)),0_1px_3px_rgba(65,69,73,0.2)]', + 'transition-[background-color,box-shadow] duration-[var(--duration)] ease-[var(--easing)]' + )} + style={{ + padding: '10px 2em 10px 2.2em', + borderRadius: 10 + }} + /> + <span className="pointer-events-none absolute left-[.7em] top-1/2 -translate-y-1/2 z-[1] flex text-[color:rgba(var(--em-rgb-color,34,36,39),.7)] dark:text-[color:rgba(var(--em-rgb-color,222,222,221),.7)]"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + className="h-[1em] w-[1em] fill-current" + > + <path d="M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z" /> + </svg> + </span> + {search && ( + <button + type="button" + onClick={handleClearSearch} + className="absolute right-[.7em] top-1/2 -translate-y-1/2 z-[1] flex text-[color:rgba(var(--em-rgb-color,34,36,39),.7)] dark:text-[color:rgba(var(--em-rgb-color,222,222,221),.7)] hover:opacity-80" + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + className="h-[1em] w-[1em] fill-current" + > + <path d="M10 0a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm5.34 14.28-1.06 1.06L10 11.06l-4.28 4.28-1.06-1.06L8.94 10 4.66 5.72l1.06-1.06L10 8.94l4.28-4.28 1.06 1.06L11.06 10l4.28 4.28z" /> + </svg> + </button> + )} + </div> + </div> + </div> + </div> + + {/* Grid — mirrors emoji-mart: .scroll with padding-left via padding-lr, scrollbar in sidebar-width space */} + <div + ref={scrollRef} + className="flex-1 min-h-0 overflow-x-hidden overflow-y-auto pl-[var(--padding)] pr-0" + > + {isLoading ? ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + <Loader className="h-5 w-5 animate-spin" /> + </div> + ) : icons.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground"> + <span className="text-sm">No icons found</span> + <button + type="button" + onClick={handleClearSearch} + className="text-xs hover:text-foreground underline" + > + Clear search + </button> + </div> + ) : ( + <div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}> + {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index] + return ( + <div + key={virtualRow.index} + className="absolute left-0 top-0 flex w-full justify-between" + style={{ + height: ROW_HEIGHT, + transform: `translateY(${virtualRow.start}px)` + }} + > + {row.map((entry) => ( + <button + key={entry.name} + type="button" + onClick={() => onSelect(entry.name)} + title={entry.name.replace(/Icon$/, '')} + className={cn( + 'flex items-center justify-center shrink-0', + 'h-[36px] w-[36px]', + 'text-foreground transition-colors duration-75', + 'hover:bg-[var(--em-color-border,rgba(0,0,0,.05))]', + 'dark:hover:bg-[var(--em-color-border,rgba(255,255,255,.1))]', + 'rounded-[8px]' + )} + > + <HugeiconsIcon icon={entry.data} size={20} /> + </button> + ))} + </div> + ) + })} + </div> + )} + </div> + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/note/note-title/emoji-icon-utils.test.ts b/apps/desktop/src/renderer/src/components/note/note-title/emoji-icon-utils.test.ts new file mode 100644 index 000000000..c6f319d84 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/note-title/emoji-icon-utils.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { isIconValue, parseIconName, toIconValue, ICON_PREFIX } from './emoji-icon-utils' + +describe('emoji-icon-utils', () => { + describe('isIconValue', () => { + it('returns true for icon-prefixed values', () => { + expect(isIconValue('icon:Folder01Icon')).toBe(true) + expect(isIconValue('icon:StarIcon')).toBe(true) + }) + + it('returns false for native emoji', () => { + expect(isIconValue('😀')).toBe(false) + expect(isIconValue('📝')).toBe(false) + }) + + it('returns false for null/undefined', () => { + expect(isIconValue(null)).toBe(false) + expect(isIconValue(undefined)).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isIconValue('')).toBe(false) + }) + + it('returns false for partial prefix', () => { + expect(isIconValue('ico')).toBe(false) + expect(isIconValue('icon')).toBe(false) + }) + }) + + describe('parseIconName', () => { + it('strips the icon: prefix', () => { + expect(parseIconName('icon:Folder01Icon')).toBe('Folder01Icon') + expect(parseIconName('icon:StarIcon')).toBe('StarIcon') + }) + + it('handles edge case of prefix-only', () => { + expect(parseIconName('icon:')).toBe('') + }) + }) + + describe('toIconValue', () => { + it('adds the icon: prefix', () => { + expect(toIconValue('Folder01Icon')).toBe('icon:Folder01Icon') + expect(toIconValue('StarIcon')).toBe('icon:StarIcon') + }) + }) + + describe('ICON_PREFIX', () => { + it('is "icon:"', () => { + expect(ICON_PREFIX).toBe('icon:') + }) + }) + + describe('roundtrip', () => { + it('toIconValue → isIconValue → parseIconName', () => { + const iconName = 'Camera01Icon' + const stored = toIconValue(iconName) + expect(isIconValue(stored)).toBe(true) + expect(parseIconName(stored)).toBe(iconName) + }) + }) +}) diff --git a/apps/desktop/src/renderer/src/components/note/note-title/emoji-icon-utils.ts b/apps/desktop/src/renderer/src/components/note/note-title/emoji-icon-utils.ts new file mode 100644 index 000000000..f15ef93ed --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/note-title/emoji-icon-utils.ts @@ -0,0 +1,13 @@ +export const ICON_PREFIX = 'icon:' + +export function isIconValue(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(ICON_PREFIX) +} + +export function parseIconName(value: string): string { + return value.slice(ICON_PREFIX.length) +} + +export function toIconValue(iconName: string): string { + return `${ICON_PREFIX}${iconName}` +} diff --git a/apps/desktop/src/renderer/src/components/note/note-title/index.ts b/apps/desktop/src/renderer/src/components/note/note-title/index.ts index e10e9cd4e..54010bca2 100644 --- a/apps/desktop/src/renderer/src/components/note/note-title/index.ts +++ b/apps/desktop/src/renderer/src/components/note/note-title/index.ts @@ -4,3 +4,4 @@ export { EmojiButton } from './EmojiButton' export { EmojiPicker } from './EmojiPicker' export { TitleInput } from './TitleInput' export { useClickOutside } from './use-click-outside' +export { isIconValue, parseIconName, toIconValue } from './emoji-icon-utils' diff --git a/apps/desktop/src/renderer/src/components/note/note-title/note-title.test.tsx b/apps/desktop/src/renderer/src/components/note/note-title/note-title.test.tsx index 5b581d12b..116871c8f 100644 --- a/apps/desktop/src/renderer/src/components/note/note-title/note-title.test.tsx +++ b/apps/desktop/src/renderer/src/components/note/note-title/note-title.test.tsx @@ -205,7 +205,7 @@ describe('T510: NoteTitle - emoji picker', () => { const emojiButton = screen.getByRole('button', { name: /change emoji: 📝/i }) await user.click(emojiButton) - expect(screen.getByRole('button', { name: /remove emoji/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument() }) it('should call onEmojiChange with null when remove is clicked', async () => { @@ -215,7 +215,7 @@ describe('T510: NoteTitle - emoji picker', () => { const emojiButton = screen.getByRole('button', { name: /change emoji: 📝/i }) await user.click(emojiButton) - const removeButton = screen.getByRole('button', { name: /remove emoji/i }) + const removeButton = screen.getByRole('button', { name: /remove/i }) await user.click(removeButton) expect(defaultProps.onEmojiChange).toHaveBeenCalledWith(null) diff --git a/apps/desktop/src/renderer/src/components/note/note-title/use-hugeicon-picker.ts b/apps/desktop/src/renderer/src/components/note/note-title/use-hugeicon-picker.ts new file mode 100644 index 000000000..3e1ebc0ea --- /dev/null +++ b/apps/desktop/src/renderer/src/components/note/note-title/use-hugeicon-picker.ts @@ -0,0 +1,59 @@ +import { useState, useEffect, useMemo } from 'react' +import type { IconSvgElement } from '@hugeicons/react' +import { loadAllIcons } from '@/lib/hugeicon-renderer' + +interface IconEntry { + name: string + data: IconSvgElement +} + +function splitCamelCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/(\d+)/g, ' $1 ') + .replace(/Icon$/, '') + .toLowerCase() + .trim() +} + +export function useHugeIconPicker(): { + icons: IconEntry[] + search: string + setSearch: (s: string) => void + isLoading: boolean +} { + const [allIcons, setAllIcons] = useState<IconEntry[]>([]) + const [search, setSearch] = useState('') + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let cancelled = false + + loadAllIcons().then((mod) => { + if (cancelled) return + + const entries: IconEntry[] = [] + for (const key of Object.keys(mod)) { + if (key.endsWith('Icon') && key[0] === key[0].toUpperCase()) { + entries.push({ name: key, data: mod[key] as IconSvgElement }) + } + } + entries.sort((a, b) => a.name.localeCompare(b.name)) + setAllIcons(entries) + setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + const icons = useMemo(() => { + if (!search.trim()) return allIcons + const q = search.toLowerCase() + return allIcons.filter((entry) => splitCamelCase(entry.name).includes(q)) + }, [allIcons, search]) + + return { icons, search, setSearch, isLoading } +} diff --git a/apps/desktop/src/renderer/src/components/note/outline-edge.tsx b/apps/desktop/src/renderer/src/components/note/outline-edge.tsx index fadab32cb..d04d53e9c 100644 --- a/apps/desktop/src/renderer/src/components/note/outline-edge.tsx +++ b/apps/desktop/src/renderer/src/components/note/outline-edge.tsx @@ -163,7 +163,7 @@ export const OutlineEdge = memo(function OutlineEdge({ ref={popupRef} className={cn( 'bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-700', - 'shadow-lg rounded-lg', + 'shadow-lg rounded-md', 'py-2 min-w-[220px] max-w-[280px] max-h-[70vh] overflow-y-auto', 'animate-in fade-in-0 zoom-in-95 duration-150' )} diff --git a/apps/desktop/src/renderer/src/components/note/tags-row/AddTagButton.tsx b/apps/desktop/src/renderer/src/components/note/tags-row/AddTagButton.tsx index 66d1bd2df..b26054781 100644 --- a/apps/desktop/src/renderer/src/components/note/tags-row/AddTagButton.tsx +++ b/apps/desktop/src/renderer/src/components/note/tags-row/AddTagButton.tsx @@ -20,7 +20,7 @@ export function AddTagButton({ onClick, disabled }: AddTagButtonProps) { 'text-text-tertiary', 'transition-all duration-150', 'hover:border-muted-foreground hover:text-muted-foreground', - 'focus:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2', + 'focus:outline-none', 'disabled:pointer-events-none disabled:opacity-50' )} > diff --git a/apps/desktop/src/renderer/src/components/note/tags-row/ColorPicker.tsx b/apps/desktop/src/renderer/src/components/note/tags-row/ColorPicker.tsx index aaf423279..d83275fc8 100644 --- a/apps/desktop/src/renderer/src/components/note/tags-row/ColorPicker.tsx +++ b/apps/desktop/src/renderer/src/components/note/tags-row/ColorPicker.tsx @@ -51,7 +51,7 @@ export function ColorPicker({ 'flex h-6 w-6 items-center justify-center rounded-full', 'transition-all duration-150', 'hover:scale-110', - 'focus:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1', + 'focus:outline-none', isSelected && 'ring-2 ring-stone-400 ring-offset-1' )} style={{ backgroundColor: colors.background }} @@ -84,7 +84,7 @@ export function ColorPicker({ type="button" onClick={onCancel} className={cn( - 'rounded-lg px-3 py-1.5', + 'rounded-md px-3 py-1.5', 'text-sm text-stone-600', 'transition-colors duration-150', 'hover:bg-stone-100' @@ -96,7 +96,7 @@ export function ColorPicker({ type="button" onClick={onConfirm} className={cn( - 'rounded-lg px-3 py-1.5', + 'rounded-md px-3 py-1.5', 'text-sm font-medium text-white', 'bg-stone-900', 'transition-colors duration-150', diff --git a/apps/desktop/src/renderer/src/components/note/tags-row/TagInputPopup.tsx b/apps/desktop/src/renderer/src/components/note/tags-row/TagInputPopup.tsx index 472247201..5095040c8 100644 --- a/apps/desktop/src/renderer/src/components/note/tags-row/TagInputPopup.tsx +++ b/apps/desktop/src/renderer/src/components/note/tags-row/TagInputPopup.tsx @@ -29,6 +29,7 @@ export function TagInputPopup({ const inputRef = useRef<HTMLInputElement>(null) const [searchQuery, setSearchQuery] = useState('') const [newTagColor, setNewTagColor] = useState(getRandomColor()) + const [focusedIndex, setFocusedIndex] = useState(-1) useClickOutside(popupRef, onClose, isOpen) @@ -44,6 +45,7 @@ export function TagInputPopup({ if (!isOpen) { setSearchQuery('') setNewTagColor(getRandomColor()) + setFocusedIndex(-1) } }, [isOpen]) @@ -65,22 +67,44 @@ export function TagInputPopup({ return recentTags.filter((tag) => !currentTagIds.includes(tag.id)) }, [recentTags, currentTagIds]) + const visibleTags = filteredTags + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() onClose() + return + } + + if (e.key === 'ArrowDown') { + e.preventDefault() + setFocusedIndex((prev) => (prev < visibleTags.length - 1 ? prev + 1 : 0)) + return + } + + if (e.key === 'ArrowUp') { + e.preventDefault() + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : visibleTags.length - 1)) + return } + if (e.key === 'Enter') { e.preventDefault() + if (focusedIndex >= 0 && focusedIndex < visibleTags.length) { + const tag = visibleTags[focusedIndex] + if (!currentTagIds.includes(tag.id)) { + onAddTag(tag.id) + onClose() + } + return + } const trimmedQuery = searchQuery.trim() if (trimmedQuery) { if (!exactMatchExists) { - // Create new tag with random color onCreateTag(trimmedQuery, newTagColor) onClose() } else if (filteredTags.length > 0) { - // Select the first matching tag if it exists and not already added const firstTag = filteredTags[0] if (!currentTagIds.includes(firstTag.id)) { onAddTag(firstTag.id) @@ -98,7 +122,9 @@ export function TagInputPopup({ onCreateTag, filteredTags, currentTagIds, - onAddTag + onAddTag, + focusedIndex, + visibleTags ] ) @@ -131,14 +157,26 @@ export function TagInputPopup({ > {/* Search input */} <div className="border-b border-stone-200 p-2"> - <div className="flex items-center gap-2 rounded-lg bg-stone-50 px-3 py-2"> + <div className="flex items-center gap-2 rounded-md bg-stone-50 px-3 py-2"> <Search className="h-4 w-4 text-stone-400" /> <input ref={inputRef} type="text" value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { + setSearchQuery(e.target.value) + setFocusedIndex(-1) + }} placeholder="Type tag name..." + role="combobox" + aria-label="Search or create tag" + aria-expanded={filteredTags.length > 0} + aria-haspopup="listbox" + aria-autocomplete="list" + aria-controls="tag-input-popup-listbox" + aria-activedescendant={ + focusedIndex >= 0 ? `tag-input-option-${focusedIndex}` : undefined + } className={cn( 'flex-1 bg-transparent text-sm', 'placeholder:text-stone-400', @@ -173,12 +211,13 @@ export function TagInputPopup({ <div className="mb-1.5 px-1 text-xs font-medium uppercase text-stone-400"> {searchQuery ? 'Matching' : 'All Tags'} </div> - <div className="flex flex-wrap gap-1.5"> - {filteredTags.map((tag) => ( + <div className="flex flex-wrap gap-1.5" role="listbox" aria-label="Available tags"> + {filteredTags.map((tag, index) => ( <TagOption key={tag.id} tag={tag} isSelected={currentTagIds.includes(tag.id)} + isFocused={index === focusedIndex} onClick={() => handleTagClick(tag)} /> ))} @@ -199,10 +238,11 @@ export function TagInputPopup({ interface TagOptionProps { tag: Tag isSelected: boolean + isFocused?: boolean onClick: () => void } -function TagOption({ tag, isSelected, onClick }: TagOptionProps) { +function TagOption({ tag, isSelected, isFocused = false, onClick }: TagOptionProps) { const colors = getTagColors(tag.color) return ( @@ -216,11 +256,14 @@ function TagOption({ tag, isSelected, onClick }: TagOptionProps) { 'inline-flex items-center gap-1 rounded-full px-2.5 py-1', 'text-xs font-medium', 'transition-all duration-150', - isSelected ? 'opacity-50 cursor-default' : 'hover:opacity-80 cursor-pointer' + 'focus-visible:outline-none', + isSelected ? 'opacity-50 cursor-default' : 'hover:opacity-80 cursor-pointer', + isFocused && !isSelected && 'ring-2 ring-offset-1 opacity-100' )} style={{ backgroundColor: colors.background, - color: colors.text + color: colors.text, + ...(isFocused && !isSelected ? { ringColor: colors.text } : {}) }} > {tag.name} diff --git a/apps/desktop/src/renderer/src/components/note/tags-row/tags-row.test.tsx b/apps/desktop/src/renderer/src/components/note/tags-row/tags-row.test.tsx index 197b02dd9..ebcf62017 100644 --- a/apps/desktop/src/renderer/src/components/note/tags-row/tags-row.test.tsx +++ b/apps/desktop/src/renderer/src/components/note/tags-row/tags-row.test.tsx @@ -140,7 +140,7 @@ describe('T512: TagsRow - tag add and autocomplete', () => { await user.click(addButton) // Should show popup with input - expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('combobox')).toBeInTheDocument() }) it('should close popup when add button is clicked again', async () => { @@ -150,12 +150,12 @@ describe('T512: TagsRow - tag add and autocomplete', () => { const addButton = screen.getByRole('button', { name: /add tag/i }) await user.click(addButton) - expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('combobox')).toBeInTheDocument() // Click somewhere else or close await user.keyboard('{Escape}') - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() }) it('should not open popup when disabled', async () => { @@ -165,7 +165,7 @@ describe('T512: TagsRow - tag add and autocomplete', () => { const addButton = screen.getByRole('button', { name: /add tag/i }) await user.click(addButton) - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() }) it('should call onAddTag when existing tag is selected', async () => { @@ -176,7 +176,7 @@ describe('T512: TagsRow - tag add and autocomplete', () => { await user.click(addButton) // Type to filter - const input = screen.getByRole('textbox') + const input = screen.getByRole('combobox') await user.type(input, 'type') // Click on the typescript option (it should appear in filtered results) @@ -207,7 +207,7 @@ describe('T512: TagsRow - tag add and autocomplete', () => { const addButton = screen.getByRole('button', { name: /add tag/i }) await user.click(addButton) - const input = screen.getByRole('textbox') + const input = screen.getByRole('combobox') await user.type(input, 'new-tag{enter}') // Should create new tag with default color @@ -229,7 +229,7 @@ describe('T512: TagsRow - tag add and autocomplete', () => { await user.click(typescriptOption) // Popup should close - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() }) it('should show recent tags section', async () => { @@ -252,7 +252,7 @@ describe('T512: TagsRow - tag add and autocomplete', () => { const addButton = screen.getByRole('button', { name: /add tag/i }) await user.click(addButton) - const input = screen.getByRole('textbox') + const input = screen.getByRole('combobox') await user.type(input, 'java') // Should show javascript (may appear multiple times) diff --git a/apps/desktop/src/renderer/src/components/note/template-selector.tsx b/apps/desktop/src/renderer/src/components/note/template-selector.tsx index 385042763..283ffb99d 100644 --- a/apps/desktop/src/renderer/src/components/note/template-selector.tsx +++ b/apps/desktop/src/renderer/src/components/note/template-selector.tsx @@ -164,7 +164,6 @@ export function TemplateSelector({ 'bg-card/50', 'border-border', 'focus:border-amber-400 dark:focus:border-amber-600', - 'focus:ring-1 focus:ring-amber-100 dark:focus:ring-amber-900/30', 'placeholder:text-muted-foreground/50', 'transition-all duration-200' )} diff --git a/apps/desktop/src/renderer/src/components/note/version-history.tsx b/apps/desktop/src/renderer/src/components/note/version-history.tsx index cd0a6425f..65ed5a4b4 100644 --- a/apps/desktop/src/renderer/src/components/note/version-history.tsx +++ b/apps/desktop/src/renderer/src/components/note/version-history.tsx @@ -305,8 +305,7 @@ export function VersionHistory({ onClick={() => handleSelectVersion(version.id)} className={cn( 'w-full text-left px-3 py-2.5 rounded-md transition-colors', - 'hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1', - 'focus-visible:ring-ring focus-visible:ring-offset-2', + 'hover:bg-muted/50 focus-visible:outline-none', isSelected && 'bg-muted' )} > diff --git a/apps/desktop/src/renderer/src/components/notes-tree.test.tsx b/apps/desktop/src/renderer/src/components/notes-tree.test.tsx index b28e65f06..54670ee37 100644 --- a/apps/desktop/src/renderer/src/components/notes-tree.test.tsx +++ b/apps/desktop/src/renderer/src/components/notes-tree.test.tsx @@ -10,6 +10,7 @@ import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { NotesTree } from './notes-tree' import type { NoteListItem } from '@/hooks/use-notes-query' +import type { FolderInfo } from '../../../preload/index.d' import { TooltipProvider } from '@/components/ui/tooltip' // Wrapper for tests that provides TooltipProvider @@ -45,14 +46,16 @@ vi.mock('@/contexts/tabs', () => ({ }), useTabActions: () => ({ openTab: vi.fn(), - closeTab: vi.fn() + closeTab: vi.fn(), + updateTabTitleByEntityId: vi.fn() }) })) vi.mock('@/hooks/use-notes-query', () => ({ useNotesList: vi.fn(), useNoteFoldersQuery: vi.fn(), - useNoteMutations: vi.fn() + useNoteMutations: vi.fn(), + notesKeys: { notes: () => ['notes'], note: (id: string) => ['notes', id] } })) vi.mock('@/services/notes-service', () => ({ @@ -63,7 +66,9 @@ vi.mock('@/services/notes-service', () => ({ openExternal: vi.fn().mockResolvedValue({}), revealInFinder: vi.fn().mockResolvedValue({}), deleteFolder: vi.fn().mockResolvedValue({}), - renameFolder: vi.fn().mockResolvedValue({}) + renameFolder: vi.fn().mockResolvedValue({}), + getAllPositions: vi.fn().mockResolvedValue({ success: true, positions: {} }), + reorder: vi.fn().mockResolvedValue({ success: true }) } })) @@ -110,11 +115,14 @@ const mockNotes: NoteListItem[] = [ createNote('note-5', 'notes/Daily Journal.md', { emoji: '📝' }) ] -const mockFolders = ['Projects', 'Archive'] +const mockFolders: FolderInfo[] = [ + { path: 'Projects', icon: null }, + { path: 'Archive', icon: null } +] const setupMocks = ( notes: NoteListItem[] = mockNotes, - folders: string[] = mockFolders, + folders: FolderInfo[] = mockFolders, loading = false, error: Error | null = null ) => { @@ -132,7 +140,9 @@ const setupMocks = ( isLoading: false, error: null, refetch: vi.fn(), - createFolder: vi.fn().mockResolvedValue(true) + refreshFolders: vi.fn(), + createFolder: vi.fn().mockResolvedValue(true), + setFolderIcon: vi.fn().mockResolvedValue(true) }) ;(useNoteMutations as ReturnType<typeof vi.fn>).mockReturnValue({ createNote: { @@ -156,9 +166,19 @@ const setupMocks = ( // T521: NotesTree - Folder Tree Display Tests // ============================================================================ +const patchTemplatesMock = () => { + const w = window as Window & { api?: Record<string, unknown> } + if (w.api?.templates) { + ;(w.api.templates as Record<string, unknown>).list = vi + .fn() + .mockResolvedValue({ templates: [] }) + } +} + describe('T521: NotesTree - folder tree display', () => { beforeEach(() => { vi.clearAllMocks() + patchTemplatesMock() setupMocks() }) @@ -279,6 +299,7 @@ describe('T521: NotesTree - folder tree display', () => { describe('T522: NotesTree - context menu', () => { beforeEach(() => { vi.clearAllMocks() + patchTemplatesMock() setupMocks() }) @@ -371,6 +392,7 @@ describe('T522: NotesTree - context menu', () => { describe('T522: NotesTree - inline rename', () => { beforeEach(() => { vi.clearAllMocks() + patchTemplatesMock() setupMocks() }) @@ -408,6 +430,7 @@ describe('T522: NotesTree - inline rename', () => { describe('T522: NotesTree - delete', () => { beforeEach(() => { vi.clearAllMocks() + patchTemplatesMock() setupMocks() }) @@ -454,6 +477,7 @@ describe('T522: NotesTree - delete', () => { describe('T522: NotesTree - multi-selection', () => { beforeEach(() => { vi.clearAllMocks() + patchTemplatesMock() setupMocks() }) @@ -496,6 +520,7 @@ describe('T522: NotesTree - multi-selection', () => { describe('T522: NotesTree - keyboard navigation', () => { beforeEach(() => { vi.clearAllMocks() + patchTemplatesMock() setupMocks() }) @@ -530,6 +555,7 @@ describe('T522: NotesTree - keyboard navigation', () => { describe('NotesTree - accessibility', () => { beforeEach(() => { vi.clearAllMocks() + patchTemplatesMock() setupMocks() }) diff --git a/apps/desktop/src/renderer/src/components/notes-tree.tsx b/apps/desktop/src/renderer/src/components/notes-tree.tsx index 86231ea2b..d58848370 100644 --- a/apps/desktop/src/renderer/src/components/notes-tree.tsx +++ b/apps/desktop/src/renderer/src/components/notes-tree.tsx @@ -33,6 +33,7 @@ import { useNoteMutations, type NoteListItem } from '@/hooks/use-notes-query' +import type { FolderInfo } from '../../../preload/index.d' import { notesService } from '@/services/notes-service' import { FileText, @@ -47,7 +48,6 @@ import { FolderOpen, FilePlus, FolderPlus, - Import, LayoutTemplate, LayoutGrid, X, @@ -55,12 +55,12 @@ import { Image, Music, Video, - Monitor + Monitor, + Smile } from '@/lib/icons' import { toast } from 'sonner' import { Skeleton } from '@/components/ui/skeleton' import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu' import { AlertDialog, @@ -73,6 +73,8 @@ import { AlertDialogTitle } from '@/components/ui/alert-dialog' import { TemplateSelector } from '@/components/note/template-selector' +import { NoteIconDisplay } from '@/lib/render-note-icon' +import { FolderIconButton } from '@/components/folder-icon-button' import { getTabIconForFileType, type FileType } from '@memry/shared/file-types' import { createLogger } from '@/lib/logger' @@ -85,6 +87,7 @@ const log = createLogger('Component:NotesTree') interface FolderNode { name: string path: string + icon?: string | null children: FolderNode[] notes: NoteListItem[] } @@ -94,6 +97,30 @@ interface TreeStructure { rootNotes: NoteListItem[] } +/** + * Wrapper that reads tree expand state and passes it to FolderIconButton. + * Must be rendered inside TreeProvider. + */ +function TreeFolderIcon({ + nodeId, + hasChildren, + ...props +}: Omit<React.ComponentProps<typeof FolderIconButton>, 'isExpanded'> & { + nodeId: string +}) { + const { expandedIds, toggleExpanded } = useTree() + const isExpanded = expandedIds.has(nodeId) + + return ( + <FolderIconButton + {...props} + isExpanded={isExpanded} + hasChildren={hasChildren} + onToggleExpand={() => toggleExpanded(nodeId)} + /> + ) +} + /** * Get display name from note path (filename without extension) */ @@ -109,13 +136,9 @@ function getDisplayName(notePath: string): string { * Returns the icon element to render in the tree. */ function getFileIcon(note: NoteListItem): React.ReactElement { - // Emoji takes priority for markdown files + // Emoji/icon takes priority for markdown files if (note.emoji) { - return ( - <span className="text-sm leading-none" role="img" aria-label="note icon"> - {note.emoji} - </span> - ) + return <NoteIconDisplay value={note.emoji} className="text-sm leading-none" /> } // Get icon based on file type @@ -130,7 +153,7 @@ function getFileIcon(note: NoteListItem): React.ReactElement { case 'audio': return <Music className={`${iconClass} text-green-500`} /> case 'video': - return <Video className={`${iconClass} text-purple-500`} /> + return <Video className={iconClass} /> case 'markdown': default: return <FileText className={iconClass} /> @@ -226,12 +249,17 @@ function getFoldersInParent(tree: TreeStructure, parentPath: string): string[] { */ function buildTreeFromNotes( notes: NoteListItem[], - folders: string[], + folders: FolderInfo[], positions: Record<string, number> ): TreeStructure { const folderMap = new Map<string, FolderNode>() const rootNotes: NoteListItem[] = [] + const folderIconMap = new Map<string, string | null>() + for (const f of folders) { + folderIconMap.set(f.path, f.icon ?? null) + } + const ensureFolderInMap = (folderPath: string): FolderNode => { const existing = folderMap.get(folderPath) if (existing) return existing @@ -248,6 +276,7 @@ function buildTreeFromNotes( const node: FolderNode = { name: part, path: currentPath, + icon: folderIconMap.get(currentPath) ?? null, children: [], notes: [] } @@ -268,8 +297,8 @@ function buildTreeFromNotes( return lastNode! } - folders.forEach((folderPath) => { - ensureFolderInMap(folderPath) + folders.forEach((f) => { + ensureFolderInMap(f.path) }) notes.forEach((note) => { @@ -478,14 +507,17 @@ function FolderRevealHandler() { // Main Component // ============================================================================ +interface NotesTreeActions { + createNote: () => void + createFolder: () => void +} + interface NotesTreeProps { - /** Callback to receive action buttons for external rendering */ - onActionsReady?: (actions: React.ReactNode) => void - /** Callback when the focused target folder changes */ onTargetFolderChange?: (folder: string) => void + onActionsReady?: (actions: NotesTreeActions) => void } -export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreeProps = {}) { +export function NotesTree({ onTargetFolderChange, onActionsReady }: NotesTreeProps = {}) { // Load all notes so the tree can correctly show files in all folders // Tree views need complete data - pagination doesn't make sense here const { notes, isLoading, error } = useNotesList({ limit: 10000 }) @@ -496,7 +528,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro const deleteNoteMutateAsync = mutations.deleteNote.mutateAsync const renameNoteMutateAsync = mutations.renameNote.mutateAsync const moveNoteMutateAsync = mutations.moveNote.mutateAsync - const { folders, createFolder, refetch: refreshFolders } = useNoteFoldersQuery() + const { folders, createFolder, setFolderIcon, refetch: refreshFolders } = useNoteFoldersQuery() const { openTab, closeTab, updateTabTitleByEntityId } = useTabActions() const queryClient = useQueryClient() const originalRenameTitle = useRef<string>('') @@ -522,6 +554,9 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro const [isTreeFocused, setIsTreeFocused] = useState(false) const isTreeFocusedRef = useRef(false) + // Folder icon picker state + const [iconPickerFolderPath, setIconPickerFolderPath] = useState<string | null>(null) + // Inline rename state for folders const [renamingFolderPath, setRenamingFolderPath] = useState<string | null>(null) const [folderRenameValue, setFolderRenameValue] = useState('') @@ -560,13 +595,13 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro // Fetch configs for all folders and build names map const namesMap = new Map<string, string>() await Promise.all( - folders.map(async (folderPath) => { + folders.map(async (f) => { try { - const config = await notesService.getFolderConfig(folderPath) + const config = await notesService.getFolderConfig(f.path) if (config?.template) { const templateName = templatesMap.get(config.template) if (templateName) { - namesMap.set(folderPath, templateName) + namesMap.set(f.path, templateName) } } } catch { @@ -724,6 +759,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro } } catch (err) { log.error('Failed to create note', err) + toast.error(extractErrorMessage(err, 'Failed to create note')) } finally { setIsCreating(false) } @@ -797,7 +833,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro let counter = 1 const targetPath = folder ? `${folder}/` : '' - while (folders.includes(`${targetPath}${folderName}`)) { + while (folders.some((f) => f.path === `${targetPath}${folderName}`)) { folderName = `${baseName} ${counter++}` } @@ -811,40 +847,20 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro } } catch (err) { log.error('Failed to create folder', err) + toast.error(extractErrorMessage(err, 'Failed to create folder')) } finally { setIsCreatingFolder(false) } }, [isCreatingFolder, createFolder, folders, targetFolder, refreshFolders]) - const handleImportFiles = useCallback(async () => { - const folder = isTreeFocusedRef.current ? targetFolder : '' - try { - const dialogResult = await notesService.showImportDialog() - if (dialogResult.canceled || dialogResult.filePaths.length === 0) { - return - } - - const result = await notesService.importFiles(dialogResult.filePaths, folder || '') - - if (result.imported > 0) { - toast.success(`Imported ${result.imported} file${result.imported > 1 ? 's' : ''}`) - } - - if (result.failed > 0) { - toast.error(`Failed to import ${result.failed} file${result.failed > 1 ? 's' : ''}`, { - description: result.errors.join('\n') - }) - } - } catch (err) { - log.error('Failed to import files', err) - toast.error('Failed to import files') - } - }, [targetFolder]) - useEffect(() => { onTargetFolderChange?.(targetFolder) }, [targetFolder, onTargetFolderChange]) + useEffect(() => { + onActionsReady?.({ createNote: handleCreateNote, createFolder: handleCreateFolder }) + }, [onActionsReady, handleCreateNote, handleCreateFolder]) + // Handle creating a note in a specific folder (from context menu) const handleCreateNoteInFolder = useCallback( async (folderPath: string) => { @@ -879,6 +895,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro } } catch (err) { log.error('Failed to create note', err) + toast.error(extractErrorMessage(err, 'Failed to create note')) } finally { setIsCreating(false) } @@ -898,7 +915,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro let counter = 1 const targetPath = parentPath ? `${parentPath}/` : '' - while (folders.includes(`${targetPath}${folderName}`)) { + while (folders.some((f) => f.path === `${targetPath}${folderName}`)) { folderName = `${baseName} ${counter++}` } @@ -910,6 +927,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro } } catch (err) { log.error('Failed to create folder', err) + toast.error(extractErrorMessage(err, 'Failed to create folder')) } finally { setIsCreatingFolder(false) } @@ -979,6 +997,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro } catch (err) { log.error('Failed to rename note', err) revertOptimisticTitle(noteId) + toast.error(extractErrorMessage(err, 'Failed to rename note')) } finally { setIsRenaming(false) setRenamingNoteId(null) @@ -1032,6 +1051,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro await refreshFolders() } catch (err) { log.error('Failed to rename folder', err) + toast.error(extractErrorMessage(err, 'Failed to rename folder')) } finally { setIsFolderRenaming(false) setRenamingFolderPath(null) @@ -1565,83 +1585,6 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro [setSelectedIds] ) - // Render action buttons (must be before early returns to follow Rules of Hooks) - const actionButtons = useMemo( - () => ( - <> - {/* New Note button */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="h-6 w-6" - onClick={handleCreateNote} - disabled={isCreating} - > - {isCreating ? ( - <Loader2 className="h-3.5 w-3.5 animate-spin" /> - ) : ( - <FilePlus className="h-3.5 w-3.5" /> - )} - <span className="sr-only">New Note</span> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom"> - <p>New note{targetFolder ? ` in ${targetFolder}` : ''}</p> - </TooltipContent> - </Tooltip> - {/* New Folder button */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="h-6 w-6" - onClick={handleCreateFolder} - disabled={isCreatingFolder} - > - {isCreatingFolder ? ( - <Loader2 className="h-3.5 w-3.5 animate-spin" /> - ) : ( - <FolderPlus className="h-3.5 w-3.5" /> - )} - <span className="sr-only">New Folder</span> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom"> - <p>New folder{targetFolder ? ` in ${targetFolder}` : ''}</p> - </TooltipContent> - </Tooltip> - {/* Import Files button */} - <Tooltip> - <TooltipTrigger asChild> - <Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleImportFiles}> - <Import className="h-3.5 w-3.5" /> - <span className="sr-only">Import Files</span> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom"> - <p>Import files{targetFolder ? ` to ${targetFolder}` : ''}</p> - </TooltipContent> - </Tooltip> - </> - ), - [ - handleCreateNote, - handleCreateFolder, - handleImportFiles, - isCreating, - isCreatingFolder, - targetFolder - ] - ) - - // Notify parent about action buttons (must be before early returns) - useEffect(() => { - onActionsReady?.(actionButtons) - }, [onActionsReady, actionButtons]) - // Render loading state if (isLoading) { return <NotesTreeSkeleton /> @@ -1658,14 +1601,14 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro } // Render note item with context menu - const renderNote = (note: NoteListItem, level: number, isLast: boolean) => { + const renderNote = (note: NoteListItem, level: number, isLast: boolean, hideLines = false) => { const isBeingRenamed = renamingNoteId === note.id const isSelected = selectedIds.includes(note.id) const hasMultipleSelected = selectedIds.length > 1 const isPartOfSelection = isSelected && hasMultipleSelected return ( - <TreeNode key={note.id} nodeId={note.id} level={level} isLast={isLast}> + <TreeNode key={note.id} nodeId={note.id} level={level} isLast={isLast} hideLines={hideLines}> <TreeNodeTrigger contextMenuContent={ <> @@ -1702,7 +1645,6 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro </> } > - <TreeExpander /> <TreeIcon icon={getFileIcon(note)} /> {isBeingRenamed ? ( <input @@ -1723,7 +1665,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro onBlur={() => handleRenameSubmit(note.id, note.path)} onClick={(e) => e.stopPropagation()} disabled={isRenaming} - className="flex-1 h-5 px-1 text-sm bg-background border border-input rounded focus:outline-none focus:ring-1 focus:ring-ring" + className="flex-1 h-5 px-1 text-sm bg-background border border-input rounded focus:outline-none" /> ) : ( <TreeLabel>{getDisplayName(note.path)}</TreeLabel> @@ -1749,6 +1691,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro > <TreeNodeTrigger expandOnly + className="group/folderrow" contextMenuContent={ <> <ContextMenuItem onClick={() => handleCreateNoteInFolder(folder.path)}> @@ -1774,6 +1717,17 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro Clear Default Template </ContextMenuItem> <ContextMenuSeparator /> + <ContextMenuItem onClick={() => setIconPickerFolderPath(folder.path)}> + <Smile className="mr-2 h-4 w-4" /> + Set Icon + </ContextMenuItem> + {folder.icon && ( + <ContextMenuItem onClick={() => void setFolderIcon(folder.path, null)}> + <X className="mr-2 h-4 w-4" /> + Remove Icon + </ContextMenuItem> + )} + <ContextMenuSeparator /> <ContextMenuItem onClick={() => handleRenameFolderClick(folder.path)}> <Pencil className="mr-2 h-4 w-4" /> Rename @@ -1788,8 +1742,14 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro </> } > - <TreeExpander hasChildren={hasChildren} /> - <TreeIcon hasChildren={hasChildren} icon={<Folder className="h-4 w-4" />} /> + <TreeFolderIcon + nodeId={`folder-${folder.path}`} + icon={folder.icon ?? null} + hasChildren={hasChildren} + onIconChange={(icon) => void setFolderIcon(folder.path, icon)} + pickerOpen={iconPickerFolderPath === folder.path} + onPickerOpenChange={(open) => setIconPickerFolderPath(open ? folder.path : null)} + /> {isBeingRenamed ? ( <input ref={folderRenameCallbackRef} @@ -1809,7 +1769,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro onBlur={() => handleFolderRenameSubmit(folder.path)} onClick={(e) => e.stopPropagation()} disabled={isFolderRenaming} - className="flex-1 h-5 px-1 text-sm bg-background border border-input rounded focus:outline-none focus:ring-1 focus:ring-ring" + className="flex-1 h-5 px-1 text-sm bg-background border border-input rounded focus:outline-none" /> ) : ( <div className="group/folder flex flex-1 items-center min-w-0"> @@ -1822,7 +1782,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro e.stopPropagation() handleOpenFolderView(folder.path) }} - className="p-1 cursor-pointer rounded hover:bg-accent/80 transition-colors" + className="p-1 cursor-pointer rounded" aria-label="Open folder view" > <LayoutGrid className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" /> @@ -1874,7 +1834,7 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro onMove={handleMove} animateExpand={false} multiSelect={true} - indent={16} + indent={26} > {/* Handle reveal-in-sidebar requests */} <RevealHandler @@ -1894,9 +1854,9 @@ export function NotesTree({ onActionsReady, onTargetFolderChange }: NotesTreePro ) )} - {/* Root notes after folders */} + {/* Root notes — indented to align with folder children, no indent lines */} {tree.rootNotes.map((note, index) => - renderNote(note, 0, index === tree.rootNotes.length - 1) + renderNote(note, 1, index === tree.rootNotes.length - 1, true) )} </TreeView> </TreeProvider> diff --git a/apps/desktop/src/renderer/src/components/quick-actions.tsx b/apps/desktop/src/renderer/src/components/quick-actions.tsx index a4143376f..85fcd9dc9 100644 --- a/apps/desktop/src/renderer/src/components/quick-actions.tsx +++ b/apps/desktop/src/renderer/src/components/quick-actions.tsx @@ -48,7 +48,7 @@ const QuickActions = ({ className={cn( 'rounded-md', 'transition-[background-color,color,transform] duration-[var(--duration-instant)] ease-[var(--ease-out)]', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', 'hover:scale-110 active:scale-95', isRow ? 'p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent' diff --git a/apps/desktop/src/renderer/src/components/quick-capture-footer.tsx b/apps/desktop/src/renderer/src/components/quick-capture-footer.tsx new file mode 100644 index 000000000..91645220b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/quick-capture-footer.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/lib/utils' + +interface QuickCaptureFooterProps { + className?: string +} + +export function QuickCaptureFooter({ className }: QuickCaptureFooterProps): React.JSX.Element { + const isMac = navigator.platform.includes('Mac') + const modKey = isMac ? '⌘' : 'Ctrl' + + return ( + <div + className={cn( + 'flex items-center justify-between px-4 py-2 border-t border-border/30', + className + )} + > + <div className="flex items-center gap-1"> + <kbd className="flex items-center justify-center rounded-[4px] px-1.5 py-0.5 bg-foreground/[0.06] border border-foreground/[0.08] font-mono text-[10px]/3 font-medium text-muted-foreground/60"> + Esc + </kbd> + <span className="font-sans text-[11px]/3.5 text-muted-foreground/60">close</span> + </div> + <div className="flex items-center gap-1"> + <kbd className="flex items-center justify-center rounded-[4px] px-1.5 py-0.5 bg-accent-orange/[0.12] border border-accent-orange/20 font-mono text-[10px]/3 font-medium text-accent-orange"> + {modKey} ↵ + </kbd> + <span className="font-sans text-[11px]/3.5 font-medium text-accent-orange/70">capture</span> + </div> + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/quick-capture-image-preview.tsx b/apps/desktop/src/renderer/src/components/quick-capture-image-preview.tsx new file mode 100644 index 000000000..3b458606f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/quick-capture-image-preview.tsx @@ -0,0 +1,95 @@ +import { Check, X } from '@/lib/icons' + +export type PreviewVariant = 'image' | 'pdf' | 'social' + +const VARIANT_CONFIG: Record< + PreviewVariant, + { + gradient: string + defaultInitial: string + badge: string + badgeBg: string + badgeText: string + } +> = { + image: { + gradient: 'from-[oklch(0.541_0.096_-0.227)] to-[oklch(0.627_0.130_-0.193)]', + defaultInitial: 'I', + badge: 'IMAGE', + badgeBg: 'bg-accent-purple/[0.08]', + badgeText: 'text-accent-purple' + }, + pdf: { + gradient: 'from-[oklch(0.505_0.169_0.088)] to-[oklch(0.637_0.188_0.089)]', + defaultInitial: 'P', + badge: 'PDF', + badgeBg: 'bg-destructive/[0.08]', + badgeText: 'text-destructive' + }, + social: { + gradient: 'from-[oklch(0.164_0_0)] to-[oklch(0.269_0_0)]', + defaultInitial: 'X', + badge: 'SOCIAL', + badgeBg: 'bg-accent-cyan/[0.08]', + badgeText: 'text-accent-cyan' + } +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +interface FilePreviewCardProps { + variant: PreviewVariant + title: string + subtitle: string + onClear: () => void + initial?: string +} + +export function FilePreviewCard({ + variant, + title, + subtitle, + onClear, + initial +}: FilePreviewCardProps): React.JSX.Element { + const config = VARIANT_CONFIG[variant] + const displayInitial = initial ?? config.defaultInitial + + return ( + <div className="flex items-center gap-2.5 px-4 py-2.5 bg-foreground/[0.02] border-t border-border/30"> + <div + className={`flex items-center justify-center size-7 rounded-md shrink-0 bg-gradient-to-br ${config.gradient}`} + > + <span className="font-heading text-[13px] font-bold text-white">{displayInitial}</span> + </div> + + <div className="flex flex-col gap-px flex-1 min-w-0"> + <span className="text-[13px]/[18px] font-medium text-foreground truncate">{title}</span> + <span className="text-[11px]/[16px] text-muted-foreground/60 truncate">{subtitle}</span> + </div> + + <div + className={`flex items-center gap-1 rounded-full py-0.5 px-2 shrink-0 ${config.badgeBg}`} + > + <Check className={`size-2.5 ${config.badgeText}`} /> + <span className={`text-[10px]/3 font-semibold tracking-wide ${config.badgeText}`}> + {config.badge} + </span> + </div> + + <button + onClick={onClear} + className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-foreground/60 transition-colors" + aria-label="Remove attachment" + > + <X className="size-3" /> + </button> + </div> + ) +} + +export { FilePreviewCard as ImagePreviewCard } diff --git a/apps/desktop/src/renderer/src/components/quick-capture-input.tsx b/apps/desktop/src/renderer/src/components/quick-capture-input.tsx new file mode 100644 index 000000000..af925cf31 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/quick-capture-input.tsx @@ -0,0 +1,146 @@ +import { useEffect, useCallback, useRef } from 'react' +import { Plus, Link, Mic, Image, FileIcon, Paperclip, Globe } from '@/lib/icons' +import { cn } from '@/lib/utils' + +type DetectedType = 'note' | 'link' | 'image' | 'voice' | 'pdf' | 'social' + +const TYPE_ICONS: Record<DetectedType, typeof Plus> = { + note: Plus, + link: Link, + image: Image, + voice: Mic, + pdf: FileIcon, + social: Globe +} + +const MAX_TEXTAREA_HEIGHT = 200 + +interface QuickCaptureInputProps { + value: string + onChange: (value: string) => void + onSubmit: () => void + onStartRecording: () => void + onPaste: (e: React.ClipboardEvent) => void + detectedType: DetectedType + isCapturing: boolean + hasAttachment: boolean + textareaRef: React.RefObject<HTMLTextAreaElement | null> +} + +export function QuickCaptureInput({ + value, + onChange, + onSubmit, + onStartRecording, + onPaste, + detectedType, + isCapturing, + hasAttachment, + textareaRef +}: QuickCaptureInputProps): React.JSX.Element { + const fileInputRef = useRef<HTMLInputElement>(null) + + const TypeIcon = TYPE_ICONS[detectedType] + + useEffect(() => { + const textarea = textareaRef.current + if (!textarea) return + + textarea.style.height = 'auto' + const scrollHeight = Math.min(textarea.scrollHeight, MAX_TEXTAREA_HEIGHT) + textarea.style.height = `${scrollHeight}px` + }, [value, textareaRef]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + window.api.quickCapture.close() + return + } + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + onSubmit() + } + }, + [onSubmit] + ) + + const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0] + if (file) { + const event = new CustomEvent('quick-capture:file-selected', { detail: file }) + window.dispatchEvent(event) + } + if (fileInputRef.current) fileInputRef.current.value = '' + }, []) + + return ( + <div className="flex items-start gap-2.5 px-4 py-3.5"> + <div className="mt-[3px] shrink-0 text-accent-orange transition-colors duration-150"> + <TypeIcon className="size-[18px]" aria-hidden="true" /> + </div> + + <textarea + ref={textareaRef} + value={value} + onChange={(e) => onChange(e.target.value)} + onKeyDown={handleKeyDown} + onPaste={onPaste} + placeholder={hasAttachment ? 'Add a note (optional)...' : 'Capture anything...'} + disabled={isCapturing} + rows={1} + className={cn( + 'flex-1 min-h-[24px] max-h-[200px]', + 'resize-none bg-transparent', + 'text-[15px]/[22px] text-foreground font-sans', + 'placeholder:text-foreground/[0.28]', + 'focus:outline-none', + 'disabled:cursor-not-allowed disabled:opacity-50' + )} + aria-label="Quick capture input" + /> + + <div className="flex items-center gap-0.5 mt-[2px]"> + <button + onClick={onStartRecording} + disabled={isCapturing} + className={cn( + 'flex items-center justify-center size-8 rounded-lg', + 'bg-foreground/[0.04] text-muted-foreground/40', + 'transition-colors duration-150', + 'hover:text-foreground/60 hover:bg-foreground/[0.07]', + 'disabled:cursor-not-allowed disabled:opacity-30' + )} + aria-label="Record voice memo" + > + <Mic className="size-[15px]" /> + </button> + + <button + onClick={() => fileInputRef.current?.click()} + disabled={isCapturing} + className={cn( + 'flex items-center justify-center size-8 rounded-lg', + 'bg-foreground/[0.04] text-muted-foreground/40', + 'transition-colors duration-150', + 'hover:text-foreground/60 hover:bg-foreground/[0.07]', + 'disabled:cursor-not-allowed disabled:opacity-30' + )} + aria-label="Attach file" + > + <Paperclip className="size-[15px]" /> + </button> + </div> + + <input + ref={fileInputRef} + type="file" + accept="image/*,audio/*,application/pdf" + onChange={handleFileSelect} + className="hidden" + aria-hidden="true" + /> + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/quick-capture-link-preview.tsx b/apps/desktop/src/renderer/src/components/quick-capture-link-preview.tsx new file mode 100644 index 000000000..487266d17 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/quick-capture-link-preview.tsx @@ -0,0 +1,65 @@ +import { Check } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface LinkPreviewCardProps { + title: string + domain: string + favicon?: string + loading?: boolean +} + +export function LinkPreviewCard({ + title, + domain, + favicon, + loading +}: LinkPreviewCardProps): React.JSX.Element { + const initial = domain.charAt(0).toUpperCase() + + if (loading) { + return ( + <div className="flex items-center gap-2.5 px-4 py-2.5 bg-foreground/[0.02] border-t border-border/30"> + <div className="size-7 rounded-md bg-foreground/[0.06] animate-pulse shrink-0" /> + <div className="flex flex-col gap-1.5 flex-1 min-w-0"> + <div className="h-3.5 w-40 rounded bg-foreground/[0.06] animate-pulse" /> + <div className="h-3 w-20 rounded bg-foreground/[0.04] animate-pulse" /> + </div> + </div> + ) + } + + return ( + <div className="flex items-center gap-2.5 px-4 py-2.5 bg-foreground/[0.02] border-t border-border/30"> + {favicon ? ( + <img + src={favicon} + alt="" + className="size-7 rounded-md object-cover shrink-0 bg-foreground/[0.06]" + onError={(e) => { + e.currentTarget.style.display = 'none' + e.currentTarget.nextElementSibling?.classList.remove('hidden') + }} + /> + ) : null} + <div + className={cn( + 'flex items-center justify-center size-7 rounded-md shrink-0', + 'bg-gradient-to-br from-[oklch(0.567_0.014_-0.158)] to-[oklch(0.606_0.085_-0.202)]', + favicon && 'hidden' + )} + > + <span className="font-heading text-[13px] font-bold text-white">{initial}</span> + </div> + + <div className="flex flex-col gap-px flex-1 min-w-0"> + <span className="text-[13px]/[18px] font-medium text-foreground truncate">{title}</span> + <span className="text-[11px]/[16px] text-muted-foreground/60">{domain}</span> + </div> + + <div className="flex items-center gap-1 rounded-full py-0.5 px-2 bg-accent-green/10 shrink-0"> + <Check className="size-2.5 text-accent-green" /> + <span className="text-[10px]/3 font-semibold tracking-wide text-accent-green">LINK</span> + </div> + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/quick-capture-states.tsx b/apps/desktop/src/renderer/src/components/quick-capture-states.tsx new file mode 100644 index 000000000..cf3a12ba1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/quick-capture-states.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react' +import { Check, Copy, X } from '@/lib/icons' + +interface CaptureSuccessProps { + onAutoClose: () => void +} + +export function CaptureSuccess({ onAutoClose }: CaptureSuccessProps): React.JSX.Element { + useEffect(() => { + const timer = setTimeout(onAutoClose, 1000) + return () => clearTimeout(timer) + }, [onAutoClose]) + + return ( + <div className="flex items-center gap-2.5 px-4 py-3.5"> + <div className="flex size-5 items-center justify-center rounded-full bg-green-500/15"> + <Check className="size-3 text-green-500" /> + </div> + <span className="text-sm font-medium text-foreground">Captured</span> + </div> + ) +} + +interface CaptureErrorProps { + message: string + onDismiss: () => void +} + +export function CaptureError({ message, onDismiss }: CaptureErrorProps): React.JSX.Element { + return ( + <div className="flex items-center gap-2 px-4 py-1.5"> + <span className="flex-1 text-xs text-destructive truncate">{message}</span> + <button + onClick={onDismiss} + className="shrink-0 rounded p-0.5 text-muted-foreground/50 hover:text-foreground transition-colors" + aria-label="Dismiss error" + > + <X className="size-3" /> + </button> + </div> + ) +} + +interface CaptureDuplicateProps { + title: string + createdAt: string + onForce: () => void + onClose: () => void +} + +export function CaptureDuplicate({ + title, + createdAt, + onForce, + onClose +}: CaptureDuplicateProps): React.JSX.Element { + const dateStr = new Date(createdAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }) + + return ( + <div className="flex flex-col gap-2.5 px-4 py-3.5"> + <div className="flex items-center gap-2"> + <Copy className="size-4 text-accent-orange" /> + <span className="text-sm font-medium text-foreground">Already captured</span> + </div> + <p className="text-xs text-muted-foreground truncate"> + “{title.slice(0, 60)} + {title.length > 60 ? '...' : ''}” · {dateStr} + </p> + <div className="flex gap-2"> + <button + onClick={onForce} + className="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted" + > + Capture Anyway + </button> + <button + onClick={onClose} + className="rounded-md bg-accent-orange px-2.5 py-1 text-xs font-medium text-white transition-colors hover:opacity-90" + > + Close + </button> + </div> + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/quick-capture.tsx b/apps/desktop/src/renderer/src/components/quick-capture.tsx index 4e716d54e..8fa0a5bf9 100644 --- a/apps/desktop/src/renderer/src/components/quick-capture.tsx +++ b/apps/desktop/src/renderer/src/components/quick-capture.tsx @@ -1,15 +1,21 @@ import { useState, useCallback, useRef, useEffect } from 'react' -import { Send, Loader2, Link, FileText, Check, X, Image, Mic, FileIcon, Copy } from '@/lib/icons' +import { Image, Loader2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { extractErrorMessage } from '@/lib/ipc-error' import { useCaptureText, useCaptureLink, useCaptureImage, useCaptureVoice } from '@/hooks/use-inbox' import { VoiceRecorder } from './voice-recorder' +import { QuickCaptureInput } from './quick-capture-input' +import { QuickCaptureFooter } from './quick-capture-footer' +import { CaptureSuccess, CaptureError, CaptureDuplicate } from './quick-capture-states' +import { LinkPreviewCard } from './quick-capture-link-preview' +import { FilePreviewCard, formatFileSize } from './quick-capture-image-preview' +import { detectPlatformFromUrl, extractHandleFromUrl } from './social-card' import { createLogger } from '@/lib/logger' const log = createLogger('Component:QuickCapture') type CaptureState = 'idle' | 'capturing' | 'success' | 'error' | 'duplicate' -type DetectedType = 'note' | 'link' | 'image' | 'voice' | 'pdf' +type DetectedType = 'note' | 'link' | 'image' | 'voice' | 'pdf' | 'social' const URL_REGEX = /^(https?:\/\/|www\.)[^\s]+$|^[^\s]+\.(com|org|net|io|co|dev|app|me|info|biz|edu|gov)[^\s]*$/i @@ -27,14 +33,6 @@ function normalizeUrl(text: string): string { return `https://${trimmed}` } -const TYPE_ICONS: Record<DetectedType, typeof FileText> = { - note: FileText, - link: Link, - image: Image, - voice: Mic, - pdf: FileIcon -} - const DROPPABLE_TYPES = new Set([ 'image/png', 'image/jpeg', @@ -71,9 +69,39 @@ export function QuickCapture(): React.JSX.Element { const captureImage = useCaptureImage() const captureVoice = useCaptureVoice() + const [linkPreview, setLinkPreview] = useState<{ + title: string + domain: string + favicon?: string + description?: string + } | null>(null) + const [previewLoading, setPreviewLoading] = useState(false) + const previewUrlRef = useRef<string | null>(null) + const isCapturing = captureState === 'capturing' + const hasAttachment = !!clipboardImage || !!droppedFile + + const attachmentFilename = + droppedFile?.name ?? + (clipboardImage ? `clipboard-${new Date().toISOString().slice(0, 10)}.png` : '') + const attachmentSize = droppedFile?.size ?? clipboardImage?.size ?? 0 + const attachmentExtension = droppedFile + ? (droppedFile.name.split('.').pop() ?? droppedFile.type.split('/')[1] ?? 'unknown') + : (clipboardImage?.type.split('/')[1] ?? 'png') + + useEffect(() => { + if (hasAttachment && (detectedType === 'image' || detectedType === 'pdf')) { + const name = droppedFile?.name ?? `clipboard-${new Date().toISOString().slice(0, 10)}.png` + setValue(name) + } + }, [hasAttachment, detectedType, droppedFile]) + + useEffect(() => { + if (isRecording) { + setValue('Voice memo') + } + }, [isRecording]) - // Detect type from current input state useEffect(() => { if (clipboardImage || droppedFile?.type.startsWith('image/')) { setDetectedType('image') @@ -84,13 +112,39 @@ export function QuickCapture(): React.JSX.Element { } else if (isRecording) { setDetectedType('voice') } else if (isLikelyUrl(value)) { - setDetectedType('link') + const url = normalizeUrl(value.trim()) + const platform = detectPlatformFromUrl(url) + setDetectedType(platform === 'twitter' ? 'social' : 'link') } else { setDetectedType('note') } }, [value, clipboardImage, droppedFile, isRecording]) - // Check clipboard for image on mount + useEffect(() => { + if (detectedType !== 'link') { + setLinkPreview(null) + setPreviewLoading(false) + previewUrlRef.current = null + return + } + const url = normalizeUrl(value.trim()) + if (url === previewUrlRef.current) return + + setPreviewLoading(true) + const timer = setTimeout(async () => { + previewUrlRef.current = url + try { + const data = await window.api.inbox.previewLink(url) + setLinkPreview(data) + } catch { + setLinkPreview(null) + } finally { + setPreviewLoading(false) + } + }, 500) + return () => clearTimeout(timer) + }, [detectedType, value]) + useEffect(() => { const checkClipboard = async (): Promise<void> => { try { @@ -105,10 +159,9 @@ export function QuickCapture(): React.JSX.Element { } } } catch { - // Clipboard API may not be available or permission denied — that's fine + // Clipboard API may not be available or permission denied } - // No image found — load text clipboard try { const clipboardText = await window.api.quickCapture.getClipboard() if ( @@ -134,24 +187,6 @@ export function QuickCapture(): React.JSX.Element { } }, []) - // Auto-resize textarea - useEffect(() => { - const textarea = textareaRef.current - if (textarea) { - textarea.style.height = 'auto' - textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` - } - }, [value]) - - // Close window after success - useEffect(() => { - if (captureState === 'success') { - const timer = setTimeout(() => window.api.quickCapture.close(), 1000) - return () => clearTimeout(timer) - } - return undefined - }, [captureState]) - const clearAttachment = useCallback(() => { if (clipboardImageUrl) URL.revokeObjectURL(clipboardImageUrl) setClipboardImage(null) @@ -168,7 +203,6 @@ export function QuickCapture(): React.JSX.Element { setErrorMessage('') try { - // Image from clipboard or dropped file if (clipboardImage) { const arrayBuffer = await clipboardImage.arrayBuffer() const result = await captureImage.mutateAsync({ @@ -186,7 +220,6 @@ export function QuickCapture(): React.JSX.Element { return } - // Dropped file if (droppedFile) { const arrayBuffer = await droppedFile.arrayBuffer() if (droppedFile.type.startsWith('audio/')) { @@ -205,7 +238,6 @@ export function QuickCapture(): React.JSX.Element { setCaptureState('error') return } - // Image or PDF — both go through captureImage IPC const result = await captureImage.mutateAsync({ data: arrayBuffer, filename: droppedFile.name, @@ -221,9 +253,11 @@ export function QuickCapture(): React.JSX.Element { return } - // Text or URL const trimmed = value.trim() - if (!trimmed) return + if (!trimmed) { + setCaptureState('idle') + return + } if (isLikelyUrl(trimmed)) { const url = normalizeUrl(trimmed) @@ -277,7 +311,6 @@ export function QuickCapture(): React.JSX.Element { ] ) - // Paste handler — intercept image paste const handlePaste = useCallback(async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items if (!items) return @@ -296,22 +329,6 @@ export function QuickCapture(): React.JSX.Element { } }, []) - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - window.api.quickCapture.close() - return - } - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSubmit() - } - }, - [handleSubmit] - ) - - // Drag-and-drop handlers const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() e.stopPropagation() @@ -351,7 +368,6 @@ export function QuickCapture(): React.JSX.Element { [clipboardImageUrl] ) - // Voice recording handlers const handleRecordingComplete = useCallback( async (audioBlob: Blob, duration: number) => { setIsRecording(false) @@ -379,138 +395,82 @@ export function QuickCapture(): React.JSX.Element { [captureVoice] ) - // Global escape handler + const handleFileSelected = useCallback( + (e: Event) => { + const file = (e as CustomEvent<File>).detail + if (!file) return + + if (!DROPPABLE_TYPES.has(file.type)) { + setErrorMessage(`Unsupported file type: ${file.type || 'unknown'}`) + setCaptureState('error') + return + } + + setDroppedFile(file) + setClipboardImage(null) + if (clipboardImageUrl) URL.revokeObjectURL(clipboardImageUrl) + setClipboardImageUrl(null) + + if (file.type.startsWith('image/')) { + setClipboardImageUrl(URL.createObjectURL(file)) + } + }, + [clipboardImageUrl] + ) + useEffect(() => { const handler = (e: KeyboardEvent): void => { if (e.key === 'Escape') window.api.quickCapture.close() } window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, []) + window.addEventListener('quick-capture:file-selected', handleFileSelected) + return () => { + window.removeEventListener('keydown', handler) + window.removeEventListener('quick-capture:file-selected', handleFileSelected) + } + }, [handleFileSelected]) - // Success state - if (captureState === 'success') { - return ( - <div className="flex h-screen w-screen items-center justify-center bg-background p-4"> - <div className="flex flex-col items-center gap-3 text-center"> - <div className="flex size-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"> - <Check className="size-6 text-green-600 dark:text-green-400" /> - </div> - <p className="text-sm font-medium text-foreground">Captured!</p> - </div> - </div> - ) - } - - // Duplicate detected state - if (captureState === 'duplicate' && duplicateMatch) { - const capturedDate = new Date(duplicateMatch.createdAt) - const dateStr = capturedDate.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric' - }) + const containerRef = useRef<HTMLDivElement>(null) - return ( - <div className="flex h-screen w-screen items-center justify-center bg-background p-4"> - <div className="flex w-full max-w-xs flex-col items-center gap-3 text-center"> - <div className="flex size-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30"> - <Copy className="size-5 text-amber-600 dark:text-amber-400" /> - </div> - <div> - <p className="text-sm font-medium text-foreground">Already captured</p> - <p className="mt-1 text-xs text-muted-foreground"> - “{duplicateMatch.title.slice(0, 60)} - {duplicateMatch.title.length > 60 ? '...' : ''}” · {dateStr} - </p> - </div> - <div className="mt-1 flex gap-2"> - <button - onClick={() => { - setCaptureState('idle') - setDuplicateMatch(null) - handleSubmit(true) - }} - className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted" - > - Capture Anyway - </button> - <button - onClick={() => window.api.quickCapture.close()} - className="rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600" - > - Close - </button> - </div> - </div> - </div> - ) - } - - // Voice recording state - if (isRecording) { - return ( - <div className="flex h-screen w-screen flex-col bg-background p-4"> - <div className="drag-region mb-3 flex items-center justify-between"> - <div className="flex items-center gap-2"> - <div className="size-3 rounded-full bg-red-500" /> - <div className="size-3 rounded-full bg-yellow-500" /> - <div className="size-3 rounded-full bg-green-500" /> - </div> - <span className="text-xs text-muted-foreground">Voice Capture</span> - <button - onClick={() => window.api.quickCapture.close()} - className="rounded p-1 text-muted-foreground hover:bg-muted/50" - aria-label="Close" - > - <X className="size-4" /> - </button> - </div> - <div className="flex flex-1 items-center"> - <VoiceRecorder - onRecordingComplete={handleRecordingComplete} - onCancel={() => setIsRecording(false)} - maxDuration={300} - autoStart - className="w-full" - /> - </div> - </div> - ) - } + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver((entries) => { + const height = entries[0]?.contentRect.height + if (height && height > 0) { + window.api.quickCapture.resize(Math.ceil(height) + 2) + } + }) + observer.observe(el) + return () => observer.disconnect() + }, []) - const TypeIcon = TYPE_ICONS[detectedType] - const hasAttachment = !!clipboardImage || !!droppedFile + const handleValueChange = useCallback( + (newValue: string) => { + setValue(newValue) + if (captureState === 'error') { + setCaptureState('idle') + setErrorMessage('') + } + }, + [captureState] + ) return ( <div + ref={containerRef} className={cn( - 'flex h-screen w-screen flex-col bg-background p-4', - isDragOver && 'ring-2 ring-primary/50 ring-inset' + 'drag-region flex w-screen flex-col', + 'bg-background rounded-[14px] overflow-hidden', + 'border border-border/30', + isDragOver && 'ring-2 ring-accent-orange/40 ring-inset' )} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} > - {/* Header */} - <div className="drag-region mb-3 flex items-center justify-between"> - <div className="flex items-center gap-2"> - <div className="size-3 rounded-full bg-red-500" /> - <div className="size-3 rounded-full bg-yellow-500" /> - <div className="size-3 rounded-full bg-green-500" /> - </div> - <span className="text-xs text-muted-foreground">Quick Capture</span> - <button - onClick={() => window.api.quickCapture.close()} - className="rounded p-1 text-muted-foreground hover:bg-muted/50" - aria-label="Close" - > - <X className="size-4" /> - </button> - </div> - - {/* Drop overlay */} {isDragOver && ( - <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-background/80"> + <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-[14px] bg-background/80"> <div className="flex flex-col items-center gap-2 text-muted-foreground"> <Image className="size-8" /> <span className="text-sm font-medium">Drop to capture</span> @@ -518,134 +478,102 @@ export function QuickCapture(): React.JSX.Element { </div> )} - {/* Content */} - <div className="flex flex-1 flex-col min-h-0"> - {/* Image preview (clipboard or dropped image) */} - {clipboardImageUrl && ( - <div className="relative mb-3 overflow-hidden rounded-lg border border-border/50"> - <img - src={clipboardImageUrl} - alt="Clipboard image" - className="max-h-32 w-full object-contain bg-muted/20" + {captureState === 'success' ? ( + <CaptureSuccess onAutoClose={() => window.api.quickCapture.close()} /> + ) : captureState === 'capturing' && !value.trim() && hasAttachment ? ( + <div className="flex items-center gap-2.5 px-4 py-3.5"> + <Loader2 className="size-4 animate-spin text-accent-orange" /> + <span className="text-sm text-muted-foreground">Capturing...</span> + </div> + ) : ( + <> + {captureState === 'duplicate' && duplicateMatch ? ( + <CaptureDuplicate + title={duplicateMatch.title} + createdAt={duplicateMatch.createdAt} + onForce={() => { + setCaptureState('idle') + setDuplicateMatch(null) + handleSubmit(true) + }} + onClose={() => window.api.quickCapture.close()} + /> + ) : ( + <QuickCaptureInput + value={value} + onChange={handleValueChange} + onSubmit={() => handleSubmit()} + onStartRecording={() => setIsRecording(true)} + onPaste={handlePaste} + detectedType={detectedType} + isCapturing={isCapturing || isRecording} + hasAttachment={hasAttachment} + textareaRef={textareaRef} + /> + )} + + {(linkPreview || previewLoading) && detectedType === 'link' && ( + <LinkPreviewCard + title={linkPreview?.title ?? ''} + domain={linkPreview?.domain ?? ''} + favicon={linkPreview?.favicon} + loading={previewLoading} /> - <button - onClick={clearAttachment} - className="absolute right-1.5 top-1.5 rounded-full bg-background/80 p-1 text-muted-foreground hover:text-foreground" - aria-label="Remove image" - > - <X className="size-3" /> - </button> - </div> - )} - - {/* Dropped non-image file preview */} - {droppedFile && !clipboardImageUrl && ( - <div className="relative mb-3 flex items-center gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2"> - <FileIcon className="size-4 text-muted-foreground" /> - <span className="flex-1 truncate text-sm text-foreground">{droppedFile.name}</span> - <button - onClick={clearAttachment} - className="rounded-full p-1 text-muted-foreground hover:text-foreground" - aria-label="Remove file" - > - <X className="size-3" /> - </button> - </div> - )} - - {/* Input row */} - <div - className={cn( - 'relative flex items-start gap-3', - 'rounded-lg border border-border/50 bg-muted/30 px-3 py-2.5', - 'transition-all duration-200', - 'focus-within:border-border focus-within:bg-muted/50' )} - > - {/* Dynamic type icon */} - <div className="mt-0.5 shrink-0 text-muted-foreground/60 transition-all duration-200"> - <TypeIcon className="size-4" aria-hidden="true" /> - </div> - {/* Textarea */} - <textarea - ref={textareaRef} - value={value} - onChange={(e) => { - setValue(e.target.value) - if (captureState === 'error') { + {hasAttachment && detectedType === 'image' && ( + <FilePreviewCard + variant="image" + title={value || attachmentFilename} + subtitle={`${formatFileSize(attachmentSize)} · ${attachmentExtension.toUpperCase()}`} + initial={attachmentExtension.charAt(0).toUpperCase()} + onClear={clearAttachment} + /> + )} + + {hasAttachment && detectedType === 'pdf' && ( + <FilePreviewCard + variant="pdf" + title={value || attachmentFilename} + subtitle={`${formatFileSize(attachmentSize)} · PDF`} + onClear={clearAttachment} + /> + )} + + {detectedType === 'social' && ( + <FilePreviewCard + variant="social" + title={extractHandleFromUrl(normalizeUrl(value.trim())) || 'Social post'} + subtitle="x.com" + onClear={() => {}} + /> + )} + + {isRecording && ( + <div className="px-3 py-2 border-t border-border/30 bg-foreground/[0.02]"> + <VoiceRecorder + onRecordingComplete={handleRecordingComplete} + onCancel={() => setIsRecording(false)} + maxDuration={300} + autoStart + className="w-full" + /> + </div> + )} + + {captureState === 'error' && errorMessage && ( + <CaptureError + message={errorMessage} + onDismiss={() => { setCaptureState('idle') setErrorMessage('') - } - }} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - placeholder={ - hasAttachment ? 'Add a note (optional)...' : 'Type, paste, or drop anything...' - } - disabled={isCapturing} - rows={1} - className={cn( - 'max-h-[120px] min-h-[24px] flex-1', - 'resize-none bg-transparent', - 'text-sm text-foreground', - 'placeholder:text-muted-foreground/50', - 'focus:outline-none', - 'disabled:cursor-not-allowed disabled:opacity-50' - )} - aria-label="Quick capture input" - /> - - {/* Mic button */} - <button - onClick={() => setIsRecording(true)} - disabled={isCapturing} - className={cn( - 'mt-0.5 shrink-0 rounded-md p-1.5', - 'text-muted-foreground/50 transition-all duration-200', - 'hover:bg-foreground/5 hover:text-foreground/70', - 'disabled:cursor-not-allowed disabled:opacity-30' - )} - aria-label="Record voice memo" - > - <Mic className="size-4" /> - </button> - - {/* Submit button */} - <button - onClick={() => handleSubmit()} - disabled={(!value.trim() && !hasAttachment) || isCapturing} - className={cn( - 'mt-0.5 shrink-0 rounded-md p-1.5', - 'text-muted-foreground/50 transition-all duration-200', - 'hover:bg-foreground/5 hover:text-foreground/70', - 'disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent', - (value.trim() || hasAttachment) && !isCapturing && 'text-primary' - )} - aria-label="Capture" - > - {isCapturing ? ( - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - ) : ( - <Send className="size-4" aria-hidden="true" /> - )} - </button> - </div> + }} + /> + )} + </> + )} - {/* Error message */} - {captureState === 'error' && errorMessage && ( - <p className="mt-2 px-1 text-xs text-red-500">{errorMessage}</p> - )} - - {/* Hint text */} - <div className="mt-2 px-1 text-xs text-muted-foreground/50"> - <kbd className="rounded bg-muted/50 px-1 py-0.5 text-[10px]">Enter</kbd> capture - {' · '} - <kbd className="rounded bg-muted/50 px-1 py-0.5 text-[10px]">⌘V</kbd> paste image - {' · '} - <kbd className="rounded bg-muted/50 px-1 py-0.5 text-[10px]">Esc</kbd> close - </div> - </div> + <QuickCaptureFooter /> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/search/recent-reasons.tsx b/apps/desktop/src/renderer/src/components/search/recent-reasons.tsx index 990d2bd04..305fdffd3 100644 --- a/apps/desktop/src/renderer/src/components/search/recent-reasons.tsx +++ b/apps/desktop/src/renderer/src/components/search/recent-reasons.tsx @@ -49,7 +49,7 @@ export function RecentReasons({ key={reason.id} type="button" onClick={() => onSelect(reason)} - className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-left + className="flex items-center gap-3 w-full px-3 py-2 rounded-md text-left hover:bg-muted transition-colors duration-75 group" > {reason.itemIcon ? ( diff --git a/apps/desktop/src/renderer/src/components/search/search-filters.tsx b/apps/desktop/src/renderer/src/components/search/search-filters.tsx index a23252e5b..91fb7b720 100644 --- a/apps/desktop/src/renderer/src/components/search/search-filters.tsx +++ b/apps/desktop/src/renderer/src/components/search/search-filters.tsx @@ -176,7 +176,7 @@ export function SearchFilters({ placeholder="Filter by tag..." className="w-full h-7 px-2 text-xs bg-muted rounded border border-border text-foreground - placeholder:text-text-tertiary focus:outline-none focus:ring-1 focus:ring-border" + placeholder:text-text-tertiary focus:outline-none" /> {filteredTags.length > 0 && ( <div diff --git a/apps/desktop/src/renderer/src/components/search/search-result-item.tsx b/apps/desktop/src/renderer/src/components/search/search-result-item.tsx index 6126b0b63..8fa3b0cf6 100644 --- a/apps/desktop/src/renderer/src/components/search/search-result-item.tsx +++ b/apps/desktop/src/renderer/src/components/search/search-result-item.tsx @@ -9,6 +9,7 @@ import type { InboxResultMetadata } from '@memry/contracts/search-api' import { highlightTerms, stripMarkTags } from '@/services/search-service' +import { NoteIconDisplay } from '@/lib/render-note-icon' interface SearchResultItemProps { item: SearchResultItemType @@ -153,14 +154,15 @@ export function SearchResultItem({ <Command.Item value={`${item.type}-${item.id}`} onSelect={() => onSelect(item)} - className="flex items-start gap-3 px-3 py-2.5 rounded-lg cursor-pointer + className="flex items-start gap-3 px-3 py-2.5 rounded-md cursor-pointer data-[selected=true]:bg-muted transition-colors duration-75" > {noteEmoji ? ( - <span className="size-4 shrink-0 mt-0.5 text-sm leading-none flex items-center justify-center"> - {noteEmoji} - </span> + <NoteIconDisplay + value={noteEmoji} + className="size-4 shrink-0 mt-0.5 text-sm leading-none flex items-center justify-center" + /> ) : ( <Icon className="size-4 shrink-0 mt-0.5 text-text-tertiary" /> )} diff --git a/apps/desktop/src/renderer/src/components/settings/integration-list.tsx b/apps/desktop/src/renderer/src/components/settings/integration-list.tsx index 64efcc39a..60e28ab80 100644 --- a/apps/desktop/src/renderer/src/components/settings/integration-list.tsx +++ b/apps/desktop/src/renderer/src/components/settings/integration-list.tsx @@ -12,42 +12,46 @@ export function IntegrationList(): React.JSX.Element { const integrations = getAvailableIntegrations() return ( - <div className="space-y-1"> - {integrations.map((integration) => { + <div className="flex flex-col rounded-lg overflow-clip border border-border"> + {integrations.map((integration, i) => { const Icon = integration.icon return ( - <div - key={integration.id} - className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/50 group" - > - <div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center shrink-0"> - <Icon className="w-4 h-4 text-muted-foreground" /> - </div> - - <div className="flex-1 min-w-0"> - <span className="text-sm font-medium block">{integration.name}</span> - <span className="text-xs text-muted-foreground block">{integration.description}</span> - </div> + <div key={integration.id}> + {i > 0 && <div className="h-px bg-border" />} + <div className="flex items-center justify-between h-12 px-4 shrink-0 group"> + <div className="flex items-center gap-2.5 min-w-0"> + <div className="w-7 h-7 rounded-md bg-muted flex items-center justify-center shrink-0"> + <Icon className="w-3.5 h-3.5 text-muted-foreground" /> + </div> + <div className="flex flex-col gap-px min-w-0"> + <span className="font-medium text-[13px]/4 text-foreground"> + {integration.name} + </span> + <span className="text-xs/4 text-muted-foreground truncate"> + {integration.description} + </span> + </div> + </div> - <Badge variant="outline" className="text-xs shrink-0"> - {AUTH_LABELS[integration.authFlow]} - </Badge> - - {integration.comingSoon ? ( - <> - <Badge variant="secondary" className="text-xs shrink-0"> - Coming Soon + <div className="flex items-center gap-2 shrink-0 ml-4"> + <Badge variant="secondary" className="text-[10px]/3 px-1.5 py-0 h-4 border-0"> + {AUTH_LABELS[integration.authFlow]} </Badge> - <Button variant="outline" size="sm" disabled> - Connect - </Button> - </> - ) : ( - <Button variant="outline" size="sm"> - Connect - </Button> - )} + {integration.comingSoon ? ( + <Badge + variant="secondary" + className="text-[10px]/3 px-1.5 py-0 h-4 border-0 text-muted-foreground" + > + Coming Soon + </Badge> + ) : ( + <Button variant="outline" size="sm" className="h-7 px-3 text-xs/4"> + Connect + </Button> + )} + </div> + </div> </div> ) })} diff --git a/apps/desktop/src/renderer/src/components/settings/recovery-key-dialog.tsx b/apps/desktop/src/renderer/src/components/settings/recovery-key-dialog.tsx new file mode 100644 index 000000000..1bafe1f6f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/recovery-key-dialog.tsx @@ -0,0 +1,143 @@ +import { useState, useCallback } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Copy, Eye, EyeOff, Key } from '@/lib/icons' +import { toast } from 'sonner' +import { extractErrorMessage } from '@/lib/ipc-error' + +interface RecoveryKeyDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function RecoveryKeyDialog({ open, onOpenChange }: RecoveryKeyDialogProps) { + const [recoveryKey, setRecoveryKey] = useState<string | null>(null) + const [isLoading, setIsLoading] = useState(false) + const [revealed, setRevealed] = useState(false) + + const handleOpen = useCallback( + async (nextOpen: boolean) => { + if (!nextOpen) { + setRecoveryKey(null) + setRevealed(false) + onOpenChange(false) + return + } + setIsLoading(true) + onOpenChange(true) + try { + const result = await window.api.account.getRecoveryKey() + if (!result.success || !result.key) { + toast.error(result.error ?? 'Failed to retrieve recovery key') + onOpenChange(false) + return + } + setRecoveryKey(result.key) + } catch (err) { + toast.error(extractErrorMessage(err, 'Failed to retrieve recovery key')) + onOpenChange(false) + } finally { + setIsLoading(false) + } + }, + [onOpenChange] + ) + + const handleCopy = useCallback(async () => { + if (!recoveryKey) return + try { + await navigator.clipboard.writeText(recoveryKey) + toast.success('Recovery key copied to clipboard') + } catch { + toast.error('Failed to copy to clipboard') + } + }, [recoveryKey]) + + return ( + <Dialog open={open} onOpenChange={(next) => void handleOpen(next)}> + <DialogContent className="max-w-md"> + <DialogHeader> + <div className="flex items-center gap-2"> + <Key className="w-5 h-5 text-primary" /> + <DialogTitle>Recovery Key</DialogTitle> + </div> + <DialogDescription> + Store this key securely. It can restore your vault if you lose access to all devices. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-2"> + {isLoading ? ( + <div className="flex items-center justify-center h-20"> + <p className="text-sm text-muted-foreground">Loading...</p> + </div> + ) : recoveryKey ? ( + <div className="space-y-3"> + <div className="relative"> + <div + className={`font-mono text-xs p-4 rounded-md bg-muted break-all leading-relaxed select-all transition-all ${ + !revealed ? 'blur-sm select-none cursor-pointer' : '' + }`} + onClick={() => setRevealed(true)} + onKeyDown={(e) => e.key === 'Enter' && setRevealed(true)} + role={revealed ? undefined : 'button'} + tabIndex={revealed ? undefined : 0} + > + {recoveryKey} + </div> + {!revealed && ( + <div className="absolute inset-0 flex items-center justify-center rounded-md"> + <div className="flex items-center gap-2 bg-background/90 px-3 py-1.5 rounded-md text-xs text-muted-foreground border"> + <EyeOff className="w-3.5 h-3.5" /> + Click to reveal + </div> + </div> + )} + </div> + + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={() => setRevealed((r) => !r)} + > + {revealed ? ( + <> + <EyeOff className="w-4 h-4" /> + Hide + </> + ) : ( + <> + <Eye className="w-4 h-4" /> + Reveal + </> + )} + </Button> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={() => void handleCopy()} + > + <Copy className="w-4 h-4" /> + Copy + </Button> + </div> + + <p className="text-xs text-muted-foreground"> + This key is shown once per session. Close this dialog to clear it from memory. + </p> + </div> + ) : null} + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/apps/desktop/src/renderer/src/components/settings/settings-primitives.tsx b/apps/desktop/src/renderer/src/components/settings/settings-primitives.tsx new file mode 100644 index 000000000..95728763e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/settings-primitives.tsx @@ -0,0 +1,87 @@ +import { Children, type ReactNode } from 'react' + +export const ACCENT_SWITCH = 'data-[state=checked]:bg-[var(--tint)]' + +export const COMPACT_SELECT = + 'h-auto w-auto shrink-0 rounded-md py-1 px-2.5 gap-1.5 bg-muted/50 border-border text-xs/4 text-muted-foreground shadow-none' + +interface SettingsHeaderProps { + title: string + subtitle: string + action?: ReactNode +} + +export function SettingsHeader({ title, subtitle, action }: SettingsHeaderProps) { + return ( + <div className="flex items-start justify-between pb-6 gap-4"> + <div className="flex flex-col gap-0.5"> + <h3 className="font-semibold text-base/5 tracking-[-0.01em] text-foreground">{title}</h3> + <p className="text-xs/4 text-muted-foreground">{subtitle}</p> + </div> + {action} + </div> + ) +} + +interface SettingsGroupProps { + label?: string + children: ReactNode +} + +export function SettingsGroup({ label, children }: SettingsGroupProps) { + const items = Children.toArray(children).filter(Boolean) + + return ( + <div className="flex flex-col pb-6"> + {label && ( + <h4 className="uppercase pb-2 text-muted-foreground font-medium text-[11px]/3.5 tracking-[0.05em]"> + {label} + </h4> + )} + <div className="flex flex-col rounded-lg overflow-clip border border-border"> + {items.map((child, i) => ( + <div key={i}> + {i > 0 && <div className="h-px bg-border" />} + {child} + </div> + ))} + </div> + </div> + ) +} + +interface SettingRowProps { + label: string + description?: string + children: ReactNode +} + +export function SettingRow({ label, description, children }: SettingRowProps) { + return ( + <div className="flex items-center justify-between h-11 py-3 px-4 shrink-0"> + <div className="flex flex-col gap-px min-w-0"> + <span className="font-medium text-[13px]/4 text-foreground">{label}</span> + {description && ( + <span className="text-xs/4 text-muted-foreground truncate">{description}</span> + )} + </div> + <div className="shrink-0 ml-4">{children}</div> + </div> + ) +} + +export function SettingRowTall({ label, description, children }: SettingRowProps) { + return ( + <div className="flex flex-col gap-2 min-h-14 py-3 px-4 shrink-0"> + <div className="flex items-center justify-between"> + <div className="flex flex-col gap-px min-w-0"> + <span className="font-medium text-[13px]/4 text-foreground">{label}</span> + {description && ( + <span className="text-xs/4 text-muted-foreground truncate">{description}</span> + )} + </div> + </div> + {children} + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/components/settings/storage-usage-bar.tsx b/apps/desktop/src/renderer/src/components/settings/storage-usage-bar.tsx index 53c00cb10..ee36c2d44 100644 --- a/apps/desktop/src/renderer/src/components/settings/storage-usage-bar.tsx +++ b/apps/desktop/src/renderer/src/components/settings/storage-usage-bar.tsx @@ -19,7 +19,7 @@ export function StorageUsageBar() { if (error || !data) { return ( - <div className="rounded-lg border p-4"> + <div className="rounded-md border p-4"> <div className="flex items-center justify-between"> <p className="text-sm text-muted-foreground"> {error || 'Sign in to view storage usage'} @@ -41,7 +41,7 @@ export function StorageUsageBar() { return ( <div className="space-y-4"> - <div className="rounded-lg border p-4 space-y-4"> + <div className="rounded-md border p-4 space-y-4"> {/* Header */} <div className="flex items-center justify-between"> <h4 className="text-sm font-medium">Storage</h4> @@ -102,7 +102,7 @@ export function StorageUsageBar() { {/* Warning Banner */} {showWarning && ( - <div className="rounded-lg bg-amber-500/10 border border-amber-500/20 p-3" role="alert"> + <div className="rounded-md bg-amber-500/10 border border-amber-500/20 p-3" role="alert"> <p className="text-sm font-medium text-amber-600 dark:text-amber-400"> Storage almost full </p> @@ -117,7 +117,7 @@ export function StorageUsageBar() { function StorageSkeleton() { return ( - <div className="rounded-lg border p-4 space-y-4 animate-pulse"> + <div className="rounded-md border p-4 space-y-4 animate-pulse"> <div className="flex items-center justify-between"> <div className="h-4 w-16 rounded bg-muted" /> <div className="h-4 w-24 rounded bg-muted" /> diff --git a/apps/desktop/src/renderer/src/components/settings/tag-manager.tsx b/apps/desktop/src/renderer/src/components/settings/tag-manager.tsx index ef52376cd..ee90334c7 100644 --- a/apps/desktop/src/renderer/src/components/settings/tag-manager.tsx +++ b/apps/desktop/src/renderer/src/components/settings/tag-manager.tsx @@ -1,8 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { Input } from '@/components/ui/input' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Separator } from '@/components/ui/separator' import { DropdownMenu, DropdownMenuContent, @@ -141,128 +139,116 @@ export function TagManager() { ) if (isLoading) { - return <p className="text-sm text-muted-foreground">Loading tags...</p> + return <p className="text-xs/4 text-muted-foreground">Loading tags...</p> } if (error) { - return <p className="text-sm text-destructive">{error}</p> + return <p className="text-xs/4 text-destructive">{error}</p> } if (tags.length === 0) { return ( - <p className="text-sm text-muted-foreground"> + <p className="text-xs/4 text-muted-foreground"> No tags yet. Tags will appear here as you add them to notes and tasks. </p> ) } return ( - <div className="space-y-4"> - {/* Search */} - <div className="relative"> - <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> + <div className="flex flex-col"> + <div className="relative pb-6"> + <Search className="absolute left-3 top-2 w-3.5 h-3.5 text-muted-foreground" /> <Input placeholder="Filter tags..." value={search} onChange={(e) => setSearch(e.target.value)} - className="pl-9" + className="pl-8 h-8 text-xs/4 rounded-lg border-border bg-transparent" /> </div> - {/* Tag list */} - <div className="space-y-1"> + <div className="flex flex-col rounded-lg overflow-clip border border-border"> {filteredTags.length === 0 && ( - <p className="text-sm text-muted-foreground py-4 text-center"> + <p className="text-xs/4 text-muted-foreground py-4 text-center"> No tags matching “{search}” </p> )} - {filteredTags.map((tag) => { + {filteredTags.map((tag, i) => { const colors = tag.color ? getTagColors(tag.color) : null return ( - <div - key={tag.name} - className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/50 group" - > - {/* Color dot */} - <div - className="w-3 h-3 rounded-full shrink-0" - style={{ - backgroundColor: colors?.background ?? '#e5e7eb', - border: `1px solid ${colors?.text ?? '#6b7280'}40` - }} - /> - - {/* Tag name (inline edit) */} - <div className="flex-1 min-w-0"> - {editingTag === tag.name ? ( - <Input - ref={editInputRef} - value={editValue} - onChange={(e) => setEditValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') void handleConfirmRename() - if (e.key === 'Escape') handleCancelRename() + <div key={tag.name}> + {i > 0 && <div className="h-px bg-border" />} + <div className="flex items-center justify-between h-11 py-3 px-4 shrink-0 group"> + <div className="flex items-center gap-2.5 min-w-0"> + <div + className="w-2.5 h-2.5 rounded-full shrink-0" + style={{ + backgroundColor: colors?.background ?? '#6366f1' }} - onBlur={() => void handleConfirmRename()} - className="h-7 text-sm" /> - ) : ( - <span className="text-sm truncate block">{tag.name}</span> - )} - </div> - - {/* Count badge */} - <Badge variant="secondary" className="text-xs tabular-nums shrink-0"> - {tag.count} - </Badge> + {editingTag === tag.name ? ( + <Input + ref={editInputRef} + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleConfirmRename() + if (e.key === 'Escape') handleCancelRename() + }} + onBlur={() => void handleConfirmRename()} + className="h-6 text-[13px]/4 px-1.5 w-40" + /> + ) : ( + <span className="font-medium text-[13px]/4 text-foreground truncate"> + {tag.name} + </span> + )} + </div> - {/* Action menu */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="icon" - className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" - > - <MoreHorizontal className="w-4 h-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => handleStartRename(tag.name)}> - <Pencil className="w-4 h-4 mr-2" /> - Rename - </DropdownMenuItem> - <DropdownMenuItem onClick={() => setColorTarget(tag.name)}> - <Palette className="w-4 h-4 mr-2" /> - Change color - </DropdownMenuItem> - <DropdownMenuItem onClick={() => setMergeSource(tag.name)}> - <Merge className="w-4 h-4 mr-2" /> - Merge into... - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - onClick={() => setDeleteTarget({ name: tag.name, count: tag.count })} - className="text-destructive focus:text-destructive" - > - <Trash2 className="w-4 h-4 mr-2" /> - Delete - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + <div className="flex items-center gap-1.5 shrink-0 ml-4"> + <span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-muted text-[10px]/3 font-medium text-muted-foreground tabular-nums"> + {tag.count} + </span> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button className="p-1 rounded text-muted-foreground/50 opacity-0 group-hover:opacity-100 hover:text-foreground transition-all"> + <MoreHorizontal className="w-3.5 h-3.5" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => handleStartRename(tag.name)}> + <Pencil className="w-4 h-4 mr-2" /> + Rename + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setColorTarget(tag.name)}> + <Palette className="w-4 h-4 mr-2" /> + Change color + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setMergeSource(tag.name)}> + <Merge className="w-4 h-4 mr-2" /> + Merge into... + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => setDeleteTarget({ name: tag.name, count: tag.count })} + className="text-destructive focus:text-destructive" + > + <Trash2 className="w-4 h-4 mr-2" /> + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> </div> ) })} </div> - <Separator /> - - <p className="text-xs text-muted-foreground"> + <p className="text-xs/4 text-muted-foreground pt-3"> {tags.length} tag{tags.length !== 1 ? 's' : ''} across notes and tasks </p> - {/* Delete confirmation dialog */} <AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}> <AlertDialogContent> <AlertDialogHeader> @@ -284,7 +270,6 @@ export function TagManager() { </AlertDialogContent> </AlertDialog> - {/* Merge dialog */} <Dialog open={!!mergeSource} onOpenChange={(open) => !open && setMergeSource(null)}> <DialogContent> <DialogHeader> @@ -326,7 +311,6 @@ export function TagManager() { </DialogContent> </Dialog> - {/* Color picker dialog */} <Dialog open={!!colorTarget} onOpenChange={(open) => !open && setColorTarget(null)}> <DialogContent className="max-w-sm"> <DialogHeader> @@ -336,7 +320,7 @@ export function TagManager() { {COLOR_ROWS.map((row, rowIndex) => ( <div key={rowIndex} className="flex gap-2 justify-center"> {row.map((colorName) => { - const colors = TAG_COLORS[colorName] + const clrs = TAG_COLORS[colorName] const currentTag = tags.find((t) => t.name === colorTarget) const isSelected = currentTag?.color === colorName @@ -347,11 +331,11 @@ export function TagManager() { onClick={() => void handleColorChange(colorName)} className={cn( 'w-7 h-7 rounded-full transition-all hover:scale-110', - 'focus:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus:outline-none', isSelected && 'ring-2 ring-foreground/50 ring-offset-2 ring-offset-background' )} - style={{ backgroundColor: colors.background }} + style={{ backgroundColor: clrs.background }} title={colorName} /> ) diff --git a/apps/desktop/src/renderer/src/components/shared/outline-info-panel.tsx b/apps/desktop/src/renderer/src/components/shared/outline-info-panel.tsx index bc0bef871..ff5d11125 100644 --- a/apps/desktop/src/renderer/src/components/shared/outline-info-panel.tsx +++ b/apps/desktop/src/renderer/src/components/shared/outline-info-panel.tsx @@ -181,7 +181,7 @@ export const OutlineInfoPanel = memo(function OutlineInfoPanel({ ref={popupRef} className={cn( 'bg-background border border-border', - 'shadow-lg rounded-lg', + 'shadow-lg rounded-md', 'min-w-[240px] max-w-[300px]', !isFadingOut && 'animate-in fade-in-0 zoom-in-95 duration-150' )} diff --git a/apps/desktop/src/renderer/src/components/sidebar-section.tsx b/apps/desktop/src/renderer/src/components/sidebar-section.tsx index 395ec3365..4a45d7e3d 100644 --- a/apps/desktop/src/renderer/src/components/sidebar-section.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar-section.tsx @@ -21,7 +21,7 @@ function SectionChevron({ expanded }: { expanded: boolean }): React.JSX.Element <ChevronRight size={10} className={cn( - 'shrink-0 text-sidebar-muted transition-transform duration-200 ease-in-out', + 'shrink-0 text-sidebar-muted transition-transform duration-200 ease-in-out opacity-0 group-hover/section:opacity-100', expanded && 'rotate-90' )} aria-hidden="true" @@ -111,31 +111,33 @@ export const SidebarSection = ({ return ( <div className={cn('group/section', className)}> - <SidebarGroup className="py-0"> + <SidebarGroup className="p-0 px-2"> {/* Section Header */} - <div className="flex items-center [font-synthesis:none] antialiased"> + <div className="flex items-center h-6 [font-synthesis:none] antialiased"> <button id={headerId} type="button" onClick={handleToggle} onKeyDown={handleKeyDown} className={cn( - 'flex flex-1 min-w-0 cursor-pointer items-center gap-2 px-2.5 py-1.5', - 'text-[12px] leading-4 font-medium', + 'flex flex-1 min-w-0 cursor-pointer items-center gap-1.5 px-2 py-1 h-6 shrink-0', + 'text-[11px] leading-3.5 font-medium tracking-[0.04em]', "font-['DM_Sans',system-ui,sans-serif]", 'text-sidebar-muted hover:text-sidebar-foreground', - 'transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'transition-colors focus-visible:outline-none' )} aria-expanded={isExpanded} aria-controls={contentId} aria-label={`${label} section, ${isExpanded ? 'expanded' : 'collapsed'}${totalCount !== undefined ? `, ${totalCount} items` : ''}`} tabIndex={0} > + <span className="truncate text-left uppercase">{label}</span> <SectionChevron expanded={isExpanded} /> - <span className="flex-1 truncate text-left">{label}</span> {!isExpanded && totalCount !== undefined && totalCount > 0 && ( - <span className="text-sidebar-muted/60 tabular-nums text-[11px]">({totalCount})</span> + <span className="text-sidebar-muted/60 tabular-nums text-[10px] leading-3"> + ({totalCount}) + </span> )} </button> @@ -161,7 +163,7 @@ export const SidebarSection = ({ )} > <div className="overflow-hidden"> - <SidebarMenu>{children}</SidebarMenu> + <SidebarMenu className="gap-0">{children}</SidebarMenu> </div> </div> </SidebarGroup> diff --git a/apps/desktop/src/renderer/src/components/sidebar/projects-empty-state.tsx b/apps/desktop/src/renderer/src/components/sidebar/projects-empty-state.tsx index d9a10027d..de866d1a5 100644 --- a/apps/desktop/src/renderer/src/components/sidebar/projects-empty-state.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar/projects-empty-state.tsx @@ -40,7 +40,7 @@ export const ProjectsEmptyState = ({ 'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md', 'text-sm text-sidebar-foreground/70 hover:text-sidebar-foreground', 'hover:bg-sidebar-accent transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'focus-visible:outline-none' )} tabIndex={0} aria-label="Create your first project" diff --git a/apps/desktop/src/renderer/src/components/sidebar/sidebar-bookmark-list.tsx b/apps/desktop/src/renderer/src/components/sidebar/sidebar-bookmark-list.tsx index 973757c23..4eca71bdd 100644 --- a/apps/desktop/src/renderer/src/components/sidebar/sidebar-bookmark-list.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar/sidebar-bookmark-list.tsx @@ -18,6 +18,7 @@ import { } from '@/lib/icons' import { cn } from '@/lib/utils' +import { NoteIconDisplay } from '@/lib/render-note-icon' import { SidebarMenuItem, SidebarMenuButton, SidebarMenuAction } from '@/components/ui/sidebar' import { DropdownMenu, @@ -158,12 +159,10 @@ export function SidebarBookmarkList({ > {/* Icon or emoji */} {emoji ? ( - <span - className="size-4 flex items-center justify-center text-sm" - aria-hidden="true" - > - {emoji} - </span> + <NoteIconDisplay + value={emoji} + className="size-4 flex items-center justify-center text-sm shrink-0" + /> ) : ( <Icon className={cn('size-4 shrink-0', iconColor)} aria-hidden="true" /> )} diff --git a/apps/desktop/src/renderer/src/components/sidebar/sidebar-nav-item.tsx b/apps/desktop/src/renderer/src/components/sidebar/sidebar-nav-item.tsx index d82f6ab46..df1167947 100644 --- a/apps/desktop/src/renderer/src/components/sidebar/sidebar-nav-item.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar/sidebar-nav-item.tsx @@ -93,7 +93,7 @@ export const SidebarNavItem = ({ // Base styles 'group relative w-full flex items-center gap-2 py-1.5 rounded-md text-sm', 'transition-colors duration-100 cursor-pointer', - 'outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring focus-visible:ring-offset-1', + 'outline-none', 'hover:bg-muted', diff --git a/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.tsx b/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.tsx index c17dc24c9..e599f37fe 100644 --- a/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar/sidebar-tag-list.tsx @@ -201,7 +201,7 @@ export function SidebarTagList({ <div className={cn('flex flex-col gap-1.5', className)}> {/* Search input — only visible when toggled */} {searchOpen && ( - <div className="px-5"> + <div className="px-2"> <input ref={searchInputRef} type="text" @@ -209,13 +209,13 @@ export function SidebarTagList({ onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={handleSearchKeyDown} placeholder="Filter tags..." - className="w-full h-6 px-2 text-[11px] rounded-md border bg-transparent placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + className="w-full h-6 px-2 text-[11px] rounded-md border bg-transparent placeholder:text-muted-foreground focus:outline-none" /> </div> )} {/* Tag pills */} - <div className="pl-5 pr-2.5 flex flex-wrap gap-1.5"> + <div className="pl-2 pr-2.5 flex flex-wrap gap-1.5"> {visibleTags.length === 0 && searchQuery ? ( <span className="text-[11px] text-muted-foreground">No matching tags</span> ) : ( diff --git a/apps/desktop/src/renderer/src/components/sidebar/sidebar-user-profile.tsx b/apps/desktop/src/renderer/src/components/sidebar/sidebar-user-profile.tsx deleted file mode 100644 index 87879d66a..000000000 --- a/apps/desktop/src/renderer/src/components/sidebar/sidebar-user-profile.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useAuth } from '@/contexts/auth-context' -import { useTabActions } from '@/contexts/tabs' -import { useSidebar } from '@/components/ui/sidebar' -import { User } from '@/lib/icons' - -export function SidebarUserProfile(): React.JSX.Element | null { - const { state } = useAuth() - const { openTab } = useTabActions() - const { state: sidebarState } = useSidebar() - const isCollapsed = sidebarState === 'collapsed' - - if (state.status !== 'authenticated' || !state.email) return null - - const displayName = state.email.split('@')[0] - const initial = displayName.charAt(0).toUpperCase() - - const handleOpenSettings = () => { - openTab({ - type: 'settings', - title: 'Settings', - icon: 'settings', - path: '/settings', - isPinned: false, - isModified: false, - isPreview: false, - isDeleted: false - }) - } - - return ( - <button - type="button" - onClick={handleOpenSettings} - className="flex items-center gap-2 h-8 px-2 w-full rounded-md hover:bg-sidebar-accent/50 transition-colors cursor-pointer" - > - <div className="size-[22px] rounded-full bg-sidebar-terracotta flex items-center justify-center shrink-0"> - <span className="text-white font-bold text-[10px] leading-none">{initial}</span> - </div> - {!isCollapsed && ( - <> - <span className="text-sidebar-foreground text-[12.5px] truncate flex-1 text-left"> - {displayName} - </span> - <User className="size-3.5 opacity-30 shrink-0 ml-auto" /> - </> - )} - </button> - ) -} diff --git a/apps/desktop/src/renderer/src/components/sidebar/tag-detail-view.tsx b/apps/desktop/src/renderer/src/components/sidebar/tag-detail-view.tsx index c214966eb..754221375 100644 --- a/apps/desktop/src/renderer/src/components/sidebar/tag-detail-view.tsx +++ b/apps/desktop/src/renderer/src/components/sidebar/tag-detail-view.tsx @@ -49,6 +49,9 @@ import { COLOR_NAMES, getTagColors } from '@/components/note/tags-row/tag-colors import { tagsService, type TagNoteItem } from '@/services/tags-service' import type { SidebarItem } from '@/contexts/tabs/types' import { createLogger } from '@/lib/logger' +import { toast } from 'sonner' +import { extractErrorMessage } from '@/lib/ipc-error' +import { NoteIconDisplay } from '@/lib/render-note-icon' const log = createLogger('Component:TagDetailView') @@ -239,7 +242,7 @@ function NoteItem({ note, isPinned, onClick, onPin, onUnpin }: NoteItemProps): R {/* Icon */} <div className="mt-0.5 shrink-0"> {note.emoji ? ( - <span className="text-sm">{note.emoji}</span> + <NoteIconDisplay value={note.emoji} className="text-sm" /> ) : ( <FileText className="h-4 w-4 text-muted-foreground" /> )} @@ -319,6 +322,7 @@ function TagOverflowMenu({ tag, color }: TagOverflowMenuProps): React.JSX.Elemen } } catch (error) { log.error('Failed to update tag color', error) + toast.error(extractErrorMessage(error, 'Failed to update tag color')) } finally { setIsUpdatingColor(false) } diff --git a/apps/desktop/src/renderer/src/components/social-card.tsx b/apps/desktop/src/renderer/src/components/social-card.tsx index 39b713ef0..a70486e4c 100644 --- a/apps/desktop/src/renderer/src/components/social-card.tsx +++ b/apps/desktop/src/renderer/src/components/social-card.tsx @@ -1,8 +1,7 @@ /** * Social Post Card Component * - * Displays social media posts (Twitter/X, Bluesky, Mastodon, LinkedIn, Threads) - * with author info, post content, and platform branding. + * Displays Twitter/X posts with author info, post content, and platform branding. * * @module components/social-card */ @@ -45,28 +44,13 @@ interface SocialPreviewProps { // Platform Detection from URL // ============================================================================ -type SocialPlatform = 'twitter' | 'bluesky' | 'mastodon' | 'linkedin' | 'threads' | 'other' +type SocialPlatform = 'twitter' | 'other' -/** - * Detect social platform from URL - */ function detectPlatformFromUrl(url: string | null): SocialPlatform { if (!url) return 'other' const lowerUrl = url.toLowerCase() - if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) return 'twitter' - if (lowerUrl.includes('bsky.app') || lowerUrl.includes('bsky.social')) return 'bluesky' - if (lowerUrl.includes('linkedin.com')) return 'linkedin' - if (lowerUrl.includes('threads.net')) return 'threads' - if ( - lowerUrl.includes('mastodon') || - lowerUrl.includes('fosstodon') || - lowerUrl.includes('hachyderm') || - lowerUrl.includes('mstdn') - ) { - return 'mastodon' - } return 'other' } @@ -81,26 +65,10 @@ function extractHandleFromUrl(url: string | null): string { const urlObj = new URL(url) const pathParts = urlObj.pathname.split('/').filter(Boolean) - // Twitter/X: /username/status/id if (url.includes('twitter.com') || url.includes('x.com')) { return pathParts[0] ? `@${pathParts[0]}` : '' } - // Bluesky: /profile/handle/post/id - if (url.includes('bsky.app')) { - return pathParts[1] ? `@${pathParts[1]}` : '' - } - - // Threads: /@username/post/id - if (url.includes('threads.net')) { - return pathParts[0] || '' - } - - // Mastodon: /@username/id - if (pathParts[0]?.startsWith('@')) { - return pathParts[0] - } - return '' } catch { return '' @@ -125,40 +93,12 @@ const PlatformIcon = ({ switch (platform) { case 'twitter': - // X/Twitter icon (simplified X shape) return ( <svg viewBox="0 0 24 24" className={iconClass} fill="currentColor" aria-hidden="true"> <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> </svg> ) - case 'bluesky': - // Bluesky butterfly icon (simplified) - return ( - <svg viewBox="0 0 24 24" className={iconClass} fill="currentColor" aria-hidden="true"> - <path d="M12 2C8.5 2 6 5 6 8c0 3.5 3 6 6 6s6-2.5 6-6c0-3-2.5-6-6-6zm0 10c-2 0-4-1.5-4-4s2-4 4-4 4 1.5 4 4-2 4-4 4zm-5 4c-1 0-2 .5-2 2s1 2 2 2h10c1 0 2-.5 2-2s-1-2-2-2z" /> - </svg> - ) - case 'mastodon': - // Mastodon icon (simplified elephant/mastodon head) - return ( - <svg viewBox="0 0 24 24" className={iconClass} fill="currentColor" aria-hidden="true"> - <path d="M21.258 13.99c-.274 1.41-2.456 2.955-4.962 3.254-1.306.156-2.593.3-3.965.236-2.243-.103-4.014-.535-4.014-.535 0 .218.014.426.04.62.292 2.215 2.2 2.347 4.002 2.41 1.82.062 3.44-.45 3.44-.45l.076 1.66s-1.274.684-3.542.81c-1.25.068-2.803-.032-4.612-.51-3.923-1.039-4.598-5.22-4.701-9.464-.031-1.26-.012-2.447-.012-3.44 0-4.34 2.843-5.611 2.843-5.611 1.433-.658 3.892-.935 6.45-.956h.062c2.557.02 5.018.298 6.451.956 0 0 2.843 1.272 2.843 5.61 0 0 .036 3.201-.419 5.41z" /> - </svg> - ) - case 'linkedin': - return ( - <svg viewBox="0 0 24 24" className={iconClass} fill="currentColor" aria-hidden="true"> - <path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" /> - </svg> - ) - case 'threads': - return ( - <svg viewBox="0 0 24 24" className={iconClass} fill="currentColor" aria-hidden="true"> - <path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.96-.065-1.182.408-2.256 1.332-3.023.873-.725 2.055-1.128 3.322-1.134.873-.004 1.682.12 2.41.365l.044-.614c.068-.964-.068-1.755-.404-2.352-.391-.693-1.04-1.048-1.978-1.086-.818-.033-1.494.156-2.01.562-.469.368-.762.905-.87 1.596l-2.095-.346c.186-1.15.704-2.082 1.542-2.773.93-.766 2.147-1.155 3.618-1.155h.153c1.593.052 2.82.593 3.648 1.607.704.864 1.073 1.994 1.098 3.363.003.188-.006.378-.028.569.838.36 1.576.879 2.168 1.548 1.054 1.192 1.569 2.73 1.49 4.452-.112 2.444-1.17 4.455-3.145 5.984C17.8 23.28 15.382 24 12.186 24z" /> - </svg> - ) default: - // Generic share icon for unknown platforms return ( <svg viewBox="0 0 24 24" className={iconClass} fill="currentColor" aria-hidden="true"> <path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z" /> @@ -174,14 +114,6 @@ function getPlatformName(platform: SocialPlatform): string { switch (platform) { case 'twitter': return 'X' - case 'bluesky': - return 'Bluesky' - case 'mastodon': - return 'Mastodon' - case 'linkedin': - return 'LinkedIn' - case 'threads': - return 'Threads' default: return 'Social' } @@ -194,14 +126,6 @@ function getPlatformColor(platform: SocialPlatform): string { switch (platform) { case 'twitter': return 'text-[#1DA1F2]' - case 'bluesky': - return 'text-[#0085FF]' - case 'mastodon': - return 'text-[#6364FF]' - case 'linkedin': - return 'text-[#0077B5]' - case 'threads': - return 'text-[#000000] dark:text-[#FFFFFF]' default: return 'text-[var(--muted-foreground)]' } @@ -468,7 +392,7 @@ const SocialPreview = ({ {/* Media (if any) */} {mediaUrls.length > 0 && ( - <div className="grid grid-cols-2 gap-2 rounded-lg overflow-hidden"> + <div className="grid grid-cols-2 gap-2 rounded-md overflow-hidden"> {mediaUrls.slice(0, 4).map((url, index) => ( <img key={index} @@ -548,6 +472,7 @@ export { AuthorAvatar, getPlatformName, getPlatformColor, - detectPlatformFromUrl + detectPlatformFromUrl, + extractHandleFromUrl } export type { SocialCardContentProps, SocialPreviewProps } diff --git a/apps/desktop/src/renderer/src/components/split-view/drop-zone.tsx b/apps/desktop/src/renderer/src/components/split-view/drop-zone.tsx index ff9dfe859..7af891ccc 100644 --- a/apps/desktop/src/renderer/src/components/split-view/drop-zone.tsx +++ b/apps/desktop/src/renderer/src/components/split-view/drop-zone.tsx @@ -56,16 +56,16 @@ export const DropZone = ({ zone, groupId, className }: DropZoneProps): React.JSX {/* Visual indicator when hovering */} <div className={cn( - 'absolute inset-2 rounded-lg transition-all duration-150', + 'absolute inset-2 rounded-md transition-all duration-150', isOver - ? 'bg-blue-500/20 border-2 border-dashed border-blue-500' + ? 'bg-tint-light border-2 border-dashed border-tint' : 'bg-transparent border-2 border-transparent' )} > {/* Zone label */} {isOver && ( <div className="absolute inset-0 flex items-center justify-center"> - <div className="bg-blue-500 text-white px-3 py-1.5 rounded-md text-sm font-medium shadow-lg"> + <div className="bg-tint text-tint-foreground px-3 py-1.5 rounded-md text-sm font-medium shadow-lg"> {getDropZoneLabel(zone)} </div> </div> diff --git a/apps/desktop/src/renderer/src/components/split-view/empty-pane-state.tsx b/apps/desktop/src/renderer/src/components/split-view/empty-pane-state.tsx index 3f93350d2..505620021 100644 --- a/apps/desktop/src/renderer/src/components/split-view/empty-pane-state.tsx +++ b/apps/desktop/src/renderer/src/components/split-view/empty-pane-state.tsx @@ -79,7 +79,7 @@ export const EmptyPaneState = ({ groupId, className }: EmptyPaneStateProps): Rea type="button" onClick={handleOpenInbox} className={cn( - 'flex items-center gap-2 px-4 py-2.5 rounded-lg', + 'flex items-center gap-2 px-4 py-2.5 rounded-md', 'bg-primary', 'text-primary-foreground', 'text-sm font-medium', @@ -99,7 +99,7 @@ export const EmptyPaneState = ({ groupId, className }: EmptyPaneStateProps): Rea type="button" onClick={handleClosePane} className={cn( - 'px-4 py-2.5 rounded-lg', + 'px-4 py-2.5 rounded-md', 'bg-muted/80', 'text-muted-foreground', 'text-sm font-medium', diff --git a/apps/desktop/src/renderer/src/components/split-view/resize-handle.tsx b/apps/desktop/src/renderer/src/components/split-view/resize-handle.tsx index 267005793..c413ed1b1 100644 --- a/apps/desktop/src/renderer/src/components/split-view/resize-handle.tsx +++ b/apps/desktop/src/renderer/src/components/split-view/resize-handle.tsx @@ -30,13 +30,13 @@ export const ResizeHandle = ({ // Base styles 'relative flex-shrink-0 transition-colors', 'bg-surface-active', - 'hover:bg-blue-400 dark:hover:bg-blue-500', + 'hover:bg-tint', // Direction-specific styles isHorizontal ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize', // Active state - isResizing && 'bg-blue-500 dark:bg-blue-400', + isResizing && 'bg-tint', // Expand hit area with pseudo-element 'before:absolute before:inset-0', diff --git a/apps/desktop/src/renderer/src/components/split-view/split-preview.tsx b/apps/desktop/src/renderer/src/components/split-view/split-preview.tsx index 5ddb42bb5..a6d73e179 100644 --- a/apps/desktop/src/renderer/src/components/split-view/split-preview.tsx +++ b/apps/desktop/src/renderer/src/components/split-view/split-preview.tsx @@ -36,7 +36,7 @@ export const SplitPreview = ({ zone }: SplitPreviewProps): React.JSX.Element | n <div className={cn( 'absolute pointer-events-none z-40', - 'bg-blue-500/10 border-2 border-blue-500/50 rounded-lg', + 'bg-tint-lighter border-2 border-tint-border rounded-md', 'transition-all duration-200' )} style={getPreviewStyle()} diff --git a/apps/desktop/src/renderer/src/components/stale/age-indicator.tsx b/apps/desktop/src/renderer/src/components/stale/age-indicator.tsx deleted file mode 100644 index a1cee86cb..000000000 --- a/apps/desktop/src/renderer/src/components/stale/age-indicator.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { cn } from '@/lib/utils' -import { getDaysInInbox, formatAge } from '@/lib/stale-utils' -import type { InboxItem, InboxItemListItem } from '@/types' - -interface AgeIndicatorProps { - item: InboxItem | InboxItemListItem - className?: string -} - -/** - * Displays how long an item has been in the inbox - * Uses subtle amber coloring that escalates slightly with age - */ -export const AgeIndicator = ({ item, className }: AgeIndicatorProps): React.JSX.Element => { - const days = getDaysInInbox(item) - const ageText = formatAge(days) - - // Subtle color escalation based on age - const getIndicatorColor = (): string => { - if (days >= 30) return 'text-amber-600 dark:text-amber-400' - if (days >= 14) return 'text-amber-500 dark:text-amber-500' - return 'text-amber-500/70 dark:text-amber-500/70' - } - - return ( - <div - className={cn('flex items-center gap-1.5 text-xs', getIndicatorColor(), className)} - aria-label={`${days} days old`} - > - <span className="text-[10px]" aria-hidden="true"> - ○ - </span> - <span>{ageText}</span> - </div> - ) -} diff --git a/apps/desktop/src/renderer/src/components/stale/index.ts b/apps/desktop/src/renderer/src/components/stale/index.ts deleted file mode 100644 index df39e787c..000000000 --- a/apps/desktop/src/renderer/src/components/stale/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { StaleSection, StaleSectionHeader } from './stale-section' -export { StaleItemRow } from './stale-item-row' -export { StaleActionFooter } from './stale-action-footer' -export { AgeIndicator } from './age-indicator' diff --git a/apps/desktop/src/renderer/src/components/stale/stale-action-footer.tsx b/apps/desktop/src/renderer/src/components/stale/stale-action-footer.tsx deleted file mode 100644 index cee62e14f..000000000 --- a/apps/desktop/src/renderer/src/components/stale/stale-action-footer.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Button } from '@/components/ui/button' -import { getNudgeMessage } from '@/lib/stale-utils' - -interface StaleActionFooterProps { - itemCount: number - onFileAllToUnsorted: () => void - onReviewOneByOne: () => void -} - -/** - * Footer component for the stale section with nudge message and action buttons - */ -export const StaleActionFooter = ({ - itemCount, - onFileAllToUnsorted, - onReviewOneByOne -}: StaleActionFooterProps): React.JSX.Element => { - const nudgeMessage = getNudgeMessage(itemCount) - - return ( - <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 py-3 bg-amber-500/5 dark:bg-amber-500/10 border-t border-amber-500/20 rounded-b-lg"> - <p className="text-sm text-[var(--muted-foreground)]">{nudgeMessage}</p> - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={onFileAllToUnsorted} - className="text-xs border-amber-500/30 hover:bg-amber-500/10 hover:border-amber-500/50" - > - File all to "Unsorted" - </Button> - <span className="text-xs text-[var(--muted-foreground)]">or</span> - <Button - variant="ghost" - size="sm" - onClick={onReviewOneByOne} - className="text-xs hover:bg-amber-500/10" - > - Review one by one - </Button> - </div> - </div> - ) -} diff --git a/apps/desktop/src/renderer/src/components/stale/stale-item-row.tsx b/apps/desktop/src/renderer/src/components/stale/stale-item-row.tsx deleted file mode 100644 index 6776c98ed..000000000 --- a/apps/desktop/src/renderer/src/components/stale/stale-item-row.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { Link, FileText, Image, Mic, Scissors, FileIcon, Share2 } from '@/lib/icons' - -import { Checkbox } from '@/components/ui/checkbox' -import { QuickActions } from '@/components/quick-actions' -import { AgeIndicator } from '@/components/stale/age-indicator' -import { formatTimestamp } from '@/lib/inbox-utils' -import { cn } from '@/lib/utils' -import { type DisplayDensity, DENSITY_CONFIG } from '@/hooks/use-display-density' -import type { InboxItemListItem, InboxItemType } from '@/types' - -// Type alias for convenience (backend type) -type InboxItem = InboxItemListItem - -// Icon component based on item type -const TypeIcon = ({ type }: { type: InboxItemType }): React.JSX.Element => { - const iconClass = 'size-4 text-[var(--muted-foreground)]' - - switch (type) { - case 'link': - return <Link className={iconClass} aria-hidden="true" /> - case 'note': - return <FileText className={iconClass} aria-hidden="true" /> - case 'image': - return <Image className={iconClass} aria-hidden="true" /> - case 'voice': - return <Mic className={iconClass} aria-hidden="true" /> - case 'clip': - return <Scissors className={iconClass} aria-hidden="true" /> - case 'pdf': - return <FileIcon className={iconClass} aria-hidden="true" /> - case 'social': - return <Share2 className={iconClass} aria-hidden="true" /> - default: - return <FileText className={iconClass} aria-hidden="true" /> - } -} - -interface StaleItemRowProps { - item: InboxItem - isFocused: boolean - isSelected: boolean - isInBulkMode: boolean - isExiting?: boolean - density?: DisplayDensity - onArchive: (id: string) => void - onSnooze?: (id: string, snoozeUntil: string) => void - onFocus: (id: string) => void - onPreview: (id: string) => void - onSelectionToggle: (id: string, shiftKey: boolean) => void -} - -/** - * Row component for stale items - includes age indicator below the title - */ -export const StaleItemRow = ({ - item, - isFocused, - isSelected, - isInBulkMode, - isExiting = false, - density = 'comfortable', - onArchive, - onSnooze, - onFocus, - onPreview, - onSelectionToggle -}: StaleItemRowProps): React.JSX.Element => { - const densityConfig = DENSITY_CONFIG[density] - const handleClick = (): void => { - onFocus(item.id) - onPreview(item.id) - } - - const handleCheckboxClick = (e: React.MouseEvent): void => { - e.stopPropagation() - onSelectionToggle(item.id, e.shiftKey) - } - - const handleCheckboxChange = (_checked: boolean | 'indeterminate'): void => { - // Handled in handleCheckboxClick for shift-key support - } - - return ( - <div - className={cn( - 'group relative flex flex-col gap-0.5 cursor-pointer', - densityConfig.itemPadding, - densityConfig.itemRadius, - 'transition-[background-color,box-shadow] duration-150 ease-out', - // Exit animation - isExiting - ? 'opacity-0 scale-95 -translate-y-1 motion-reduce:opacity-0 motion-reduce:scale-100 motion-reduce:translate-y-0' - : 'opacity-100 scale-100 translate-y-0', - // Selection/focus states (using ring-inset to prevent layout shift) - isSelected - ? 'bg-primary/10 ring-1 ring-inset ring-primary/30' - : isFocused - ? 'bg-amber-500/10 ring-2 ring-inset ring-amber-500/50' - : 'hover:bg-amber-500/5' - )} - role="listitem" - tabIndex={isFocused ? 0 : -1} - aria-label={`${item.type}: ${item.title}`} - aria-selected={isSelected} - onClick={handleClick} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - handleClick() - } - }} - data-item-id={item.id} - > - {/* Top row: checkbox, icon, title, timestamp */} - <div className={cn('flex items-center', densityConfig.itemGap)}> - {/* Checkbox */} - <Checkbox - id={`stale-item-${item.id}`} - checked={isSelected} - onCheckedChange={handleCheckboxChange} - className={cn( - 'shrink-0 transition-opacity duration-150', - isSelected - ? 'opacity-100' - : isInBulkMode - ? 'opacity-80' - : isFocused - ? 'opacity-100' - : 'opacity-60 group-hover:opacity-100' - )} - aria-label={`Select ${item.title}`} - onClick={handleCheckboxClick} - /> - - {/* Type Icon */} - <TypeIcon type={item.type} /> - - {/* Title */} - <span className="flex-1 text-sm text-[var(--foreground)] truncate min-w-0"> - {item.title} - </span> - - {/* Timestamp & Quick Actions container - fixed width to prevent layout shift */} - <div className="relative shrink-0 flex items-center"> - {/* Timestamp - fades out on hover or when focused */} - <span - className={cn( - 'text-xs text-muted-foreground tabular-nums transition-opacity duration-100', - isInBulkMode ? '' : isFocused ? 'opacity-0' : 'group-hover:opacity-0' - )} - > - {formatTimestamp(item.createdAt, 'OLDER')} - </span> - - {/* Quick Actions - absolutely positioned over timestamp, visible on hover or when focused */} - {!isInBulkMode && ( - <div - className={cn( - 'absolute right-0 top-1/2 -translate-y-1/2 flex transition-opacity duration-100', - isFocused ? 'opacity-100' : 'opacity-0 group-hover:opacity-100' - )} - > - <QuickActions - itemId={item.id} - onArchive={onArchive} - onSnooze={onSnooze} - variant="row" - /> - </div> - )} - </div> - </div> - - {/* Bottom row: Age indicator */} - <div className="ml-11"> - <AgeIndicator item={item} /> - </div> - </div> - ) -} diff --git a/apps/desktop/src/renderer/src/components/stale/stale-section.tsx b/apps/desktop/src/renderer/src/components/stale/stale-section.tsx deleted file mode 100644 index 01f678a72..000000000 --- a/apps/desktop/src/renderer/src/components/stale/stale-section.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { AlertTriangle } from '@/lib/icons' - -import { StaleItemRow } from '@/components/stale/stale-item-row' -import { StaleActionFooter } from '@/components/stale/stale-action-footer' -import { STALE_THRESHOLD_DAYS } from '@/lib/stale-utils' -import { cn } from '@/lib/utils' -import { type DisplayDensity, DENSITY_CONFIG } from '@/hooks/use-display-density' -import type { InboxItemListItem } from '@/types' - -// Type alias for convenience (backend type) -type InboxItem = InboxItemListItem - -interface StaleSectionHeaderProps { - itemCount: number - threshold?: number -} - -/** - * Header for the stale section showing count and threshold - */ -const StaleSectionHeader = ({ - itemCount, - threshold = STALE_THRESHOLD_DAYS -}: StaleSectionHeaderProps): React.JSX.Element => { - return ( - <div className="flex items-center gap-2 px-4 py-3 border-b border-amber-500/20"> - <AlertTriangle className="size-4 text-amber-500" aria-hidden="true" /> - <h2 className="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400"> - Needs attention - </h2> - <span className="text-xs text-[var(--muted-foreground)]"> - · {itemCount} item{itemCount !== 1 ? 's' : ''} older than {threshold} days - </span> - </div> - ) -} - -interface StaleSectionProps { - items: InboxItem[] - selectedItemIds: Set<string> - exitingItemIds?: Set<string> - focusedItemId: string | null - density?: DisplayDensity - onArchive: (id: string) => void - onSnooze?: (id: string, snoozeUntil: string) => void - onFocus: (id: string) => void - onPreview: (id: string) => void - onSelectionToggle: (id: string, shiftKey: boolean) => void - onFileAllToUnsorted: () => void - onReviewOneByOne: () => void - className?: string -} - -/** - * Complete stale items section with header, items, and action footer - */ -export const StaleSection = ({ - items, - selectedItemIds, - exitingItemIds = new Set(), - focusedItemId, - density = 'comfortable', - onArchive, - onSnooze, - onFocus, - onPreview, - onSelectionToggle, - onFileAllToUnsorted, - onReviewOneByOne, - className -}: StaleSectionProps): React.JSX.Element | null => { - const densityConfig = DENSITY_CONFIG[density] - if (items.length === 0) { - return null - } - - const isInBulkMode = selectedItemIds.size > 0 - - return ( - <section - className={cn( - 'border border-amber-500/20 bg-amber-500/5 dark:bg-amber-500/5', - densityConfig.itemRadius, - densityConfig.captureMargin, - 'animate-in fade-in duration-300', - className - )} - aria-labelledby="stale-section-header" - > - {/* Section Header */} - <StaleSectionHeader itemCount={items.length} /> - - {/* Items */} - <div className="p-2"> - <div className="space-y-0.5"> - {items.map((item) => ( - <StaleItemRow - key={item.id} - item={item} - isFocused={focusedItemId === item.id} - isSelected={selectedItemIds.has(item.id)} - isInBulkMode={isInBulkMode} - isExiting={exitingItemIds.has(item.id)} - density={density} - onArchive={onArchive} - onSnooze={onSnooze} - onFocus={onFocus} - onPreview={onPreview} - onSelectionToggle={onSelectionToggle} - /> - ))} - </div> - </div> - - {/* Action Footer */} - <StaleActionFooter - itemCount={items.length} - onFileAllToUnsorted={onFileAllToUnsorted} - onReviewOneByOne={onReviewOneByOne} - /> - </section> - ) -} - -export { StaleSectionHeader } diff --git a/apps/desktop/src/renderer/src/components/sync/device-list.tsx b/apps/desktop/src/renderer/src/components/sync/device-list.tsx index 7c79d8ca2..b4d3aefe4 100644 --- a/apps/desktop/src/renderer/src/components/sync/device-list.tsx +++ b/apps/desktop/src/renderer/src/components/sync/device-list.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, Fragment } from 'react' import { formatDistanceToNow } from 'date-fns' import { Monitor, @@ -6,11 +6,10 @@ import { Laptop, MoreHorizontal, Pencil, - Trash2, Loader2, - Shield, ChevronDown, - ChevronUp + ChevronUp, + QrCode } from '@/lib/icons' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -51,6 +50,10 @@ interface Device { linkedAt: number } +interface DeviceListProps { + onLinkDevice?: () => void +} + const PLATFORM_ICONS: Record<string, typeof Monitor> = { macos: Laptop, windows: Monitor, @@ -72,7 +75,7 @@ const platformLabel = (platform: string): string => { const COLLAPSED_LIMIT = 3 -export function DeviceList(): React.JSX.Element { +export function DeviceList({ onLinkDevice }: DeviceListProps): React.JSX.Element { const [devices, setDevices] = useState<Device[]>([]) const [loading, setLoading] = useState(true) const [expanded, setExpanded] = useState(false) @@ -153,7 +156,7 @@ export function DeviceList(): React.JSX.Element { if (loading) { return ( <div - className="flex items-center gap-2 py-4 text-sm text-muted-foreground" + className="flex items-center gap-2 py-4 text-xs text-muted-foreground" role="status" aria-label="Loading devices" > @@ -164,105 +167,138 @@ export function DeviceList(): React.JSX.Element { } if (devices.length === 0) { - return <p className="text-sm text-muted-foreground py-2">No devices linked yet.</p> + return ( + <div className="flex flex-col rounded-lg border border-border overflow-clip"> + <div className="flex items-center justify-center h-12 px-4 text-xs text-muted-foreground"> + No devices linked yet + </div> + {onLinkDevice && ( + <> + <div className="h-px bg-border shrink-0" /> + <button + onClick={onLinkDevice} + className="flex items-center gap-2.5 h-12 px-4 text-xs text-muted-foreground hover:text-foreground transition-colors" + > + <QrCode className="w-4 h-4" /> + Link new device + </button> + </> + )} + </div> + ) } return ( <> - <div className="space-y-1"> - {visibleDevices.map((device) => { + <div className="flex flex-col rounded-lg border border-border overflow-clip"> + {visibleDevices.map((device, i) => { const Icon = PLATFORM_ICONS[device.platform] ?? Monitor const syncLabel = device.lastSyncAt - ? `Synced ${formatDistanceToNow(device.lastSyncAt, { addSuffix: true })}` - : `Linked ${formatDistanceToNow(device.linkedAt, { addSuffix: true })}` + ? `Last seen ${formatDistanceToNow(device.lastSyncAt, { addSuffix: false })} ago` + : `Linked ${formatDistanceToNow(device.linkedAt, { addSuffix: false })} ago` return ( - <div - key={device.id} - className="flex items-center gap-3 p-3 rounded-lg hover:bg-muted/50 transition-colors group" - > - <div className="flex items-center justify-center w-8 h-8 rounded-full bg-muted shrink-0"> - <Icon className="w-4 h-4 text-muted-foreground" /> - </div> - - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - <span className="text-sm font-medium truncate">{device.name}</span> - {device.isCurrentDevice && ( - <span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-primary/10 text-primary shrink-0"> - <Shield className="w-3 h-3" /> - This device + <Fragment key={device.id}> + {i > 0 && <div className="h-px bg-border shrink-0" />} + <div className="flex items-center justify-between h-12 px-4 shrink-0 group"> + <div className="flex items-center gap-2.5"> + <Icon className="w-4 h-4 text-muted-foreground shrink-0" /> + <div className="flex flex-col gap-px"> + <div className="flex items-center gap-1.5"> + <span className="text-[13px]/4 font-medium text-foreground"> + {device.name} + </span> + {device.isCurrentDevice && ( + <span className="rounded-[10px] px-1.5 py-px text-[10px]/3.5 font-medium bg-green-500/15 text-green-600 dark:text-green-400"> + This device + </span> + )} + </div> + <span className="text-[11px]/3.5 text-muted-foreground"> + {platformLabel(device.platform)} · {syncLabel} </span> - )} + </div> </div> - <p className="text-xs text-muted-foreground"> - {platformLabel(device.platform)} · {syncLabel} - </p> - </div> - {!device.isCurrentDevice && ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="icon" - className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity" - aria-label={`Actions for ${device.name}`} - > - <MoreHorizontal className="w-4 h-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => openRenameDialog(device)}> - <Pencil className="w-4 h-4 mr-2" /> - Rename - </DropdownMenuItem> - <DropdownMenuItem + {!device.isCurrentDevice && ( + <div className="flex items-center gap-2"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity" + aria-label={`Rename ${device.name}`} + > + <MoreHorizontal className="w-3.5 h-3.5" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => openRenameDialog(device)}> + <Pencil className="w-4 h-4 mr-2" /> + Rename + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + <button onClick={() => setRemoveTarget(device)} - className="text-destructive focus:text-destructive" + className="text-xs text-destructive hover:text-destructive/80 transition-colors" > - <Trash2 className="w-4 h-4 mr-2" /> - Remove - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - )} - </div> + Revoke + </button> + </div> + )} + </div> + </Fragment> ) })} {hasMore && ( - <Button - variant="ghost" - size="sm" - className="w-full text-xs text-muted-foreground hover:text-foreground" - onClick={() => setExpanded((prev) => !prev)} - aria-expanded={expanded} - aria-label={ - expanded - ? 'Show fewer devices' - : `Show ${hiddenCount} more ${hiddenCount === 1 ? 'device' : 'devices'}` - } - > - {expanded ? ( - <> - <ChevronUp className="w-3.5 h-3.5 mr-1.5" /> - Show less - </> - ) : ( - <> - <ChevronDown className="w-3.5 h-3.5 mr-1.5" /> - {hiddenCount} more {hiddenCount === 1 ? 'device' : 'devices'} - </> - )} - </Button> + <> + <div className="h-px bg-border shrink-0" /> + <button + className="flex items-center justify-center gap-1.5 h-10 text-xs text-muted-foreground hover:text-foreground transition-colors" + onClick={() => setExpanded((prev) => !prev)} + aria-expanded={expanded} + aria-label={ + expanded + ? 'Show fewer devices' + : `Show ${hiddenCount} more ${hiddenCount === 1 ? 'device' : 'devices'}` + } + > + {expanded ? ( + <> + <ChevronUp className="w-3.5 h-3.5" /> + Show less + </> + ) : ( + <> + <ChevronDown className="w-3.5 h-3.5" /> + {hiddenCount} more {hiddenCount === 1 ? 'device' : 'devices'} + </> + )} + </button> + </> + )} + + {onLinkDevice && ( + <> + <div className="h-px bg-border shrink-0" /> + <button + onClick={onLinkDevice} + className="flex items-center gap-2.5 h-12 px-4 text-xs text-muted-foreground hover:text-foreground transition-colors" + > + <QrCode className="w-4 h-4" /> + Link new device + </button> + </> )} </div> <AlertDialog open={!!removeTarget} onOpenChange={(open) => !open && setRemoveTarget(null)}> <AlertDialogContent> <AlertDialogHeader> - <AlertDialogTitle>Remove “{removeTarget?.name}”?</AlertDialogTitle> + <AlertDialogTitle>Revoke “{removeTarget?.name}”?</AlertDialogTitle> <AlertDialogDescription> This device will lose access to your synced data. It will need to be linked again to restore sync. Local data on that device will remain. @@ -275,7 +311,7 @@ export function DeviceList(): React.JSX.Element { disabled={busy} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {busy ? 'Removing...' : 'Remove device'} + {busy ? 'Revoking...' : 'Revoke device'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> diff --git a/apps/desktop/src/renderer/src/components/sync/email-entry-form.tsx b/apps/desktop/src/renderer/src/components/sync/email-entry-form.tsx index 527c730fd..3e9d6b841 100644 --- a/apps/desktop/src/renderer/src/components/sync/email-entry-form.tsx +++ b/apps/desktop/src/renderer/src/components/sync/email-entry-form.tsx @@ -44,14 +44,14 @@ export function EmailEntryForm({ <div className="space-y-2.5"> <Label htmlFor="email" - className="text-[11px] font-semibold tracking-widest uppercase text-muted-foreground" + className="uppercase tracking-[0.05em] text-[11px]/3.5 font-medium text-muted-foreground" > Email address </Label> <Input id="email" type="email" - placeholder="you@example.com" + placeholder="Enter your email address..." value={email} onChange={(e) => { setEmail(e.target.value) @@ -61,7 +61,7 @@ export function EmailEntryForm({ aria-describedby={displayError ? 'email-error' : undefined} aria-invalid={!!displayError} autoFocus - className="h-11 text-[15px] focus-visible:ring-amber-600/15 focus-visible:border-amber-600/50 dark:focus-visible:ring-amber-400/10 dark:focus-visible:border-amber-400/40" + className="h-9 text-sm focus-visible:border-[var(--tint)]/50" /> {displayError && ( <p id="email-error" className="text-sm text-destructive" role="alert"> @@ -69,7 +69,11 @@ export function EmailEntryForm({ </p> )} </div> - <Button type="submit" className="w-full h-11" disabled={isLoading || !email.trim()}> + <Button + type="submit" + className="w-full h-9 bg-background text-foreground border border-border hover:bg-accent" + disabled={isLoading || !email.trim()} + > {isLoading ? ( <> <Loader2 className="w-4 h-4 animate-spin" /> diff --git a/apps/desktop/src/renderer/src/components/sync/key-rotation-wizard.tsx b/apps/desktop/src/renderer/src/components/sync/key-rotation-wizard.tsx index d6dc3f93b..3608e4b46 100644 --- a/apps/desktop/src/renderer/src/components/sync/key-rotation-wizard.tsx +++ b/apps/desktop/src/renderer/src/components/sync/key-rotation-wizard.tsx @@ -188,7 +188,7 @@ function ConfirmStep({ onStart }: { onStart: () => void }): React.JSX.Element { return ( <div className="space-y-5 pt-1"> - <div className="flex items-start gap-3 p-3.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.07]"> + <div className="flex items-start gap-3 p-3.5 rounded-md border border-amber-500/20 bg-amber-500/[0.07]"> <ShieldAlert className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" /> <div className="text-[13px] leading-relaxed text-amber-800 dark:text-amber-300/90 space-y-1.5"> <p className="font-medium">This action will:</p> @@ -305,7 +305,7 @@ function ErrorStep({ return ( <div className="space-y-5 pt-1"> <div - className="flex items-start gap-3 p-3.5 rounded-lg border border-red-500/20 bg-red-500/[0.07]" + className="flex items-start gap-3 p-3.5 rounded-md border border-red-500/20 bg-red-500/[0.07]" role="alert" > <AlertTriangle diff --git a/apps/desktop/src/renderer/src/components/sync/linking-approval-dialog.tsx b/apps/desktop/src/renderer/src/components/sync/linking-approval-dialog.tsx index 23338fd5b..c2ae51aff 100644 --- a/apps/desktop/src/renderer/src/components/sync/linking-approval-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/sync/linking-approval-dialog.tsx @@ -95,7 +95,7 @@ export function LinkingApprovalDialog({ </DialogHeader> {event && ( - <div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"> + <div className="flex items-center gap-3 p-3 rounded-md bg-muted/50"> <div className="flex items-center justify-center w-10 h-10 rounded-xl bg-amber-500/10 dark:bg-amber-400/10"> <PlatformIcon className="w-5 h-5 text-amber-700 dark:text-amber-400" /> </div> @@ -110,7 +110,7 @@ export function LinkingApprovalDialog({ </div> )} - <div className="rounded-lg border border-amber-200 dark:border-amber-900/50 bg-amber-50/50 dark:bg-amber-950/20 p-3 space-y-1.5"> + <div className="rounded-md border border-amber-200 dark:border-amber-900/50 bg-amber-50/50 dark:bg-amber-950/20 p-3 space-y-1.5"> <p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground"> Verification code </p> diff --git a/apps/desktop/src/renderer/src/components/sync/linking-code-entry.tsx b/apps/desktop/src/renderer/src/components/sync/linking-code-entry.tsx index bc039c522..3cb5ef6c1 100644 --- a/apps/desktop/src/renderer/src/components/sync/linking-code-entry.tsx +++ b/apps/desktop/src/renderer/src/components/sync/linking-code-entry.tsx @@ -2,7 +2,8 @@ import { useState, useCallback, type FormEvent } from 'react' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { extractErrorMessage } from '@/lib/ipc-error' -import { ArrowLeft, Link2, Loader2 } from '@/lib/icons' +import { ArrowLeft, Loader2, CheckCircle, AlertCircle } from '@/lib/icons' +import { cn } from '@/lib/utils' interface LinkingCodeEntryProps { onLinked: (sessionId: string, verificationCode?: string) => void @@ -73,26 +74,43 @@ export function LinkingCodeEntry({ return ( <div className="wizard-step-enter space-y-6"> - <div className="space-y-2"> - <div className="flex items-center gap-2.5 mb-1"> - <div className="w-9 h-9 rounded-xl bg-amber-500/10 dark:bg-amber-400/10 flex items-center justify-center"> - <Link2 className="w-4.5 h-4.5 text-amber-700 dark:text-amber-400" /> - </div> - <h3 className="font-display text-xl tracking-tight">Enter linking code</h3> + <div className="flex flex-col pb-1 gap-1.5"> + <div className="tracking-[-0.02em] font-semibold text-xl/6.5 text-foreground"> + Enter linking code </div> - <p className="font-serif text-[15px] text-muted-foreground leading-relaxed"> + <div className="text-[13px]/4.5 text-muted-foreground"> Paste the linking code from your other device to securely transfer your encryption keys. - </p> + </div> </div> <form onSubmit={handleSubmit} className="space-y-5"> <div className="space-y-2.5"> - <Label - htmlFor="linking-code" - className="text-[11px] font-semibold tracking-widest uppercase text-muted-foreground" - > - Linking code - </Label> + <div className="flex items-center justify-between"> + <Label + htmlFor="linking-code" + className="text-[11px] font-semibold tracking-widest uppercase text-muted-foreground" + > + Linking code + </Label> + {code.trim() && ( + <span + className={cn( + 'flex items-center gap-1 text-[11px] font-mono', + isValid ? 'text-green-500' : 'text-muted-foreground/70' + )} + > + {isValid ? ( + <> + <CheckCircle className="w-3 h-3" /> Valid + </> + ) : ( + <> + <AlertCircle className="w-3 h-3" /> Invalid format + </> + )} + </span> + )} + </div> <textarea id="linking-code" value={code} @@ -106,7 +124,12 @@ export function LinkingCodeEntry({ autoFocus aria-describedby={error ? 'linking-error' : undefined} aria-invalid={!!error} - className="flex w-full rounded-md border border-input bg-background px-3 py-2.5 text-[15px] font-mono leading-relaxed ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-amber-600/15 focus-visible:border-amber-600/50 dark:focus-visible:ring-amber-400/10 dark:focus-visible:border-amber-400/40 disabled:cursor-not-allowed disabled:opacity-50 resize-none" + className={cn( + 'flex w-full rounded-md border bg-background px-3 py-2.5 text-[15px] font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-[var(--tint)]/50 disabled:cursor-not-allowed disabled:opacity-50 resize-none', + !code.trim() && 'border-input', + code.trim() && !isValid && 'border-[var(--tint-border)]', + isValid && 'border-green-500' + )} /> {error && ( <p id="linking-error" className="text-sm text-destructive" role="alert"> @@ -114,8 +137,8 @@ export function LinkingCodeEntry({ </p> )} {code.trim() && !isValid && !error && ( - <p className="text-sm text-muted-foreground"> - Invalid format. The linking code should be a JSON string from your other device. + <p className="text-[13px] text-muted-foreground/70"> + Paste the JSON linking code from your other device </p> )} </div> @@ -123,16 +146,20 @@ export function LinkingCodeEntry({ <div className="flex items-center gap-3"> <Button type="button" - variant="ghost" + variant="outline" size="sm" onClick={onBack} disabled={isLoading} - className="gap-1.5 text-muted-foreground" + className="gap-1.5" > <ArrowLeft className="w-3.5 h-3.5" /> Back </Button> - <Button type="submit" className="flex-1 h-11" disabled={isLoading || !isValid}> + <Button + type="submit" + className="flex-1 h-9 bg-[var(--tint)] text-tint-foreground hover:bg-[var(--tint)]/90" + disabled={isLoading || !isValid} + > {isLoading ? ( <> <Loader2 className="w-4 h-4 animate-spin" /> diff --git a/apps/desktop/src/renderer/src/components/sync/linking-pending.tsx b/apps/desktop/src/renderer/src/components/sync/linking-pending.tsx index 1d1e64838..bd3761030 100644 --- a/apps/desktop/src/renderer/src/components/sync/linking-pending.tsx +++ b/apps/desktop/src/renderer/src/components/sync/linking-pending.tsx @@ -106,10 +106,7 @@ export function LinkingPending({ role="status" aria-live="polite" > - <Loader2 - className="w-10 h-10 animate-spin text-amber-600 dark:text-amber-400" - aria-hidden="true" - /> + <Loader2 className="w-10 h-10 animate-spin text-[var(--tint)]" aria-hidden="true" /> <div className="text-center space-y-1"> <p className="font-display text-lg tracking-tight">Waiting for approval</p> <p className="font-serif text-[15px] text-muted-foreground leading-relaxed max-w-xs"> @@ -117,11 +114,11 @@ export function LinkingPending({ </p> </div> {verificationCode && ( - <div className="rounded-lg border border-amber-200 dark:border-amber-900/50 bg-amber-50/50 dark:bg-amber-950/20 px-6 py-3 text-center space-y-1"> + <div className="rounded-md border border-border bg-muted/50 px-6 py-3 text-center space-y-1"> <p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground"> Verification code </p> - <p className="font-mono text-2xl tracking-[0.3em] font-semibold text-amber-700 dark:text-amber-400"> + <p className="font-mono text-2xl tracking-[0.3em] font-semibold text-[var(--tint)]"> {formatSasCode(verificationCode)} </p> <p className="text-xs text-muted-foreground"> diff --git a/apps/desktop/src/renderer/src/components/sync/oauth-buttons.tsx b/apps/desktop/src/renderer/src/components/sync/oauth-buttons.tsx index 52dfd4894..68feb6251 100644 --- a/apps/desktop/src/renderer/src/components/sync/oauth-buttons.tsx +++ b/apps/desktop/src/renderer/src/components/sync/oauth-buttons.tsx @@ -39,7 +39,7 @@ export function OAuthButtons({ <div className="space-y-3"> <Button variant="outline" - className="w-full h-11 gap-2.5 text-[14px]" + className="w-full h-9 gap-2.5 text-sm border-border bg-muted/30" onClick={onGoogleClick} disabled={isLoading} > diff --git a/apps/desktop/src/renderer/src/components/sync/otp-input.tsx b/apps/desktop/src/renderer/src/components/sync/otp-input.tsx index 9612c6fd5..ef2248114 100644 --- a/apps/desktop/src/renderer/src/components/sync/otp-input.tsx +++ b/apps/desktop/src/renderer/src/components/sync/otp-input.tsx @@ -1,17 +1,23 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { Button } from '@/components/ui/button' import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp' -import { Loader2 } from '@/lib/icons' +import { Loader2, Clock } from '@/lib/icons' interface OtpInputProps { onComplete: (code: string) => void onResend: () => void + onBack: () => void isVerifying: boolean isResending: boolean error: string | null expiresIn: number } +function formatCountdown(s: number): string { + const mins = Math.floor(s / 60) + const secs = s % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` +} + function useCountdown(onResend: () => void): { seconds: number canResend: boolean @@ -63,6 +69,7 @@ function useCountdown(onResend: () => void): { export function OtpInput({ onComplete, onResend, + onBack, isVerifying, isResending, error, @@ -105,9 +112,12 @@ export function OtpInput({ [onComplete] ) + const slotClassName = + 'h-14 w-12 rounded-lg bg-foreground/[0.03] border border-foreground/10 data-[filled]:border-[var(--tint)]/40 font-mono font-medium text-[22px]/7 shadow-none' + return ( <div className="space-y-5"> - <div className="flex justify-center"> + <div className="flex justify-center pb-1"> <InputOTP maxLength={6} value={value} @@ -121,14 +131,14 @@ export function OtpInput({ <InputOTPSlot key={`otp-${i}`} index={i} - className="h-12 w-11 text-lg font-semibold border rounded-lg" + className={slotClassName} style={{ animationDelay: `${i * 60}ms` }} /> ))} </InputOTPGroup> - <div className="flex items-center px-2" role="separator" aria-hidden="true"> - <div className="w-1.5 h-1.5 rounded-full bg-border" /> + <div className="flex items-center justify-center w-5" role="separator" aria-hidden="true"> + <div className="size-1 rounded-xs bg-muted-foreground/50" /> </div> <InputOTPGroup className="gap-1.5"> @@ -136,7 +146,7 @@ export function OtpInput({ <InputOTPSlot key={`otp-${i}`} index={i} - className="h-12 w-11 text-lg font-semibold border rounded-lg" + className={slotClassName} style={{ animationDelay: `${(i + 1) * 60}ms` }} /> ))} @@ -161,25 +171,34 @@ export function OtpInput({ </p> )} - <div className="text-center"> - <Button - variant="ghost" - size="sm" - onClick={reset} - disabled={!canResend || isResending || isVerifying} - className="text-muted-foreground" + <div className="flex flex-col items-center gap-3"> + {isResending ? ( + <div className="flex items-center gap-1.5 text-muted-foreground"> + <Loader2 className="w-3 h-3 animate-spin" /> + <span className="text-xs">Resending...</span> + </div> + ) : canResend ? ( + <button + type="button" + onClick={reset} + disabled={isVerifying} + className="text-[var(--tint)] text-[13px] hover:underline disabled:opacity-50" + > + Resend code + </button> + ) : ( + <div className="flex items-center gap-1.5 text-muted-foreground/70"> + <Clock className="w-3 h-3" /> + <span className="text-xs tabular-nums">Resend in {formatCountdown(seconds)}</span> + </div> + )} + <button + type="button" + onClick={onBack} + className="text-[var(--tint)] text-[13px] hover:underline" > - {isResending ? ( - <> - <Loader2 className="w-3 h-3 animate-spin" /> - Resending... - </> - ) : canResend ? ( - 'Resend code' - ) : ( - <span className="tabular-nums">Resend in {seconds}s</span> - )} - </Button> + Use a different email + </button> </div> </div> ) diff --git a/apps/desktop/src/renderer/src/components/sync/otp-verification.tsx b/apps/desktop/src/renderer/src/components/sync/otp-verification.tsx index 45fdd9247..36d4f57bf 100644 --- a/apps/desktop/src/renderer/src/components/sync/otp-verification.tsx +++ b/apps/desktop/src/renderer/src/components/sync/otp-verification.tsx @@ -1,5 +1,3 @@ -import { Button } from '@/components/ui/button' -import { ArrowLeft } from '@/lib/icons' import { OtpInput } from './otp-input' interface OtpVerificationProps { @@ -25,27 +23,25 @@ export function OtpVerification({ }: OtpVerificationProps): React.JSX.Element { return ( <div className="space-y-6"> - <div className="space-y-2"> - <h3 className="font-display text-xl tracking-tight">Enter verification code</h3> - <p className="font-serif text-[15px] text-muted-foreground leading-relaxed"> - We sent a 6-digit code to{' '} - <span className="font-sans font-medium text-foreground">{email}</span> - </p> + <div className="flex flex-col pb-1 gap-1.5"> + <div className="tracking-[-0.02em] font-semibold text-xl/6.5 text-foreground"> + Enter verification code + </div> + <div className="text-[13px]/4.5"> + <span className="text-muted-foreground/60">We sent a 6-digit code to </span> + <span className="text-muted-foreground">{email}</span> + </div> </div> <OtpInput onComplete={onVerify} onResend={onResend} + onBack={onBack} isVerifying={isVerifying} isResending={isResending} error={error} expiresIn={expiresIn} /> - - <Button variant="ghost" size="sm" onClick={onBack} className="gap-1.5 text-muted-foreground"> - <ArrowLeft className="w-3.5 h-3.5" /> - Different email - </Button> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/sync/qr-linking.tsx b/apps/desktop/src/renderer/src/components/sync/qr-linking.tsx index 1ba8df1f9..bbe6986a3 100644 --- a/apps/desktop/src/renderer/src/components/sync/qr-linking.tsx +++ b/apps/desktop/src/renderer/src/components/sync/qr-linking.tsx @@ -1,9 +1,10 @@ import { useState, useCallback, useEffect } from 'react' import { QRCodeSVG } from 'qrcode.react' +import { DialogDescription, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { useCountdown } from '@/hooks/use-countdown' import { extractErrorMessage } from '@/lib/ipc-error' -import { QrCode, RefreshCw, X, Loader2, Clock, AlertCircle } from '@/lib/icons' +import { RefreshCw, Loader2, Clock, AlertCircle, Copy, Lock } from '@/lib/icons' type QrState = 'loading' | 'ready' | 'expired' | 'error' @@ -17,6 +18,12 @@ interface QrLinkingProps { onCancel: () => void } +function truncateCode(code: string, maxLen = 32): string { + if (code.length <= maxLen) return code + const half = Math.floor((maxLen - 3) / 2) + return `${code.slice(0, half)}...${code.slice(-half)}` +} + export function QrLinking({ onCancel }: QrLinkingProps): React.JSX.Element { const [qrState, setQrState] = useState<QrState>('loading') const [session, setSession] = useState<QrSession | null>(null) @@ -52,30 +59,27 @@ export function QrLinking({ onCancel }: QrLinkingProps): React.JSX.Element { }, [generateQr]) return ( - <div className="space-y-5"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2.5"> - <div className="w-9 h-9 rounded-xl bg-amber-500/10 dark:bg-amber-400/10 flex items-center justify-center"> - <QrCode className="w-4.5 h-4.5 text-amber-700 dark:text-amber-400" /> - </div> - <h3 className="font-display text-xl tracking-tight">Link a device</h3> - </div> - <Button - variant="ghost" - size="icon" - onClick={onCancel} - className="h-8 w-8" - aria-label="Cancel device linking" - > - <X className="w-4 h-4" aria-hidden="true" /> - </Button> + <div className="flex flex-col items-center gap-5"> + <div className="flex flex-col items-center gap-1.5 text-center"> + <DialogTitle className="font-heading text-xl font-semibold tracking-tight text-foreground"> + Link new device + </DialogTitle> + <DialogDescription className="text-sm text-muted-foreground"> + Scan this QR code from the device you want to link to transfer your encryption keys + securely. + </DialogDescription> </div> - <p className="font-serif text-[15px] text-muted-foreground leading-relaxed"> - Scan this code from your new device, or copy the linking code below. - </p> - <QrDisplay qrState={qrState} session={session} error={error} onRegenerate={generateQr} /> + + <Button variant="outline" className="w-full rounded-[10px]" onClick={onCancel}> + Cancel + </Button> + + <div className="flex items-center gap-1.5 text-muted-foreground/60"> + <Lock className="w-3 h-3" /> + <span className="text-[11px]">End-to-end encrypted</span> + </div> </div> ) } @@ -153,7 +157,7 @@ function QrReady({ return ( <div className="flex flex-col items-center justify-center py-10 gap-3"> <div className="w-12 h-12 rounded-2xl bg-amber-500/10 dark:bg-amber-400/10 flex items-center justify-center"> - <Clock className="w-6 h-6 text-amber-700 dark:text-amber-400" /> + <Clock className="w-6 h-6 text-amber-600 dark:text-amber-400" /> </div> <p className="text-sm text-muted-foreground">Linking code expired</p> <Button variant="outline" size="sm" onClick={onRegenerate} className="gap-1.5"> @@ -165,14 +169,14 @@ function QrReady({ } return ( - <div className="flex flex-col items-center gap-4"> + <div className="flex flex-col items-center gap-4 w-full"> <div - className="p-4 bg-white rounded-2xl border shadow-sm" + className="p-5 bg-white rounded-[14px] shadow-sm" aria-label="QR code for device linking" > <QRCodeSVG value={session.qrData} - size={200} + size={180} level="M" marginSize={0} role="img" @@ -180,14 +184,36 @@ function QrReady({ /> </div> - <div className="flex items-center gap-1.5 text-xs text-muted-foreground"> - <Clock className="w-3 h-3" /> - <span className="tabular-nums">Expires in {formattedTime}</span> + <div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400"> + <Clock className="w-3.5 h-3.5" /> + <span className="text-[13px] font-medium tabular-nums">Expires in {formattedTime}</span> </div> - <Button variant="outline" size="sm" onClick={handleCopy} className="w-full max-w-[260px]"> - {copied ? 'Copied!' : 'Copy linking code'} - </Button> + <div className="flex items-center gap-3 w-full"> + <div className="flex-1 h-px bg-border" /> + <span className="text-xs text-muted-foreground">or</span> + <div className="flex-1 h-px bg-border" /> + </div> + + <div className="w-full space-y-2"> + <span className="text-[11px] font-semibold tracking-widest uppercase text-muted-foreground"> + Linking code + </span> + <div className="flex items-center gap-2 rounded-[10px] bg-foreground/[0.04] border border-border px-3.5 py-2.5 min-w-0"> + <code className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-muted-foreground"> + {truncateCode(session.qrData)} + </code> + <button + type="button" + onClick={handleCopy} + className="shrink-0 flex items-center gap-1.5 rounded-md bg-foreground/[0.06] px-2.5 py-1 text-muted-foreground transition-colors hover:bg-foreground/[0.1]" + aria-label={copied ? 'Copied' : 'Copy linking code'} + > + <Copy className="w-3.5 h-3.5" /> + <span className="text-xs font-medium">{copied ? 'Copied!' : 'Copy'}</span> + </button> + </div> + </div> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/sync/recovery-phrase-confirm.tsx b/apps/desktop/src/renderer/src/components/sync/recovery-phrase-confirm.tsx index 0a7ffdfc4..00b41dbe2 100644 --- a/apps/desktop/src/renderer/src/components/sync/recovery-phrase-confirm.tsx +++ b/apps/desktop/src/renderer/src/components/sync/recovery-phrase-confirm.tsx @@ -1,8 +1,6 @@ import { useState, useCallback, useMemo } from 'react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { ArrowLeft, Check, X } from '@/lib/icons' +import { Check, X } from '@/lib/icons' import { cn } from '@/lib/utils' interface RecoveryPhraseConfirmProps { @@ -68,74 +66,68 @@ export function RecoveryPhraseConfirm({ }, []) return ( - <div className="space-y-6"> - <div className="wizard-step-enter space-y-2"> - <h3 className="font-display text-xl tracking-tight">Confirm your recovery phrase</h3> - <p className="font-serif text-[15px] text-muted-foreground leading-relaxed"> - Enter the requested words to verify you've saved it. - </p> + <div className="[font-synthesis:none] flex flex-col antialiased text-xs/4"> + <div className="wizard-step-enter flex flex-col pb-7 gap-1.5"> + <div className="tracking-[-0.02em] font-semibold text-xl/6.5 text-foreground"> + Confirm your recovery phrase + </div> + <div className="text-[13px]/4.5 text-muted-foreground"> + Enter the requested words to verify you've saved it correctly. + </div> </div> - <div className="space-y-4 wizard-step-enter wiz-delay-2"> + <div className="flex flex-col pb-7 gap-3 wizard-step-enter wiz-delay-2"> {indices.map((wordIndex, slotIndex) => { const showFeedback = touched[slotIndex] && inputs[slotIndex].trim().length > 0 const isCorrect = matches[slotIndex] return ( - <div key={wordIndex} className="space-y-1.5"> - <Label - htmlFor={`word-${wordIndex}`} - className="text-[11px] font-semibold tracking-widest uppercase text-muted-foreground" - > - Word #{wordIndex + 1} - </Label> - <div className="relative"> - <Input - id={`word-${wordIndex}`} - value={inputs[slotIndex]} - onChange={(e) => handleChange(slotIndex, e.target.value)} - onBlur={() => handleBlur(slotIndex)} - placeholder={`Enter word #${wordIndex + 1}`} - className={cn( - 'h-11 pr-9 font-mono text-[15px]', - 'focus-visible:ring-amber-600/15 focus-visible:border-amber-600/50', - 'dark:focus-visible:ring-amber-400/10 dark:focus-visible:border-amber-400/40', - showFeedback && isCorrect && 'border-green-500 focus-visible:ring-green-500/20', - showFeedback && - !isCorrect && - 'border-destructive focus-visible:ring-destructive/20' - )} - autoFocus={slotIndex === 0} - /> - {showFeedback && ( - <span className="absolute right-3 top-1/2 -translate-y-1/2"> - {isCorrect ? ( - <Check className="w-4 h-4 text-green-500" /> - ) : ( - <X className="w-4 h-4 text-destructive" /> - )} - </span> + <div key={wordIndex} className="flex items-center gap-2.5"> + <input + type="text" + value={inputs[slotIndex]} + onChange={(e) => handleChange(slotIndex, e.target.value)} + onBlur={() => handleBlur(slotIndex)} + placeholder={`Enter word #${wordIndex + 1}`} + autoFocus={slotIndex === 0} + className={cn( + 'flex-1 h-9 rounded-lg px-3.5 font-mono text-sm/4.5 bg-foreground/[0.03] border outline-none transition-colors', + 'placeholder:text-muted-foreground/50', + 'focus-visible:border-[var(--tint)]/50', + showFeedback && isCorrect && 'border-green-500/40', + showFeedback && !isCorrect && 'border-destructive/40', + !showFeedback && 'border-foreground/10' )} - </div> + /> + {showFeedback ? ( + isCorrect ? ( + <Check className="w-4 h-4 text-green-500 shrink-0" /> + ) : ( + <X className="w-4 h-4 text-destructive shrink-0" /> + ) + ) : ( + <div className="shrink-0 size-4" /> + )} </div> ) })} </div> - <div className="flex items-center gap-3 wizard-step-enter wiz-delay-3"> - <Button - variant="ghost" - size="sm" - onClick={onBack} - className="gap-1.5 text-muted-foreground" - > - <ArrowLeft className="w-3.5 h-3.5" /> + <div className="flex items-center gap-2.5 wizard-step-enter wiz-delay-3"> + <Button variant="outline" onClick={onBack} className="h-9 px-5"> Back </Button> - <Button onClick={onConfirmed} disabled={!allCorrect} className="flex-1 h-11"> + <Button + onClick={onConfirmed} + disabled={!allCorrect} + className="flex-1 h-9 bg-[var(--tint)] text-tint-foreground hover:bg-[var(--tint)]/90" + > Verify </Button> </div> + <p className="pt-2.5 text-[13px] text-muted-foreground/70"> + All 3 words must match to continue + </p> </div> ) } diff --git a/apps/desktop/src/renderer/src/components/sync/recovery-phrase-display.tsx b/apps/desktop/src/renderer/src/components/sync/recovery-phrase-display.tsx index edab19a3f..2af25aab8 100644 --- a/apps/desktop/src/renderer/src/components/sync/recovery-phrase-display.tsx +++ b/apps/desktop/src/renderer/src/components/sync/recovery-phrase-display.tsx @@ -45,58 +45,59 @@ export function RecoveryPhraseDisplay({ }, [phrase]) return ( - <div className="space-y-6"> - <div className="wizard-step-enter space-y-2"> - <h3 className="font-display text-xl tracking-tight">Save your recovery phrase</h3> - <p className="font-serif text-[15px] text-muted-foreground leading-relaxed"> + <div className="[font-synthesis:none] flex flex-col antialiased text-xs/4"> + <div className="wizard-step-enter flex flex-col pb-5 gap-1.5"> + <div className="tracking-[-0.02em] font-semibold text-xl/6.5 text-foreground"> + Save your recovery phrase + </div> + <div className="text-[13px]/4.5 text-muted-foreground"> This is the only way to recover your encrypted data if you lose access to all your devices. - </p> + </div> </div> - <div className="wizard-step-enter wiz-delay-2 flex items-start gap-3 p-3.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.07]"> - <ShieldAlert className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" /> - <p className="text-[13px] leading-relaxed text-amber-800 dark:text-amber-300/90"> + <div className="wizard-step-enter wiz-delay-2 flex items-center mb-5 rounded-lg py-2.5 px-3.5 gap-2.5 bg-amber-500/[0.08] border border-amber-500/15"> + <ShieldAlert className="w-4 h-4 text-amber-500 dark:text-amber-400 shrink-0" /> + <p className="text-xs/4 text-amber-500 dark:text-amber-400"> Write this down and store it somewhere safe. You will not see it again. </p> </div> <div - className="wizard-card-lift recovery-phrase-card rounded-xl border p-5" + className="mb-5 rounded-lg bg-foreground/[0.02] border border-foreground/[0.06]" aria-label="Recovery phrase words" > - <div className="grid grid-cols-4 gap-x-3 gap-y-2" role="list"> + <div className="flex flex-wrap gap-1.5 p-4" role="list"> {words.map((word, i) => ( <div - key={word} + key={`${i}-${word}`} role="listitem" - className="flex items-baseline gap-1.5 px-2.5 py-1.5 rounded-md bg-background/60" + className="flex items-center w-27.5 py-1 gap-1.5 shrink-0" aria-label={`Word ${i + 1}: ${word}`} > - <span className="text-[10px] tabular-nums text-muted-foreground/50 w-4 text-right select-none"> + <span className="w-4 shrink-0 font-mono text-muted-foreground/50 text-[10px]/3 select-none"> {i + 1} </span> - <span className="font-mono text-sm font-medium select-all">{word}</span> + <span className="font-mono text-[13px]/4 text-foreground select-all">{word}</span> </div> ))} </div> - - <div className="mt-4 pt-3 border-t border-border/50 flex justify-end"> + <div className="border-t border-foreground/[0.06] px-4 py-2.5 flex justify-end"> <Button variant="ghost" size="sm" onClick={() => void handleCopy()} - className="gap-1.5 text-muted-foreground h-8" + className="gap-1.5 text-muted-foreground h-7" aria-label={copied ? 'Recovery phrase copied' : 'Copy recovery phrase to clipboard'} > {copied ? ( <> - <Check className="w-3.5 h-3.5 text-green-600 dark:text-green-400" /> + <Check className="w-3 h-3 text-green-600 dark:text-green-400" /> <span className="text-green-700 dark:text-green-400">Copied</span> </> ) : ( <> - <Copy className="w-3.5 h-3.5" /> + <Copy className="w-3 h-3" /> Copy </> )} @@ -104,7 +105,10 @@ export function RecoveryPhraseDisplay({ </div> </div> - <Button onClick={onContinue} className="w-full h-11 wizard-step-enter wiz-delay-3"> + <Button + onClick={onContinue} + className="w-full h-9 bg-[var(--tint)] text-tint-foreground hover:bg-[var(--tint)]/90 wizard-step-enter wiz-delay-3" + > I've saved my recovery phrase </Button> </div> diff --git a/apps/desktop/src/renderer/src/components/sync/recovery-phrase-input.tsx b/apps/desktop/src/renderer/src/components/sync/recovery-phrase-input.tsx index 2adb4d880..b2733a20d 100644 --- a/apps/desktop/src/renderer/src/components/sync/recovery-phrase-input.tsx +++ b/apps/desktop/src/renderer/src/components/sync/recovery-phrase-input.tsx @@ -1,7 +1,8 @@ import { useState, useCallback, useEffect, useRef, type FormEvent } from 'react' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' -import { ArrowLeft, KeyRound, Loader2 } from '@/lib/icons' +import { ArrowLeft, Loader2 } from '@/lib/icons' +import { cn } from '@/lib/utils' interface RecoveryPhraseInputProps { onSubmit: (phrase: string) => void @@ -60,17 +61,13 @@ export function RecoveryPhraseInput({ return ( <div className="wizard-step-enter space-y-6"> - <div className="space-y-2"> - <div className="flex items-center gap-2.5 mb-1"> - <div className="w-9 h-9 rounded-xl bg-amber-500/10 dark:bg-amber-400/10 flex items-center justify-center"> - <KeyRound className="w-4.5 h-4.5 text-amber-700 dark:text-amber-400" /> - </div> - <h3 className="font-display text-xl tracking-tight">Enter recovery phrase</h3> + <div className="flex flex-col pb-1 gap-1.5"> + <div className="tracking-[-0.02em] font-semibold text-xl/6.5 text-foreground"> + Enter recovery phrase + </div> + <div className="text-[13px]/4.5 text-muted-foreground"> + Enter your 24-word recovery phrase to restore access to your encrypted data. </div> - <p className="font-serif text-[15px] text-muted-foreground leading-relaxed"> - This device was previously signed out. Enter your 24-word recovery phrase to restore - access to your encrypted data. - </p> </div> <form onSubmit={handleSubmit} className="space-y-5"> @@ -82,7 +79,7 @@ export function RecoveryPhraseInput({ > Recovery phrase </Label> - <span className="text-[11px] tabular-nums text-muted-foreground/70"> + <span className="text-[11px] font-mono tabular-nums text-muted-foreground/70"> {wordCount} / {EXPECTED_WORD_COUNT} words </span> </div> @@ -96,28 +93,42 @@ export function RecoveryPhraseInput({ autoFocus aria-describedby={error ? 'recovery-error' : undefined} aria-invalid={!!error} - className="flex w-full rounded-md border border-input bg-background px-3 py-2.5 text-[15px] font-mono leading-relaxed ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-amber-600/15 focus-visible:border-amber-600/50 dark:focus-visible:ring-amber-400/10 dark:focus-visible:border-amber-400/40 disabled:cursor-not-allowed disabled:opacity-50 resize-none" + className={cn( + 'flex w-full rounded-md border bg-background px-3 py-2.5 text-[15px] font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-[var(--tint)]/50 disabled:cursor-not-allowed disabled:opacity-50 resize-none', + wordCount === 0 && 'border-input', + wordCount > 0 && !isValidLength && 'border-[var(--tint-border)]', + isValidLength && 'border-green-500' + )} /> {error && ( <p id="recovery-error" className="text-sm text-destructive" role="alert"> {error} </p> )} + {wordCount > 0 && !isValidLength && !error && ( + <p className="text-[13px] text-muted-foreground/70"> + Enter all 24 words to enable the restore button + </p> + )} </div> <div className="flex items-center gap-3"> <Button type="button" - variant="ghost" + variant="outline" size="sm" onClick={onBack} disabled={isLoading} - className="gap-1.5 text-muted-foreground" + className="gap-1.5" > <ArrowLeft className="w-3.5 h-3.5" /> Back </Button> - <Button type="submit" className="flex-1 h-11" disabled={isLoading || !isValidLength}> + <Button + type="submit" + className="flex-1 h-9 bg-[var(--tint)] text-tint-foreground hover:bg-[var(--tint)]/90" + disabled={isLoading || !isValidLength} + > {isLoading ? ( <> <Loader2 className="w-4 h-4 animate-spin" /> diff --git a/apps/desktop/src/renderer/src/components/sync/sync-status.tsx b/apps/desktop/src/renderer/src/components/sync/sync-status.tsx index 3c025ad4b..242dbd0f3 100644 --- a/apps/desktop/src/renderer/src/components/sync/sync-status.tsx +++ b/apps/desktop/src/renderer/src/components/sync/sync-status.tsx @@ -12,9 +12,10 @@ const log = createLogger('SyncStatus') interface SyncStatusProps { onOpenSettings: () => void + iconOnly?: boolean } -export function SyncStatus({ onOpenSettings }: SyncStatusProps): React.JSX.Element { +export function SyncStatus({ onOpenSettings, iconOnly }: SyncStatusProps): React.JSX.Element { const { status, label, @@ -69,17 +70,11 @@ export function SyncStatus({ onOpenSettings }: SyncStatusProps): React.JSX.Eleme aria-label={`Sync status: ${label}`} className={cn('text-muted-foreground', hasIssues && 'text-destructive')} > - <span className="relative"> - <IconComponent - className={cn('size-4', isAnimating && 'animate-spin')} - aria-hidden="true" - /> - <span - className={cn('absolute -top-0.5 -right-0.5 size-2 rounded-full', dotColor)} - aria-hidden="true" - /> - </span> - <span className="text-xs">{label}</span> + <IconComponent + className={cn('size-4', isAnimating && 'animate-spin')} + aria-hidden="true" + /> + {!iconOnly && <span className="text-xs">{label}</span>} </SidebarMenuButton> </PopoverTrigger> diff --git a/apps/desktop/src/renderer/src/components/tabs/accessible-tab-panel.tsx b/apps/desktop/src/renderer/src/components/tabs/accessible-tab-panel.tsx index 37356fedb..29182c9bb 100644 --- a/apps/desktop/src/renderer/src/components/tabs/accessible-tab-panel.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/accessible-tab-panel.tsx @@ -29,11 +29,7 @@ export const AccessibleTabPanel = ({ id={`tabpanel-${tab.id}`} aria-labelledby={`tab-${tab.id}`} tabIndex={0} - className={cn( - 'h-full outline-none', - 'focus:ring-1 focus:ring-blue-500 focus:ring-inset', - className - )} + className={cn('h-full outline-none', className)} > {children} </div> diff --git a/apps/desktop/src/renderer/src/components/tabs/accessible-tab.tsx b/apps/desktop/src/renderer/src/components/tabs/accessible-tab.tsx index acfdb7622..fee97e7b8 100644 --- a/apps/desktop/src/renderer/src/components/tabs/accessible-tab.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/accessible-tab.tsx @@ -96,9 +96,8 @@ export const AccessibleTab = ({ className={cn( 'flex items-center gap-2 px-3 h-8 min-w-0', 'border-b-2 transition-colors outline-none', - 'focus:ring-1 focus:ring-blue-500 focus:ring-inset', isActive - ? 'bg-background border-blue-500 text-foreground' + ? 'bg-background border-tint text-foreground' : 'bg-muted/50 border-transparent text-muted-foreground hover:bg-surface-active', className )} @@ -121,7 +120,7 @@ export const AccessibleTab = ({ {/* Modified indicator */} {tab.isModified && ( - <span className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" aria-hidden="true" /> + <span className="w-2 h-2 rounded-full bg-tint flex-shrink-0" aria-hidden="true" /> )} {/* Preview indicator - only show when preview mode is enabled */} @@ -143,7 +142,7 @@ export const AccessibleTab = ({ className={cn( 'p-0.5 rounded opacity-0 group-hover:opacity-100', 'hover:bg-border', - 'focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-blue-500', + 'focus:opacity-100 focus:outline-none', isActive && 'opacity-100' )} tabIndex={-1} diff --git a/apps/desktop/src/renderer/src/components/tabs/pinned-tab.tsx b/apps/desktop/src/renderer/src/components/tabs/pinned-tab.tsx index 23a20c3eb..27c172ca3 100644 --- a/apps/desktop/src/renderer/src/components/tabs/pinned-tab.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/pinned-tab.tsx @@ -87,7 +87,7 @@ export const PinnedTab = ({ className={cn( 'absolute top-1 right-1', 'w-1.5 h-1.5 rounded-full', - 'bg-blue-400 dark:bg-blue-500', + 'bg-tint', 'animate-pulse' )} aria-label="Unsaved changes" diff --git a/apps/desktop/src/renderer/src/components/tabs/regular-tab.tsx b/apps/desktop/src/renderer/src/components/tabs/regular-tab.tsx index a2a5ab6d3..cd37fda77 100644 --- a/apps/desktop/src/renderer/src/components/tabs/regular-tab.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/regular-tab.tsx @@ -136,7 +136,7 @@ const RegularTabComponent = ({ <div className={cn( 'w-1.5 h-1.5 rounded-full', - 'bg-blue-400 dark:bg-blue-500', + 'bg-tint', 'transition-transform duration-150', 'animate-pulse' )} diff --git a/apps/desktop/src/renderer/src/components/tabs/skip-to-content.tsx b/apps/desktop/src/renderer/src/components/tabs/skip-to-content.tsx index aa49d2581..b4aa6def0 100644 --- a/apps/desktop/src/renderer/src/components/tabs/skip-to-content.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/skip-to-content.tsx @@ -25,8 +25,8 @@ export const SkipToContent = ({ className={cn( 'sr-only focus:not-sr-only', 'fixed top-2 left-2 z-50', - 'px-4 py-2 bg-blue-500 text-white rounded-md', - 'focus:outline-none focus:ring-1 focus:ring-blue-300' + 'px-4 py-2 bg-tint text-tint-foreground rounded-md', + 'focus:outline-none' )} > {children} diff --git a/apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx b/apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx index 9472a7f8d..9b96faf43 100644 --- a/apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/tab-bar-with-drag.tsx @@ -196,7 +196,7 @@ export const TabBarWithDrag = ({ <Bot className={cn( 'w-4 h-4 transition-colors duration-150', - isAIAgentOpen && 'text-blue-500 dark:text-blue-400' + isAIAgentOpen && 'text-tint' )} /> } diff --git a/apps/desktop/src/renderer/src/components/tabs/tab-error-boundary.tsx b/apps/desktop/src/renderer/src/components/tabs/tab-error-boundary.tsx index d316d7fbc..3cacc0165 100644 --- a/apps/desktop/src/renderer/src/components/tabs/tab-error-boundary.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/tab-error-boundary.tsx @@ -61,7 +61,7 @@ export class TabErrorBoundary extends Component<TabErrorBoundaryProps, TabErrorB )} <button onClick={this.handleRetry} - className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors" + className="flex items-center gap-2 px-4 py-2 bg-tint text-tint-foreground rounded-md hover:bg-tint-hover transition-colors" > <RefreshCw className="w-4 h-4" /> Try Again diff --git a/apps/desktop/src/renderer/src/components/tabs/tab-icon.tsx b/apps/desktop/src/renderer/src/components/tabs/tab-icon.tsx index a38e8ef8d..a630607e9 100644 --- a/apps/desktop/src/renderer/src/components/tabs/tab-icon.tsx +++ b/apps/desktop/src/renderer/src/components/tabs/tab-icon.tsx @@ -28,6 +28,7 @@ import { } from '@/lib/icons' import type { TabType } from '@/contexts/tabs/types' import { cn } from '@/lib/utils' +import { NoteIconDisplay } from '@/lib/render-note-icon' interface TabIconProps { /** Tab type for default icon lookup */ @@ -96,9 +97,13 @@ const TYPE_TO_ICON: Record<TabType, string> = { * Memoized to prevent unnecessary re-renders */ const TabIconComponent = ({ type, icon, emoji, className }: TabIconProps): React.JSX.Element => { - // If emoji is provided, render it instead of icon if (emoji) { - return <span className={cn('shrink-0 text-center leading-none', className)}>{emoji}</span> + return ( + <NoteIconDisplay + value={emoji} + className={cn('shrink-0 text-center leading-none', className)} + /> + ) } // Use provided icon name or fall back to type-based default diff --git a/apps/desktop/src/renderer/src/components/tasks/add-task-modal.tsx b/apps/desktop/src/renderer/src/components/tasks/add-task-modal.tsx index 1c26fba0e..a991e4d7a 100644 --- a/apps/desktop/src/renderer/src/components/tasks/add-task-modal.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/add-task-modal.tsx @@ -273,7 +273,7 @@ export const AddTaskModal = ({ rows={3} className={cn( 'flex min-h-[80px] w-full rounded-sm border border-input bg-transparent px-3 py-2 text-sm shadow-sm', - 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'placeholder:text-muted-foreground focus-visible:outline-none', 'disabled:cursor-not-allowed disabled:opacity-50 resize-none' )} /> diff --git a/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-button.tsx b/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-button.tsx index 1d432f85c..c3269bccb 100644 --- a/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-button.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-button.tsx @@ -67,7 +67,7 @@ export const BulkActionButton = ({ className={cn( 'flex items-center gap-1.5 rounded-sm border px-3 py-1.5 text-sm font-medium', 'transition-colors duration-150', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', 'disabled:cursor-not-allowed disabled:opacity-50', variantStyles[variant], className diff --git a/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-dropdown.tsx b/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-dropdown.tsx index 9945da885..399f44e31 100644 --- a/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-dropdown.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-dropdown.tsx @@ -68,7 +68,7 @@ export const BulkActionDropdown = <T extends string | number = string>({ 'flex items-center gap-1.5 rounded-sm border px-3 py-1.5 text-sm font-medium', 'bg-background border-border hover:bg-accent text-foreground', 'transition-colors duration-150', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', 'disabled:cursor-not-allowed disabled:opacity-50', className )} diff --git a/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-toolbar.tsx b/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-toolbar.tsx index 2a12bdbc2..1520ce168 100644 --- a/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-toolbar.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/bulk-actions/bulk-action-toolbar.tsx @@ -253,7 +253,7 @@ export const BulkActionToolbar = ({ 'flex items-center gap-1 rounded-sm px-3 py-1.5 text-sm', 'text-muted-foreground hover:text-foreground hover:bg-accent', 'transition-colors duration-150', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'focus-visible:outline-none' )} aria-label="Cancel selection" > diff --git a/apps/desktop/src/renderer/src/components/tasks/bulk-actions/selection-checkbox.tsx b/apps/desktop/src/renderer/src/components/tasks/bulk-actions/selection-checkbox.tsx index 6bf956c1e..de90ccaec 100644 --- a/apps/desktop/src/renderer/src/components/tasks/bulk-actions/selection-checkbox.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/bulk-actions/selection-checkbox.tsx @@ -77,7 +77,7 @@ export const SelectionCheckbox = ({ aria-label={ariaLabel} className={cn( 'size-4 shrink-0 cursor-pointer rounded border-gray-300', - 'text-primary focus:ring-primary focus:ring-offset-0', + 'text-primary', 'transition-colors duration-150', 'disabled:cursor-not-allowed disabled:opacity-50', // Custom styling for indeterminate state diff --git a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-drag-overlay.tsx b/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-drag-overlay.tsx deleted file mode 100644 index 859602e18..000000000 --- a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-drag-overlay.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react' -import { DragOverlay } from '@dnd-kit/core' - -import { cn } from '@/lib/utils' -import type { Task } from '@/data/sample-tasks' -import { isBefore, startOfDay } from '@/lib/task-utils' - -interface CalendarDragOverlayProps { - activeTask: Task | null -} - -const getPriorityBarColor = (priority: Task['priority']): string => { - switch (priority) { - case 'urgent': - return 'var(--task-priority-urgent)' - case 'high': - return 'var(--task-priority-high)' - case 'medium': - return 'var(--task-priority-medium)' - case 'low': - return 'var(--task-priority-low)' - default: - return 'var(--cal-weekday)' - } -} - -export const CalendarDragOverlay = ({ - activeTask -}: CalendarDragOverlayProps): React.JSX.Element => { - if (!activeTask) { - return <DragOverlay dropAnimation={null} /> - } - - const isCompleted = !!activeTask.completedAt - const isOverdue = - activeTask.dueDate !== null && - isBefore(startOfDay(activeTask.dueDate), startOfDay(new Date())) && - !isCompleted - - return ( - <DragOverlay dropAnimation={null}> - <div - className={cn( - 'flex items-center gap-1 rounded px-1.5 py-[3px] text-[11px] leading-[14px] shadow-lg', - isOverdue ? 'bg-cal-task-overdue-bg' : 'bg-cal-task-bg-today', - isCompleted && 'opacity-50 line-through' - )} - style={{ - color: isOverdue ? 'var(--cal-task-overdue-text)' : 'var(--cal-task-text)' - }} - > - <span - className="block w-[3px] h-3 shrink-0 rounded-sm" - style={{ backgroundColor: getPriorityBarColor(activeTask.priority) }} - aria-hidden="true" - /> - <span className="truncate">{activeTask.title}</span> - </div> - </DragOverlay> - ) -} - -export default CalendarDragOverlay diff --git a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-grid.tsx b/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-grid.tsx deleted file mode 100644 index 44b1ca5ee..000000000 --- a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-grid.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useMemo } from 'react' - -import { DayCell } from './day-cell' -import { formatDateKey, type CalendarDay } from '@/lib/task-utils' -import type { Task } from '@/data/sample-tasks' - -const WEEKDAYS_SUN_START = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] -const WEEKDAYS_MON_START = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - -interface CalendarGridProps { - days: CalendarDay[] - tasksByDate: Map<string, Task[]> - allTasks?: Task[] - weekStartsOn?: 0 | 1 - selectedDate: Date | null - focusedDate: Date | null - maxVisibleTasks?: number - isCompact?: boolean - onOpenDay: (date: Date) => void - onTaskClick: (taskId: string) => void - onAddTask: (date: Date) => void -} - -export const CalendarGrid = ({ - days, - tasksByDate, - allTasks = [], - weekStartsOn = 0, - selectedDate, - focusedDate, - maxVisibleTasks = 3, - isCompact = false, - onOpenDay, - onTaskClick, - onAddTask -}: CalendarGridProps): React.JSX.Element => { - const weekdayLabels = useMemo( - () => (weekStartsOn === 0 ? WEEKDAYS_SUN_START : WEEKDAYS_MON_START), - [weekStartsOn] - ) - - const weeks = useMemo(() => { - const result: CalendarDay[][] = [] - for (let i = 0; i < days.length; i += 7) { - result.push(days.slice(i, i + 7)) - } - return result - }, [days]) - - return ( - <div className="flex flex-col" style={{ gap: 'var(--cal-grid-gap)' }}> - {/* Weekday header */} - <div className="grid grid-cols-7 px-0 pb-2" style={{ gap: 'var(--cal-grid-gap)' }}> - {weekdayLabels.map((day) => ( - <div - key={day} - className="py-1 px-2 text-[10px] font-medium uppercase tracking-[0.08em]" - style={{ - color: 'var(--cal-weekday)', - fontFamily: 'var(--font-mono)' - }} - > - {day} - </div> - ))} - </div> - - {/* Week rows */} - {weeks.map((week, weekIndex) => ( - <div key={weekIndex} className="grid grid-cols-7" style={{ gap: 'var(--cal-grid-gap)' }}> - {week.map((day) => { - const dateKey = formatDateKey(day.date) - const dayTasks = tasksByDate.get(dateKey) || [] - const isSelected = - selectedDate !== null && - selectedDate.getFullYear() === day.date.getFullYear() && - selectedDate.getMonth() === day.date.getMonth() && - selectedDate.getDate() === day.date.getDate() - const isFocused = - focusedDate !== null && - focusedDate.getFullYear() === day.date.getFullYear() && - focusedDate.getMonth() === day.date.getMonth() && - focusedDate.getDate() === day.date.getDate() - - return ( - <DayCell - key={dateKey} - day={day} - tasks={dayTasks} - allTasks={allTasks} - maxVisible={maxVisibleTasks} - isSelected={isSelected} - isFocused={isFocused} - isCompact={isCompact} - onOpenDay={onOpenDay} - onTaskClick={onTaskClick} - onAddTask={onAddTask} - /> - ) - })} - </div> - ))} - </div> - ) -} - -export default CalendarGrid diff --git a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-header.tsx b/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-header.tsx deleted file mode 100644 index c71a3d0db..000000000 --- a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-header.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from 'react' -import { ChevronLeft, ChevronRight, Filter } from '@/lib/icons' - -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuCheckboxItem, - DropdownMenuTrigger, - DropdownMenuSeparator -} from '@/components/ui/dropdown-menu' -import type { Project } from '@/data/tasks-data' - -interface CalendarHeaderProps { - currentMonth: Date - onPreviousMonth: () => void - onNextMonth: () => void - onToday: () => void - showCompleted: boolean - onToggleCompleted: (value: boolean) => void - projects?: Project[] - projectFilter: string | null - onProjectFilterChange: (projectId: string | null) => void -} - -export const CalendarHeader = ({ - currentMonth, - onPreviousMonth, - onNextMonth, - onToday, - showCompleted, - onToggleCompleted, - projects, - projectFilter, - onProjectFilterChange -}: CalendarHeaderProps): React.JSX.Element => { - const monthLabel = currentMonth.toLocaleDateString('en-US', { - month: 'long', - year: 'numeric' - }) - - const selectedProject = projects?.find((p) => p.id === projectFilter) - - return ( - <div className="flex flex-wrap items-center justify-between gap-3 pb-2"> - <div className="flex items-center gap-2"> - {/* Month navigation */} - <div className="flex items-center gap-1"> - <button - type="button" - onClick={onPreviousMonth} - aria-label="Previous month" - className="flex items-center justify-center size-8 rounded-md text-cal-weekday hover:bg-cal-cell-outside-bg transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - > - <ChevronLeft className="size-4" /> - </button> - - <h2 - className="text-2xl font-semibold tracking-tight select-none min-w-[180px]" - style={{ - color: 'var(--cal-month-text)', - fontFamily: 'var(--font-heading)', - letterSpacing: '-0.02em' - }} - > - {monthLabel} - </h2> - - <button - type="button" - onClick={onNextMonth} - aria-label="Next month" - className="flex items-center justify-center size-8 rounded-md text-cal-weekday hover:bg-cal-cell-outside-bg transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - > - <ChevronRight className="size-4" /> - </button> - </div> - - {/* Today pill */} - <button - type="button" - onClick={onToday} - className="rounded-full px-3 py-1 text-xs font-medium bg-cal-cell-outside-bg text-cal-date-current hover:bg-cal-cell-weekend-bg transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - > - Today - </button> - </div> - - <div className="flex items-center gap-4"> - {/* Project Filter */} - {projects && projects.length > 0 && ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline" size="sm" className="gap-2"> - <Filter className="size-3.5" /> - {selectedProject ? selectedProject.name : 'All Projects'} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-48"> - <DropdownMenuCheckboxItem - checked={projectFilter === null} - onCheckedChange={() => onProjectFilterChange(null)} - > - All Projects - </DropdownMenuCheckboxItem> - <DropdownMenuSeparator /> - {projects - .filter((p) => !p.isArchived) - .map((project) => ( - <DropdownMenuCheckboxItem - key={project.id} - checked={projectFilter === project.id} - onCheckedChange={() => onProjectFilterChange(project.id)} - > - <span - className="mr-2 inline-block size-2 rounded-full" - style={{ backgroundColor: project.color }} - /> - {project.name} - </DropdownMenuCheckboxItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - )} - - <label className="flex items-center gap-2 text-sm text-muted-foreground"> - <Checkbox - checked={showCompleted} - onCheckedChange={(checked) => onToggleCompleted(!!checked)} - /> - Show completed - </label> - </div> - </div> - ) -} - -export default CalendarHeader diff --git a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-task-item.tsx b/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-task-item.tsx deleted file mode 100644 index 397b706fb..000000000 --- a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-task-item.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { useMemo } from 'react' - -import { cn } from '@/lib/utils' -import type { Task } from '@/data/sample-tasks' -import { isBefore, startOfDay } from '@/lib/task-utils' -import { getSubtasks, calculateProgress } from '@/lib/subtask-utils' -import { MiniProgressBar } from '@/components/tasks/mini-progress-bar' - -interface CalendarTaskItemProps { - task: Task - allTasks?: Task[] - compact?: boolean - isToday?: boolean - onClick?: (taskId: string) => void -} - -const getPriorityBarColor = (priority: Task['priority']): string => { - switch (priority) { - case 'urgent': - return 'var(--task-priority-urgent)' - case 'high': - return 'var(--task-priority-high)' - case 'medium': - return 'var(--task-priority-medium)' - case 'low': - return 'var(--task-priority-low)' - default: - return 'var(--cal-weekday)' - } -} - -export const CalendarTaskItem = ({ - task, - allTasks = [], - compact = false, - isToday = false, - onClick -}: CalendarTaskItemProps): React.JSX.Element => { - const isCompleted = !!task.completedAt - const isOverdue = - task.dueDate !== null && - isBefore(startOfDay(task.dueDate), startOfDay(new Date())) && - !isCompleted - - const subtasks = useMemo(() => { - if (allTasks.length === 0) return [] - return getSubtasks(task.id, allTasks) - }, [task.id, allTasks]) - - const subtaskProgress = useMemo(() => { - return calculateProgress(subtasks) - }, [subtasks]) - - const hasSubtasks = subtasks.length > 0 - - if (compact) { - return ( - <span - className={cn('inline-flex size-2 rounded-full', isOverdue && 'ring-2 ring-red-300')} - style={{ backgroundColor: getPriorityBarColor(task.priority) }} - title={task.title} - /> - ) - } - - const handleClick = (): void => { - if (onClick) onClick(task.id) - } - - const handleKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onClick?.(task.id) - } - } - - return ( - <div - role="button" - tabIndex={0} - onClick={handleClick} - onKeyDown={handleKeyDown} - className={cn( - 'flex items-center gap-1 rounded px-1.5 py-[3px] text-[11px] leading-[14px] font-normal', - 'cursor-pointer truncate transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', - isOverdue - ? 'bg-cal-task-overdue-bg hover:bg-cal-task-overdue-bg/80' - : isToday - ? 'bg-cal-task-bg-today hover:bg-white/90' - : 'bg-cal-task-bg hover:bg-cal-cell-outside-bg', - isCompleted && 'opacity-50 line-through' - )} - style={{ - color: isOverdue - ? 'var(--cal-task-overdue-text)' - : isToday - ? 'var(--cal-task-text-today)' - : 'var(--cal-task-text)', - fontWeight: isToday ? 500 : 400 - }} - aria-label={task.title} - > - {/* Priority bar */} - <span - className="block w-[3px] h-3 shrink-0 rounded-sm" - style={{ backgroundColor: getPriorityBarColor(task.priority) }} - aria-hidden="true" - /> - - {/* Mini progress bar for subtasks */} - {hasSubtasks && !isCompleted && <MiniProgressBar progress={subtaskProgress} />} - - {/* Title */} - <span className="truncate">{task.title}</span> - </div> - ) -} - -export default CalendarTaskItem diff --git a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-view.tsx b/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-view.tsx deleted file mode 100644 index 2f634b3ea..000000000 --- a/apps/desktop/src/renderer/src/components/tasks/calendar/calendar-view.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' - -import { CalendarHeader } from './calendar-header' -import { CalendarGrid } from './calendar-grid' -import { DayDetailPopover } from './day-detail-popover' -import { - addDays, - addMonths, - addWeeks, - endOfMonth, - endOfWeek, - formatDateKey, - formatDateShort, - getCalendarDays, - groupTasksByCalendarDate, - isTaskCompleted, - startOfDay, - startOfMonth, - subMonths, - isBefore, - isAfter, - isSameDay, - type CalendarDay -} from '@/lib/task-utils' -import { calculateNextOccurrence } from '@/lib/repeat-utils' -import type { Project } from '@/data/tasks-data' -import type { Task } from '@/data/sample-tasks' -import { ScrollArea } from '@/components/ui/scroll-area' - -type SelectionType = 'view' | 'project' - -interface CalendarViewProps { - tasks: Task[] - projects: Project[] - selectedId: string - selectedType: SelectionType - onUpdateTask: (taskId: string, updates: Partial<Task>) => void - onTaskClick?: (taskId: string) => void - onAddTaskWithDate: (date: Date) => void - onToggleComplete: (taskId: string) => void - // Selection props - isSelectionMode?: boolean - selectedIds?: Set<string> - onToggleSelect?: (taskId: string) => void -} - -const useIsCompact = (): boolean => { - const [compact, setCompact] = useState(false) - - useEffect(() => { - const handleResize = (): void => { - setCompact(window.innerWidth < 768) - } - handleResize() - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, []) - - return compact -} - -export const CalendarView = ({ - tasks, - projects, - selectedId, - selectedType, - onUpdateTask, - onTaskClick, - onAddTaskWithDate, - onToggleComplete, - // Selection props - isSelectionMode = false, - selectedIds, - onToggleSelect -}: CalendarViewProps): React.JSX.Element => { - const [currentMonth, setCurrentMonth] = useState<Date>(startOfDay(new Date())) - const [selectedDate, setSelectedDate] = useState<Date | null>(null) - const [focusedDate, setFocusedDate] = useState<Date | null>(null) - const [isDayDetailOpen, setIsDayDetailOpen] = useState(false) - const [showCompleted, setShowCompleted] = useState(false) - const [projectFilter, setProjectFilter] = useState<string | null>(null) - - const isCompact = useIsCompact() - - const calendarDays: CalendarDay[] = useMemo(() => getCalendarDays(currentMonth), [currentMonth]) - - const visibleStart = useMemo( - () => calendarDays[0]?.date || startOfDay(currentMonth), - [calendarDays, currentMonth] - ) - const visibleEnd = useMemo( - () => calendarDays[calendarDays.length - 1]?.date || endOfWeek(currentMonth), - [calendarDays, currentMonth] - ) - - // Generate occurrences for repeating tasks within visible range - const expandRepeatingTasks = useCallback( - (taskList: Task[], rangeStart: Date, rangeEnd: Date): Task[] => { - const expanded: Task[] = [] - - taskList.forEach((task) => { - if (!task.dueDate) return - - // For non-repeating tasks, just check if in range - if (!task.isRepeating || !task.repeatConfig) { - const taskDate = startOfDay(task.dueDate) - if ( - taskDate.getTime() >= rangeStart.getTime() && - taskDate.getTime() <= rangeEnd.getTime() - ) { - expanded.push(task) - } - return - } - - // For repeating tasks, generate occurrences within the range - let currentDate = startOfDay(task.dueDate) - let occurrenceCount = 0 - const maxOccurrences = 50 // Safety limit - - while (occurrenceCount < maxOccurrences) { - // If current date is past the range end, stop - if (isAfter(currentDate, rangeEnd)) break - - // If current date is within range, add an occurrence - if (!isBefore(currentDate, rangeStart) && !isAfter(currentDate, rangeEnd)) { - const occurrence: Task = { - ...task, - // Create unique ID for each occurrence to avoid key conflicts - id: isSameDay(currentDate, task.dueDate) - ? task.id - : `${task.id}-occ-${formatDateKey(currentDate)}`, - dueDate: currentDate - } - expanded.push(occurrence) - } - - // Calculate next occurrence - const next = calculateNextOccurrence(currentDate, task.repeatConfig) - if (!next) break - - currentDate = next - occurrenceCount++ - } - }) - - return expanded - }, - [] - ) - - const visibleTasks = useMemo(() => { - const rangeStart = startOfDay(visibleStart) - const rangeEnd = startOfDay(visibleEnd) - - // First filter by project and completed status - const filteredTasks = tasks.filter((task) => { - // Apply project filter (only in All Tasks view) - if (selectedType === 'view' && selectedId === 'all' && projectFilter) { - if (task.projectId !== projectFilter) return false - } - - if (!showCompleted && isTaskCompleted(task, projects)) { - return false - } - return true - }) - - // Then expand repeating tasks - return expandRepeatingTasks(filteredTasks, rangeStart, rangeEnd) - }, [ - tasks, - visibleStart, - visibleEnd, - showCompleted, - projects, - selectedType, - selectedId, - projectFilter, - expandRepeatingTasks - ]) - - const tasksByDate = useMemo( - () => groupTasksByCalendarDate(visibleTasks, startOfDay(visibleStart), startOfDay(visibleEnd)), - [visibleTasks, visibleStart, visibleEnd] - ) - - const goToPreviousMonth = useCallback(() => { - setCurrentMonth((prev) => subMonths(prev, 1)) - }, []) - - const goToNextMonth = useCallback(() => { - setCurrentMonth((prev) => addMonths(prev, 1)) - }, []) - - const goToToday = useCallback(() => { - const today = startOfDay(new Date()) - setCurrentMonth(today) - setSelectedDate(today) - }, []) - - const handleOpenDay = useCallback((date: Date) => { - setSelectedDate(date) - setIsDayDetailOpen(true) - }, []) - - const handleAddTask = useCallback( - (date: Date) => { - onAddTaskWithDate(startOfDay(date)) - }, - [onAddTaskWithDate] - ) - // Drag-and-drop (rescheduling and project moves) is handled by the shared DragProvider. - - const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => { - // Skip if in an input/textarea - const target = e.target as HTMLElement - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return - - switch (e.key) { - case 'ArrowLeft': - e.preventDefault() - if (focusedDate) { - // Navigate to previous day - setFocusedDate(addDays(focusedDate, -1)) - } else { - goToPreviousMonth() - } - break - case 'ArrowRight': - e.preventDefault() - if (focusedDate) { - // Navigate to next day - setFocusedDate(addDays(focusedDate, 1)) - } else { - goToNextMonth() - } - break - case 'ArrowUp': - e.preventDefault() - if (focusedDate) { - // Navigate to previous week - setFocusedDate(addWeeks(focusedDate, -1)) - } - break - case 'ArrowDown': - e.preventDefault() - if (focusedDate) { - // Navigate to next week - setFocusedDate(addWeeks(focusedDate, 1)) - } - break - case 'Home': - e.preventDefault() - setFocusedDate(startOfMonth(currentMonth)) - break - case 'End': - e.preventDefault() - setFocusedDate(endOfMonth(currentMonth)) - break - case 'Enter': - e.preventDefault() - if (focusedDate) { - handleOpenDay(focusedDate) - } - break - case ' ': - e.preventDefault() - if (focusedDate) { - handleAddTask(focusedDate) - } - break - case 'Escape': - e.preventDefault() - setFocusedDate(null) - break - case 't': - case 'T': - e.preventDefault() - goToToday() - break - default: - break - } - } - - const selectedDateTasks = useMemo(() => { - if (!selectedDate) return [] - const key = formatDateKey(selectedDate) - return tasksByDate.get(key) || [] - }, [selectedDate, tasksByDate]) - - // Show project filter only in All Tasks view - const showProjectFilter = selectedType === 'view' && selectedId === 'all' - - return ( - <div - className="flex h-full flex-col gap-4 px-8 py-6 outline-none bg-cal-bg" - tabIndex={0} - onKeyDown={handleKeyDown} - aria-label="Calendar view" - > - <CalendarHeader - currentMonth={currentMonth} - onPreviousMonth={goToPreviousMonth} - onNextMonth={goToNextMonth} - onToday={goToToday} - showCompleted={showCompleted} - onToggleCompleted={setShowCompleted} - projects={showProjectFilter ? projects : undefined} - projectFilter={projectFilter} - onProjectFilterChange={setProjectFilter} - /> - - <ScrollArea className="h-full"> - <CalendarGrid - days={calendarDays} - tasksByDate={tasksByDate} - allTasks={tasks} - selectedDate={selectedDate} - focusedDate={focusedDate} - maxVisibleTasks={isCompact ? 2 : 3} - isCompact={isCompact} - onOpenDay={handleOpenDay} - onTaskClick={onTaskClick ?? (() => {})} - onAddTask={handleAddTask} - /> - </ScrollArea> - - <DayDetailPopover - date={selectedDate} - tasks={selectedDateTasks} - allTasks={tasks} - isOpen={isDayDetailOpen} - onClose={() => setIsDayDetailOpen(false)} - onTaskClick={onTaskClick ?? (() => {})} - onToggleComplete={onToggleComplete} - onAddTask={handleAddTask} - // Selection props - isSelectionMode={isSelectionMode} - selectedIds={selectedIds} - onToggleSelect={onToggleSelect} - /> - </div> - ) -} - -export default CalendarView diff --git a/apps/desktop/src/renderer/src/components/tasks/calendar/day-cell.tsx b/apps/desktop/src/renderer/src/components/tasks/calendar/day-cell.tsx deleted file mode 100644 index 9d92a4977..000000000 --- a/apps/desktop/src/renderer/src/components/tasks/calendar/day-cell.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useMemo } from 'react' -import { useDraggable, useDroppable } from '@dnd-kit/core' - -import { cn } from '@/lib/utils' -import { CalendarTaskItem } from './calendar-task-item' -import { formatDateKey, type CalendarDay } from '@/lib/task-utils' -import type { Task } from '@/data/sample-tasks' - -interface DayCellProps { - day: CalendarDay - tasks: Task[] - allTasks?: Task[] - maxVisible?: number - isSelected?: boolean - isFocused?: boolean - isCompact?: boolean - onOpenDay: (date: Date) => void - onTaskClick: (taskId: string) => void - onAddTask: (date: Date) => void -} - -const DraggableCalendarTask = ({ - task, - children -}: { - task: Task - children: React.ReactNode -}): React.JSX.Element => { - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ - id: task.id, - data: { - type: 'calendar-task', - task, - sourceType: 'calendar' - } - }) - - const style = useMemo(() => { - if (!transform) return undefined - return { - transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` - } - }, [transform]) - - return ( - <div - ref={setNodeRef} - style={style} - {...listeners} - {...attributes} - className={cn(isDragging && 'z-10 opacity-80')} - > - {children} - </div> - ) -} - -export const DayCell = ({ - day, - tasks, - allTasks = [], - maxVisible = 3, - isSelected = false, - isFocused = false, - isCompact = false, - onOpenDay, - onTaskClick, - onAddTask -}: DayCellProps): React.JSX.Element => { - const { setNodeRef, isOver } = useDroppable({ - id: formatDateKey(day.date), - data: { type: 'date', date: day.date } - }) - - const visibleTasks = tasks.slice(0, maxVisible) - const overflowCount = Math.max(tasks.length - maxVisible, 0) - - const handleCellClick = (e: React.MouseEvent): void => { - const target = e.target as HTMLElement - if (target.closest('[data-task-item]')) return - onAddTask(day.date) - } - - const handleDayKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter') { - e.preventDefault() - onOpenDay(day.date) - } - if (e.key === ' ' || e.key === 'Spacebar') { - e.preventDefault() - onAddTask(day.date) - } - } - - return ( - <div - ref={setNodeRef} - role="gridcell" - tabIndex={0} - aria-label={day.date.toDateString()} - onClick={handleCellClick} - onKeyDown={handleDayKeyDown} - className={cn( - 'relative flex min-h-[110px] flex-col gap-1 rounded-[6px] p-2 transition-colors cursor-pointer', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', - // Background - day.isToday - ? 'bg-cal-today-bg border-[1.5px] border-cal-today-border' - : !day.isCurrentMonth - ? 'bg-cal-cell-outside-bg' - : day.isWeekend - ? 'bg-cal-cell-weekend-bg' - : 'bg-cal-cell-bg', - // States - isSelected && !day.isToday && 'ring-2 ring-primary', - isFocused && 'ring-2 ring-ring', - isOver && 'ring-2 ring-cal-today-border' - )} - > - {/* Day number */} - <div className="flex items-center gap-1.5"> - {day.isToday ? ( - <> - <span - className="inline-flex size-[22px] items-center justify-center rounded-full text-[11px] font-semibold text-white" - style={{ backgroundColor: 'var(--cal-today-badge)' }} - > - {day.date.getDate()} - </span> - <span - className="text-[9px] font-medium uppercase tracking-[0.06em]" - style={{ - color: 'var(--cal-today-label)', - fontFamily: 'var(--font-mono)' - }} - > - Today - </span> - </> - ) : ( - <span - className={cn('text-xs', day.isCurrentMonth ? 'font-medium' : 'font-normal')} - style={{ - color: day.isCurrentMonth ? 'var(--cal-date-current)' : 'var(--cal-date-outside)' - }} - > - {day.date.getDate()} - </span> - )} - </div> - - {/* Tasks */} - <div className="flex flex-1 flex-col gap-1"> - {visibleTasks.map((task) => ( - <DraggableCalendarTask key={task.id} task={task}> - <div data-task-item> - <CalendarTaskItem - task={task} - allTasks={allTasks} - compact={isCompact} - isToday={day.isToday} - onClick={() => onTaskClick(task.id)} - /> - </div> - </DraggableCalendarTask> - ))} - </div> - - {/* Overflow */} - {overflowCount > 0 && ( - <button - type="button" - className="text-left text-[10px] font-medium pl-0.5 transition-colors hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - style={{ color: 'var(--cal-overflow)' }} - onClick={(e) => { - e.stopPropagation() - onOpenDay(day.date) - }} - > - +{overflowCount} more - </button> - )} - </div> - ) -} - -export default DayCell diff --git a/apps/desktop/src/renderer/src/components/tasks/calendar/day-detail-popover.tsx b/apps/desktop/src/renderer/src/components/tasks/calendar/day-detail-popover.tsx deleted file mode 100644 index ade245c1a..000000000 --- a/apps/desktop/src/renderer/src/components/tasks/calendar/day-detail-popover.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import React, { useMemo, useState } from 'react' -import { X, ChevronDown } from '@/lib/icons' - -import { Dialog, DialogContent } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { SelectionCheckbox } from '@/components/tasks/bulk-actions' -import { SubtaskBadge } from '@/components/tasks/subtask-badge' -import { cn } from '@/lib/utils' -import { formatDayName } from '@/lib/task-utils' -import { getSubtasks, calculateProgress } from '@/lib/subtask-utils' -import { priorityConfig, type Task } from '@/data/sample-tasks' - -interface DayDetailPopoverProps { - date: Date | null - tasks: Task[] - allTasks?: Task[] - isOpen: boolean - onClose: () => void - onTaskClick: (taskId: string) => void - onToggleComplete: (taskId: string) => void - onAddTask: (date: Date) => void - // Selection props - isSelectionMode?: boolean - selectedIds?: Set<string> - onToggleSelect?: (taskId: string) => void -} - -const sortTasks = (tasks: Task[]): Task[] => { - return [...tasks].sort((a, b) => { - if (a.dueTime && b.dueTime && a.dueTime !== b.dueTime) { - return a.dueTime.localeCompare(b.dueTime) - } - if (a.dueTime && !b.dueTime) return -1 - if (!a.dueTime && b.dueTime) return 1 - - const pa = priorityConfig[a.priority].order - const pb = priorityConfig[b.priority].order - if (pa !== pb) return pa - pb - - return a.title.localeCompare(b.title) - }) -} - -export const DayDetailPopover = ({ - date, - tasks, - allTasks = [], - isOpen, - onClose, - onTaskClick, - onToggleComplete, - onAddTask, - // Selection props - isSelectionMode = false, - selectedIds, - onToggleSelect -}: DayDetailPopoverProps): React.JSX.Element | null => { - const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set()) - const sortedTasks = useMemo(() => sortTasks(tasks), [tasks]) - const title = date - ? `${formatDayName(date)}, ${date.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric' - })}` - : '' - - const handleAdd = (): void => { - if (!date) return - onAddTask(date) - onClose() - } - - const handleTaskClick = (taskId: string): void => { - // In selection mode, clicking toggles selection - if (isSelectionMode && onToggleSelect) { - onToggleSelect(taskId) - return - } - onTaskClick(taskId) - } - - const handleSelectionCheckboxChange = (taskId: string): void => { - onToggleSelect?.(taskId) - } - - const toggleExpanded = (taskId: string): void => { - setExpandedTasks((prev) => { - const next = new Set(prev) - if (next.has(taskId)) { - next.delete(taskId) - } else { - next.add(taskId) - } - return next - }) - } - - return ( - <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> - <DialogContent className="max-w-md p-0"> - <div className="flex items-center justify-between border-b border-border px-4 py-3"> - <div> - <h3 className="text-base font-semibold leading-tight">{title}</h3> - <p className="text-sm text-muted-foreground"> - {tasks.length} task{tasks.length !== 1 ? 's' : ''} - </p> - </div> - <button - type="button" - className="rounded p-2 text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - onClick={onClose} - aria-label="Close day details" - > - <X className="size-4" /> - </button> - </div> - - <div className="max-h-[320px] overflow-y-auto px-4 py-3"> - {sortedTasks.length === 0 && ( - <p className="text-sm text-muted-foreground">No tasks for this day.</p> - )} - - <div className="space-y-2"> - {sortedTasks.map((task) => { - const isCheckedForSelection = selectedIds?.has(task.id) ?? false - const taskSubtasks = allTasks.length > 0 ? getSubtasks(task.id, allTasks) : [] - const taskHasSubtasks = taskSubtasks.length > 0 - const subtaskProgress = calculateProgress(taskSubtasks) - const isExpanded = expandedTasks.has(task.id) - - return ( - <div key={task.id}> - {/* Parent task row */} - <button - type="button" - className={cn( - 'group flex w-full items-center gap-3 rounded-sm px-2 py-2 text-left text-sm', - 'hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', - isCheckedForSelection && 'bg-primary/10 hover:bg-primary/15' - )} - onClick={() => handleTaskClick(task.id)} - > - {/* Expand/collapse toggle for tasks with subtasks */} - {taskHasSubtasks ? ( - <button - type="button" - onClick={(e) => { - e.stopPropagation() - toggleExpanded(task.id) - }} - className="shrink-0 p-0.5 hover:bg-accent rounded" - aria-label={isExpanded ? 'Collapse subtasks' : 'Expand subtasks'} - > - <ChevronDown - className={cn( - 'size-4 text-muted-foreground transition-transform', - isExpanded && 'rotate-180' - )} - /> - </button> - ) : ( - <span className="w-5 shrink-0" /> - )} - - {/* Selection checkbox - visible only in selection mode */} - {onToggleSelect && isSelectionMode && ( - <div className="shrink-0" onClick={(e) => e.stopPropagation()}> - <SelectionCheckbox - checked={isCheckedForSelection} - onChange={() => handleSelectionCheckboxChange(task.id)} - aria-label={`Select ${task.title}`} - /> - </div> - )} - - {/* Task completion checkbox */} - <div onClick={(e) => e.stopPropagation()}> - <Checkbox - checked={!!task.completedAt} - onCheckedChange={() => onToggleComplete(task.id)} - aria-label="Toggle complete" - /> - </div> - <div className="flex flex-1 items-center gap-2"> - <span - className={cn( - 'w-12 shrink-0 tabular-nums text-xs', - task.dueTime ? 'text-muted-foreground' : 'text-muted-foreground/60' - )} - > - {task.dueTime || '—'} - </span> - {task.priority !== 'none' && ( - <span - className="block size-2 shrink-0 rounded-full" - style={{ - backgroundColor: priorityConfig[task.priority].color || undefined - }} - aria-hidden="true" - /> - )} - <span - className={cn( - 'truncate text-sm', - task.completedAt && 'line-through text-muted-foreground' - )} - > - {task.title} - </span> - {task.isRepeating && <span className="shrink-0 text-xs opacity-60">🔄</span>} - </div> - </button> - - {/* Subtask badge (if has subtasks and not expanded) */} - {taskHasSubtasks && !isExpanded && ( - <div className="ml-7 pl-6 py-1"> - <SubtaskBadge - completed={subtaskProgress.completed} - total={subtaskProgress.total} - size="sm" - /> - </div> - )} - - {/* Expanded subtasks list */} - {taskHasSubtasks && isExpanded && ( - <div className="ml-7 border-l border-border/50 pl-2 space-y-1 py-1"> - {taskSubtasks.map((subtask, index) => { - const isLastSubtask = index === taskSubtasks.length - 1 - - return ( - <button - key={subtask.id} - type="button" - onClick={() => handleTaskClick(subtask.id)} - className={cn( - 'flex w-full items-center gap-2 rounded-sm px-2 py-1 text-left text-sm', - 'hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' - )} - > - {/* Tree connector */} - <span className="text-muted-foreground/50 text-xs font-mono shrink-0"> - {isLastSubtask ? '└─' : '├─'} - </span> - - {/* Subtask checkbox */} - <div onClick={(e) => e.stopPropagation()}> - <Checkbox - checked={!!subtask.completedAt} - onCheckedChange={() => onToggleComplete(subtask.id)} - aria-label="Toggle subtask complete" - /> - </div> - - {/* Subtask title */} - <span - className={cn( - 'truncate text-sm', - subtask.completedAt && 'line-through text-muted-foreground' - )} - > - {subtask.title} - </span> - </button> - ) - })} - </div> - )} - </div> - ) - })} - </div> - </div> - - <div className="border-t border-border px-4 py-3"> - <Button className="w-full" onClick={handleAdd}> - + Add task for this day - </Button> - </div> - </DialogContent> - </Dialog> - ) -} - -export default DayDetailPopover diff --git a/apps/desktop/src/renderer/src/components/tasks/calendar/index.ts b/apps/desktop/src/renderer/src/components/tasks/calendar/index.ts deleted file mode 100644 index 845ce2a1a..000000000 --- a/apps/desktop/src/renderer/src/components/tasks/calendar/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './calendar-view' -export * from './calendar-header' -export * from './calendar-grid' -export * from './day-cell' -export * from './calendar-task-item' -export * from './day-detail-popover' -export * from './calendar-drag-overlay' diff --git a/apps/desktop/src/renderer/src/components/tasks/color-picker.tsx b/apps/desktop/src/renderer/src/components/tasks/color-picker.tsx index 118668e37..05a9bcf2b 100644 --- a/apps/desktop/src/renderer/src/components/tasks/color-picker.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/color-picker.tsx @@ -61,7 +61,7 @@ export const ColorPicker = ({ tabIndex={0} className={cn( 'rounded-full transition-all duration-150', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2', + 'focus-visible:outline-none', 'hover:scale-110', sizeClasses, isSelected && 'ring-2 ring-offset-2 ring-ring' diff --git a/apps/desktop/src/renderer/src/components/tasks/custom-repeat-dialog.tsx b/apps/desktop/src/renderer/src/components/tasks/custom-repeat-dialog.tsx index fd8e1f65b..0e453cc2d 100644 --- a/apps/desktop/src/renderer/src/components/tasks/custom-repeat-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/custom-repeat-dialog.tsx @@ -79,7 +79,7 @@ const DayOfWeekPicker = ({ selectedDays, onChange }: DayOfWeekPickerProps): Reac onClick={() => handleToggleDay(index)} className={cn( 'flex size-9 items-center justify-center rounded-full text-sm font-medium transition-colors', - 'border focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1', + 'border focus:outline-none', selectedDays.includes(index) ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground' diff --git a/apps/desktop/src/renderer/src/components/tasks/date-picker-calendar.tsx b/apps/desktop/src/renderer/src/components/tasks/date-picker-calendar.tsx index 5636a6571..683a2328e 100644 --- a/apps/desktop/src/renderer/src/components/tasks/date-picker-calendar.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/date-picker-calendar.tsx @@ -138,7 +138,7 @@ export function DatePickerCalendar({ <button type="button" onClick={goToPrevMonth} - className="text-text-tertiary hover:text-text-secondary transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-sm" + className="text-text-tertiary hover:text-text-secondary transition-colors focus-visible:outline-none rounded-sm" aria-label="Previous month" > <ChevronLeftIcon /> @@ -149,7 +149,7 @@ export function DatePickerCalendar({ <button type="button" onClick={goToNextMonth} - className="text-text-tertiary hover:text-text-secondary transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-sm" + className="text-text-tertiary hover:text-text-secondary transition-colors focus-visible:outline-none rounded-sm" aria-label="Next month" > <ChevronRightIcon /> @@ -184,7 +184,7 @@ export function DatePickerCalendar({ disabled={isDisabled} className={cn( 'w-[30px] h-[26px] flex items-center justify-center shrink-0 text-[11px] leading-3.5 transition-colors rounded-[5px]', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', isOutsideMonth && 'text-text-tertiary/30 cursor-default', !isOutsideMonth && isDisabled && 'text-text-tertiary/30 cursor-not-allowed', !isDisabled && diff --git a/apps/desktop/src/renderer/src/components/tasks/dialogs/parent-picker-dialog.tsx b/apps/desktop/src/renderer/src/components/tasks/dialogs/parent-picker-dialog.tsx index 2ee2aa124..94cd31cc0 100644 --- a/apps/desktop/src/renderer/src/components/tasks/dialogs/parent-picker-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/dialogs/parent-picker-dialog.tsx @@ -102,7 +102,7 @@ export const ParentPickerDialog = ({ className={cn( 'w-full flex items-center gap-3 px-3 py-2.5 rounded-sm text-left', 'hover:bg-accent transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'focus-visible:outline-none' )} > <TaskCheckbox checked={isCompleted} onChange={() => {}} /> diff --git a/apps/desktop/src/renderer/src/components/tasks/drag-drop/multi-drag-overlay.tsx b/apps/desktop/src/renderer/src/components/tasks/drag-drop/multi-drag-overlay.tsx index dcb63de1c..123468573 100644 --- a/apps/desktop/src/renderer/src/components/tasks/drag-drop/multi-drag-overlay.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/drag-drop/multi-drag-overlay.tsx @@ -206,7 +206,9 @@ export const MultiDragOverlay = ({ > {index === 0 ? ( <> - <div className="font-medium truncate text-sm">{task.title}</div> + <div className="font-medium truncate text-[13px] text-foreground/90"> + {task.title} + </div> {totalCount > 1 && ( <div className="text-sm text-primary mt-1"> +{totalCount - 1} more task{totalCount > 2 ? 's' : ''} @@ -272,7 +274,7 @@ export const SingleTaskPreview = ({ )} > <div className="flex items-center gap-2"> - <span className="font-medium truncate text-sm">{task.title}</span> + <span className="font-medium truncate text-[13px] text-foreground/90">{task.title}</span> </div> {!isCompleted && (task.dueDate || task.priority !== 'none') && ( diff --git a/apps/desktop/src/renderer/src/components/tasks/drag-drop/sidebar-drop-zones.tsx b/apps/desktop/src/renderer/src/components/tasks/drag-drop/sidebar-drop-zones.tsx index 4ce3a06e2..376e19f1d 100644 --- a/apps/desktop/src/renderer/src/components/tasks/drag-drop/sidebar-drop-zones.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/drag-drop/sidebar-drop-zones.tsx @@ -95,7 +95,7 @@ export const DroppableProjectItem = ({ aria-pressed={isSelected} className={cn( 'group flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all duration-150', - 'hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'hover:bg-accent/50 focus-visible:outline-none', isSelected && 'bg-accent border-l-3 border-l-primary font-medium', // Drop zone styling showAsDropZone && 'border border-dotted border-muted-foreground/40', @@ -134,7 +134,7 @@ export const DroppableProjectItem = ({ className={cn( 'shrink-0 rounded p-0.5 text-text-tertiary opacity-0 transition-opacity', 'hover:bg-accent hover:text-text-secondary', - 'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:opacity-100 focus-visible:outline-none', 'group-hover:opacity-100' )} > diff --git a/apps/desktop/src/renderer/src/components/tasks/drag-drop/task-row.tsx b/apps/desktop/src/renderer/src/components/tasks/drag-drop/task-row.tsx index 78257f66b..231343b41 100644 --- a/apps/desktop/src/renderer/src/components/tasks/drag-drop/task-row.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/drag-drop/task-row.tsx @@ -209,7 +209,7 @@ const TaskRowComponent = ({ : [ 'group relative flex items-center py-[7px] px-3 gap-3 transition-colors', 'rounded-md hover:bg-accent/60', - onClick && 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + onClick && 'focus-visible:outline-none', dragHandleListeners && !isDragging && 'cursor-grab', isDragging && 'cursor-grabbing opacity-[0.35] border-dashed border-primary/30 bg-primary/[0.03]', @@ -280,14 +280,14 @@ const TaskRowComponent = ({ <span className={cn( - 'text-[13px] leading-4 grow shrink basis-0 truncate', + 'text-[13px] font-medium grow shrink min-w-0 truncate', isExiting || isCompleted ? isOverlay - ? 'text-text-tertiary line-through decoration-1 [text-underline-position:from-font]' - : 'text-text-tertiary line-through decoration-1 [text-underline-position:from-font]' + ? 'text-muted-foreground/60 line-through decoration-1 [text-underline-position:from-font]' + : 'text-muted-foreground/60 line-through decoration-1 [text-underline-position:from-font]' : isOverlay - ? 'text-card-foreground font-medium' - : 'text-text-primary' + ? 'text-foreground/90' + : 'text-foreground/90' )} > {task.title} diff --git a/apps/desktop/src/renderer/src/components/tasks/empty-states/collapsed-empty-section.tsx b/apps/desktop/src/renderer/src/components/tasks/empty-states/collapsed-empty-section.tsx index b84e467d1..ffd76f17d 100644 --- a/apps/desktop/src/renderer/src/components/tasks/empty-states/collapsed-empty-section.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/empty-states/collapsed-empty-section.tsx @@ -100,8 +100,7 @@ export const CollapsedEmptySection = ({ 'shrink-0 ml-3', 'text-xs font-medium text-primary hover:text-primary/80', 'transition-colors', - 'focus-visible:outline-none focus-visible:ring-1', - 'focus-visible:ring-ring focus-visible:ring-offset-2 rounded' + 'focus-visible:outline-none rounded' )} aria-label={`Add task for ${label.toLowerCase()}`} > diff --git a/apps/desktop/src/renderer/src/components/tasks/empty-states/section-empty-states.tsx b/apps/desktop/src/renderer/src/components/tasks/empty-states/section-empty-states.tsx index 52fc10c92..379f19707 100644 --- a/apps/desktop/src/renderer/src/components/tasks/empty-states/section-empty-states.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/empty-states/section-empty-states.tsx @@ -102,7 +102,7 @@ export const SimpleEmptyState = ({ className={cn( 'inline-flex items-center gap-1 text-sm text-primary hover:text-primary/80', 'transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 rounded' + 'focus-visible:outline-none rounded' )} aria-label={`Add task for ${label.toLowerCase()}`} > diff --git a/apps/desktop/src/renderer/src/components/tasks/expand-chevron.tsx b/apps/desktop/src/renderer/src/components/tasks/expand-chevron.tsx index 3a373c4f1..52a955e2a 100644 --- a/apps/desktop/src/renderer/src/components/tasks/expand-chevron.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/expand-chevron.tsx @@ -57,7 +57,7 @@ export const ExpandChevron = ({ 'flex items-center justify-center shrink-0', 'transition-all duration-150', 'text-text-tertiary hover:text-muted-foreground', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 rounded', + 'focus-visible:outline-none rounded', isAnimating && 'scale-110', className )} diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/filter-dropdown.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/filter-dropdown.tsx index b89825d04..f4ea0a379 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/filter-dropdown.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/filter-dropdown.tsx @@ -196,11 +196,11 @@ export const FilterDropdown = ({ <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger asChild>{children}</PopoverTrigger> <PopoverContent - className="w-[220px] p-0 rounded-lg overflow-clip bg-popover border-border shadow-[var(--shadow-card-hover)] max-h-[calc(100vh-120px)] overflow-y-auto" + className="w-[220px] p-0 rounded-md overflow-clip bg-popover border-border shadow-[var(--shadow-card-hover)] max-h-[calc(100vh-120px)] overflow-y-auto" align="end" sideOffset={8} > - <div className="flex flex-col text-[12px] leading-4 [font-synthesis:none] antialiased"> + <div className="flex flex-col text-[13px] leading-4 [font-synthesis:none] antialiased"> {/* Main menu */} {activePanel === null && ( <> @@ -224,8 +224,8 @@ export const FilterDropdown = ({ className="flex items-center rounded-[5px] py-1.5 px-2 gap-2 hover:bg-accent transition-colors" > {CATEGORY_ICONS[cat.key]} - <span className="text-[12px] text-text-secondary leading-4">{cat.label}</span> - <ChevronRight size={10} className="ml-auto text-text-tertiary" /> + <span className="text-[13px] text-muted-foreground leading-4">{cat.label}</span> + <ChevronRight size={10} className="ml-auto text-muted-foreground/60" /> </button> ))} </div> diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/due-date-panel.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/due-date-panel.tsx index fbf96c787..0413d09af 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/due-date-panel.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/due-date-panel.tsx @@ -108,7 +108,7 @@ export function DueDatePanel({ /> <path d="M2 5.5h10" stroke="currentColor" strokeWidth="1.1" /> </svg> - <span className="text-[12px] text-foreground font-medium leading-4">Due date</span> + <span className="text-[13px] text-foreground font-medium leading-4">Due date</span> </div> <DatePickerContent selected={selectedDate} onSelect={handleSelect} /> </> diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/priority-panel.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/priority-panel.tsx index e49fe06ff..8245d791b 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/priority-panel.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/priority-panel.tsx @@ -71,7 +71,7 @@ export function PriorityPanel({ <rect x="5" y="4.5" width="2.5" height="7" rx="0.5" fill="currentColor" /> <rect x="9" y="2" width="2.5" height="9.5" rx="0.5" fill="currentColor" /> </svg> - <span className="text-[12px] text-foreground font-medium leading-4">Priority</span> + <span className="text-[13px] text-foreground font-medium leading-4">Priority</span> <span className="text-[11px] ml-auto text-foreground leading-3.5">is</span> </div> <FilterSearchHeader @@ -96,9 +96,9 @@ export function PriorityPanel({ <PriorityIcon priority={p} className={cn(p === 'none' && 'text-text-tertiary')} /> <span className={cn( - 'text-[12px] leading-4', - checked ? 'text-foreground' : 'text-text-secondary', - p === 'none' && !checked && 'text-text-tertiary' + 'text-[13px] leading-4', + checked ? 'text-foreground' : 'text-muted-foreground', + p === 'none' && !checked && 'text-muted-foreground/60' )} > {PRIORITY_LABELS[p]} diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/project-panel.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/project-panel.tsx index 3bbf46641..d220da8c6 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/project-panel.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/project-panel.tsx @@ -39,7 +39,7 @@ export function ProjectPanel({ strokeWidth="1.1" /> </svg> - <span className="text-[12px] text-foreground font-medium leading-4">Project</span> + <span className="text-[13px] text-foreground font-medium leading-4">Project</span> </div> <div className="flex flex-col p-1"> <button @@ -51,7 +51,7 @@ export function ProjectPanel({ )} > <div className="shrink-0 rounded-[3px] border-[1.2px] border-solid border-border size-2.5" /> - <span className="text-[12px] text-text-tertiary leading-4">No project</span> + <span className="text-[13px] text-muted-foreground/60 leading-4">No project</span> {selectedProjectIds.length === 0 && <CheckMark className="ml-auto text-primary" />} </button> {visibleProjects.map((project) => { @@ -72,8 +72,8 @@ export function ProjectPanel({ /> <span className={cn( - 'text-[12px] leading-4', - selected ? 'text-foreground' : 'text-text-secondary' + 'text-[13px] leading-4', + selected ? 'text-foreground' : 'text-muted-foreground' )} > {project.name} diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/status-panel.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/status-panel.tsx index ac29050cc..b4825021b 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/status-panel.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/status-panel.tsx @@ -35,7 +35,7 @@ export function StatusPanel({ <div className="flex items-center py-2 px-3 gap-1.5 border-b border-border"> <BackButton onClick={onGoBack} /> <StatusIcon type="todo" color="var(--muted-foreground)" size="md" /> - <span className="text-[12px] text-foreground font-medium leading-4">Status</span> + <span className="text-[13px] text-foreground font-medium leading-4">Status</span> </div> <div className="flex flex-col p-1"> {statuses.map((status) => { @@ -54,8 +54,8 @@ export function StatusPanel({ <StatusIcon type={status.type} color={status.color} /> <span className={cn( - 'text-[12px] leading-4', - selected ? 'text-foreground' : 'text-text-secondary' + 'text-[13px] leading-4', + selected ? 'text-foreground' : 'text-muted-foreground' )} > {status.name} diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/status-project-picker-panel.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/status-project-picker-panel.tsx index 38992d1c3..0a9957926 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/status-project-picker-panel.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/filter-panels/status-project-picker-panel.tsx @@ -30,10 +30,10 @@ export function StatusProjectPickerPanel({ > <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.2" /> </svg> - <span className="text-[12px] text-foreground font-medium leading-4">Status</span> + <span className="text-[13px] text-foreground font-medium leading-4">Status</span> </div> <div className="px-3 pt-1.5 pb-1"> - <span className="text-[11px] text-text-tertiary leading-3.5">Pick a project</span> + <span className="text-[11px] text-muted-foreground/60 leading-3.5">Pick a project</span> </div> <div className="flex flex-col p-1"> {visibleProjects.map((project) => ( @@ -47,8 +47,8 @@ export function StatusProjectPickerPanel({ className="shrink-0 rounded-[3px] size-2.5" style={{ backgroundColor: project.color }} /> - <span className="text-[12px] text-text-secondary leading-4">{project.name}</span> - <ChevronRight size={10} className="ml-auto text-text-tertiary" /> + <span className="text-[13px] text-muted-foreground leading-4">{project.name}</span> + <ChevronRight size={10} className="ml-auto text-muted-foreground/60" /> </button> ))} </div> diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/group-by-dropdown.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/group-by-dropdown.tsx index 01cd4a0fe..d1e2d2ebc 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/group-by-dropdown.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/group-by-dropdown.tsx @@ -69,22 +69,22 @@ export const GroupByDropdown = ({ className={cn( 'flex items-center shrink-0 rounded-[5px] py-1 px-2 gap-1 border transition-colors', isOpen || isNonDefault - ? 'border-foreground/20 bg-foreground/5 text-text-primary' - : 'border-border text-text-secondary hover:bg-surface-active/50', + ? 'border-foreground/20 bg-foreground/5 text-foreground/90' + : 'border-border text-muted-foreground hover:bg-surface-active/50', className )} > <Layers size={13} /> - <span className="text-[11px] leading-3.5">Group by</span> + <span className="text-[13px]">Group by</span> </button> </PopoverTrigger> <PopoverContent - className="w-auto min-w-[180px] p-0 rounded-lg overflow-clip border-border shadow-[var(--shadow-card-hover)]" + className="w-auto min-w-[180px] p-0 rounded-md overflow-clip border-border shadow-[var(--shadow-card-hover)]" align="end" sideOffset={8} > - <div className="[font-synthesis:none] text-[12px] leading-4 flex flex-col antialiased"> + <div className="[font-synthesis:none] text-[13px] leading-4 flex flex-col antialiased"> <div className="flex flex-col p-1"> {VISIBLE_FIELDS.map((field) => { const isSelected = sort.field === field @@ -101,8 +101,8 @@ export const GroupByDropdown = ({ > <span className={cn( - 'text-[12px] leading-4', - isSelected ? 'text-foreground' : 'text-text-secondary' + 'text-[13px] leading-4', + isSelected ? 'text-foreground' : 'text-muted-foreground' )} > {GROUP_FIELD_LABELS[field]} @@ -116,7 +116,7 @@ export const GroupByDropdown = ({ {/* Direction toggle */} <div className="border-t border-border p-1"> <div className="flex items-center justify-between rounded-[5px] py-1.5 px-2"> - <span className="text-[12px] text-text-secondary leading-4"> + <span className="text-[13px] text-muted-foreground leading-4"> {DIRECTION_LABELS[sort.direction]} </span> <div className="flex items-center rounded-sm overflow-clip border border-border"> diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/more-filters-dropdown.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/more-filters-dropdown.tsx index 8fda0ed46..7243f7e3f 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/more-filters-dropdown.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/more-filters-dropdown.tsx @@ -126,7 +126,7 @@ export const MoreFiltersDropdown = ({ onClick={() => setShowStatusPanel(true)} className="flex items-center py-[9px] px-4 gap-2.5 hover:bg-accent focus:outline-none transition-colors" > - <Clock className="size-3.5 text-text-tertiary" /> + <Clock className="size-3.5 text-muted-foreground/60" /> <span className="text-[13px] text-foreground leading-4">Status</span> {selectedStatusIds.length > 0 && ( <span className="text-[11px] text-text-tertiary"> @@ -143,7 +143,7 @@ export const MoreFiltersDropdown = ({ onClick={() => setShowStatusPanel(false)} className="flex items-center py-[9px] px-4 gap-2.5 hover:bg-accent focus:outline-none transition-colors" > - <Calendar className="size-3.5 text-text-tertiary" /> + <Calendar className="size-3.5 text-muted-foreground/60" /> <span className="text-[13px] text-foreground leading-4">Has time set</span> <ToggleSwitch enabled={hasTime === 'with-time'} @@ -159,7 +159,7 @@ export const MoreFiltersDropdown = ({ type="button" className="flex items-center py-[9px] px-4 gap-2.5 hover:bg-accent focus:outline-none transition-colors" > - <RefreshCw className="size-3.5 text-text-tertiary" /> + <RefreshCw className="size-3.5 text-muted-foreground/60" /> <span className="text-[13px] text-foreground leading-4">Recurring only</span> <ToggleSwitch enabled={repeatType === 'repeating'} @@ -177,7 +177,7 @@ export const MoreFiltersDropdown = ({ onClick={() => setShowStatusPanel(false)} className="flex items-center py-2.5 px-4 gap-1.5 bg-surface border-b border-border" > - <ChevronDown className="size-2.5 text-text-tertiary rotate-90" /> + <ChevronDown className="size-2.5 text-muted-foreground/60 rotate-90" /> <span className="text-[13px] text-foreground font-semibold leading-4">Status</span> </button> <div className="flex flex-col py-2"> diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/priority-filter.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/priority-filter.tsx index 36374ce55..50133e9ef 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/priority-filter.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/priority-filter.tsx @@ -150,7 +150,7 @@ export const PriorityFilter = ({ className={cn( 'text-[13px] leading-4', isSelected ? 'font-medium text-foreground' : 'text-foreground', - priority === 'none' && !isSelected && 'text-text-secondary' + priority === 'none' && !isSelected && 'text-muted-foreground' )} > {display.label} diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/saved-filters-section.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/saved-filters-section.tsx index 3203a4da7..afd21e46f 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/saved-filters-section.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/saved-filters-section.tsx @@ -55,7 +55,7 @@ export const SavedFiltersSection = ({ placeholder={hasActiveFilters ? 'Save current filter...' : 'Set filters first'} disabled={!hasActiveFilters} aria-label="Filter name" - className="flex-1 min-w-0 bg-transparent text-[12px] leading-4 text-foreground placeholder:text-text-tertiary outline-none disabled:opacity-40" + className="flex-1 min-w-0 bg-transparent text-[13px] leading-4 text-foreground placeholder:text-muted-foreground/40 outline-none disabled:opacity-40" /> </div> <button @@ -67,7 +67,7 @@ export const SavedFiltersSection = ({ 'shrink-0 rounded-[5px] px-2 py-1 text-[11px] font-medium leading-4 transition-colors', filterName.trim() && hasActiveFilters ? 'bg-foreground text-background hover:bg-foreground/80' - : 'bg-foreground/10 text-text-tertiary cursor-not-allowed' + : 'bg-foreground/10 text-muted-foreground/60 cursor-not-allowed' )} > Save @@ -117,8 +117,8 @@ export const SavedFiltersSection = ({ > <span className={cn( - 'text-[12px] leading-4 truncate block', - isActive ? 'text-foreground font-medium' : 'text-text-secondary' + 'text-[13px] leading-4 truncate block', + isActive ? 'text-foreground font-medium' : 'text-muted-foreground' )} > {filter.name} diff --git a/apps/desktop/src/renderer/src/components/tasks/filters/search-input.tsx b/apps/desktop/src/renderer/src/components/tasks/filters/search-input.tsx index 3d6981508..8620ad105 100644 --- a/apps/desktop/src/renderer/src/components/tasks/filters/search-input.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/filters/search-input.tsx @@ -64,7 +64,7 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>( return ( <div className={cn('relative group', className)}> <Search - className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-text-tertiary pointer-events-none" + className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground/60 pointer-events-none" aria-hidden="true" /> <Input @@ -75,7 +75,7 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>( onKeyDown={handleKeyDown} placeholder={placeholder} className={cn( - 'pl-9 pr-8 h-9 text-sm', + 'pl-9 pr-8 h-9 text-[13px]', expandOnFocus && 'w-48 focus:w-64 transition-all duration-200' )} aria-label="Search tasks" @@ -89,7 +89,7 @@ export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>( aria-label="Clear search" tabIndex={0} > - <X className="size-4 text-text-tertiary" /> + <X className="size-4 text-muted-foreground/60" /> </Button> )} </div> diff --git a/apps/desktop/src/renderer/src/components/tasks/group-header.tsx b/apps/desktop/src/renderer/src/components/tasks/group-header.tsx index 0c9f3fb02..f432ab853 100644 --- a/apps/desktop/src/renderer/src/components/tasks/group-header.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/group-header.tsx @@ -85,7 +85,7 @@ export const GroupHeader = ({ className={cn( 'flex items-center w-full py-2 px-6 gap-2', 'cursor-pointer select-none transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', !bgColor && 'bg-foreground/[0.02] hover:bg-foreground/[0.04]' )} style={ diff --git a/apps/desktop/src/renderer/src/components/tasks/inline-priority-popover.tsx b/apps/desktop/src/renderer/src/components/tasks/inline-priority-popover.tsx index 36dd8d897..8f9faab07 100644 --- a/apps/desktop/src/renderer/src/components/tasks/inline-priority-popover.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/inline-priority-popover.tsx @@ -67,7 +67,7 @@ export const InlinePriorityPopover = ({ className={cn( 'shrink-0 rounded-sm p-0.5 transition-colors cursor-pointer', 'hover:bg-accent/80', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', disabled && 'pointer-events-none' )} aria-label={`Priority: ${config.label || 'none'}. Click to change.`} diff --git a/apps/desktop/src/renderer/src/components/tasks/inline-status-popover.tsx b/apps/desktop/src/renderer/src/components/tasks/inline-status-popover.tsx index 8bbacc361..c8bcf38a8 100644 --- a/apps/desktop/src/renderer/src/components/tasks/inline-status-popover.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/inline-status-popover.tsx @@ -58,7 +58,7 @@ export const InlineStatusPopover = ({ className={cn( 'shrink-0 rounded-sm p-0.5 transition-colors cursor-pointer', 'hover:bg-accent/80', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', disabled && 'pointer-events-none' )} aria-label={`Status: ${currentStatus?.name || 'Unknown'}. Click to change.`} diff --git a/apps/desktop/src/renderer/src/components/tasks/interactive-due-date-badge.tsx b/apps/desktop/src/renderer/src/components/tasks/interactive-due-date-badge.tsx index 48d171a61..ec550a5d1 100644 --- a/apps/desktop/src/renderer/src/components/tasks/interactive-due-date-badge.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/interactive-due-date-badge.tsx @@ -68,7 +68,7 @@ export const InteractiveDueDateBadge = ({ type="button" className={cn( 'flex items-center gap-1.5 cursor-pointer transition-opacity rounded-[5px] py-[3px] px-2 border border-solid', - 'hover:opacity-80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'hover:opacity-80 focus-visible:outline-none', badgeStyles[dateStatus], fixedWidth && 'w-[110px] flex justify-end', className @@ -81,7 +81,7 @@ export const InteractiveDueDateBadge = ({ </button> </PopoverTrigger> <PopoverContent - className="w-auto p-0 rounded-lg overflow-clip" + className="w-auto p-0 rounded-md overflow-clip" align="end" onClick={handleTriggerClick} > diff --git a/apps/desktop/src/renderer/src/components/tasks/interactive-priority-badge.tsx b/apps/desktop/src/renderer/src/components/tasks/interactive-priority-badge.tsx index 45f47342c..6192d5b70 100644 --- a/apps/desktop/src/renderer/src/components/tasks/interactive-priority-badge.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/interactive-priority-badge.tsx @@ -80,7 +80,7 @@ export const InteractivePriorityBadge = ({ type="button" className={cn( 'flex items-center rounded-sm py-px px-[7px] gap-1 cursor-pointer transition-opacity [font-synthesis:none] antialiased', - 'hover:opacity-80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'hover:opacity-80 focus-visible:outline-none', fixedWidth && 'w-[70px] justify-start', className )} diff --git a/apps/desktop/src/renderer/src/components/tasks/interactive-project-badge.tsx b/apps/desktop/src/renderer/src/components/tasks/interactive-project-badge.tsx index ea1cd3675..4583bd7ab 100644 --- a/apps/desktop/src/renderer/src/components/tasks/interactive-project-badge.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/interactive-project-badge.tsx @@ -48,7 +48,7 @@ export const InteractiveProjectBadge = ({ type="button" className={cn( 'flex items-center rounded-sm py-0.5 px-2 gap-1.5 cursor-pointer transition-opacity', - 'hover:opacity-80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'hover:opacity-80 focus-visible:outline-none', className )} style={{ backgroundColor: `${projectColor}14` }} diff --git a/apps/desktop/src/renderer/src/components/tasks/interactive-status-badge.tsx b/apps/desktop/src/renderer/src/components/tasks/interactive-status-badge.tsx index f72b2df4c..e58139f57 100644 --- a/apps/desktop/src/renderer/src/components/tasks/interactive-status-badge.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/interactive-status-badge.tsx @@ -41,7 +41,7 @@ export const InteractiveStatusBadge = ({ type="button" className={cn( 'flex items-center rounded-sm py-0.5 px-2 gap-1 cursor-pointer transition-opacity', - 'hover:opacity-80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'hover:opacity-80 focus-visible:outline-none', className )} style={{ backgroundColor: `${statusColor}14` }} diff --git a/apps/desktop/src/renderer/src/components/tasks/kanban/kanban-card.tsx b/apps/desktop/src/renderer/src/components/tasks/kanban/kanban-card.tsx index 94c4ea277..cc31d5bd1 100644 --- a/apps/desktop/src/renderer/src/components/tasks/kanban/kanban-card.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/kanban/kanban-card.tsx @@ -51,6 +51,7 @@ export const KanbanCardContent = forwardRef<HTMLDivElement, KanbanCardContentPro isSelectionMode = false, showProjectBadge = true, onClick, + onToggleComplete, onToggleSelect, style, attributes, @@ -79,6 +80,20 @@ export const KanbanCardContent = forwardRef<HTMLDivElement, KanbanCardContentPro onClick?.() } + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + e.preventDefault() + onClick?.() + } else if (e.key === ' ') { + e.preventDefault() + if (isSelectionMode && onToggleSelect) { + onToggleSelect() + } else { + onToggleComplete?.() + } + } + } + return ( <div ref={(node) => { @@ -91,6 +106,7 @@ export const KanbanCardContent = forwardRef<HTMLDivElement, KanbanCardContentPro aria-selected={isSelected} aria-label={task.title} onClick={handleClick} + onKeyDown={handleKeyDown} style={style} className={cn( 'group flex cursor-grab rounded-md overflow-clip antialiased transition-all duration-150', @@ -107,6 +123,7 @@ export const KanbanCardContent = forwardRef<HTMLDivElement, KanbanCardContentPro !isDragging && !isSelected && 'ring-1 ring-inset ring-primary/40 border-primary/40', + 'focus-visible:outline-none', isJustDropped && 'animate-drop-flash' )} {...attributes} @@ -123,8 +140,8 @@ export const KanbanCardContent = forwardRef<HTMLDivElement, KanbanCardContentPro <div className="flex items-start gap-1.5"> <span className={cn( - 'text-[13px] leading-[18px] font-medium line-clamp-2', - isDone ? 'text-muted-foreground line-through' : 'text-foreground' + 'text-[13px] font-medium line-clamp-2', + isDone ? 'text-muted-foreground/60 line-through' : 'text-foreground/90' )} > {task.title} diff --git a/apps/desktop/src/renderer/src/components/tasks/kanban/kanban-column.tsx b/apps/desktop/src/renderer/src/components/tasks/kanban/kanban-column.tsx index e2d2caf50..57f1381b7 100644 --- a/apps/desktop/src/renderer/src/components/tasks/kanban/kanban-column.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/kanban/kanban-column.tsx @@ -148,8 +148,8 @@ export const KanbanColumn = ({ )} <span className={cn( - 'text-[13px]/4 font-medium truncate', - shouldHighlight ? 'text-text-primary' : 'text-text-secondary' + 'text-[13px] font-medium truncate', + shouldHighlight ? 'text-foreground/90' : 'text-foreground/60' )} > {column.title} @@ -174,7 +174,7 @@ export const KanbanColumn = ({ <div ref={setNodeRef} className={cn( - 'flex flex-col rounded-lg border p-2 gap-1.5 transition-all duration-150', + 'flex flex-col rounded-md border p-2 gap-1.5 transition-all duration-150', shouldHighlight ? 'bg-primary/[0.03] border-[1.5px] border-primary/20' : 'bg-sidebar border-border' @@ -245,7 +245,7 @@ export const KanbanColumn = ({ onBlur={handleAddSubmit} placeholder="Task title..." autoFocus - className="w-full rounded-md border border-border bg-card px-2.5 py-1.5 text-[13px] text-foreground placeholder:text-text-tertiary outline-none focus:ring-1 focus:ring-primary/40" + className="w-full rounded-md border border-border bg-card px-2.5 py-1.5 text-[13px] text-foreground placeholder:text-text-tertiary outline-none" /> </div> )} diff --git a/apps/desktop/src/renderer/src/components/tasks/natural-date-input.tsx b/apps/desktop/src/renderer/src/components/tasks/natural-date-input.tsx index b2324d613..68db32e88 100644 --- a/apps/desktop/src/renderer/src/components/tasks/natural-date-input.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/natural-date-input.tsx @@ -103,8 +103,8 @@ export const NaturalDateInput = forwardRef<NaturalDateInputRef, NaturalDateInput placeholder={placeholder} className={cn( 'pl-9 pr-3', - isValid && 'border-task-complete focus-visible:ring-task-complete/20', - isInvalid && 'border-task-due-today focus-visible:ring-task-due-today/20' + isValid && 'border-task-complete', + isInvalid && 'border-task-due-today' )} aria-label="Type a date in natural language" autoComplete="off" diff --git a/apps/desktop/src/renderer/src/components/tasks/parent-task-row.tsx b/apps/desktop/src/renderer/src/components/tasks/parent-task-row.tsx index 024a9c583..9186e5aae 100644 --- a/apps/desktop/src/renderer/src/components/tasks/parent-task-row.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/parent-task-row.tsx @@ -196,8 +196,7 @@ export const ParentTaskRow = ({ : [ 'relative flex items-center py-[7px] px-3 gap-3 transition-colors', 'rounded-md hover:bg-accent/60', - onClick && - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + onClick && 'focus-visible:outline-none', dragHandleListeners && !isDragging && 'cursor-grab', isDragging && 'cursor-grabbing opacity-[0.35] border-dashed border-primary/30 bg-primary/[0.03]', @@ -293,14 +292,14 @@ export const ParentTaskRow = ({ <span className={cn( - 'text-[13px] leading-4 grow shrink basis-0 truncate', + 'text-[13px] font-medium grow shrink min-w-0 truncate', isCompleted ? isOverlay - ? 'text-text-tertiary line-through decoration-1 [text-underline-position:from-font]' - : 'text-text-tertiary line-through decoration-1 [text-underline-position:from-font]' + ? 'text-muted-foreground/60 line-through decoration-1 [text-underline-position:from-font]' + : 'text-muted-foreground/60 line-through decoration-1 [text-underline-position:from-font]' : isOverlay - ? 'text-card-foreground font-medium' - : 'text-text-primary' + ? 'text-foreground/90' + : 'text-foreground/90' )} > {task.title} diff --git a/apps/desktop/src/renderer/src/components/tasks/project-modal.tsx b/apps/desktop/src/renderer/src/components/tasks/project-modal.tsx index 4e287863c..3d2b6a9b3 100644 --- a/apps/desktop/src/renderer/src/components/tasks/project-modal.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/project-modal.tsx @@ -265,7 +265,7 @@ export const ProjectModal = ({ className={cn( 'flex size-12 shrink-0 items-center justify-center rounded-sm border-2 border-dashed', 'transition-colors hover:border-primary hover:bg-accent/50', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'focus-visible:outline-none' )} aria-label="Select icon" > @@ -319,7 +319,7 @@ export const ProjectModal = ({ className={cn( 'w-full resize-none rounded-sm border bg-transparent px-3 py-2 text-sm', 'placeholder:text-muted-foreground', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'focus-visible:outline-none' )} /> </div> diff --git a/apps/desktop/src/renderer/src/components/tasks/quick-add-input.tsx b/apps/desktop/src/renderer/src/components/tasks/quick-add-input.tsx index 394c608ca..ac184615d 100644 --- a/apps/desktop/src/renderer/src/components/tasks/quick-add-input.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/quick-add-input.tsx @@ -441,7 +441,7 @@ export const QuickAddInput = ({ aria-hidden="true" className={cn( 'pointer-events-none absolute inset-0 overflow-hidden whitespace-pre leading-[normal]', - compact ? 'text-[12px]' : 'text-sm' + compact ? 'text-[13px]' : 'text-sm' )} > {value && hasSpecialSyntax(value) && <TokenOverlay value={value} />} @@ -457,12 +457,12 @@ export const QuickAddInput = ({ onKeyDown={handleKeyDown} placeholder={placeholder} className={cn( - 'relative w-full bg-transparent outline-none caret-text-primary', - compact ? 'text-[12px] leading-4' : 'text-sm', + 'relative w-full bg-transparent outline-none caret-foreground', + compact ? 'text-[13px] leading-4' : 'text-sm', value && hasSpecialSyntax(value) ? 'text-transparent selection:bg-primary/20 selection:text-transparent placeholder:text-muted-foreground/40' : isFocused - ? 'text-text-primary placeholder:text-muted-foreground/40' + ? 'text-foreground/90 placeholder:text-muted-foreground/40' : 'text-muted-foreground placeholder:text-muted-foreground/40' )} aria-label="Quick add task" diff --git a/apps/desktop/src/renderer/src/components/tasks/quick-add/autocomplete-dropdown.tsx b/apps/desktop/src/renderer/src/components/tasks/quick-add/autocomplete-dropdown.tsx index c9b2ac651..464b08d1a 100644 --- a/apps/desktop/src/renderer/src/components/tasks/quick-add/autocomplete-dropdown.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/quick-add/autocomplete-dropdown.tsx @@ -164,7 +164,7 @@ export const AutocompleteDropdown = ({ <div className={cn( 'absolute top-full left-0 mt-1 w-[220px]', - 'bg-popover rounded-lg border border-border shadow-[var(--shadow-card-hover)]', + 'bg-popover rounded-md border border-border shadow-[var(--shadow-card-hover)]', 'z-50 overflow-clip', 'text-[12px] leading-4 [font-synthesis:none] antialiased', 'animate-in fade-in-0 zoom-in-95 duration-100', diff --git a/apps/desktop/src/renderer/src/components/tasks/section-divider.tsx b/apps/desktop/src/renderer/src/components/tasks/section-divider.tsx index 8412e4ed8..0ab939461 100644 --- a/apps/desktop/src/renderer/src/components/tasks/section-divider.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/section-divider.tsx @@ -59,7 +59,7 @@ export const SectionDivider = ({ 'size-5 flex items-center justify-center rounded-sm shrink-0', 'text-text-tertiary hover:text-text-secondary hover:bg-accent/50', 'opacity-0 group-hover/section:opacity-100 transition-all cursor-pointer', - 'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'focus-visible:opacity-100 focus-visible:outline-none' )} title={`Add task to ${label}`} > diff --git a/apps/desktop/src/renderer/src/components/tasks/sortable-subtask-row.tsx b/apps/desktop/src/renderer/src/components/tasks/sortable-subtask-row.tsx index 3caa3f3e4..2d6d3665f 100644 --- a/apps/desktop/src/renderer/src/components/tasks/sortable-subtask-row.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/sortable-subtask-row.tsx @@ -63,7 +63,7 @@ export const SortableSubtaskRow = ({ 'py-1.5 pl-[44px] pr-3', 'hover:bg-accent/50 rounded-r-sm', 'transition-colors duration-150', - onClick && 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + onClick && 'focus-visible:outline-none', isDragging ? 'cursor-grabbing opacity-50 shadow-lg ring-2 ring-primary bg-background z-10' : 'cursor-grab' @@ -82,10 +82,10 @@ export const SortableSubtaskRow = ({ {/* Title */} <span className={cn( - 'text-xs leading-4 whitespace-nowrap', + 'text-[13px] font-medium whitespace-nowrap', isCompleted - ? 'line-through text-[#A3A09B] decoration-1' - : 'text-[#4A4A46] dark:text-foreground/80' + ? 'line-through text-muted-foreground/60 decoration-1' + : 'text-foreground/90' )} > {subtask.title} diff --git a/apps/desktop/src/renderer/src/components/tasks/status-editor.tsx b/apps/desktop/src/renderer/src/components/tasks/status-editor.tsx index 9e5b6004f..c8519f8eb 100644 --- a/apps/desktop/src/renderer/src/components/tasks/status-editor.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/status-editor.tsx @@ -68,7 +68,7 @@ const StatusColorPicker = ({ <PopoverTrigger asChild> <button type="button" - className="size-4 shrink-0 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + className="size-4 shrink-0 rounded-full transition-transform hover:scale-110 focus-visible:outline-none" style={{ backgroundColor: value }} aria-label="Change status color" /> @@ -82,7 +82,7 @@ const StatusColorPicker = ({ onClick={handleColorSelect(color.value)} className={cn( 'size-6 rounded-full transition-transform hover:scale-110', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', value === color.value && 'ring-2 ring-ring ring-offset-1' )} style={{ backgroundColor: color.value }} diff --git a/apps/desktop/src/renderer/src/components/tasks/status-icon.tsx b/apps/desktop/src/renderer/src/components/tasks/status-icon.tsx index 94d34cb8f..2e30bb294 100644 --- a/apps/desktop/src/renderer/src/components/tasks/status-icon.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/status-icon.tsx @@ -54,7 +54,7 @@ export const InteractiveStatusIcon = ({ onClick={onClick} className={cn( 'shrink-0 cursor-pointer rounded-full', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', className )} aria-label={isCompleted ? 'Mark as incomplete' : 'Mark as complete'} diff --git a/apps/desktop/src/renderer/src/components/tasks/subtask-badge.tsx b/apps/desktop/src/renderer/src/components/tasks/subtask-badge.tsx index 743a958fe..04dfb7145 100644 --- a/apps/desktop/src/renderer/src/components/tasks/subtask-badge.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/subtask-badge.tsx @@ -42,7 +42,7 @@ export const SubtaskBadge = ({ tabIndex={onClick ? 0 : -1} className={cn( 'inline-flex items-center gap-[3px] shrink-0', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 rounded', + 'focus-visible:outline-none rounded', onClick ? 'cursor-pointer' : 'cursor-default', className )} diff --git a/apps/desktop/src/renderer/src/components/tasks/subtask-dots.tsx b/apps/desktop/src/renderer/src/components/tasks/subtask-dots.tsx index 4c4d51cde..4f8ce1143 100644 --- a/apps/desktop/src/renderer/src/components/tasks/subtask-dots.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/subtask-dots.tsx @@ -62,7 +62,7 @@ export const SubtaskDots = ({ className={cn( 'text-xs text-muted-foreground tabular-nums', 'hover:text-foreground transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 rounded', + 'focus-visible:outline-none rounded', onClick ? 'cursor-pointer' : 'cursor-default', className )} @@ -98,7 +98,7 @@ export const SubtaskDots = ({ className={cn( 'inline-flex items-center gap-1.5 px-1 py-0.5 rounded', 'hover:bg-muted transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1', + 'focus-visible:outline-none', onClick ? 'cursor-pointer' : 'cursor-default', className )} diff --git a/apps/desktop/src/renderer/src/components/tasks/subtask-progress-badge.tsx b/apps/desktop/src/renderer/src/components/tasks/subtask-progress-badge.tsx index 8397de6e0..755d91cfa 100644 --- a/apps/desktop/src/renderer/src/components/tasks/subtask-progress-badge.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/subtask-progress-badge.tsx @@ -60,7 +60,7 @@ export const SubtaskProgressBadge = ({ className={cn( 'inline-flex items-center gap-2 px-2 py-1 rounded', 'hover:bg-muted transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1', + 'focus-visible:outline-none', onClick ? 'cursor-pointer' : 'cursor-default', className )} diff --git a/apps/desktop/src/renderer/src/components/tasks/subtask-row.tsx b/apps/desktop/src/renderer/src/components/tasks/subtask-row.tsx index 307f410b1..fd7d4e797 100644 --- a/apps/desktop/src/renderer/src/components/tasks/subtask-row.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/subtask-row.tsx @@ -46,7 +46,7 @@ export const SubtaskRow = ({ 'py-1.5 pl-[44px] pr-3', 'hover:bg-accent/50 cursor-pointer rounded-r-sm', 'transition-colors duration-150', - onClick && 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + onClick && 'focus-visible:outline-none', className )} aria-label={`Subtask: ${subtask.title}${isCompleted ? ', completed' : ''}`} @@ -62,10 +62,8 @@ export const SubtaskRow = ({ <span className={cn( - 'text-xs leading-4 whitespace-nowrap', - isCompleted - ? 'line-through text-[#A3A09B] decoration-1' - : 'text-[#4A4A46] dark:text-foreground/80' + 'text-[13px] font-medium whitespace-nowrap', + isCompleted ? 'line-through text-muted-foreground/60 decoration-1' : 'text-foreground/90' )} > {subtask.title} diff --git a/apps/desktop/src/renderer/src/components/tasks/task-badges.tsx b/apps/desktop/src/renderer/src/components/tasks/task-badges.tsx index 971a02ac4..98c9e1860 100644 --- a/apps/desktop/src/renderer/src/components/tasks/task-badges.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/task-badges.tsx @@ -263,7 +263,7 @@ export const TaskCheckbox = ({ className={cn( 'shrink-0 rounded-full transition-all duration-200', isSm ? 'size-3.5' : 'size-4', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'focus-visible:outline-none', disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer', className )} diff --git a/apps/desktop/src/renderer/src/components/tasks/task-detail-drawer.tsx b/apps/desktop/src/renderer/src/components/tasks/task-detail-drawer.tsx index e8dc8d79b..b37d7ca31 100644 --- a/apps/desktop/src/renderer/src/components/tasks/task-detail-drawer.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/task-detail-drawer.tsx @@ -254,7 +254,7 @@ export const TaskDetailDrawer = memo(function TaskDetailDrawer({ <button type="button" onClick={onClose} - className="shrink-0 rounded-sm p-0.5 text-text-tertiary hover:text-text-secondary transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + className="shrink-0 rounded-sm p-0.5 text-text-tertiary hover:text-text-secondary transition-colors focus-visible:outline-none" aria-label="Close task details" > <X size={16} /> diff --git a/apps/desktop/src/renderer/src/components/tasks/task-row.tsx b/apps/desktop/src/renderer/src/components/tasks/task-row.tsx index 6b623c51b..efc089aa8 100644 --- a/apps/desktop/src/renderer/src/components/tasks/task-row.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/task-row.tsx @@ -116,8 +116,7 @@ export const TaskRow = ({ className={cn( 'group flex items-center py-[7px] px-6 gap-3 transition-colors', 'rounded-md hover:bg-accent/60', - onClick && - 'cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + onClick && 'cursor-pointer focus-visible:outline-none', isCheckedForSelection && 'bg-primary/10 hover:bg-primary/15', isSelected && !isCheckedForSelection && 'bg-primary/10 ring-1 ring-inset ring-primary/30', className @@ -150,10 +149,10 @@ export const TaskRow = ({ <span className={cn( - 'text-[13px] leading-4 grow shrink basis-0 truncate', + 'text-[13px] font-medium grow shrink min-w-0 truncate', isCompleted - ? 'text-text-tertiary line-through decoration-1 [text-underline-position:from-font]' - : 'text-text-primary' + ? 'text-muted-foreground/60 line-through decoration-1 [text-underline-position:from-font]' + : 'text-foreground/90' )} > {task.title} diff --git a/apps/desktop/src/renderer/src/components/tasks/task-section.tsx b/apps/desktop/src/renderer/src/components/tasks/task-section.tsx index 5399e6a04..aeb8f8666 100644 --- a/apps/desktop/src/renderer/src/components/tasks/task-section.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/task-section.tsx @@ -69,7 +69,7 @@ export const TaskSection = ({ role="region" className={cn( 'flex flex-col transition-all duration-200', - isDropTarget && 'ring-2 ring-primary/25 bg-primary/[0.04] rounded-lg', + isDropTarget && 'ring-2 ring-primary/25 bg-primary/[0.04] rounded-md', isDragSource && 'opacity-50', className )} @@ -112,7 +112,7 @@ export const TaskSection = ({ className={cn( 'block mx-auto mt-3 text-primary hover:text-primary/80', 'text-sm font-medium transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + 'focus-visible:outline-none' )} > + Add task diff --git a/apps/desktop/src/renderer/src/components/tasks/tasks-tab-bar.tsx b/apps/desktop/src/renderer/src/components/tasks/tasks-tab-bar.tsx index 7517320ba..48c22c572 100644 --- a/apps/desktop/src/renderer/src/components/tasks/tasks-tab-bar.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/tasks-tab-bar.tsx @@ -106,7 +106,7 @@ export const TasksTabBar = ({ return ( <div className={cn( - 'flex items-center shrink-0 gap-2.5 [font-synthesis:none] text-[12px] leading-4 antialiased', + 'flex items-center shrink-0 gap-2.5 [font-synthesis:none] text-[13px] leading-4 antialiased', className )} > @@ -133,19 +133,19 @@ export const TasksTabBar = ({ onKeyDown={(e) => handleKeyDown(e, index)} className={cn( 'flex items-center py-1 px-2.5 gap-1 transition-colors', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset', + 'focus-visible:outline-none', index > 0 && 'border-l border-border', isActive ? 'bg-foreground text-background font-medium' - : 'text-text-secondary hover:text-text-primary hover:bg-surface-active/50' + : 'text-muted-foreground hover:text-foreground/90 hover:bg-surface-active/50' )} > - <span className="text-[12px] leading-4">{tab.label}</span> + <span className="text-[13px] leading-4">{tab.label}</span> <span className={cn( 'text-[9px] font-[family-name:var(--font-mono)] leading-3 tabular-nums min-w-[2ch] text-center', count === 0 && 'invisible', - isActive ? 'text-background/45' : 'text-text-tertiary' + isActive ? 'text-background/45' : 'text-muted-foreground/60' )} > {count} @@ -164,16 +164,16 @@ export const TasksTabBar = ({ 'group/pill flex items-center whitespace-nowrap border-l border-border transition-colors', isActive ? 'saved-filter-active bg-task-star/15 text-task-star font-medium' - : 'text-text-tertiary hover:text-text-primary hover:bg-surface-active/50' + : 'text-muted-foreground/60 hover:text-foreground/90 hover:bg-surface-active/50' )} > <button type="button" aria-label={sf.name} onClick={() => onApplySavedFilter?.(sf)} - className="flex items-baseline py-1 pl-2.5 pr-1 gap-1 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset" + className="flex items-baseline py-1 pl-2.5 pr-1 gap-1 focus-visible:outline-none" > - <span className="text-[12px] leading-4">{sf.name}</span> + <span className="text-[13px] leading-4">{sf.name}</span> </button> <button type="button" @@ -250,7 +250,7 @@ function ProjectDropdown({ <PopoverTrigger asChild> <button type="button" - className="flex items-center shrink-0 whitespace-nowrap rounded-[5px] py-1 px-2.5 gap-[5px] border border-border text-text-secondary hover:text-text-primary hover:bg-surface-active/50 transition-colors" + className="flex items-center shrink-0 whitespace-nowrap rounded-[5px] py-1 px-2.5 gap-[5px] border border-border text-muted-foreground hover:text-foreground/90 hover:bg-surface-active/50 transition-colors" > <span className={cn( @@ -259,13 +259,13 @@ function ProjectDropdown({ )} style={selectedProject ? { backgroundColor: selectedProject.color } : undefined} /> - <span className="text-[12px] leading-4">{selectedProject?.name ?? 'All projects'}</span> + <span className="text-[13px] leading-4">{selectedProject?.name ?? 'All projects'}</span> <svg width="10" height="10" viewBox="0 0 10 10" fill="none" - className="text-text-tertiary" + className="text-muted-foreground/60" > <path d="M2.5 3.75l2.5 2.5 2.5-2.5" @@ -278,11 +278,11 @@ function ProjectDropdown({ </button> </PopoverTrigger> <PopoverContent - className="w-[200px] p-0 rounded-lg overflow-clip bg-popover border-border shadow-[var(--shadow-card-hover)]" + className="w-[200px] p-0 rounded-md overflow-clip bg-popover border-border shadow-[var(--shadow-card-hover)]" align="start" sideOffset={8} > - <div className="flex flex-col text-[12px] leading-4 [font-synthesis:none] antialiased"> + <div className="flex flex-col text-[13px] leading-4 [font-synthesis:none] antialiased"> <FilterSearchHeader value={search} onChange={setSearch} @@ -298,48 +298,67 @@ function ProjectDropdown({ )} > <div className="shrink-0 rounded-[3px] border-[1.2px] border-solid border-border size-2.5" /> - <span className="text-[12px] text-text-tertiary leading-4">All projects</span> + <span className="text-[13px] text-muted-foreground/60 leading-4">All projects</span> {!selectedProjectId && <CheckMark className="ml-auto text-primary" />} </button> {filtered.map((p) => { const isSelected = p.id === selectedProjectId return ( - <div key={p.id} className="group/project-item flex items-center"> - <button - type="button" - onClick={() => onProjectChange(p.id)} + <div + key={p.id} + role="button" + tabIndex={0} + onClick={() => onProjectChange(p.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onProjectChange(p.id) + } + }} + className={cn( + 'group/project-item flex items-center rounded-[5px] py-1.5 px-2 gap-2 cursor-pointer transition-colors', + isSelected ? 'bg-accent' : 'hover:bg-accent' + )} + > + <div + className="shrink-0 rounded-[3px] size-2.5" + style={{ backgroundColor: p.color }} + /> + <span className={cn( - 'flex-1 flex items-center rounded-[5px] py-1.5 px-2 gap-2 transition-colors', - isSelected ? 'bg-accent' : 'hover:bg-accent' + 'text-[13px] leading-4', + isSelected ? 'text-foreground' : 'text-muted-foreground' )} > - <div - className="shrink-0 rounded-[3px] size-2.5" - style={{ backgroundColor: p.color }} - /> - <span - className={cn( - 'text-[12px] leading-4', - isSelected ? 'text-foreground' : 'text-text-secondary' + {p.name} + </span> + {(isSelected || onProjectEdit) && ( + <div className="ml-auto shrink-0 size-3 relative"> + {isSelected && ( + <span + className={cn( + 'absolute inset-0 flex items-center justify-center transition-opacity', + onProjectEdit && 'group-hover/project-item:opacity-0' + )} + > + <CheckMark className="text-primary" /> + </span> + )} + {onProjectEdit && ( + <button + type="button" + onClick={(e) => { + e.stopPropagation() + onProjectEdit(p) + handleOpenChange(false) + }} + className="absolute inset-0 flex items-center justify-center opacity-0 group-hover/project-item:opacity-100 transition-opacity" + aria-label={`Edit ${p.name}`} + > + <Settings className="size-3 text-text-tertiary" /> + </button> )} - > - {p.name} - </span> - {isSelected && <CheckMark className="ml-auto text-primary" />} - </button> - {onProjectEdit && ( - <button - type="button" - onClick={(e) => { - e.stopPropagation() - onProjectEdit(p) - handleOpenChange(false) - }} - className="shrink-0 p-1.5 mr-1 rounded-sm opacity-0 group-hover/project-item:opacity-100 transition-opacity hover:bg-accent" - aria-label={`Edit ${p.name}`} - > - <Settings className="size-3 text-text-tertiary" /> - </button> + </div> )} </div> ) diff --git a/apps/desktop/src/renderer/src/components/tasks/today-task-row.tsx b/apps/desktop/src/renderer/src/components/tasks/today-task-row.tsx index 50e762d1a..3c0c00ec1 100644 --- a/apps/desktop/src/renderer/src/components/tasks/today-task-row.tsx +++ b/apps/desktop/src/renderer/src/components/tasks/today-task-row.tsx @@ -101,8 +101,7 @@ export const TodayTaskRow = ({ className={cn( 'group flex items-center gap-3 rounded-sm px-3 py-2.5 transition-colors duration-150', 'hover:bg-accent/50', - onClick && - 'cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + onClick && 'cursor-pointer focus-visible:outline-none', isOverdue && 'bg-task-due-overdue-bg/50', isSelected && 'bg-primary/10 ring-1 ring-inset ring-primary/30', className @@ -123,7 +122,9 @@ export const TodayTaskRow = ({ </span> {/* Title */} - <span className="flex-1 truncate text-sm font-medium text-text-primary">{task.title}</span> + <span className="flex-1 truncate text-[13px] font-medium text-foreground/90"> + {task.title} + </span> {/* Repeat indicator */} {task.isRepeating && task.repeatConfig && ( diff --git a/apps/desktop/src/renderer/src/components/team-switcher.tsx b/apps/desktop/src/renderer/src/components/team-switcher.tsx index 4638761c6..544a9120d 100644 --- a/apps/desktop/src/renderer/src/components/team-switcher.tsx +++ b/apps/desktop/src/renderer/src/components/team-switcher.tsx @@ -43,7 +43,7 @@ export function TeamSwitcher({ <DropdownMenuTrigger asChild> <SidebarMenuButton size="lg" - className="bg-white rounded-lg gap-2 border border-gray-200/60 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center" + className="bg-white rounded-md gap-2 border border-gray-200/60 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center" > <div className="flex aspect-square size-6 items-center justify-center rounded-full bg-sidebar-primary text-sidebar-primary-foreground"> <activeTeam.logo className="size-3" /> @@ -67,7 +67,7 @@ export function TeamSwitcher({ <DropdownMenuItem key={team.name} onClick={() => setActiveTeam(team)} - className="rounded-lg cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors" + className="rounded-md cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors" > <div className="flex size-7 items-center justify-center rounded-md border border-gray-200 bg-white"> <team.logo className="size-4 shrink-0" /> @@ -79,7 +79,7 @@ export function TeamSwitcher({ })} {/* Add workspace */} - <DropdownMenuItem className="rounded-lg cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors"> + <DropdownMenuItem className="rounded-md cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors"> <div className="flex size-7 items-center justify-center"> <Plus className="size-4 text-gray-500" /> </div> @@ -90,7 +90,7 @@ export function TeamSwitcher({ {/* Settings section */} <DropdownMenuItem - className="rounded-lg cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors" + className="rounded-md cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors" onSelect={(e) => { e.preventDefault() // Prevent dropdown from closing }} @@ -106,14 +106,14 @@ export function TeamSwitcher({ /> </DropdownMenuItem> - <DropdownMenuItem className="rounded-lg cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors"> + <DropdownMenuItem className="rounded-md cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors"> <div className="flex size-7 items-center justify-center"> <Settings className="size-4 text-gray-500" /> </div> <span className="text-gray-900">Settings</span> </DropdownMenuItem> - <DropdownMenuItem className="rounded-lg cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors"> + <DropdownMenuItem className="rounded-md cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors"> <div className="flex size-7 items-center justify-center"> <LayoutGrid className="size-4 text-gray-500" /> </div> @@ -123,7 +123,7 @@ export function TeamSwitcher({ <DropdownMenuSeparator className="my-2 -mx-2 bg-gray-200/80" /> {/* Sign out */} - <DropdownMenuItem className="rounded-lg cursor-pointer hover:bg-red-50 focus:bg-red-50 transition-colors"> + <DropdownMenuItem className="rounded-md cursor-pointer hover:bg-red-50 focus:bg-red-50 transition-colors"> <div className="flex size-7 items-center justify-center"> <LogOut className="size-4 text-red-500" /> </div> @@ -133,7 +133,7 @@ export function TeamSwitcher({ <DropdownMenuSeparator className="my-2 -mx-2 bg-gray-200/80" /> {/* Download on iOS */} - <DropdownMenuItem className="rounded-lg cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors"> + <DropdownMenuItem className="rounded-md cursor-pointer hover:bg-gray-100 focus:bg-gray-100 transition-colors"> <div className="flex size-7 items-center justify-center"> <svg className="size-4 text-gray-500" viewBox="0 0 24 24" fill="currentColor"> <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" /> diff --git a/apps/desktop/src/renderer/src/components/ui/alert-dialog.tsx b/apps/desktop/src/renderer/src/components/ui/alert-dialog.tsx index 668f5e435..b2c3375a4 100644 --- a/apps/desktop/src/renderer/src/components/ui/alert-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/ui/alert-dialog.tsx @@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef< <AlertDialogPrimitive.Content ref={ref} className={cn( - 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', + 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-md', className )} {...props} diff --git a/apps/desktop/src/renderer/src/components/ui/badge.tsx b/apps/desktop/src/renderer/src/components/ui/badge.tsx index dbfeb7858..0e53cdc30 100644 --- a/apps/desktop/src/renderer/src/components/ui/badge.tsx +++ b/apps/desktop/src/renderer/src/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const badgeVariants = cva( - 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', { variants: { variant: { @@ -13,7 +13,7 @@ const badgeVariants = cva( secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', destructive: - 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 dark:bg-destructive/60', outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', status: 'border-transparent gap-1.5', priority: 'border-transparent gap-1.5', diff --git a/apps/desktop/src/renderer/src/components/ui/button.tsx b/apps/desktop/src/renderer/src/components/ui/button.tsx index 33af058fb..d336aefe6 100644 --- a/apps/desktop/src/renderer/src/components/ui/button.tsx +++ b/apps/desktop/src/renderer/src/components/ui/button.tsx @@ -5,13 +5,12 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: - 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + destructive: 'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', diff --git a/apps/desktop/src/renderer/src/components/ui/checkbox.tsx b/apps/desktop/src/renderer/src/components/ui/checkbox.tsx index 94736cdeb..21df34446 100644 --- a/apps/desktop/src/renderer/src/components/ui/checkbox.tsx +++ b/apps/desktop/src/renderer/src/components/ui/checkbox.tsx @@ -11,7 +11,7 @@ const Checkbox = React.forwardRef< <CheckboxPrimitive.Root ref={ref} className={cn( - 'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', + 'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', className )} {...props} diff --git a/apps/desktop/src/renderer/src/components/ui/dialog.tsx b/apps/desktop/src/renderer/src/components/ui/dialog.tsx index df1d0c9df..a58af38fe 100644 --- a/apps/desktop/src/renderer/src/components/ui/dialog.tsx +++ b/apps/desktop/src/renderer/src/components/ui/dialog.tsx @@ -38,13 +38,13 @@ const DialogContent = React.forwardRef< <DialogPrimitive.Content ref={ref} className={cn( - 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', + 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-md', className )} {...props} > {children} - <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <X className="h-4 w-4" /> <span className="sr-only">Close</span> </DialogPrimitive.Close> diff --git a/apps/desktop/src/renderer/src/components/ui/filter-footer.tsx b/apps/desktop/src/renderer/src/components/ui/filter-footer.tsx index f0697854f..de1fb83e8 100644 --- a/apps/desktop/src/renderer/src/components/ui/filter-footer.tsx +++ b/apps/desktop/src/renderer/src/components/ui/filter-footer.tsx @@ -28,7 +28,7 @@ export function FilterFooter({ <button type="button" onClick={onClear} - className="text-[12px] text-text-tertiary font-medium leading-4 hover:text-foreground transition-colors" + className="text-[13px] text-muted-foreground/60 font-medium leading-4 hover:text-foreground transition-colors" > {clearLabel} </button> @@ -38,7 +38,7 @@ export function FilterFooter({ onClick={onApply} className="flex items-center rounded-sm py-[5px] px-3.5 gap-1 bg-foreground hover:bg-foreground/80 transition-colors" > - <span className="text-[12px] text-background font-semibold leading-4">{applyLabel}</span> + <span className="text-[13px] text-background font-semibold leading-4">{applyLabel}</span> </button> </div> ) diff --git a/apps/desktop/src/renderer/src/components/ui/filter-option-row.tsx b/apps/desktop/src/renderer/src/components/ui/filter-option-row.tsx index 60af0f022..1c960cab6 100644 --- a/apps/desktop/src/renderer/src/components/ui/filter-option-row.tsx +++ b/apps/desktop/src/renderer/src/components/ui/filter-option-row.tsx @@ -40,8 +40,8 @@ export function FilterOptionRow({ {icon} <span className={cn( - 'text-[12px] leading-4', - selected ? 'text-foreground' : 'text-text-secondary' + 'text-[13px] leading-4', + selected ? 'text-foreground' : 'text-muted-foreground' )} > {label} diff --git a/apps/desktop/src/renderer/src/components/ui/filter-search-header.tsx b/apps/desktop/src/renderer/src/components/ui/filter-search-header.tsx index df09cbbe7..314461057 100644 --- a/apps/desktop/src/renderer/src/components/ui/filter-search-header.tsx +++ b/apps/desktop/src/renderer/src/components/ui/filter-search-header.tsx @@ -19,13 +19,13 @@ export function FilterSearchHeader({ return ( <div className={cn('flex items-center py-2 px-3 gap-2 border-b border-border', className)}> {leading} - <Search size={12} className="shrink-0 text-text-tertiary" /> + <Search size={12} className="shrink-0 text-muted-foreground/60" /> <input type="text" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} - className="flex-1 min-w-0 bg-transparent text-[12px] text-foreground placeholder:text-text-tertiary outline-none leading-4" + className="flex-1 min-w-0 bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/40 outline-none leading-4" onClick={(e) => e.stopPropagation()} /> </div> diff --git a/apps/desktop/src/renderer/src/components/ui/input-group.tsx b/apps/desktop/src/renderer/src/components/ui/input-group.tsx index 019377926..62ccd9b59 100644 --- a/apps/desktop/src/renderer/src/components/ui/input-group.tsx +++ b/apps/desktop/src/renderer/src/components/ui/input-group.tsx @@ -8,7 +8,7 @@ const InputGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv <div ref={ref} className={cn( - 'flex h-9 w-full items-center overflow-hidden rounded-md border border-input bg-transparent text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]', + 'flex h-9 w-full items-center overflow-hidden rounded-md border border-input bg-transparent text-sm shadow-xs transition-[color,box-shadow]', className )} {...props} diff --git a/apps/desktop/src/renderer/src/components/ui/input-otp.tsx b/apps/desktop/src/renderer/src/components/ui/input-otp.tsx index 22328f36c..5143e55a3 100644 --- a/apps/desktop/src/renderer/src/components/ui/input-otp.tsx +++ b/apps/desktop/src/renderer/src/components/ui/input-otp.tsx @@ -38,9 +38,10 @@ const InputOTPSlot = React.forwardRef< return ( <div ref={ref} + data-filled={char ? '' : undefined} className={cn( 'relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md', - isActive && 'z-10 ring-1 ring-ring', + isActive && 'z-10', className )} {...props} diff --git a/apps/desktop/src/renderer/src/components/ui/input.tsx b/apps/desktop/src/renderer/src/components/ui/input.tsx index af2b4a27f..b2e3e0696 100644 --- a/apps/desktop/src/renderer/src/components/ui/input.tsx +++ b/apps/desktop/src/renderer/src/components/ui/input.tsx @@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) { type={type} data-slot="input" className={cn( - 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', - 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', + 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + 'focus-visible:outline-none focus-visible:border-foreground/20', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', className )} diff --git a/apps/desktop/src/renderer/src/components/ui/page-toolbar.tsx b/apps/desktop/src/renderer/src/components/ui/page-toolbar.tsx new file mode 100644 index 000000000..b06c44b92 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/page-toolbar.tsx @@ -0,0 +1,114 @@ +import { cn } from '@/lib/utils' + +interface PageToolbarProps { + children: React.ReactNode + className?: string +} + +export function PageToolbar({ children, className }: PageToolbarProps): React.JSX.Element { + return ( + <div + className={cn( + 'flex items-center gap-2.5 shrink-0 min-w-0 py-0.5 border-b border-border', + '[font-synthesis:none] text-[12px] leading-4 antialiased', + className + )} + > + {children} + </div> + ) +} + +interface ToolbarSegmentProps { + children: React.ReactNode + label?: string + className?: string +} + +export function ToolbarSegment({ + children, + label = 'Navigation', + className +}: ToolbarSegmentProps): React.JSX.Element { + return ( + <div + className={cn( + 'flex items-center shrink-0 rounded-[5px] overflow-clip border border-border', + className + )} + role="tablist" + aria-label={label} + > + {children} + </div> + ) +} + +interface ToolbarSegmentTabProps { + children: React.ReactNode + isActive: boolean + showBorder?: boolean + onClick: () => void + ariaControls?: string + className?: string +} + +export function ToolbarSegmentTab({ + children, + isActive, + showBorder = true, + onClick, + ariaControls, + className +}: ToolbarSegmentTabProps): React.JSX.Element { + return ( + <button + type="button" + role="tab" + aria-selected={isActive} + aria-controls={ariaControls} + tabIndex={isActive ? 0 : -1} + onClick={onClick} + className={cn( + 'flex items-center py-1 px-2.5 gap-1 transition-colors', + 'focus-visible:outline-none', + showBorder && 'border-l border-border', + isActive + ? 'bg-foreground text-background font-medium' + : 'text-text-secondary hover:text-text-primary hover:bg-surface-active/50', + className + )} + > + {children} + </button> + ) +} + +interface ToolbarButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { + children: React.ReactNode + isActive?: boolean + className?: string +} + +export function ToolbarButton({ + children, + isActive = false, + className, + ...props +}: ToolbarButtonProps): React.JSX.Element { + return ( + <button + type="button" + className={cn( + 'flex items-center shrink-0 rounded-[5px] py-1 px-2 gap-1 border transition-colors', + isActive + ? 'border-foreground/20 bg-foreground/5 text-text-primary' + : 'border-border text-text-secondary hover:bg-surface-active/50', + className + )} + {...props} + > + {children} + </button> + ) +} diff --git a/apps/desktop/src/renderer/src/components/ui/pill.tsx b/apps/desktop/src/renderer/src/components/ui/pill.tsx new file mode 100644 index 000000000..ff9539ff4 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/pill.tsx @@ -0,0 +1,81 @@ +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const pillVariants = cva('inline-flex items-center shrink-0 rounded-[10px] gap-1', { + variants: { + variant: { + bordered: 'border border-solid py-px px-[7px]', + filled: 'py-px px-[7px]' + }, + size: { + default: 'text-[11px] leading-[14px]', + sm: 'text-[10px] leading-[14px] font-medium' + } + }, + defaultVariants: { + variant: 'bordered', + size: 'default' + } +}) + +type PillColor = 'amber' | 'emerald' | 'red' | 'indigo' | 'purple' | 'sky' | 'gray' | 'destructive' + +const PILL_COLORS: Record<PillColor, { bordered: string; filled: string }> = { + amber: { + bordered: 'border-amber-500/30 text-amber-500 dark:border-amber-400/30 dark:text-amber-400', + filled: 'bg-amber-500/10 text-amber-500 dark:bg-amber-400/10 dark:text-amber-400' + }, + emerald: { + bordered: + 'border-emerald-500/30 text-emerald-500 dark:border-emerald-400/30 dark:text-emerald-400', + filled: 'bg-emerald-500/10 text-emerald-500 dark:bg-emerald-400/10 dark:text-emerald-400' + }, + red: { + bordered: 'border-red-500/30 text-red-500 dark:border-red-400/30 dark:text-red-400', + filled: 'bg-red-500/10 text-red-500 dark:bg-red-400/10 dark:text-red-400' + }, + indigo: { + bordered: 'border-indigo-500/30 text-indigo-500 dark:border-indigo-400/30 dark:text-indigo-400', + filled: 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-400/10 dark:text-indigo-400' + }, + purple: { + bordered: 'border-purple-400/30 text-purple-400 dark:border-purple-300/30 dark:text-purple-300', + filled: 'bg-purple-400/10 text-purple-400 dark:bg-purple-300/10 dark:text-purple-300' + }, + sky: { + bordered: 'border-sky-400/30 text-sky-400 dark:border-sky-300/30 dark:text-sky-300', + filled: 'bg-sky-400/10 text-sky-400 dark:bg-sky-300/10 dark:text-sky-300' + }, + gray: { + bordered: 'border-muted-foreground/30 text-muted-foreground', + filled: 'bg-muted-foreground/10 text-muted-foreground' + }, + destructive: { + bordered: 'border-destructive/30 text-destructive', + filled: 'bg-destructive/10 text-destructive' + } +} + +interface PillProps + extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof pillVariants> { + color?: PillColor +} + +function Pill({ + className, + variant = 'bordered', + size, + color = 'gray', + children, + ...props +}: PillProps): React.JSX.Element { + const colorClasses = PILL_COLORS[color]?.[variant ?? 'bordered'] ?? '' + + return ( + <span className={cn(pillVariants({ variant, size }), colorClasses, className)} {...props}> + {children} + </span> + ) +} + +export { Pill, pillVariants, type PillProps, type PillColor } diff --git a/apps/desktop/src/renderer/src/components/ui/radio-group.tsx b/apps/desktop/src/renderer/src/components/ui/radio-group.tsx index 48db2aaf1..ab0f31f4a 100644 --- a/apps/desktop/src/renderer/src/components/ui/radio-group.tsx +++ b/apps/desktop/src/renderer/src/components/ui/radio-group.tsx @@ -20,7 +20,7 @@ const RadioGroupItem = React.forwardRef< <RadioGroupPrimitive.Item ref={ref} className={cn( - 'aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', + 'aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none disabled:cursor-not-allowed disabled:opacity-50', className )} {...props} diff --git a/apps/desktop/src/renderer/src/components/ui/select.tsx b/apps/desktop/src/renderer/src/components/ui/select.tsx index 0dd7280c3..8e54a7493 100644 --- a/apps/desktop/src/renderer/src/components/ui/select.tsx +++ b/apps/desktop/src/renderer/src/components/ui/select.tsx @@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef< <SelectPrimitive.Trigger ref={ref} className={cn( - 'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', + 'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm data-[placeholder]:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', className )} {...props} diff --git a/apps/desktop/src/renderer/src/components/ui/selectable-list.tsx b/apps/desktop/src/renderer/src/components/ui/selectable-list.tsx index 3d6371f90..e667ba9ce 100644 --- a/apps/desktop/src/renderer/src/components/ui/selectable-list.tsx +++ b/apps/desktop/src/renderer/src/components/ui/selectable-list.tsx @@ -143,7 +143,7 @@ export function SelectableListItem({ className={cn( 'group relative w-full text-left', 'flex items-center gap-3', - 'px-3 py-2.5 rounded-lg', + 'px-3 py-2.5 rounded-md', 'transition-all duration-150 ease-out', // Base state 'hover:bg-muted/50', @@ -172,7 +172,7 @@ export function SelectableListItem({ {/* Icon */} <div className={cn( - 'flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center text-lg', + 'flex-shrink-0 w-9 h-9 rounded-md flex items-center justify-center text-lg', 'transition-colors duration-150', isSelected ? 'bg-amber-100 dark:bg-amber-900/40' : 'bg-muted/60 dark:bg-muted/40', 'group-hover:bg-muted dark:group-hover:bg-muted/60' @@ -228,7 +228,7 @@ export function StandaloneSelectableItem({ className={cn( 'group relative w-full text-left', 'flex items-center gap-3', - 'px-3 py-2.5 rounded-lg', + 'px-3 py-2.5 rounded-md', 'transition-all duration-150 ease-out', 'hover:bg-muted/50', isSelected && [ @@ -255,7 +255,7 @@ export function StandaloneSelectableItem({ {/* Icon */} <div className={cn( - 'flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center text-lg', + 'flex-shrink-0 w-9 h-9 rounded-md flex items-center justify-center text-lg', 'transition-colors duration-150', isSelected ? 'bg-amber-100 dark:bg-amber-900/40' : 'bg-muted/60 dark:bg-muted/40', 'group-hover:bg-muted dark:group-hover:bg-muted/60' diff --git a/apps/desktop/src/renderer/src/components/ui/sheet.tsx b/apps/desktop/src/renderer/src/components/ui/sheet.tsx index 6d0aa70ae..3ec188a88 100644 --- a/apps/desktop/src/renderer/src/components/ui/sheet.tsx +++ b/apps/desktop/src/renderer/src/components/ui/sheet.tsx @@ -66,7 +66,7 @@ function SheetContent({ {...props} > {children} - <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-1 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> + <SheetPrimitive.Close className="data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden disabled:pointer-events-none"> <XIcon className="size-4" /> <span className="sr-only">Close</span> </SheetPrimitive.Close> diff --git a/apps/desktop/src/renderer/src/components/ui/sidebar.tsx b/apps/desktop/src/renderer/src/components/ui/sidebar.tsx index 354e9aede..289cb1cf2 100644 --- a/apps/desktop/src/renderer/src/components/ui/sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/ui/sidebar.tsx @@ -255,7 +255,7 @@ function Sidebar({ <div data-sidebar="sidebar" data-slot="sidebar-inner" - className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" + className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-md group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" > {children} </div> @@ -464,7 +464,7 @@ function SidebarGroupLabel({ data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( - 'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-1 [&>svg]:size-4 [&>svg]:shrink-0', + 'text-sidebar-foreground/70 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear [&>svg]:size-4 [&>svg]:shrink-0', 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', className )} @@ -485,7 +485,7 @@ function SidebarGroupAction({ data-slot="sidebar-group-action" data-sidebar="group-action" className={cn( - 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-1 [&>svg]:size-4 [&>svg]:shrink-0', + 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform [&>svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 md:after:hidden', 'group-data-[collapsible=icon]:hidden', @@ -530,7 +530,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) { } const sidebarMenuButtonVariants = cva( - 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-1 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', { variants: { variant: { @@ -617,7 +617,7 @@ function SidebarMenuAction({ data-slot="sidebar-menu-action" data-sidebar="menu-action" className={cn( - 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-1 [&>svg]:size-4 [&>svg]:shrink-0', + 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform [&>svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 md:after:hidden', 'peer-data-[size=sm]/menu-button:top-1', @@ -731,7 +731,7 @@ function SidebarMenuSubButton({ data-size={size} data-active={isActive} className={cn( - 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', size === 'sm' && 'text-xs', size === 'md' && 'text-sm', diff --git a/apps/desktop/src/renderer/src/components/ui/slider.tsx b/apps/desktop/src/renderer/src/components/ui/slider.tsx index 25e8d9d83..610ce3854 100644 --- a/apps/desktop/src/renderer/src/components/ui/slider.tsx +++ b/apps/desktop/src/renderer/src/components/ui/slider.tsx @@ -15,7 +15,7 @@ const Slider = React.forwardRef< <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> <SliderPrimitive.Range className="absolute h-full bg-primary" /> </SliderPrimitive.Track> - <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> + <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" /> </SliderPrimitive.Root> )) Slider.displayName = SliderPrimitive.Root.displayName diff --git a/apps/desktop/src/renderer/src/components/ui/switch.tsx b/apps/desktop/src/renderer/src/components/ui/switch.tsx index a30dfe3a5..5f77eb42c 100644 --- a/apps/desktop/src/renderer/src/components/ui/switch.tsx +++ b/apps/desktop/src/renderer/src/components/ui/switch.tsx @@ -9,7 +9,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( <SwitchPrimitives.Root className={cn( - 'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input', + 'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input', className )} {...props} diff --git a/apps/desktop/src/renderer/src/components/ui/tabs.tsx b/apps/desktop/src/renderer/src/components/ui/tabs.tsx index 4db17ff07..d4db9318e 100644 --- a/apps/desktop/src/renderer/src/components/ui/tabs.tsx +++ b/apps/desktop/src/renderer/src/components/ui/tabs.tsx @@ -12,7 +12,7 @@ const TabsList = React.forwardRef< <TabsPrimitive.List ref={ref} className={cn( - 'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground', + 'inline-flex h-9 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground', className )} {...props} @@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef< <TabsPrimitive.Trigger ref={ref} className={cn( - 'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow', + 'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow', className )} {...props} @@ -41,10 +41,7 @@ const TabsContent = React.forwardRef< >(({ className, ...props }, ref) => ( <TabsPrimitive.Content ref={ref} - className={cn( - 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2', - className - )} + className={cn('mt-2 focus-visible:outline-none', className)} {...props} /> )) diff --git a/apps/desktop/src/renderer/src/components/ui/textarea.tsx b/apps/desktop/src/renderer/src/components/ui/textarea.tsx index 2fe59d958..323ddd7bd 100644 --- a/apps/desktop/src/renderer/src/components/ui/textarea.tsx +++ b/apps/desktop/src/renderer/src/components/ui/textarea.tsx @@ -7,7 +7,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'tex return ( <textarea className={cn( - 'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + 'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', className )} ref={ref} diff --git a/apps/desktop/src/renderer/src/components/ui/toast.tsx b/apps/desktop/src/renderer/src/components/ui/toast.tsx deleted file mode 100644 index fcc09de88..000000000 --- a/apps/desktop/src/renderer/src/components/ui/toast.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useEffect, useState } from 'react' -import { Check, X } from '@/lib/icons' - -import { cn } from '@/lib/utils' - -export interface Toast { - id: string - message: string - type?: 'success' | 'error' | 'info' - onUndo?: () => void - duration?: number -} - -interface ToastItemProps { - toast: Toast - onDismiss: (id: string) => void -} - -const ToastItem = ({ toast, onDismiss }: ToastItemProps): React.JSX.Element => { - const [isVisible, setIsVisible] = useState(false) - - useEffect(() => { - // Trigger enter animation - requestAnimationFrame(() => setIsVisible(true)) - - const timer = setTimeout(() => { - setIsVisible(false) - setTimeout(() => onDismiss(toast.id), 150) - }, toast.duration ?? 4000) - - return () => clearTimeout(timer) - }, [toast.id, onDismiss]) - - const handleUndo = (): void => { - if (toast.onUndo) { - toast.onUndo() - } - setIsVisible(false) - setTimeout(() => onDismiss(toast.id), 150) - } - - return ( - <div - className={cn( - 'flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg', - 'transition-[transform,opacity] duration-[var(--duration-normal)] ease-[var(--ease-out)]', - 'bg-foreground text-background', - isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0' - )} - role="alert" - aria-live="polite" - > - {/* Success icon */} - <div className="size-5 rounded-full bg-green-500 flex items-center justify-center shrink-0"> - <Check className="size-3 text-white" aria-hidden="true" /> - </div> - - {/* Message */} - <span className="text-sm font-medium flex-1">{toast.message}</span> - - {/* Undo button with hover scale effect */} - {toast.onUndo && ( - <button - type="button" - onClick={handleUndo} - className={cn( - 'text-sm font-medium text-background/70 underline underline-offset-2', - 'transition-[color,transform] duration-[var(--duration-instant)] ease-[var(--ease-out)]', - 'hover:text-background hover:scale-105 active:scale-95' - )} - > - Undo - </button> - )} - - {/* Close button */} - <button - type="button" - onClick={() => { - setIsVisible(false) - setTimeout(() => onDismiss(toast.id), 150) - }} - className={cn( - 'text-background/50 p-0.5', - 'transition-[color,transform] duration-[var(--duration-instant)] ease-[var(--ease-out)]', - 'hover:text-background hover:scale-110 active:scale-95' - )} - aria-label="Dismiss notification" - > - <X className="size-4" aria-hidden="true" /> - </button> - </div> - ) -} - -interface ToastContainerProps { - toasts: Toast[] - onDismiss: (id: string) => void -} - -const ToastContainer = ({ toasts, onDismiss }: ToastContainerProps): React.JSX.Element => { - return ( - <div - className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2 pointer-events-none" - aria-label="Notifications" - > - {toasts.map((toast) => ( - <div key={toast.id} className="pointer-events-auto"> - <ToastItem toast={toast} onDismiss={onDismiss} /> - </div> - ))} - </div> - ) -} - -export { ToastItem, ToastContainer } diff --git a/apps/desktop/src/renderer/src/components/ui/toggle.tsx b/apps/desktop/src/renderer/src/components/ui/toggle.tsx index 5fb6c611c..812ad58bd 100644 --- a/apps/desktop/src/renderer/src/components/ui/toggle.tsx +++ b/apps/desktop/src/renderer/src/components/ui/toggle.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const toggleVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", { variants: { variant: { diff --git a/apps/desktop/src/renderer/src/components/vault-onboarding.tsx b/apps/desktop/src/renderer/src/components/vault-onboarding.tsx index df42119cf..875a70ec1 100644 --- a/apps/desktop/src/renderer/src/components/vault-onboarding.tsx +++ b/apps/desktop/src/renderer/src/components/vault-onboarding.tsx @@ -57,14 +57,14 @@ export function VaultOnboarding() { {/* Features */} <div className="mt-6 grid grid-cols-2 gap-4"> - <div className="flex items-start gap-3 p-3 rounded-lg bg-gray-50"> + <div className="flex items-start gap-3 p-3 rounded-md bg-gray-50"> <FileText className="w-5 h-5 text-indigo-500 shrink-0 mt-0.5" /> <div> <p className="text-sm font-medium text-gray-900">Plain Markdown</p> <p className="text-xs text-gray-500">Your notes stay portable</p> </div> </div> - <div className="flex items-start gap-3 p-3 rounded-lg bg-gray-50"> + <div className="flex items-start gap-3 p-3 rounded-md bg-gray-50"> <Clock className="w-5 h-5 text-indigo-500 shrink-0 mt-0.5" /> <div> <p className="text-sm font-medium text-gray-900">Sync Anywhere</p> @@ -86,7 +86,7 @@ export function VaultOnboarding() { disabled={isLoading} className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors text-left group" > - <div className="flex items-center justify-center w-10 h-10 rounded-lg bg-gray-100 group-hover:bg-indigo-100 transition-colors"> + <div className="flex items-center justify-center w-10 h-10 rounded-md bg-gray-100 group-hover:bg-indigo-100 transition-colors"> <FolderOpen className="w-5 h-5 text-gray-500 group-hover:text-indigo-500 transition-colors" /> </div> <div className="flex-1 min-w-0"> diff --git a/apps/desktop/src/renderer/src/components/vault-switcher.tsx b/apps/desktop/src/renderer/src/components/vault-switcher.tsx index b54260723..3f79a197a 100644 --- a/apps/desktop/src/renderer/src/components/vault-switcher.tsx +++ b/apps/desktop/src/renderer/src/components/vault-switcher.tsx @@ -1,16 +1,7 @@ 'use client' -import { useState } from 'react' -import { - ChevronDown, - Plus, - Check, - FolderOpen, - Loader2, - LayoutTemplate, - Settings, - X -} from '@/lib/icons' +import { useState, useCallback } from 'react' +import { Plus, Check, Loader2, Settings, X, LogOut, Cloud } from '@/lib/icons' import { DropdownMenu, @@ -36,6 +27,7 @@ import { import { Button } from '@/components/ui/button' import { useVault, useVaultList } from '@/hooks/use-vault' import { useTabActions } from '@/contexts/tabs' +import { useAuth } from '@/contexts/auth-context' import type { VaultInfo } from '../../../preload/index.d' export function VaultSwitcher() { @@ -43,34 +35,44 @@ export function VaultSwitcher() { const { status, isLoading, selectVault, switchVault } = useVault() const { vaults, removeVault } = useVaultList() const { openTab } = useTabActions() + const { state: authState, logout } = useAuth() const [vaultToRemove, setVaultToRemove] = useState<VaultInfo | null>(null) + const isAuthenticated = authState.status === 'authenticated' + const currentVaultName = status?.path ? status.path.split('/').pop() || 'Vault' : 'No Vault Selected' - const handleSelectNewVault = async () => { + const handleSelectNewVault = useCallback(async () => { await selectVault() - } + }, [selectVault]) - const handleSwitchVault = async (path: string) => { - await switchVault(path) - } + const handleSwitchVault = useCallback( + async (path: string) => { + await switchVault(path) + }, + [switchVault] + ) - const handleOpenTemplates = () => { + const handleOpenSettings = useCallback(() => { openTab({ - type: 'templates', - title: 'Templates', - icon: 'layout-template', - path: '/templates', + type: 'settings', + title: 'Settings', + icon: 'settings', + path: '/settings', isPinned: false, isModified: false, isPreview: false, isDeleted: false }) - } + }, [openTab]) - const handleOpenSettings = () => { + const handleSignIn = useCallback(() => { + localStorage.setItem('memry_settings_section', 'account') + window.dispatchEvent( + new StorageEvent('storage', { key: 'memry_settings_section', newValue: 'account' }) + ) openTab({ type: 'settings', title: 'Settings', @@ -81,7 +83,11 @@ export function VaultSwitcher() { isPreview: false, isDeleted: false }) - } + }, [openTab]) + + const handleLogout = useCallback(async () => { + await logout() + }, [logout]) const handleRemoveClick = (e: React.MouseEvent, vault: VaultInfo): void => { e.stopPropagation() @@ -103,42 +109,40 @@ export function VaultSwitcher() { <DropdownMenuTrigger asChild> <SidebarMenuButton size="default" - className="rounded-md gap-2 h-auto py-1.5 px-2 hover:bg-sidebar-accent/50 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center" + className="rounded-[5px] gap-2 h-6 px-2 hover:bg-sidebar-accent/50 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:justify-center" > - <div className="flex aspect-square size-[22px] shrink-0 items-center justify-center rounded-[5px] bg-sidebar-terracotta text-white"> + <div className="flex aspect-square size-[16px] shrink-0 items-center justify-center rounded-[4px] bg-sidebar-terracotta text-white"> {isLoading ? ( <Loader2 className="size-3 animate-spin" /> ) : ( - <span className="text-white font-bold text-[11px] leading-none"> + <span className="text-white font-bold text-[8px] leading-none"> {currentVaultName.charAt(0).toUpperCase()} </span> )} </div> - <div className="grid flex-1 text-left leading-tight group-data-[collapsible=icon]:hidden"> - <span className="truncate text-[13px] font-semibold text-sidebar-primary tracking-[-0.01em] leading-4"> - {currentVaultName} - </span> - {status?.isIndexing && ( - <span className="text-xs text-sidebar-muted"> - Indexing... {status.indexProgress}% - </span> - )} - </div> - <ChevronDown className="ml-auto size-3.5 opacity-40 group-data-[collapsible=icon]:hidden" /> + <span className="truncate text-[12px] font-semibold text-sidebar-primary tracking-[-0.01em] leading-none group-data-[collapsible=icon]:hidden"> + {currentVaultName} + </span> </SidebarMenuButton> </DropdownMenuTrigger> <DropdownMenuContent - className="w-[--radix-dropdown-menu-trigger-width] min-w-64 rounded-xl p-2 shadow-lg" + onCloseAutoFocus={(e) => e.preventDefault()} + className="min-w-56 rounded-lg p-1 shadow-lg" align="start" side={isMobile ? 'bottom' : 'right'} sideOffset={8} > - {/* Vault list header */} - <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider"> - Vaults - </div> + {/* Email context (signed in only) */} + {isAuthenticated && authState.email && ( + <> + <div className="px-2.5 py-2 text-[11px] text-muted-foreground/60"> + {authState.email} + </div> + <DropdownMenuSeparator /> + </> + )} - {/* Current vault and other vaults */} + {/* Vault list */} {vaults.length > 0 ? ( vaults.map((vault) => { const isActive = status?.path === vault.path @@ -146,75 +150,74 @@ export function VaultSwitcher() { <DropdownMenuItem key={vault.path} onClick={() => !isActive && handleSwitchVault(vault.path)} - className="group/vault rounded-lg cursor-pointer hover:bg-accent focus:bg-accent transition-colors" + className={`group/vault gap-2.5 rounded-[5px] cursor-pointer ${isActive ? 'bg-accent' : ''}`} > - <div className="flex size-7 items-center justify-center rounded-md border border-border bg-card"> - <FolderOpen className="size-4 shrink-0 text-sidebar-terracotta" /> - </div> - <div className="flex-1 min-w-0"> - <span className="font-medium text-foreground block truncate"> - {vault.name} - </span> - <span className="text-xs text-muted-foreground block truncate"> - {vault.noteCount} notes - </span> - </div> - {isActive ? ( - <Check className="size-4 text-sidebar-terracotta shrink-0" /> - ) : ( + <Check + className={`size-3.5 shrink-0 ${isActive ? 'text-sidebar-terracotta opacity-100' : 'opacity-0'}`} + /> + <span + className={`flex-1 truncate text-[13px] ${isActive ? 'font-medium' : 'text-muted-foreground'}`} + > + {vault.name} + </span> + {!isActive && ( <button onClick={(e) => handleRemoveClick(e, vault)} - className="size-6 flex items-center justify-center rounded-md opacity-0 group-hover/vault:opacity-100 hover:bg-accent transition-all" + className="size-5 flex items-center justify-center rounded opacity-0 group-hover/vault:opacity-100 hover:bg-accent transition-all" aria-label={`Remove ${vault.name} from list`} > - <X className="size-3.5 text-muted-foreground" /> + <X className="size-3 text-muted-foreground" /> </button> )} </DropdownMenuItem> ) }) ) : ( - <div className="px-2 py-3 text-sm text-muted-foreground text-center"> + <div className="px-2.5 py-2 text-[13px] text-muted-foreground text-center"> No vaults yet </div> )} - <DropdownMenuSeparator className="my-2 -mx-2" /> + <DropdownMenuSeparator /> - {/* Select new vault */} + {/* Actions */} <DropdownMenuItem onClick={handleSelectNewVault} - className="rounded-lg cursor-pointer hover:bg-accent focus:bg-accent transition-colors" + className="gap-2.5 rounded-[5px] cursor-pointer" > - <div className="flex size-7 items-center justify-center"> - <Plus className="size-4 text-muted-foreground" /> - </div> - <span className="text-muted-foreground">Open Another Vault</span> + <Plus className="size-3.5 text-muted-foreground" /> + <span className="text-muted-foreground text-[13px]">Open vault</span> </DropdownMenuItem> - - <DropdownMenuSeparator className="my-2 -mx-2" /> - - {/* Templates */} - <DropdownMenuItem - onClick={handleOpenTemplates} - className="rounded-lg cursor-pointer hover:bg-accent focus:bg-accent transition-colors" - > - <div className="flex size-7 items-center justify-center"> - <LayoutTemplate className="size-4 text-muted-foreground" /> - </div> - <span className="text-muted-foreground">Templates</span> - </DropdownMenuItem> - - {/* Settings */} <DropdownMenuItem onClick={handleOpenSettings} - className="rounded-lg cursor-pointer hover:bg-accent focus:bg-accent transition-colors" + className="gap-2.5 rounded-[5px] cursor-pointer" > - <div className="flex size-7 items-center justify-center"> - <Settings className="size-4 text-muted-foreground" /> - </div> - <span className="text-muted-foreground">Settings</span> + <Settings className="size-3.5 text-muted-foreground" /> + <span className="text-muted-foreground text-[13px]">Settings</span> </DropdownMenuItem> + + <DropdownMenuSeparator /> + + {/* Auth action */} + {isAuthenticated ? ( + <DropdownMenuItem + onClick={() => void handleLogout()} + className="gap-2.5 rounded-[5px] cursor-pointer" + > + <LogOut className="size-3.5 text-muted-foreground" /> + <span className="text-muted-foreground text-[13px]">Log out</span> + </DropdownMenuItem> + ) : ( + <DropdownMenuItem + onClick={handleSignIn} + className="gap-2.5 rounded-[5px] cursor-pointer" + > + <Cloud className="size-3.5 text-sidebar-terracotta" /> + <span className="text-sidebar-terracotta font-medium text-[13px]"> + Sign in to sync + </span> + </DropdownMenuItem> + )} </DropdownMenuContent> </DropdownMenu> </SidebarMenuItem> @@ -228,7 +231,7 @@ export function VaultSwitcher() { </AlertDialogTitle> <AlertDialogDescription> This vault will be removed from the app, but your files will remain on disk. You can - always re-add it later using “Open Another Vault”. + always re-add it later. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> diff --git a/apps/desktop/src/renderer/src/components/viewers/audio-player.tsx b/apps/desktop/src/renderer/src/components/viewers/audio-player.tsx index 7f8693815..3a1e9be9d 100644 --- a/apps/desktop/src/renderer/src/components/viewers/audio-player.tsx +++ b/apps/desktop/src/renderer/src/components/viewers/audio-player.tsx @@ -130,7 +130,7 @@ export function AudioPlayer({ src, fileName = 'Audio', className }: AudioPlayerP if (error) { return ( <div - className={cn('flex h-full items-center justify-center bg-muted/30 rounded-lg', className)} + className={cn('flex h-full items-center justify-center bg-muted/30 rounded-md', className)} > <div className="text-center p-8"> <p className="text-destructive font-medium mb-2">Failed to load audio</p> diff --git a/apps/desktop/src/renderer/src/components/viewers/image-viewer.tsx b/apps/desktop/src/renderer/src/components/viewers/image-viewer.tsx index f396fadfe..46b8db35d 100644 --- a/apps/desktop/src/renderer/src/components/viewers/image-viewer.tsx +++ b/apps/desktop/src/renderer/src/components/viewers/image-viewer.tsx @@ -136,7 +136,7 @@ export function ImageViewer({ src, alt = 'Image', className }: ImageViewerProps) if (error) { return ( <div - className={cn('flex h-full items-center justify-center bg-muted/30 rounded-lg', className)} + className={cn('flex h-full items-center justify-center bg-muted/30 rounded-md', className)} > <div className="text-center p-8"> <p className="text-destructive font-medium mb-2">Failed to load image</p> diff --git a/apps/desktop/src/renderer/src/components/viewers/pdf-viewer.tsx b/apps/desktop/src/renderer/src/components/viewers/pdf-viewer.tsx index 0b9fd41a7..559dfef3e 100644 --- a/apps/desktop/src/renderer/src/components/viewers/pdf-viewer.tsx +++ b/apps/desktop/src/renderer/src/components/viewers/pdf-viewer.tsx @@ -97,7 +97,7 @@ export function PdfViewer({ src, className }: PdfViewerProps) { if (error) { return ( <div - className={cn('flex h-full items-center justify-center bg-muted/30 rounded-lg', className)} + className={cn('flex h-full items-center justify-center bg-muted/30 rounded-md', className)} > <div className="text-center p-8"> <p className="text-destructive font-medium mb-2">Failed to load PDF</p> diff --git a/apps/desktop/src/renderer/src/components/viewers/video-player.tsx b/apps/desktop/src/renderer/src/components/viewers/video-player.tsx index c4f79c83e..43925dba4 100644 --- a/apps/desktop/src/renderer/src/components/viewers/video-player.tsx +++ b/apps/desktop/src/renderer/src/components/viewers/video-player.tsx @@ -188,7 +188,7 @@ export function VideoPlayer({ src, className }: VideoPlayerProps) { if (error) { return ( <div - className={cn('flex h-full items-center justify-center bg-muted/30 rounded-lg', className)} + className={cn('flex h-full items-center justify-center bg-muted/30 rounded-md', className)} > <div className="text-center p-8"> <p className="text-destructive font-medium mb-2">Failed to load video</p> diff --git a/apps/desktop/src/renderer/src/components/virtualized-notes-tree.tsx b/apps/desktop/src/renderer/src/components/virtualized-notes-tree.tsx index 7b73df813..0d2f2fe05 100644 --- a/apps/desktop/src/renderer/src/components/virtualized-notes-tree.tsx +++ b/apps/desktop/src/renderer/src/components/virtualized-notes-tree.tsx @@ -14,8 +14,6 @@ import { FileText, Folder, FolderOpen, - ChevronRight, - ChevronDown, LayoutGrid, FilePlus, FolderPlus, @@ -47,6 +45,9 @@ import { ContextMenuTrigger } from '@/components/ui/context-menu' import { getTabIconForFileType, type FileType } from '@memry/shared/file-types' +import { NoteIconDisplay } from '@/lib/render-note-icon' +import { FolderIconButton } from '@/components/folder-icon-button' +import { Smile } from '@/lib/icons' // ============================================================================ // Types @@ -99,6 +100,8 @@ interface VirtualizedNotesTreeProps { onClearFolderTemplate?: (folderPath: string) => void /** Map of folder paths to template names */ folderTemplateNames?: Map<string, string> + /** Callback when setting folder icon */ + onSetFolderIcon?: (folderPath: string, icon: string | null) => void /** Map of note IDs to notes for quick lookup */ noteMap: Map<string, NoteListItem> /** Whether drag operations are disabled */ @@ -128,13 +131,9 @@ function getDisplayName(notePath: string): string { * Returns the icon element to render in the tree. */ function getFileIcon(note: NoteListItem): React.ReactElement { - // Emoji takes priority for markdown files + // Emoji/icon takes priority for markdown files if (note.emoji) { - return ( - <span className="text-sm leading-none shrink-0" role="img" aria-label="note icon"> - {note.emoji} - </span> - ) + return <NoteIconDisplay value={note.emoji} className="text-sm leading-none shrink-0" /> } // Get icon based on file type @@ -216,6 +215,9 @@ interface FolderRowProps { onDeleteFolder?: (folderPath: string) => void onSetFolderTemplate?: (folderPath: string) => void onClearFolderTemplate?: (folderPath: string) => void + onSetFolderIcon?: (folderPath: string, icon: string | null) => void + iconPickerFolderPath?: string | null + onIconPickerOpenChange?: (folderPath: string | null) => void onBulkDelete?: () => void onDragStart: (e: React.DragEvent, itemId: string) => void onDragEnd: () => void @@ -243,6 +245,9 @@ function FolderRow({ onDeleteFolder, onSetFolderTemplate, onClearFolderTemplate, + onSetFolderIcon, + iconPickerFolderPath, + onIconPickerOpenChange, onBulkDelete, onDragStart, onDragEnd, @@ -316,8 +321,8 @@ function FolderRow({ tabIndex={0} draggable={draggable} className={cn( - 'group/folder relative flex items-center gap-1 px-2 py-1 cursor-pointer rounded-sm transition-colors min-w-0', - 'hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'group/folder group/folderrow relative flex items-center gap-1 px-2 py-1 cursor-pointer rounded-sm transition-colors min-w-0', + 'hover:bg-muted/50 focus-visible:outline-none', isSelected && 'bg-sidebar-accent text-sidebar-accent-foreground', isDragging && 'opacity-50', draggable && 'cursor-default' @@ -355,30 +360,16 @@ function FolderRow({ /> )} - {/* Expand/Collapse button */} - <button - type="button" - className="p-0.5 hover:bg-muted rounded-sm" - onClick={handleExpandClick} - aria-label={item.isExpanded ? 'Collapse folder' : 'Expand folder'} - > - {item.hasChildren ? ( - item.isExpanded ? ( - <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> - ) : ( - <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" /> - ) - ) : ( - <span className="w-3.5" /> - )} - </button> - - {/* Folder icon */} - {item.isExpanded ? ( - <FolderOpen className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" /> - ) : ( - <Folder className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" /> - )} + {/* Folder icon — shows chevron on hover for expand/collapse */} + <FolderIconButton + icon={item.folder.icon ?? null} + isExpanded={item.isExpanded} + hasChildren={item.hasChildren} + onIconChange={(icon) => onSetFolderIcon?.(item.folder.path, icon)} + onToggleExpand={() => onToggleExpand(item.id)} + pickerOpen={iconPickerFolderPath === item.folder.path} + onPickerOpenChange={(open) => onIconPickerOpenChange?.(open ? item.folder.path : null)} + /> {/* Folder name */} <span className="text-sm truncate flex-1">{item.folder.name}</span> @@ -439,6 +430,17 @@ function FolderRow({ Clear Default Template </ContextMenuItem> <ContextMenuSeparator /> + <ContextMenuItem onClick={() => onIconPickerOpenChange?.(item.folder.path)}> + <Smile className="mr-2 h-4 w-4" /> + Set Icon + </ContextMenuItem> + {item.folder.icon && ( + <ContextMenuItem onClick={() => onSetFolderIcon?.(item.folder.path, null)}> + <X className="mr-2 h-4 w-4" /> + Remove Icon + </ContextMenuItem> + )} + <ContextMenuSeparator /> <ContextMenuItem onClick={() => onRenameFolder?.(item.folder.path)}> <Pencil className="mr-2 h-4 w-4" /> Rename @@ -553,7 +555,7 @@ function NoteRow({ draggable={draggable} className={cn( 'group/note relative flex items-center gap-1 px-2 py-1 cursor-pointer rounded-sm transition-colors', - 'hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'hover:bg-muted/50 focus-visible:outline-none', isSelected && 'bg-sidebar-accent text-sidebar-accent-foreground', isDragging && 'opacity-50', draggable && 'cursor-default' @@ -662,6 +664,7 @@ export function VirtualizedNotesTree({ onSetFolderTemplate, onClearFolderTemplate, folderTemplateNames, + onSetFolderIcon, noteMap, isDragDisabled = false, className, @@ -682,6 +685,9 @@ export function VirtualizedNotesTree({ dropPosition: null }) + // Folder icon picker state + const [iconPickerFolderPath, setIconPickerFolderPath] = useState<string | null>(null) + // Anchor for shift-click range selection const [anchorId, setAnchorId] = useState<string | null>(null) @@ -1004,6 +1010,9 @@ export function VirtualizedNotesTree({ onDeleteFolder={onDeleteFolder} onSetFolderTemplate={onSetFolderTemplate} onClearFolderTemplate={onClearFolderTemplate} + onSetFolderIcon={(path, icon) => onSetFolderIcon?.(path, icon)} + iconPickerFolderPath={iconPickerFolderPath} + onIconPickerOpenChange={setIconPickerFolderPath} onBulkDelete={onBulkDelete} onDragStart={handleDragStart} onDragEnd={handleDragEnd} diff --git a/apps/desktop/src/renderer/src/components/voice-recorder.tsx b/apps/desktop/src/renderer/src/components/voice-recorder.tsx index cc0c3e03e..d6b0325d8 100644 --- a/apps/desktop/src/renderer/src/components/voice-recorder.tsx +++ b/apps/desktop/src/renderer/src/components/voice-recorder.tsx @@ -1,16 +1,3 @@ -/** - * Voice Recorder Component - * - * A component for recording voice memos using the Web MediaRecorder API. - * Features: - * - Permission request handling with settings link - * - Recording timer with max duration enforcement (5 minutes) - * - Stop and cancel controls - * - WebM audio output - * - * @module components/voice-recorder - */ - import { useState, useRef, useCallback, useEffect } from 'react' import { Mic, Square, X, Loader2, Settings, AlertCircle } from '@/lib/icons' import { Button } from '@/components/ui/button' @@ -20,48 +7,34 @@ import { createLogger } from '@/lib/logger' const log = createLogger('Component:VoiceRecorder') -// ============================================================================ -// Types -// ============================================================================ - type RecordingState = 'idle' | 'requesting-permission' | 'recording' | 'processing' interface VoiceRecorderProps { - /** Called when recording is complete with the audio blob and duration */ onRecordingComplete: (audioBlob: Blob, duration: number) => void - /** Called when recording is cancelled */ onCancel: () => void - /** Maximum recording duration in seconds (default: 300 = 5 minutes) */ maxDuration?: number - /** Start recording immediately on mount */ autoStart?: boolean - /** Additional CSS classes */ className?: string } -// ============================================================================ -// Constants -// ============================================================================ - -const DEFAULT_MAX_DURATION = 300 // 5 minutes +const DEFAULT_MAX_DURATION = 300 const MIME_TYPE = 'audio/webm' +const WAVEFORM_BAR_COUNT = 40 +const MIN_BAR_HEIGHT = 4 +const MAX_BAR_HEIGHT = 28 -// ============================================================================ -// Helpers -// ============================================================================ - -/** - * Format seconds as MM:SS - */ function formatTime(seconds: number): string { const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) return `${mins}:${secs.toString().padStart(2, '0')}` } -// ============================================================================ -// Component -// ============================================================================ +function getBarOpacity(index: number, total: number): number { + const position = index / total + if (position > 0.85) return 0.15 + if (position > 0.7) return 0.3 + return 0.4 + position * 0.6 +} export function VoiceRecorder({ onRecordingComplete, @@ -74,6 +47,9 @@ export function VoiceRecorder({ const [duration, setDuration] = useState(0) const [error, setError] = useState<string | null>(null) const [permissionDenied, setPermissionDenied] = useState(false) + const [waveformBars, setWaveformBars] = useState<number[]>(() => + Array.from({ length: WAVEFORM_BAR_COUNT }, () => MIN_BAR_HEIGHT) + ) const mediaRecorderRef = useRef<MediaRecorder | null>(null) const streamRef = useRef<MediaStream | null>(null) @@ -81,59 +57,119 @@ export function VoiceRecorder({ const timerRef = useRef<number | null>(null) const startTimeRef = useRef<number>(0) - // Cleanup on unmount + const audioContextRef = useRef<AudioContext | null>(null) + const analyserRef = useRef<AnalyserNode | null>(null) + const rafRef = useRef<number | null>(null) + const barsRef = useRef<number[]>(Array.from({ length: WAVEFORM_BAR_COUNT }, () => MIN_BAR_HEIGHT)) + + const cleanupAudio = useCallback(() => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + if (audioContextRef.current) { + void audioContextRef.current.close().catch(() => {}) + audioContextRef.current = null + } + analyserRef.current = null + }, []) + + const startWaveformAnalysis = useCallback((stream: MediaStream) => { + try { + const audioContext = new AudioContext() + const source = audioContext.createMediaStreamSource(stream) + const analyser = audioContext.createAnalyser() + analyser.fftSize = 2048 + source.connect(analyser) + + audioContextRef.current = audioContext + analyserRef.current = analyser + + const bufferLength = analyser.fftSize + const dataArray = new Uint8Array(bufferLength) + let lastUpdateTime = 0 + const UPDATE_INTERVAL = 50 + + const updateBars = (timestamp: number) => { + if (!analyserRef.current) return + + analyserRef.current.getByteTimeDomainData(dataArray) + + let sum = 0 + for (let i = 0; i < bufferLength; i++) { + const amplitude = (dataArray[i] - 128) / 128 + sum += amplitude * amplitude + } + const rms = Math.sqrt(sum / bufferLength) + + const SENSITIVITY = 4.0 + const normalized = Math.min(rms * SENSITIVITY, 1) + const height = MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT) + + if (timestamp - lastUpdateTime >= UPDATE_INTERVAL) { + const next = [...barsRef.current.slice(1), height] + barsRef.current = next + setWaveformBars(next) + lastUpdateTime = timestamp + } + + rafRef.current = requestAnimationFrame(updateBars) + } + + rafRef.current = requestAnimationFrame(updateBars) + } catch (err) { + log.error('Failed to start waveform analysis', err) + } + }, []) + useEffect(() => { return () => { stopRecording(true) + cleanupAudio() } }, []) - // Auto-start recording on mount useEffect(() => { if (autoStart && state === 'idle') { void startRecording() } }, [autoStart]) - /** - * Stop recording and clean up resources - */ - const stopRecording = useCallback((cancelled = false) => { - // Clear timer - if (timerRef.current) { - clearInterval(timerRef.current) - timerRef.current = null - } + const stopRecording = useCallback( + (cancelled = false) => { + if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null + } - // Stop media recorder - if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { - mediaRecorderRef.current.stop() - } + if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { + mediaRecorderRef.current.stop() + } - // Stop all tracks - if (streamRef.current) { - streamRef.current.getTracks().forEach((track) => track.stop()) - streamRef.current = null - } + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()) + streamRef.current = null + } - // If cancelled, don't process the audio - if (cancelled) { - chunksRef.current = [] - setState('idle') - setDuration(0) - } - }, []) + cleanupAudio() + + if (cancelled) { + chunksRef.current = [] + setState('idle') + setDuration(0) + setWaveformBars(Array.from({ length: WAVEFORM_BAR_COUNT }, () => MIN_BAR_HEIGHT)) + barsRef.current = Array.from({ length: WAVEFORM_BAR_COUNT }, () => MIN_BAR_HEIGHT) + } + }, + [cleanupAudio] + ) - /** - * Start recording - */ const startRecording = useCallback(async () => { setError(null) setPermissionDenied(false) setState('requesting-permission') try { - // Request microphone permission const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, @@ -145,33 +181,26 @@ export function VoiceRecorder({ streamRef.current = stream chunksRef.current = [] - // Create MediaRecorder const mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported(MIME_TYPE) ? MIME_TYPE : 'audio/webm' }) mediaRecorderRef.current = mediaRecorder - // Handle data available mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { chunksRef.current.push(event.data) } } - // Handle recording stop mediaRecorder.onstop = () => { if (chunksRef.current.length > 0) { setState('processing') - // Create blob from chunks const blob = new Blob(chunksRef.current, { type: MIME_TYPE }) const finalDuration = (Date.now() - startTimeRef.current) / 1000 - // Clean up chunksRef.current = [] - - // Notify parent onRecordingComplete(blob, finalDuration) setState('idle') @@ -182,25 +211,23 @@ export function VoiceRecorder({ } } - // Handle errors mediaRecorder.onerror = (event) => { log.error('MediaRecorder error', event) setError('Recording error occurred') stopRecording(true) } - // Start recording mediaRecorder.start() startTimeRef.current = Date.now() setState('recording') setDuration(0) - // Start duration timer + startWaveformAnalysis(stream) + timerRef.current = window.setInterval(() => { const elapsed = (Date.now() - startTimeRef.current) / 1000 setDuration(elapsed) - // Auto-stop at max duration if (elapsed >= maxDuration) { stopRecording(false) } @@ -223,35 +250,22 @@ export function VoiceRecorder({ setState('idle') } - }, [maxDuration, onRecordingComplete, stopRecording]) + }, [maxDuration, onRecordingComplete, stopRecording, startWaveformAnalysis]) - /** - * Handle stop button click - */ const handleStop = useCallback(() => { stopRecording(false) }, [stopRecording]) - /** - * Handle cancel button click - */ const handleCancel = useCallback(() => { stopRecording(true) onCancel() }, [stopRecording, onCancel]) - /** - * Open system settings (platform-specific) - */ const openSettings = useCallback(() => { - // On Electron, we can't directly open system settings, - // but we can show instructions setError('Please enable microphone access in your system settings, then try again.') }, []) - // Render based on state if (state === 'idle' && !error) { - // Initial state - show start button return ( <Button variant="ghost" @@ -269,7 +283,7 @@ export function VoiceRecorder({ return ( <div className={cn( - 'flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50', + 'flex items-center gap-2 px-3 py-2 rounded-md bg-muted/50', 'text-sm text-muted-foreground', className )} @@ -284,7 +298,7 @@ export function VoiceRecorder({ return ( <div className={cn( - 'flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10', + 'flex items-center gap-2 px-3 py-2 rounded-md bg-destructive/10', 'text-sm', className )} @@ -313,7 +327,7 @@ export function VoiceRecorder({ return ( <div className={cn( - 'flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50', + 'flex items-center gap-2 px-3 py-2 rounded-md bg-muted/50', 'text-sm text-muted-foreground', className )} @@ -324,55 +338,60 @@ export function VoiceRecorder({ ) } - // Recording state return ( <div className={cn( - 'flex items-center gap-3 px-3 py-2 rounded-lg', - 'bg-red-500/10 border border-red-500/20', + 'flex items-center gap-3 rounded-[10px] py-2.5 px-3.5', + 'bg-muted-foreground/[0.04] border border-muted-foreground/15', className )} > - {/* Recording indicator */} - <div className="flex items-center gap-2"> - <div className="size-2 rounded-full bg-red-500 animate-pulse" aria-hidden="true" /> - <span className="text-sm font-medium text-red-600 dark:text-red-400">Recording</span> + <div className="flex items-center justify-center shrink-0 size-2.5"> + <div className="rounded-sm bg-muted-foreground shrink-0 size-2 animate-pulse" /> </div> - {/* Timer */} - <div className="text-sm text-muted-foreground tabular-nums"> - {formatTime(duration)} / {formatTime(maxDuration)} + <div className="shrink-0 w-11 font-mono font-medium text-sm/[18px] text-foreground tabular-nums"> + {formatTime(duration)} </div> - {/* Progress bar */} - <div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden"> - <div - className="h-full bg-red-500 transition-all duration-100" - style={{ width: `${Math.min((duration / maxDuration) * 100, 100)}%` }} - /> + <div className="flex items-center grow h-7 gap-0.5"> + {waveformBars.map((height, i) => ( + <div + key={i} + className="w-0.5 rounded-[1px] bg-muted-foreground shrink-0 transition-[height] duration-75" + style={{ + height: `${height}px`, + opacity: getBarOpacity(i, WAVEFORM_BAR_COUNT) + }} + /> + ))} </div> - {/* Stop button */} - <Button - variant="ghost" - size="icon" - onClick={handleStop} - className="h-8 w-8 text-red-600 dark:text-red-400 hover:bg-red-500/10" - aria-label="Stop recording" - > - <Square className="size-4 fill-current" /> - </Button> - - {/* Cancel button */} - <Button - variant="ghost" - size="icon" + <button onClick={handleCancel} - className="h-8 w-8 text-muted-foreground hover:text-foreground" + className={cn( + 'flex items-center shrink-0 rounded-md py-1 px-2.5 gap-1', + 'border border-border/50 text-muted-foreground', + 'hover:bg-muted/50 transition-colors' + )} aria-label="Cancel recording" > - <X className="size-4" /> - </Button> + <X className="size-3" /> + <span className="text-[11px]/3.5 font-normal">Cancel</span> + </button> + + <button + onClick={handleStop} + className={cn( + 'flex items-center shrink-0 rounded-md py-1 px-3 gap-1.5', + 'bg-foreground text-background', + 'hover:bg-foreground/90 transition-colors' + )} + aria-label="Stop recording" + > + <Square className="size-2.5 fill-current" /> + <span className="text-[11px]/3.5 font-medium">Stop</span> + </button> </div> ) } diff --git a/apps/desktop/src/renderer/src/contexts/auth-context.tsx b/apps/desktop/src/renderer/src/contexts/auth-context.tsx index 0aa78d295..fa12a7d2b 100644 --- a/apps/desktop/src/renderer/src/contexts/auth-context.tsx +++ b/apps/desktop/src/renderer/src/contexts/auth-context.tsx @@ -113,7 +113,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { ...WIZARD_IDLE_FIELDS } case 'CHECK_UNAUTHENTICATED': - return { ...state, status: 'unauthenticated', error: null } + return { ...state, status: 'unauthenticated', wizardStep: 'sign-in', error: null } case 'SET_AUTHENTICATING': return { ...state, status: 'authenticating', error: null } case 'OTP_REQUESTED': @@ -161,7 +161,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { status: state.status === 'error' ? 'unauthenticated' : state.status } case 'RESET_AUTH': - return { ...initialState, status: 'unauthenticated' } + return { ...initialState, status: 'unauthenticated', wizardStep: 'sign-in' } case 'WIZARD_SET_STEP': return { ...state, diff --git a/apps/desktop/src/renderer/src/data/tasks-data.ts b/apps/desktop/src/renderer/src/data/tasks-data.ts index 3e7b95ced..ee152108c 100644 --- a/apps/desktop/src/renderer/src/data/tasks-data.ts +++ b/apps/desktop/src/renderer/src/data/tasks-data.ts @@ -46,12 +46,11 @@ export interface TaskView { // VIEW MODE TYPES // ============================================================================ -export type ViewMode = 'list' | 'kanban' | 'calendar' +export type ViewMode = 'list' | 'kanban' export const viewModes: { id: ViewMode; label: string }[] = [ { id: 'list', label: 'List' }, - { id: 'kanban', label: 'Kanban' }, - { id: 'calendar', label: 'Calendar' } + { id: 'kanban', label: 'Kanban' } ] export const LIST_ONLY_VIEWS = ['today', 'completed'] diff --git a/apps/desktop/src/renderer/src/hooks/index.ts b/apps/desktop/src/renderer/src/hooks/index.ts index 92352a95d..d61ca3b49 100644 --- a/apps/desktop/src/renderer/src/hooks/index.ts +++ b/apps/desktop/src/renderer/src/hooks/index.ts @@ -40,6 +40,7 @@ export * from './use-new-note-shortcut' // Undo export * from './use-undo' +export * from './use-undoable-task-actions' // Bookmarks export * from './use-bookmarks' diff --git a/apps/desktop/src/renderer/src/hooks/use-account-info.ts b/apps/desktop/src/renderer/src/hooks/use-account-info.ts new file mode 100644 index 000000000..111e3faa5 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-account-info.ts @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react' +import { extractErrorMessage } from '@/lib/ipc-error' + +export interface AccountInfo { + email: string | null + joinedAt: number | null +} + +interface UseAccountInfoReturn { + accountInfo: AccountInfo | null + isLoading: boolean + error: string | null + refresh: () => void +} + +export function useAccountInfo(): UseAccountInfoReturn { + const [accountInfo, setAccountInfo] = useState<AccountInfo | null>(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState<string | null>(null) + const [refreshKey, setRefreshKey] = useState(0) + + useEffect(() => { + let mounted = true + const load = async (): Promise<void> => { + try { + setIsLoading(true) + setError(null) + const result = await window.api.account.getInfo() + if (mounted) setAccountInfo(result) + } catch (err) { + if (mounted) setError(extractErrorMessage(err, 'Failed to load account info')) + } finally { + if (mounted) setIsLoading(false) + } + } + void load() + return () => { + mounted = false + } + }, [refreshKey]) + + return { + accountInfo, + isLoading, + error, + refresh: () => setRefreshKey((k) => k + 1) + } +} diff --git a/apps/desktop/src/renderer/src/hooks/use-bulk-actions.test.tsx b/apps/desktop/src/renderer/src/hooks/use-bulk-actions.test.tsx index 5011af60c..91fb65c6e 100644 --- a/apps/desktop/src/renderer/src/hooks/use-bulk-actions.test.tsx +++ b/apps/desktop/src/renderer/src/hooks/use-bulk-actions.test.tsx @@ -744,4 +744,223 @@ describe('useBulkActions', () => { expect(mockOnDeleteTask).not.toHaveBeenCalled() }) }) + + // ========================================================================== + // UNDO INTEGRATION (Cmd+Z) + // ========================================================================== + + describe('undo integration', () => { + let mockRegisterUndo: ReturnType<typeof vi.fn> + let mockOnAddTask: ReturnType<typeof vi.fn> + + beforeEach(() => { + mockRegisterUndo = vi.fn().mockReturnValue('undo-bulk-1') + mockOnAddTask = vi.fn() + }) + + const renderBulkWithUndo = (selectedIds: string[] = ['task-1', 'task-2']) => { + return renderHook( + () => + useBulkActions({ + selectedIds, + tasks: mockTasks, + projects: [mockProject], + onUpdateTask: mockOnUpdateTask, + onDeleteTask: mockOnDeleteTask, + onComplete: mockOnComplete, + registerUndo: mockRegisterUndo, + onAddTask: mockOnAddTask + }), + { wrapper } + ) + } + + it('bulkComplete should call registerUndo', async () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + await act(async () => { + await result.current.bulkComplete() + }) + + expect(mockRegisterUndo).toHaveBeenCalledWith( + expect.stringContaining('2'), + expect.any(Function) + ) + }) + + it('bulkComplete undo should restore original states', async () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + await act(async () => { + await result.current.bulkComplete() + }) + + // #when — execute undo + mockOnUpdateTask.mockClear() + const undoFn = mockRegisterUndo.mock.calls[0][1] + undoFn() + + expect(mockOnUpdateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ statusId: 'todo-status' }) + ) + expect(mockOnUpdateTask).toHaveBeenCalledWith( + 'task-2', + expect.objectContaining({ statusId: 'todo-status' }) + ) + }) + + it('bulkDelete should call registerUndo', async () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + await act(async () => { + await result.current.bulkDelete() + }) + + expect(mockRegisterUndo).toHaveBeenCalledWith( + expect.stringContaining('2'), + expect.any(Function) + ) + }) + + it('bulkDelete undo should re-create all deleted tasks', async () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + await act(async () => { + await result.current.bulkDelete() + }) + + // #when — execute undo + const undoFn = mockRegisterUndo.mock.calls[0][1] + undoFn() + + expect(mockOnAddTask).toHaveBeenCalledWith(expect.objectContaining({ id: 'task-1' })) + expect(mockOnAddTask).toHaveBeenCalledWith(expect.objectContaining({ id: 'task-2' })) + }) + + it('bulkArchive should call registerUndo', async () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + await act(async () => { + await result.current.bulkArchive() + }) + + expect(mockRegisterUndo).toHaveBeenCalledWith( + expect.stringContaining('2'), + expect.any(Function) + ) + }) + + it('bulkArchive undo should unarchive all', async () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + await act(async () => { + await result.current.bulkArchive() + }) + + // #when — undo + mockOnUpdateTask.mockClear() + const undoFn = mockRegisterUndo.mock.calls[0][1] + undoFn() + + expect(mockOnUpdateTask).toHaveBeenCalledWith('task-1', { archivedAt: null }) + expect(mockOnUpdateTask).toHaveBeenCalledWith('task-2', { archivedAt: null }) + }) + + it('bulkChangePriority should call registerUndo', () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + act(() => { + result.current.bulkChangePriority('high') + }) + + expect(mockRegisterUndo).toHaveBeenCalledWith( + expect.stringContaining('Priority'), + expect.any(Function) + ) + }) + + it('bulkChangePriority undo should restore original priorities', () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + act(() => { + result.current.bulkChangePriority('high') + }) + + // #when — undo + mockOnUpdateTask.mockClear() + const undoFn = mockRegisterUndo.mock.calls[0][1] + undoFn() + + expect(mockOnUpdateTask).toHaveBeenCalledWith('task-1', { priority: 'none' }) + expect(mockOnUpdateTask).toHaveBeenCalledWith('task-2', { priority: 'none' }) + }) + + it('bulkChangeDueDate should call registerUndo', () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + act(() => { + result.current.bulkChangeDueDate(new Date('2026-04-01')) + }) + + expect(mockRegisterUndo).toHaveBeenCalledWith( + expect.stringContaining('Due date'), + expect.any(Function) + ) + }) + + it('bulkMoveToProject should call registerUndo', async () => { + const targetProject = createMockProject({ id: 'project-2', name: 'Target' }) + const { result } = renderHook( + () => + useBulkActions({ + selectedIds: ['task-1', 'task-2'], + tasks: mockTasks, + projects: [mockProject, targetProject], + onUpdateTask: mockOnUpdateTask, + onDeleteTask: mockOnDeleteTask, + onComplete: mockOnComplete, + registerUndo: mockRegisterUndo, + onAddTask: mockOnAddTask + }), + { wrapper } + ) + + await act(async () => { + await result.current.bulkMoveToProject('project-2') + }) + + expect(mockRegisterUndo).toHaveBeenCalledWith( + expect.stringContaining('Move'), + expect.any(Function) + ) + }) + + it('bulkChangeStatus should call registerUndo', () => { + const { result } = renderBulkWithUndo(['task-1', 'task-2']) + + act(() => { + result.current.bulkChangeStatus('progress-status') + }) + + expect(mockRegisterUndo).toHaveBeenCalledWith( + expect.stringContaining('Status'), + expect.any(Function) + ) + }) + + it('bulkUncomplete should call registerUndo', () => { + // Use task-3 which is already done + const { result } = renderBulkWithUndo(['task-3']) + + act(() => { + result.current.bulkUncomplete() + }) + + expect(mockRegisterUndo).toHaveBeenCalledWith( + expect.stringContaining('1'), + expect.any(Function) + ) + }) + }) }) diff --git a/apps/desktop/src/renderer/src/hooks/use-bulk-actions.ts b/apps/desktop/src/renderer/src/hooks/use-bulk-actions.ts index 1a484691c..338c0b43d 100644 --- a/apps/desktop/src/renderer/src/hooks/use-bulk-actions.ts +++ b/apps/desktop/src/renderer/src/hooks/use-bulk-actions.ts @@ -16,38 +16,25 @@ import { useVault } from '@/hooks/use-vault' // ============================================================================ export interface UseBulkActionsOptions { - /** Array of selected task IDs */ selectedIds: string[] - /** All tasks */ tasks: Task[] - /** All projects */ projects: Project[] - /** Callback to update a single task */ onUpdateTask: (taskId: string, updates: Partial<Task>) => void - /** Callback to delete a single task */ onDeleteTask: (taskId: string) => void - /** Callback when bulk action completes (to clear selection) */ onComplete: () => void + registerUndo?: (description: string, undoFn: () => void) => string + onAddTask?: (task: Task) => void } export interface UseBulkActionsReturn { - /** Complete all selected tasks */ bulkComplete: () => void | Promise<void> - /** Uncomplete all selected tasks */ bulkUncomplete: () => void - /** Change priority for all selected tasks */ bulkChangePriority: (priority: Priority) => void - /** Change due date for all selected tasks */ bulkChangeDueDate: (dueDate: Date | null) => void - /** Move all selected tasks to a different project */ bulkMoveToProject: (projectId: string) => void | Promise<void> - /** Change status for all selected tasks (Kanban) */ bulkChangeStatus: (statusId: string) => void - /** Archive all selected tasks */ bulkArchive: () => void | Promise<void> - /** Delete all selected tasks */ bulkDelete: () => void | Promise<void> - /** Get selected tasks */ getSelectedTasks: () => Task[] } @@ -55,20 +42,19 @@ export interface UseBulkActionsReturn { // HOOK // ============================================================================ -/** - * Hook to handle bulk actions on selected tasks - */ export const useBulkActions = ({ selectedIds, tasks, projects, onUpdateTask, onDeleteTask, - onComplete + onComplete, + registerUndo, + onAddTask }: UseBulkActionsOptions): UseBulkActionsReturn => { - // Get vault status to determine if backend operations are available const { status } = useVault() const isVaultOpen = status?.isOpen ?? false + // ========== HELPERS ========== const getSelectedTasks = useCallback((): Task[] => { @@ -91,7 +77,6 @@ export const useBulkActions = ({ return } - // Store original states for undo const originalStates = tasksToComplete.map((task) => ({ id: task.id, statusId: task.statusId, @@ -100,7 +85,6 @@ export const useBulkActions = ({ const taskIds = tasksToComplete.map((t) => t.id) - // T068: Use backend bulk operation when vault is open if (isVaultOpen) { try { const result = await tasksService.bulkComplete(taskIds) @@ -108,14 +92,12 @@ export const useBulkActions = ({ toast.error(extractErrorMessage(result.error, 'Failed to complete tasks')) return } - // State updates happen via event subscriptions in TasksContext } catch (error) { log.error('bulkComplete backend error:', error) toast.error('Failed to complete tasks') return } } else { - // Fallback to individual updates when vault is not open const now = new Date() tasksToComplete.forEach((task) => { const project = projects.find((p) => p.id === task.projectId) @@ -131,27 +113,35 @@ export const useBulkActions = ({ }) } - toast.success( - `${tasksToComplete.length} task${tasksToComplete.length !== 1 ? 's' : ''} completed`, - { - duration: 10000, // T052: 10-second timeout for undo per spec - action: { - label: 'Undo', - onClick: () => { - originalStates.forEach((state) => { - onUpdateTask(state.id, { - statusId: state.statusId, - completedAt: state.completedAt - }) - }) - toast.success('Changes undone') - } + const undoRestore = () => { + originalStates.forEach((state) => { + onUpdateTask(state.id, { + statusId: state.statusId, + completedAt: state.completedAt + }) + }) + } + + const count = tasksToComplete.length + const desc = `Complete ${count} task${count !== 1 ? 's' : ''}` + + if (registerUndo) { + registerUndo(desc, undoRestore) + } + + toast.success(`${count} task${count !== 1 ? 's' : ''} completed`, { + duration: 10000, + action: { + label: 'Undo', + onClick: () => { + undoRestore() + toast.success('Changes undone') } } - ) + }) onComplete() - }, [getSelectedTasks, projects, onUpdateTask, onComplete, isVaultOpen]) + }, [getSelectedTasks, projects, onUpdateTask, onComplete, isVaultOpen, registerUndo]) const bulkUncomplete = useCallback((): void => { const selectedTasks = getSelectedTasks() @@ -167,6 +157,12 @@ export const useBulkActions = ({ return } + const originalStates = tasksToUncomplete.map((task) => ({ + id: task.id, + statusId: task.statusId, + completedAt: task.completedAt + })) + tasksToUncomplete.forEach((task) => { const project = projects.find((p) => p.id === task.projectId) if (!project) return @@ -180,26 +176,51 @@ export const useBulkActions = ({ } }) - toast.success( - `${tasksToUncomplete.length} task${tasksToUncomplete.length !== 1 ? 's' : ''} restored` - ) + const count = tasksToUncomplete.length + + if (registerUndo) { + registerUndo(`Uncomplete ${count} task${count !== 1 ? 's' : ''}`, () => { + originalStates.forEach((state) => { + onUpdateTask(state.id, { + statusId: state.statusId, + completedAt: state.completedAt + }) + }) + }) + } + + toast.success(`${count} task${count !== 1 ? 's' : ''} restored`) onComplete() - }, [getSelectedTasks, projects, onUpdateTask, onComplete]) + }, [getSelectedTasks, projects, onUpdateTask, onComplete, registerUndo]) const bulkChangePriority = useCallback( (priority: Priority): void => { const count = selectedIds.length if (count === 0) return + const originalPriorities = selectedIds.map((taskId) => { + const task = tasks.find((t) => t.id === taskId) + return { id: taskId, priority: task?.priority ?? ('none' as Priority) } + }) + selectedIds.forEach((taskId) => { onUpdateTask(taskId, { priority }) }) + if (registerUndo) { + const label = priority === 'none' ? 'removed' : `set to ${priority}` + registerUndo(`Priority ${label} for ${count} task${count !== 1 ? 's' : ''}`, () => { + originalPriorities.forEach((snap) => { + onUpdateTask(snap.id, { priority: snap.priority }) + }) + }) + } + const priorityLabel = priority === 'none' ? 'removed' : `set to ${priority}` toast.success(`Priority ${priorityLabel} for ${count} task${count !== 1 ? 's' : ''}`) onComplete() }, - [selectedIds, onUpdateTask, onComplete] + [selectedIds, tasks, onUpdateTask, onComplete, registerUndo] ) const bulkChangeDueDate = useCallback( @@ -207,10 +228,23 @@ export const useBulkActions = ({ const count = selectedIds.length if (count === 0) return + const originalDates = selectedIds.map((taskId) => { + const task = tasks.find((t) => t.id === taskId) + return { id: taskId, dueDate: task?.dueDate ?? null } + }) + selectedIds.forEach((taskId) => { onUpdateTask(taskId, { dueDate }) }) + if (registerUndo) { + registerUndo(`Due date changed for ${count} task${count !== 1 ? 's' : ''}`, () => { + originalDates.forEach((snap) => { + onUpdateTask(snap.id, { dueDate: snap.dueDate }) + }) + }) + } + const message = dueDate ? `Due date set for ${count} task${count !== 1 ? 's' : ''}` : `Due date removed from ${count} task${count !== 1 ? 's' : ''}` @@ -218,7 +252,7 @@ export const useBulkActions = ({ toast.success(message) onComplete() }, - [selectedIds, onUpdateTask, onComplete] + [selectedIds, tasks, onUpdateTask, onComplete, registerUndo] ) const bulkMoveToProject = useCallback( @@ -232,7 +266,16 @@ export const useBulkActions = ({ return } - // T070: Use backend bulk operation when vault is open + const originalMoveStates = selectedIds.map((taskId) => { + const task = tasks.find((t) => t.id === taskId) + return { + id: taskId, + projectId: task?.projectId ?? '', + statusId: task?.statusId ?? '', + completedAt: task?.completedAt ?? null + } + }) + if (isVaultOpen) { try { const result = await tasksService.bulkMove(selectedIds, projectId) @@ -240,26 +283,22 @@ export const useBulkActions = ({ toast.error(extractErrorMessage(result.error, 'Failed to move tasks')) return } - // State updates happen via event subscriptions in TasksContext } catch (error) { log.error('bulkMoveToProject backend error:', error) toast.error('Failed to move tasks') return } } else { - // Fallback to individual updates when vault is not open const defaultStatus = getDefaultTodoStatus(targetProject) selectedIds.forEach((taskId) => { const task = tasks.find((t) => t.id === taskId) if (!task) return - // Get current status type to try to match in new project const currentProject = projects.find((p) => p.id === task.projectId) const currentStatus = currentProject?.statuses.find((s) => s.id === task.statusId) const currentStatusType = currentStatus?.type || 'todo' - // Try to find matching status type in target project let newStatus = targetProject.statuses.find((s) => s.type === currentStatusType) if (!newStatus) { newStatus = defaultStatus @@ -270,7 +309,6 @@ export const useBulkActions = ({ statusId: newStatus?.id || targetProject.statuses[0]?.id } - // Handle completed status if (newStatus?.type === 'done' && !task.completedAt) { updates.completedAt = new Date() } else if (newStatus?.type !== 'done' && task.completedAt) { @@ -281,10 +319,22 @@ export const useBulkActions = ({ }) } + if (registerUndo) { + registerUndo(`Move ${count} task${count !== 1 ? 's' : ''}`, () => { + originalMoveStates.forEach((snap) => { + onUpdateTask(snap.id, { + projectId: snap.projectId, + statusId: snap.statusId, + completedAt: snap.completedAt + }) + }) + }) + } + toast.success(`${count} task${count !== 1 ? 's' : ''} moved to ${targetProject.name}`) onComplete() }, - [selectedIds, tasks, projects, onUpdateTask, onComplete, isVaultOpen] + [selectedIds, tasks, projects, onUpdateTask, onComplete, isVaultOpen, registerUndo] ) const bulkChangeStatus = useCallback( @@ -292,7 +342,6 @@ export const useBulkActions = ({ const count = selectedIds.length if (count === 0) return - // Find the status to get its name and type let statusName = '' let statusType: 'todo' | 'in_progress' | 'done' = 'todo' @@ -305,13 +354,21 @@ export const useBulkActions = ({ } } + const originalStatusStates = selectedIds.map((taskId) => { + const task = tasks.find((t) => t.id === taskId) + return { + id: taskId, + statusId: task?.statusId ?? '', + completedAt: task?.completedAt ?? null + } + }) + selectedIds.forEach((taskId) => { const task = tasks.find((t) => t.id === taskId) if (!task) return const updates: Partial<Task> = { statusId } - // Handle completedAt based on status type if (statusType === 'done' && !task.completedAt) { updates.completedAt = new Date() } else if (statusType !== 'done' && task.completedAt) { @@ -321,20 +378,29 @@ export const useBulkActions = ({ onUpdateTask(taskId, updates) }) + if (registerUndo) { + registerUndo(`Status → ${statusName} for ${count} task${count !== 1 ? 's' : ''}`, () => { + originalStatusStates.forEach((snap) => { + onUpdateTask(snap.id, { + statusId: snap.statusId, + completedAt: snap.completedAt + }) + }) + }) + } + toast.success(`${count} task${count !== 1 ? 's' : ''} moved to ${statusName}`) onComplete() }, - [selectedIds, tasks, projects, onUpdateTask, onComplete] + [selectedIds, tasks, projects, onUpdateTask, onComplete, registerUndo] ) const bulkArchive = useCallback(async (): Promise<void> => { const count = selectedIds.length if (count === 0) return - // Store for undo const archivedIds = [...selectedIds] - // T071: Use backend bulk operation when vault is open if (isVaultOpen) { try { const result = await tasksService.bulkArchive(selectedIds) @@ -342,41 +408,55 @@ export const useBulkActions = ({ toast.error(extractErrorMessage(result.error, 'Failed to archive tasks')) return } - // State updates happen via event subscriptions in TasksContext } catch (error) { log.error('bulkArchive backend error:', error) toast.error('Failed to archive tasks') return } } else { - // Fallback to individual updates when vault is not open const now = new Date() selectedIds.forEach((taskId) => { onUpdateTask(taskId, { archivedAt: now }) }) } + const undoRestore = () => { + archivedIds.forEach((taskId) => { + onUpdateTask(taskId, { archivedAt: null }) + }) + } + + const desc = `Archive ${count} task${count !== 1 ? 's' : ''}` + + if (registerUndo) { + registerUndo(desc, undoRestore) + } + toast.success(`${count} task${count !== 1 ? 's' : ''} archived`, { - duration: 10000, // T052: 10-second timeout for undo per spec + duration: 10000, action: { label: 'Undo', onClick: () => { - archivedIds.forEach((taskId) => { - onUpdateTask(taskId, { archivedAt: null }) - }) + undoRestore() toast.success('Tasks restored from archive') } } }) onComplete() - }, [selectedIds, onUpdateTask, onComplete, isVaultOpen]) + }, [selectedIds, onUpdateTask, onComplete, isVaultOpen, registerUndo]) const bulkDelete = useCallback(async (): Promise<void> => { const count = selectedIds.length if (count === 0) return - // T069: Use backend bulk operation when vault is open + const deletedSnapshots = selectedIds + .map((taskId) => { + const task = tasks.find((t) => t.id === taskId) + return task ? { ...task } : null + }) + .filter(Boolean) as Task[] + if (isVaultOpen) { try { const result = await tasksService.bulkDelete(selectedIds) @@ -384,25 +464,31 @@ export const useBulkActions = ({ toast.error(extractErrorMessage(result.error, 'Failed to delete tasks')) return } - // State updates happen via event subscriptions in TasksContext (DELETED events) } catch (error) { log.error('bulkDelete backend error:', error) toast.error('Failed to delete tasks') return } } else { - // Fallback to individual deletes when vault is not open selectedIds.forEach((taskId) => { onDeleteTask(taskId) }) } + if (registerUndo && onAddTask && deletedSnapshots.length > 0) { + registerUndo(`Delete ${count} task${count !== 1 ? 's' : ''}`, () => { + deletedSnapshots.forEach((snapshot) => { + onAddTask(snapshot) + }) + }) + } + toast.success(`${count} task${count !== 1 ? 's' : ''} deleted`, { description: 'This action can be undone for a short time.' }) onComplete() - }, [selectedIds, onDeleteTask, onComplete, isVaultOpen]) + }, [selectedIds, tasks, onDeleteTask, onComplete, isVaultOpen, registerUndo, onAddTask]) return { bulkComplete, diff --git a/apps/desktop/src/renderer/src/hooks/use-devices.ts b/apps/desktop/src/renderer/src/hooks/use-devices.ts new file mode 100644 index 000000000..851c1831b --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-devices.ts @@ -0,0 +1,76 @@ +import { useState, useEffect, useCallback } from 'react' +import { extractErrorMessage } from '@/lib/ipc-error' +import { deviceService } from '@/services/device-service' + +export interface Device { + id: string + name: string + platform: 'macos' | 'windows' | 'linux' | 'ios' | 'android' + linkedAt: number + lastSyncAt?: number + isCurrentDevice: boolean +} + +interface UseDevicesReturn { + devices: Device[] + email: string | undefined + isLoading: boolean + error: string | null + removeDevice: (deviceId: string) => Promise<boolean> + refresh: () => void +} + +export function useDevices(): UseDevicesReturn { + const [devices, setDevices] = useState<Device[]>([]) + const [email, setEmail] = useState<string | undefined>(undefined) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState<string | null>(null) + const [refreshKey, setRefreshKey] = useState(0) + + useEffect(() => { + let mounted = true + const load = async (): Promise<void> => { + try { + setIsLoading(true) + setError(null) + const result = await deviceService.getDevices() + if (mounted) { + setDevices(result.devices as Device[]) + setEmail(result.email) + } + } catch (err) { + if (mounted) setError(extractErrorMessage(err, 'Failed to load devices')) + } finally { + if (mounted) setIsLoading(false) + } + } + void load() + return () => { + mounted = false + } + }, [refreshKey]) + + const removeDevice = useCallback(async (deviceId: string): Promise<boolean> => { + try { + const result = await deviceService.removeDevice({ deviceId }) + if (result.success) { + setDevices((prev) => prev.filter((d) => d.id !== deviceId)) + return true + } + setError(result.error ?? 'Failed to remove device') + return false + } catch (err) { + setError(extractErrorMessage(err, 'Failed to remove device')) + return false + } + }, []) + + return { + devices, + email, + isLoading, + error, + removeDevice, + refresh: () => setRefreshKey((k) => k + 1) + } +} diff --git a/apps/desktop/src/renderer/src/hooks/use-display-density.ts b/apps/desktop/src/renderer/src/hooks/use-display-density.ts index ab0deca8b..04e8fce8a 100644 --- a/apps/desktop/src/renderer/src/hooks/use-display-density.ts +++ b/apps/desktop/src/renderer/src/hooks/use-display-density.ts @@ -87,7 +87,7 @@ export const DENSITY_CONFIG = { // List items itemPadding: 'px-3 py-2.5', itemGap: 'gap-3', - itemRadius: 'rounded-lg', + itemRadius: 'rounded-md', iconSize: 'w-9 h-9', iconInnerSize: 'w-4 h-4', checkboxSize: '', @@ -117,7 +117,7 @@ export const DENSITY_CONFIG = { captureMargin: 'mb-4', capturePadding: 'px-3 py-2', captureGap: 'gap-2', - captureRadius: 'rounded-lg', + captureRadius: 'rounded-md', // List items itemPadding: 'px-2 py-1.5', diff --git a/apps/desktop/src/renderer/src/hooks/use-inbox-keyboard.ts b/apps/desktop/src/renderer/src/hooks/use-inbox-keyboard.ts index ef0f45df8..19a5d555c 100644 --- a/apps/desktop/src/renderer/src/hooks/use-inbox-keyboard.ts +++ b/apps/desktop/src/renderer/src/hooks/use-inbox-keyboard.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { toast } from 'sonner' import { isInputFocused } from '@/hooks/use-keyboard-shortcuts' import type { InboxItemListItem } from '../../../preload/index.d' @@ -10,14 +11,11 @@ export interface UseInboxKeyboardOptions { isInBulkMode: boolean focusedItemId: string | null items: InboxItemListItem[] - staleItems: InboxItemListItem[] - nonStaleItems: InboxItemListItem[] onOpenShortcutsModal: () => void onRefresh: () => void onArchiveFocusedItem: (itemId: string, nextItemId: string | null) => void onOpenBulkArchiveDialog: () => void onOpenSourceUrl: (url: string) => void - addToast: (toast: { message: string; type: 'success' | 'error' | 'info' }) => void } export function useInboxKeyboard(options: UseInboxKeyboardOptions): void { @@ -29,14 +27,11 @@ export function useInboxKeyboard(options: UseInboxKeyboardOptions): void { isInBulkMode, focusedItemId, items, - staleItems, - nonStaleItems, onOpenShortcutsModal, onRefresh, onArchiveFocusedItem, onOpenBulkArchiveDialog, - onOpenSourceUrl, - addToast + onOpenSourceUrl } = options useEffect(() => { @@ -56,7 +51,7 @@ export function useInboxKeyboard(options: UseInboxKeyboardOptions): void { if (e.key.toLowerCase() === 'r' && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault() onRefresh() - addToast({ message: 'Inbox refreshed', type: 'success' }) + toast.success('Inbox refreshed') return } @@ -71,9 +66,8 @@ export function useInboxKeyboard(options: UseInboxKeyboardOptions): void { e.preventDefault() const focusedItem = items.find((i) => i.id === focusedItemId) if (focusedItem) { - const allItems = [...staleItems, ...nonStaleItems] - const currentIndex = allItems.findIndex((i) => i.id === focusedItemId) - const nextItem = allItems[currentIndex + 1] || allItems[currentIndex - 1] + const currentIndex = items.findIndex((i) => i.id === focusedItemId) + const nextItem = items[currentIndex + 1] || items[currentIndex - 1] onArchiveFocusedItem(focusedItemId, nextItem?.id ?? null) } } @@ -102,13 +96,10 @@ export function useInboxKeyboard(options: UseInboxKeyboardOptions): void { isInBulkMode, focusedItemId, items, - staleItems, - nonStaleItems, onOpenShortcutsModal, onRefresh, onArchiveFocusedItem, onOpenBulkArchiveDialog, - onOpenSourceUrl, - addToast + onOpenSourceUrl ]) } diff --git a/apps/desktop/src/renderer/src/hooks/use-inbox-notifications.ts b/apps/desktop/src/renderer/src/hooks/use-inbox-notifications.ts index 897c5029b..e1f52af62 100644 --- a/apps/desktop/src/renderer/src/hooks/use-inbox-notifications.ts +++ b/apps/desktop/src/renderer/src/hooks/use-inbox-notifications.ts @@ -1,33 +1,12 @@ -import { useState, useCallback, useEffect } from 'react' +import { useEffect } from 'react' import { useQueryClient } from '@tanstack/react-query' -import type { Toast } from '@/components/ui/toast' +import { toast } from 'sonner' import { onInboxSnoozeDue } from '@/services/inbox-service' import { inboxKeys } from '@/hooks/use-inbox' -export interface UseInboxNotificationsResult { - toasts: Toast[] - addToast: (toast: Omit<Toast, 'id'>) => string - removeToast: (id: string) => void -} - -function generateToastId(): string { - return `toast-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` -} - -export function useInboxNotifications(): UseInboxNotificationsResult { - const [toasts, setToasts] = useState<Toast[]>([]) +export function useInboxNotifications(): void { const queryClient = useQueryClient() - const addToast = useCallback((toast: Omit<Toast, 'id'>): string => { - const id = generateToastId() - setToasts((prev) => [...prev, { ...toast, id }]) - return id - }, []) - - const removeToast = useCallback((id: string): void => { - setToasts((prev) => prev.filter((toast) => toast.id !== id)) - }, []) - useEffect(() => { const unsubscribe = onInboxSnoozeDue((event) => { const { items: dueItems } = event @@ -42,13 +21,11 @@ export function useInboxNotifications(): UseInboxNotificationsResult { new Notification(title, { body, icon: '/icon.png' }) } - addToast({ - message: - dueItems.length === 1 - ? `"${dueItems[0].title}" is back from snooze` - : `${dueItems.length} snoozed items are back`, - type: 'info' - }) + toast.info( + dueItems.length === 1 + ? `"${dueItems[0].title}" is back from snooze` + : `${dueItems.length} snoozed items are back` + ) } }) @@ -57,7 +34,5 @@ export function useInboxNotifications(): UseInboxNotificationsResult { } return () => unsubscribe() - }, [queryClient, addToast]) - - return { toasts, addToast, removeToast } + }, [queryClient]) } diff --git a/apps/desktop/src/renderer/src/hooks/use-inbox.ts b/apps/desktop/src/renderer/src/hooks/use-inbox.ts index 7e71f5907..dd65a98fc 100644 --- a/apps/desktop/src/renderer/src/hooks/use-inbox.ts +++ b/apps/desktop/src/renderer/src/hooks/use-inbox.ts @@ -187,6 +187,7 @@ export function useInboxList(options: UseInboxListOptions = {}): UseInboxListRes const unsubArchived = onInboxArchived(() => { void queryClient.invalidateQueries({ queryKey: inboxKeys.lists() }) void queryClient.invalidateQueries({ queryKey: inboxKeys.stats() }) + void queryClient.invalidateQueries({ queryKey: inboxKeys.archived({}) }) }) const unsubFiled = onInboxFiled(() => { @@ -989,19 +990,3 @@ export function useInboxOperations() { isRetryMetadataPending: retryMetadata.isPending } } - -// ============================================================================= -// useInboxBankruptcy Hook -// ============================================================================= - -export function useInboxBankruptcy() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: (olderThanDays: number) => inboxService.bulkArchiveOlderThan(olderThanDays), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: inboxKeys.lists() }) - void queryClient.invalidateQueries({ queryKey: inboxKeys.stats() }) - } - }) -} diff --git a/apps/desktop/src/renderer/src/hooks/use-notes-query.ts b/apps/desktop/src/renderer/src/hooks/use-notes-query.ts index 6915a3bf4..bc21ead41 100644 --- a/apps/desktop/src/renderer/src/hooks/use-notes-query.ts +++ b/apps/desktop/src/renderer/src/hooks/use-notes-query.ts @@ -11,7 +11,8 @@ import type { Note, NoteListItem, NoteListResponse, - NoteLinksResponse + NoteLinksResponse, + FolderInfo } from '../../../preload/index.d' // Types are re-exported at the end of this file @@ -25,6 +26,7 @@ import { onNoteExternalChange, onTagsChanged } from '@/services/notes-service' +import { tagsService } from '@/services/tags-service' // ============================================================================= // Query Keys @@ -114,7 +116,7 @@ const METADATA_STALE_TIME = 60_000 const NOTE_GC_TIME = 5 * 60 * 1000 /** Stable empty arrays/objects to avoid recreating on every render */ -const EMPTY_FOLDERS: string[] = [] +const EMPTY_FOLDERS: FolderInfo[] = [] const EMPTY_TAGS: Array<{ tag: string; color: string; count: number }> = [] const EMPTY_NOTES_LIST: NoteListResponse = { notes: [], total: 0, hasMore: false } const EMPTY_LINKS: NoteLinksResponse = { outgoing: [], incoming: [] } @@ -266,7 +268,10 @@ export function useNoteTagsQuery(options: { enabled?: boolean } = {}) { const query = useQuery({ queryKey: notesKeys.tags(), - queryFn: () => notesService.getTags(), + queryFn: async () => { + const { tags } = await tagsService.getAllWithCounts() + return tags.map((t) => ({ tag: t.name, color: t.color ?? '', count: t.count })) + }, enabled, staleTime: METADATA_STALE_TIME, gcTime: NOTE_GC_TIME @@ -348,6 +353,28 @@ export function useNoteFoldersQuery(options: { enabled?: boolean } = {}) { [createFolderMutation.mutateAsync] ) + const setFolderIconMutation = useMutation({ + mutationFn: async ({ folderPath, icon }: { folderPath: string; icon: string | null }) => { + const existing = await notesService.getFolderConfig(folderPath) + return notesService.setFolderConfig(folderPath, { ...existing, icon }) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: notesKeys.folders() }) + } + }) + + const setFolderIcon = useCallback( + async (folderPath: string, icon: string | null): Promise<boolean> => { + try { + const result = await setFolderIconMutation.mutateAsync({ folderPath, icon }) + return result.success + } catch { + return false + } + }, + [setFolderIconMutation.mutateAsync] + ) + // Memoize folders to avoid recreating array reference const folders = useMemo(() => query.data ?? EMPTY_FOLDERS, [query.data]) @@ -356,7 +383,8 @@ export function useNoteFoldersQuery(options: { enabled?: boolean } = {}) { isLoading: query.isLoading, error: query.error, refetch: query.refetch, - createFolder + createFolder, + setFolderIcon } } diff --git a/apps/desktop/src/renderer/src/hooks/use-sync-status.ts b/apps/desktop/src/renderer/src/hooks/use-sync-status.ts index 4c1413bca..017bcc6b3 100644 --- a/apps/desktop/src/renderer/src/hooks/use-sync-status.ts +++ b/apps/desktop/src/renderer/src/hooks/use-sync-status.ts @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { formatDistanceToNow } from 'date-fns' import { ArrowUpFromLine, - Check, + CloudSavingDone, Loader2, Pause, CloudOff, @@ -48,7 +48,12 @@ interface SyncStatusResult extends SyncStatusDisplay { } const STATUS_MAP: Record<string, SyncStatusDisplay> = { - idle: { label: 'Synced', dotColor: 'bg-green-500', IconComponent: Check, isAnimating: false }, + idle: { + label: 'Synced', + dotColor: 'bg-green-500', + IconComponent: CloudSavingDone, + isAnimating: false + }, syncing: { label: 'Syncing...', dotColor: 'bg-blue-500', diff --git a/apps/desktop/src/renderer/src/hooks/use-theme-sync.ts b/apps/desktop/src/renderer/src/hooks/use-theme-sync.ts index 23012179e..39672029e 100644 --- a/apps/desktop/src/renderer/src/hooks/use-theme-sync.ts +++ b/apps/desktop/src/renderer/src/hooks/use-theme-sync.ts @@ -11,13 +11,16 @@ const FONT_SIZE_MAP = { large: '18px' } as const -const FONT_FAMILY_MAP = { +const FONT_FAMILY_MAP: Record<string, string> = { system: '', serif: "'Crimson Pro Variable', Georgia, 'Times New Roman', serif", 'sans-serif': 'ui-sans-serif, -apple-system, "system-ui", "Segoe UI Variable Display", "Segoe UI", Helvetica, "Apple Color Emoji", "Noto Sans Arabic", "Noto Sans Hebrew", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol"', - monospace: "'JetBrains Mono Variable', 'Fira Code', 'Cascadia Code', monospace" -} as const + monospace: "'JetBrains Mono Variable', 'Fira Code', 'Cascadia Code', monospace", + gelasio: "'Gelasio', Georgia, 'Times New Roman', serif", + geist: "'Geist Variable', ui-sans-serif, -apple-system, system-ui, sans-serif", + inter: "'Inter Variable', ui-sans-serif, -apple-system, system-ui, sans-serif" +} export function useThemeSync(): void { const { settings, isLoading } = useGeneralSettings() diff --git a/apps/desktop/src/renderer/src/hooks/use-undo.test.ts b/apps/desktop/src/renderer/src/hooks/use-undo.test.ts index 93ff093e5..701a6837d 100644 --- a/apps/desktop/src/renderer/src/hooks/use-undo.test.ts +++ b/apps/desktop/src/renderer/src/hooks/use-undo.test.ts @@ -372,6 +372,77 @@ describe('useUndoKeyboardShortcut', () => { }) }) +// ============================================================================ +// removeUndoEntry Tests +// ============================================================================ + +describe('removeUndoEntry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should remove a specific entry by ID', () => { + const { result } = renderHook(() => useUndoTracker()) + const undoFn1 = vi.fn() + const undoFn2 = vi.fn() + + let id1 = '' + act(() => { + id1 = result.current.registerUndo('Action 1', undoFn1) + result.current.registerUndo('Action 2', undoFn2) + }) + + // #when — remove the first entry + act(() => { + result.current.removeUndoEntry(id1) + }) + + // #then — only undoFn2 remains; undoing should call it + act(() => { + result.current.undo() + }) + expect(undoFn2).toHaveBeenCalledTimes(1) + expect(undoFn1).not.toHaveBeenCalled() + }) + + it('should be a no-op for non-existent ID', () => { + const { result } = renderHook(() => useUndoTracker()) + const undoFn = vi.fn() + + act(() => { + result.current.registerUndo('Action', undoFn) + }) + + // #when — remove non-existent ID + act(() => { + result.current.removeUndoEntry('undo-does-not-exist') + }) + + // #then — original entry still works (verify by executing undo) + act(() => { + result.current.undo() + }) + expect(undoFn).toHaveBeenCalledTimes(1) + }) + + it('should update canUndo when last entry is removed', () => { + const { result } = renderHook(() => useUndoTracker()) + + let id = '' + act(() => { + id = result.current.registerUndo('Only action', vi.fn()) + }) + + act(() => { + result.current.removeUndoEntry(id) + }) + + // #then — fresh hook read sees empty stack + const { result: freshResult } = renderHook(() => useUndoTracker()) + expect(freshResult.current.canUndo).toBe(false) + }) +}) + // ============================================================================ // createUndoableAction Tests // ============================================================================ diff --git a/apps/desktop/src/renderer/src/hooks/use-undo.ts b/apps/desktop/src/renderer/src/hooks/use-undo.ts index 530d07158..477437b30 100644 --- a/apps/desktop/src/renderer/src/hooks/use-undo.ts +++ b/apps/desktop/src/renderer/src/hooks/use-undo.ts @@ -98,6 +98,18 @@ function popUndoEntry(): UndoEntry | undefined { return entry } +function removeUndoEntryById(id: string): boolean { + const idx = globalUndoStack.findIndex((entry) => entry.id === id) + if (idx === -1) return false + + globalUndoStack.splice(idx, 1) + if (globalUndoStack.length === 0) { + stopCleanupInterval() + } + notifyListeners() + return true +} + function getLastUndoEntry(): UndoEntry | undefined { // Filter out expired entries const now = Date.now() @@ -115,6 +127,8 @@ function getLastUndoEntry(): UndoEntry | undefined { interface UseUndoTrackerReturn { /** Register an undo action */ registerUndo: (description: string, undoFn: () => void) => string + /** Remove a specific undo entry by ID (prevents double-fire from toast + Cmd+Z) */ + removeUndoEntry: (id: string) => void /** Execute the last undo action */ undo: () => boolean /** Whether there's an action that can be undone */ @@ -146,6 +160,10 @@ export const useUndoTracker = (): UseUndoTrackerReturn => { return pushUndoEntry({ description, undoFn }) }, []) + const removeUndoEntry = useCallback((id: string): void => { + removeUndoEntryById(id) + }, []) + const undo = useCallback((): boolean => { const entry = popUndoEntry() if (!entry) { @@ -168,6 +186,7 @@ export const useUndoTracker = (): UseUndoTrackerReturn => { return { registerUndo, + removeUndoEntry, undo, canUndo: !!lastEntry, lastActionDescription: lastEntry?.description ?? null diff --git a/apps/desktop/src/renderer/src/hooks/use-undoable-action.ts b/apps/desktop/src/renderer/src/hooks/use-undoable-action.ts index 405ad05cd..c23a728ab 100644 --- a/apps/desktop/src/renderer/src/hooks/use-undoable-action.ts +++ b/apps/desktop/src/renderer/src/hooks/use-undoable-action.ts @@ -1,8 +1,8 @@ import { useCallback, useRef } from 'react' import { useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' import { inboxService } from '@/services/inbox-service' import { inboxKeys } from './use-inbox' -import type { Toast } from '@/components/ui/toast' const UNDO_WINDOW_MS = 5000 @@ -15,14 +15,12 @@ interface PendingUndo { timer: ReturnType<typeof setTimeout> } -type AddToast = (toast: Omit<Toast, 'id'>) => void - export interface UseUndoableActionResult { archiveWithUndo: (id: string, title: string) => Promise<void> fileWithUndo: (id: string, title: string) => Promise<void> } -export function useUndoableAction(addToast: AddToast): UseUndoableActionResult { +export function useUndoableAction(): UseUndoableActionResult { const queryClient = useQueryClient() const pendingRef = useRef<Map<string, PendingUndo>>(new Map()) @@ -46,22 +44,10 @@ export function useUndoableAction(addToast: AddToast): UseUndoableActionResult { if (result.success) { invalidateAll() - addToast({ message: `"${pending.title}" restored`, type: 'info' }) + toast.info(`"${pending.title}" restored`) } }, - [invalidateAll, addToast] - ) - - const showUndoToast = useCallback( - (key: string, title: string, verb: string) => { - addToast({ - message: `${verb} "${title}"`, - type: 'success', - duration: UNDO_WINDOW_MS, - onUndo: () => void performUndo(key) - }) - }, - [addToast, performUndo] + [invalidateAll] ) const enqueue = useCallback( @@ -75,9 +61,15 @@ export function useUndoableAction(addToast: AddToast): UseUndoableActionResult { pendingRef.current.set(key, { id, type, title, timer }) const verb = type === 'archive' ? 'Archived' : 'Filed' - showUndoToast(key, title, verb) + toast.success(`${verb} "${title}"`, { + duration: UNDO_WINDOW_MS, + action: { + label: 'Undo', + onClick: () => void performUndo(key) + } + }) }, - [showUndoToast] + [performUndo] ) const archiveWithUndo = useCallback( diff --git a/apps/desktop/src/renderer/src/hooks/use-undoable-task-actions.test.ts b/apps/desktop/src/renderer/src/hooks/use-undoable-task-actions.test.ts new file mode 100644 index 000000000..e202acc66 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-undoable-task-actions.test.ts @@ -0,0 +1,776 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useUndoableTaskActions, UNDOABLE_FIELDS } from './use-undoable-task-actions' +import type { Task, Priority } from '@/data/sample-tasks' + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn() + } +})) + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn() + }) +})) + +import { toast } from 'sonner' + +// ============================================================================ +// FACTORIES +// ============================================================================ + +const makeTask = (overrides: Partial<Task> = {}): Task => ({ + id: 'task-1', + title: 'Test task', + description: '', + projectId: 'proj-1', + statusId: 'status-todo', + priority: 'none' as Priority, + dueDate: null, + dueTime: null, + isRepeating: false, + repeatConfig: null, + linkedNoteIds: [], + sourceNoteId: null, + parentId: null, + subtaskIds: [], + createdAt: new Date('2026-01-01'), + completedAt: null, + archivedAt: null, + ...overrides +}) + +const makeSubtask = (parentId: string, overrides: Partial<Task> = {}): Task => + makeTask({ + id: `subtask-${Math.random().toString(36).slice(2, 7)}`, + parentId, + title: 'Subtask', + ...overrides + }) + +// ============================================================================ +// SETUP +// ============================================================================ + +function setup(taskOverrides: Partial<Task>[] = [{}]) { + const tasks = taskOverrides.map((o) => makeTask(o)) + + const deps = { + tasks, + addTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + registerUndo: vi.fn().mockReturnValue('undo-123'), + removeUndoEntry: vi.fn(), + projects: [ + { + id: 'proj-1', + name: 'Project', + color: '#000', + isArchived: false, + statuses: [ + { id: 'status-todo', name: 'To Do', type: 'todo' as const, position: 0, isDefault: true }, + { + id: 'status-progress', + name: 'In Progress', + type: 'in_progress' as const, + position: 1, + isDefault: false + }, + { id: 'status-done', name: 'Done', type: 'done' as const, position: 2, isDefault: true } + ] + } + ] + } + + const { result } = renderHook(() => useUndoableTaskActions(deps)) + return { result, deps } +} + +// ============================================================================ +// TESTS +// ============================================================================ + +describe('useUndoableTaskActions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ---------- createTask ---------- + + describe('createTask', () => { + it('should call addTask with the task', () => { + const { result, deps } = setup() + const task = makeTask({ id: 'new-task', title: 'New' }) + + act(() => { + result.current.createTask(task) + }) + + expect(deps.addTask).toHaveBeenCalledWith(task) + }) + + it('should register undo after creating', () => { + const { result, deps } = setup() + const task = makeTask({ id: 'new-task', title: 'New' }) + + act(() => { + result.current.createTask(task) + }) + + expect(deps.registerUndo).toHaveBeenCalledWith( + expect.stringContaining('New'), + expect.any(Function) + ) + }) + + it('should undo by deleting the created task', () => { + const { result, deps } = setup() + const task = makeTask({ id: 'new-task', title: 'New' }) + + act(() => { + result.current.createTask(task) + }) + + // #when — execute the registered undo function + const undoFn = deps.registerUndo.mock.calls[0][1] + undoFn() + + expect(deps.deleteTask).toHaveBeenCalledWith('new-task') + }) + }) + + // ---------- deleteTask ---------- + + describe('deleteTask', () => { + it('should capture full task snapshot before delete', () => { + const task = makeTask({ id: 'task-1', title: 'Delete me', priority: 'high' }) + const { result, deps } = setup([{ id: 'task-1', title: 'Delete me', priority: 'high' }]) + + act(() => { + result.current.deleteTask('task-1') + }) + + expect(deps.deleteTask).toHaveBeenCalledWith('task-1') + }) + + it('should register undo that re-creates the task', () => { + const { result, deps } = setup([{ id: 'task-1', title: 'Delete me' }]) + + act(() => { + result.current.deleteTask('task-1') + }) + + expect(deps.registerUndo).toHaveBeenCalledWith( + expect.stringContaining('Delete me'), + expect.any(Function) + ) + + // #when — execute undo + const undoFn = deps.registerUndo.mock.calls[0][1] + undoFn() + + expect(deps.addTask).toHaveBeenCalledWith( + expect.objectContaining({ id: 'task-1', title: 'Delete me' }) + ) + }) + + it('should show toast with undo button', () => { + const { result } = setup([{ id: 'task-1', title: 'Gone' }]) + + act(() => { + result.current.deleteTask('task-1') + }) + + expect(toast.success).toHaveBeenCalledWith( + 'Task deleted', + expect.objectContaining({ + duration: 10000, + action: expect.objectContaining({ label: 'Undo' }) + }) + ) + }) + + it('should remove undo entry from stack when toast undo clicked (double-fire prevention)', () => { + const { result, deps } = setup([{ id: 'task-1', title: 'Gone' }]) + + act(() => { + result.current.deleteTask('task-1') + }) + + // #when — simulate toast undo button click + const toastCall = (toast.success as ReturnType<typeof vi.fn>).mock.calls[0] + const toastOptions = toastCall[1] + toastOptions.action.onClick() + + expect(deps.removeUndoEntry).toHaveBeenCalledWith('undo-123') + }) + + it('should be a no-op for non-existent task', () => { + const { result, deps } = setup() + + act(() => { + result.current.deleteTask('non-existent') + }) + + expect(deps.deleteTask).not.toHaveBeenCalled() + expect(deps.registerUndo).not.toHaveBeenCalled() + }) + }) + + // ---------- completeTask ---------- + + describe('completeTask', () => { + it('should mark task as done with correct status', () => { + const { result, deps } = setup([{ id: 'task-1', statusId: 'status-todo' }]) + + act(() => { + result.current.completeTask('task-1') + }) + + expect(deps.updateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + statusId: 'status-done', + completedAt: expect.any(Date) + }) + ) + }) + + it('should register undo that restores previous status', () => { + const { result, deps } = setup([{ id: 'task-1', statusId: 'status-progress' }]) + + act(() => { + result.current.completeTask('task-1') + }) + + expect(deps.registerUndo).toHaveBeenCalledWith( + expect.stringContaining('Test task'), + expect.any(Function) + ) + + // #when — undo + const undoFn = deps.registerUndo.mock.calls[0][1] + undoFn() + + expect(deps.updateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + statusId: 'status-progress', + completedAt: null + }) + ) + }) + + it('should complete subtasks when parent completed', () => { + const sub1 = makeSubtask('task-1', { id: 'sub-1', statusId: 'status-todo' }) + const sub2 = makeSubtask('task-1', { + id: 'sub-2', + statusId: 'status-done', + completedAt: new Date() + }) + const parent = makeTask({ + id: 'task-1', + subtaskIds: ['sub-1', 'sub-2'], + statusId: 'status-todo' + }) + + const deps = { + tasks: [parent, sub1, sub2], + addTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + registerUndo: vi.fn().mockReturnValue('undo-456'), + removeUndoEntry: vi.fn(), + projects: [ + { + id: 'proj-1', + name: 'Project', + color: '#000', + isArchived: false, + statuses: [ + { + id: 'status-todo', + name: 'To Do', + type: 'todo' as const, + position: 0, + isDefault: true + }, + { + id: 'status-done', + name: 'Done', + type: 'done' as const, + position: 2, + isDefault: true + } + ] + } + ] + } + + const { result } = renderHook(() => useUndoableTaskActions(deps)) + + act(() => { + result.current.completeTask('task-1') + }) + + // sub-1 (incomplete) should be completed; sub-2 (already done) should not be touched + const sub1Update = deps.updateTask.mock.calls.find(([id]: [string]) => id === 'sub-1') + expect(sub1Update).toBeDefined() + expect(sub1Update![1]).toMatchObject({ statusId: 'status-done' }) + }) + + it('should undo restoring subtask states', () => { + const sub1 = makeSubtask('task-1', { id: 'sub-1', statusId: 'status-todo' }) + const parent = makeTask({ + id: 'task-1', + subtaskIds: ['sub-1'], + statusId: 'status-progress' + }) + + const deps = { + tasks: [parent, sub1], + addTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + registerUndo: vi.fn().mockReturnValue('undo-789'), + removeUndoEntry: vi.fn(), + projects: [ + { + id: 'proj-1', + name: 'Project', + color: '#000', + isArchived: false, + statuses: [ + { + id: 'status-todo', + name: 'To Do', + type: 'todo' as const, + position: 0, + isDefault: true + }, + { + id: 'status-progress', + name: 'In Progress', + type: 'in_progress' as const, + position: 1, + isDefault: false + }, + { + id: 'status-done', + name: 'Done', + type: 'done' as const, + position: 2, + isDefault: true + } + ] + } + ] + } + + const { result } = renderHook(() => useUndoableTaskActions(deps)) + + act(() => { + result.current.completeTask('task-1') + }) + + // #when — undo + deps.updateTask.mockClear() + const undoFn = deps.registerUndo.mock.calls[0][1] + undoFn() + + // parent restored + const parentRestore = deps.updateTask.mock.calls.find(([id]: [string]) => id === 'task-1') + expect(parentRestore![1]).toMatchObject({ statusId: 'status-progress', completedAt: null }) + + // subtask restored + const sub1Restore = deps.updateTask.mock.calls.find(([id]: [string]) => id === 'sub-1') + expect(sub1Restore![1]).toMatchObject({ statusId: 'status-todo', completedAt: null }) + }) + }) + + // ---------- completeTask (repeating) ---------- + + describe('completeTask - repeating', () => { + const makeRepeatingTask = (): Task => + makeTask({ + id: 'repeat-1', + title: 'Recurring', + isRepeating: true, + dueDate: new Date('2026-03-01'), + repeatConfig: { + frequency: 'daily', + interval: 1, + endType: 'never', + completedCount: 0, + createdAt: new Date('2026-01-01') + } + }) + + it('should mark original as done and non-repeating', () => { + const task = makeRepeatingTask() + const deps = { + tasks: [task], + addTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + registerUndo: vi.fn().mockReturnValue('undo-r1'), + removeUndoEntry: vi.fn(), + projects: [ + { + id: 'proj-1', + name: 'Project', + color: '#000', + isArchived: false, + statuses: [ + { + id: 'status-todo', + name: 'To Do', + type: 'todo' as const, + position: 0, + isDefault: true + }, + { + id: 'status-done', + name: 'Done', + type: 'done' as const, + position: 2, + isDefault: true + } + ] + } + ] + } + + const { result } = renderHook(() => useUndoableTaskActions(deps)) + + act(() => { + result.current.completeTask('repeat-1') + }) + + expect(deps.updateTask).toHaveBeenCalledWith( + 'repeat-1', + expect.objectContaining({ + statusId: 'status-done', + isRepeating: false, + repeatConfig: null + }) + ) + }) + + it('should create next occurrence', () => { + const task = makeRepeatingTask() + const deps = { + tasks: [task], + addTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + registerUndo: vi.fn().mockReturnValue('undo-r2'), + removeUndoEntry: vi.fn(), + projects: [ + { + id: 'proj-1', + name: 'Project', + color: '#000', + isArchived: false, + statuses: [ + { + id: 'status-todo', + name: 'To Do', + type: 'todo' as const, + position: 0, + isDefault: true + }, + { + id: 'status-done', + name: 'Done', + type: 'done' as const, + position: 2, + isDefault: true + } + ] + } + ] + } + + const { result } = renderHook(() => useUndoableTaskActions(deps)) + + act(() => { + result.current.completeTask('repeat-1') + }) + + expect(deps.addTask).toHaveBeenCalledWith( + expect.objectContaining({ + isRepeating: true, + completedAt: null + }) + ) + }) + + it('should undo by restoring original repeat config and deleting next occurrence', () => { + const task = makeRepeatingTask() + const deps = { + tasks: [task], + addTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + registerUndo: vi.fn().mockReturnValue('undo-r3'), + removeUndoEntry: vi.fn(), + projects: [ + { + id: 'proj-1', + name: 'Project', + color: '#000', + isArchived: false, + statuses: [ + { + id: 'status-todo', + name: 'To Do', + type: 'todo' as const, + position: 0, + isDefault: true + }, + { + id: 'status-done', + name: 'Done', + type: 'done' as const, + position: 2, + isDefault: true + } + ] + } + ] + } + + const { result } = renderHook(() => useUndoableTaskActions(deps)) + + act(() => { + result.current.completeTask('repeat-1') + }) + + // Capture next occurrence ID + const nextTask = deps.addTask.mock.calls[0][0] + const nextId = nextTask.id + + // #when — undo + deps.updateTask.mockClear() + deps.deleteTask.mockClear() + const undoFn = deps.registerUndo.mock.calls[0][1] + undoFn() + + // Original restored with repeat config + expect(deps.updateTask).toHaveBeenCalledWith( + 'repeat-1', + expect.objectContaining({ + isRepeating: true, + repeatConfig: expect.objectContaining({ frequency: 'daily' }) + }) + ) + + // Next occurrence deleted + expect(deps.deleteTask).toHaveBeenCalledWith(nextId) + }) + }) + + // ---------- uncompleteTask ---------- + + describe('uncompleteTask', () => { + it('should move to todo status', () => { + const { result, deps } = setup([ + { + id: 'task-1', + statusId: 'status-done', + completedAt: new Date('2026-03-01') + } + ]) + + act(() => { + result.current.uncompleteTask('task-1') + }) + + expect(deps.updateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + statusId: 'status-todo', + completedAt: null + }) + ) + }) + + it('should register undo that re-completes', () => { + const completedAt = new Date('2026-03-01') + const { result, deps } = setup([{ id: 'task-1', statusId: 'status-done', completedAt }]) + + act(() => { + result.current.uncompleteTask('task-1') + }) + + // #when — undo + const undoFn = deps.registerUndo.mock.calls[0][1] + undoFn() + + expect(deps.updateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + statusId: 'status-done', + completedAt + }) + ) + }) + }) + + // ---------- archiveTask ---------- + + describe('archiveTask', () => { + it('should set archivedAt', () => { + const { result, deps } = setup([{ id: 'task-1' }]) + + act(() => { + result.current.archiveTask('task-1') + }) + + expect(deps.updateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ archivedAt: expect.any(Date) }) + ) + }) + + it('should register undo that unarchives', () => { + const { result, deps } = setup([{ id: 'task-1' }]) + + act(() => { + result.current.archiveTask('task-1') + }) + + const undoFn = deps.registerUndo.mock.calls[0][1] + undoFn() + + expect(deps.updateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ archivedAt: null }) + ) + }) + }) + + // ---------- updateTaskWithUndo ---------- + + describe('updateTaskWithUndo', () => { + it('should register undo for priority change', () => { + const { result, deps } = setup([{ id: 'task-1', priority: 'none' }]) + + act(() => { + result.current.updateTaskWithUndo('task-1', { priority: 'high' }) + }) + + expect(deps.updateTask).toHaveBeenCalledWith('task-1', { priority: 'high' }) + expect(deps.registerUndo).toHaveBeenCalled() + }) + + it('should register undo for status change', () => { + const { result, deps } = setup([{ id: 'task-1', statusId: 'status-todo' }]) + + act(() => { + result.current.updateTaskWithUndo('task-1', { statusId: 'status-progress' }) + }) + + expect(deps.registerUndo).toHaveBeenCalled() + }) + + it('should register undo for due date change', () => { + const { result, deps } = setup([{ id: 'task-1', dueDate: null }]) + + act(() => { + result.current.updateTaskWithUndo('task-1', { dueDate: new Date('2026-04-01') }) + }) + + expect(deps.registerUndo).toHaveBeenCalled() + }) + + it('should register undo for project change', () => { + const { result, deps } = setup([{ id: 'task-1', projectId: 'proj-1' }]) + + act(() => { + result.current.updateTaskWithUndo('task-1', { projectId: 'proj-2' }) + }) + + expect(deps.registerUndo).toHaveBeenCalled() + }) + + it('should NOT register undo for title change', () => { + const { result, deps } = setup([{ id: 'task-1', title: 'Old' }]) + + act(() => { + result.current.updateTaskWithUndo('task-1', { title: 'New' }) + }) + + expect(deps.updateTask).toHaveBeenCalledWith('task-1', { title: 'New' }) + expect(deps.registerUndo).not.toHaveBeenCalled() + }) + + it('should NOT register undo for description change', () => { + const { result, deps } = setup([{ id: 'task-1', description: '' }]) + + act(() => { + result.current.updateTaskWithUndo('task-1', { description: 'Updated' }) + }) + + expect(deps.registerUndo).not.toHaveBeenCalled() + }) + + it('should restore previous field value on undo', () => { + const { result, deps } = setup([{ id: 'task-1', priority: 'low' }]) + + act(() => { + result.current.updateTaskWithUndo('task-1', { priority: 'urgent' }) + }) + + // #when — undo + deps.updateTask.mockClear() + const undoFn = deps.registerUndo.mock.calls[0][1] + undoFn() + + expect(deps.updateTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ priority: 'low' }) + ) + }) + + it('should handle mixed undoable and non-undoable fields', () => { + const { result, deps } = setup([{ id: 'task-1', title: 'Old', priority: 'none' }]) + + act(() => { + result.current.updateTaskWithUndo('task-1', { title: 'New', priority: 'high' }) + }) + + // should update both fields + expect(deps.updateTask).toHaveBeenCalledWith('task-1', { title: 'New', priority: 'high' }) + // should register undo because priority is undoable + expect(deps.registerUndo).toHaveBeenCalled() + }) + }) + + // ---------- UNDOABLE_FIELDS constant ---------- + + describe('UNDOABLE_FIELDS', () => { + it('should include discrete fields only', () => { + expect(UNDOABLE_FIELDS).toContain('priority') + expect(UNDOABLE_FIELDS).toContain('statusId') + expect(UNDOABLE_FIELDS).toContain('dueDate') + expect(UNDOABLE_FIELDS).toContain('dueTime') + expect(UNDOABLE_FIELDS).toContain('projectId') + expect(UNDOABLE_FIELDS).toContain('archivedAt') + }) + + it('should not include text fields', () => { + expect(UNDOABLE_FIELDS).not.toContain('title') + expect(UNDOABLE_FIELDS).not.toContain('description') + }) + }) +}) diff --git a/apps/desktop/src/renderer/src/hooks/use-undoable-task-actions.ts b/apps/desktop/src/renderer/src/hooks/use-undoable-task-actions.ts new file mode 100644 index 000000000..a6aef6303 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-undoable-task-actions.ts @@ -0,0 +1,320 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import type { Task } from '@/data/sample-tasks' +import type { Project } from '@/data/tasks-data' +import { getDefaultTodoStatus, getDefaultDoneStatus } from '@/lib/task-utils' +import { getSubtasks } from '@/lib/subtask-utils' +import { calculateNextOccurrence, shouldCreateNextOccurrence } from '@/lib/repeat-utils' +import { generateTaskId } from '@/data/sample-tasks' +import { formatDateShort } from '@/lib/task-utils' +import { createLogger } from '@/lib/logger' + +const log = createLogger('Hook:UndoableTaskActions') + +export const UNDOABLE_FIELDS = new Set([ + 'priority', + 'statusId', + 'dueDate', + 'dueTime', + 'projectId', + 'archivedAt' +]) + +export interface UseUndoableTaskActionsOptions { + tasks: Task[] + projects: Project[] + addTask: (task: Task) => void + updateTask: (taskId: string, updates: Partial<Task>) => void + deleteTask: (taskId: string) => void + registerUndo: (description: string, undoFn: () => void) => string + removeUndoEntry: (id: string) => void +} + +export interface UseUndoableTaskActionsReturn { + createTask: (task: Task) => void + deleteTask: (taskId: string) => void + completeTask: (taskId: string) => void + uncompleteTask: (taskId: string) => void + archiveTask: (taskId: string) => void + updateTaskWithUndo: (taskId: string, updates: Partial<Task>) => void +} + +export const useUndoableTaskActions = ({ + tasks, + projects, + addTask, + updateTask, + deleteTask, + registerUndo, + removeUndoEntry +}: UseUndoableTaskActionsOptions): UseUndoableTaskActionsReturn => { + const findTask = useCallback( + (taskId: string): Task | undefined => tasks.find((t) => t.id === taskId), + [tasks] + ) + + const findProject = useCallback( + (projectId: string): Project | undefined => projects.find((p) => p.id === projectId), + [projects] + ) + + // ========== CREATE ========== + + const createTaskWithUndo = useCallback( + (task: Task): void => { + addTask(task) + registerUndo(`Create "${task.title}"`, () => { + deleteTask(task.id) + }) + }, + [addTask, deleteTask, registerUndo] + ) + + // ========== DELETE ========== + + const deleteTaskWithUndo = useCallback( + (taskId: string): void => { + const task = findTask(taskId) + if (!task) return + + const snapshot = { ...task } + deleteTask(taskId) + + const undoId = registerUndo(`Delete "${task.title}"`, () => { + addTask(snapshot) + }) + + toast.success('Task deleted', { + description: `"${task.title}" has been deleted.`, + duration: 10000, + action: { + label: 'Undo', + onClick: () => { + removeUndoEntry(undoId) + addTask(snapshot) + } + } + }) + }, + [findTask, deleteTask, addTask, registerUndo, removeUndoEntry] + ) + + // ========== COMPLETE ========== + + const completeTaskWithUndo = useCallback( + (taskId: string): void => { + const task = findTask(taskId) + if (!task) return + + const project = findProject(task.projectId) + if (!project) return + + const currentStatus = project.statuses.find((s) => s.id === task.statusId) + if (!currentStatus) return + + if (currentStatus.type === 'done') { + return + } + + const doneStatus = getDefaultDoneStatus(project) + const completedAt = new Date() + + const subtasks = getSubtasks(taskId, tasks) + const incompleteSubtasks = subtasks.filter((s) => !s.completedAt) + + const subtaskSnapshots = incompleteSubtasks.map((s) => ({ + id: s.id, + statusId: s.statusId, + completedAt: s.completedAt + })) + + if (task.isRepeating && task.repeatConfig && task.dueDate) { + const config = task.repeatConfig + const newCompletedCount = config.completedCount + 1 + const nextDate = calculateNextOccurrence(task.dueDate, config) + const shouldCreate = shouldCreateNextOccurrence({ + ...config, + completedCount: newCompletedCount + }) + + updateTask(taskId, { + statusId: doneStatus?.id || task.statusId, + completedAt, + isRepeating: false, + repeatConfig: null + }) + + incompleteSubtasks.forEach((subtask) => { + updateTask(subtask.id, { + statusId: doneStatus?.id || subtask.statusId, + completedAt + }) + }) + + let nextOccurrenceId: string | null = null + + if (shouldCreate && nextDate) { + const newTask: Task = { + ...task, + id: generateTaskId(), + dueDate: nextDate, + statusId: getDefaultTodoStatus(project)?.id || task.statusId, + completedAt: null, + createdAt: new Date(), + repeatConfig: { + ...config, + completedCount: newCompletedCount + } + } + nextOccurrenceId = newTask.id + addTask(newTask) + toast.success('Task completed!', { + description: `Next occurrence: ${formatDateShort(nextDate)}` + }) + } else { + toast.success('Series complete!', { + description: 'This was the final occurrence.' + }) + } + + const originalSnapshot = { + statusId: task.statusId, + completedAt: task.completedAt, + isRepeating: task.isRepeating, + repeatConfig: task.repeatConfig + } + + registerUndo(`Complete "${task.title}"`, () => { + updateTask(taskId, originalSnapshot) + subtaskSnapshots.forEach((snap) => { + updateTask(snap.id, { statusId: snap.statusId, completedAt: snap.completedAt }) + }) + if (nextOccurrenceId) { + deleteTask(nextOccurrenceId) + } + }) + } else { + updateTask(taskId, { + statusId: doneStatus?.id || task.statusId, + completedAt + }) + + incompleteSubtasks.forEach((subtask) => { + updateTask(subtask.id, { + statusId: doneStatus?.id || subtask.statusId, + completedAt + }) + }) + + if (incompleteSubtasks.length > 0) { + toast.success('Task completed!', { + description: `Also marked ${incompleteSubtasks.length} subtask(s) as done.` + }) + } + + registerUndo(`Complete "${task.title}"`, () => { + updateTask(taskId, { + statusId: task.statusId, + completedAt: null + }) + subtaskSnapshots.forEach((snap) => { + updateTask(snap.id, { statusId: snap.statusId, completedAt: snap.completedAt }) + }) + }) + } + }, + [findTask, findProject, tasks, updateTask, addTask, deleteTask, registerUndo] + ) + + // ========== UNCOMPLETE ========== + + const uncompleteTaskWithUndo = useCallback( + (taskId: string): void => { + const task = findTask(taskId) + if (!task) return + + const project = findProject(task.projectId) + if (!project) return + + const prevStatusId = task.statusId + const prevCompletedAt = task.completedAt + + const todoStatus = getDefaultTodoStatus(project) + updateTask(taskId, { + statusId: todoStatus?.id || task.statusId, + completedAt: null + }) + + registerUndo(`Uncomplete "${task.title}"`, () => { + updateTask(taskId, { + statusId: prevStatusId, + completedAt: prevCompletedAt + }) + }) + }, + [findTask, findProject, updateTask, registerUndo] + ) + + // ========== ARCHIVE ========== + + const archiveTaskWithUndo = useCallback( + (taskId: string): void => { + const task = findTask(taskId) + if (!task) return + + updateTask(taskId, { archivedAt: new Date() }) + + registerUndo(`Archive "${task.title}"`, () => { + updateTask(taskId, { archivedAt: null }) + }) + }, + [findTask, updateTask, registerUndo] + ) + + // ========== UPDATE (discrete fields only) ========== + + const updateTaskWithUndo = useCallback( + (taskId: string, updates: Partial<Task>): void => { + const task = findTask(taskId) + + updateTask(taskId, updates) + + if (!task) return + + const undoableKeys = Object.keys(updates).filter((k) => UNDOABLE_FIELDS.has(k)) + if (undoableKeys.length === 0) return + + const previousValues: Partial<Task> = {} + for (const key of undoableKeys) { + ;(previousValues as Record<string, unknown>)[key] = ( + task as unknown as Record<string, unknown> + )[key] + } + + const fieldLabel = + undoableKeys[0] === 'priority' + ? `Priority → ${String((updates as Partial<Task>).priority ?? '')}` + : undoableKeys[0] === 'statusId' + ? 'Status changed' + : undoableKeys[0] === 'dueDate' + ? 'Due date changed' + : undoableKeys[0] === 'projectId' + ? 'Moved to project' + : 'Task updated' + + registerUndo(fieldLabel, () => { + updateTask(taskId, previousValues) + }) + }, + [findTask, updateTask, registerUndo] + ) + + return { + createTask: createTaskWithUndo, + deleteTask: deleteTaskWithUndo, + completeTask: completeTaskWithUndo, + uncompleteTask: uncompleteTaskWithUndo, + archiveTask: archiveTaskWithUndo, + updateTaskWithUndo + } +} diff --git a/apps/desktop/src/renderer/src/lib/blocknote-title.test.ts b/apps/desktop/src/renderer/src/lib/blocknote-title.test.ts new file mode 100644 index 000000000..c8bb0ece4 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/blocknote-title.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest' +import { extractTitleFromBlocks, TITLE_MAX_LENGTH } from './blocknote-title' + +const textItem = (text: string) => ({ type: 'text' as const, text, styles: {} }) + +const linkItem = (href: string, ...texts: string[]) => ({ + type: 'link' as const, + href, + content: texts.map((t) => textItem(t)) +}) + +const paragraph = (...content: unknown[]) => ({ + type: 'paragraph', + props: {}, + content, + children: [] +}) + +const heading = (...content: unknown[]) => ({ + type: 'heading', + props: { level: 1 }, + content, + children: [] +}) + +describe('extractTitleFromBlocks', () => { + it('returns empty string for empty blocks array', () => { + expect(extractTitleFromBlocks([])).toBe('') + }) + + it('extracts text from single paragraph block', () => { + const blocks = [paragraph(textItem('Hello world'))] + expect(extractTitleFromBlocks(blocks as any)).toBe('Hello world') + }) + + it('concatenates multiple styled text items', () => { + const blocks = [paragraph(textItem('Hello '), textItem('bold '), textItem('world'))] + expect(extractTitleFromBlocks(blocks as any)).toBe('Hello bold world') + }) + + it('extracts text from heading block', () => { + const blocks = [heading(textItem('My Heading'))] + expect(extractTitleFromBlocks(blocks as any)).toBe('My Heading') + }) + + it('returns empty string when first block is a table', () => { + const tableBlock = { + type: 'table', + props: {}, + content: { type: 'tableContent', rows: [] }, + children: [] + } + expect(extractTitleFromBlocks([tableBlock] as any)).toBe('') + }) + + it('returns empty string when first block has undefined content', () => { + const imageBlock = { + type: 'image', + props: { url: 'test.png' }, + content: undefined, + children: [] + } + expect(extractTitleFromBlocks([imageBlock] as any)).toBe('') + }) + + it('extracts text from link inline content', () => { + const blocks = [paragraph(linkItem('https://example.com', 'Example Site'))] + expect(extractTitleFromBlocks(blocks as any)).toBe('Example Site') + }) + + it('truncates title at TITLE_MAX_LENGTH', () => { + const longText = 'a'.repeat(300) + const blocks = [paragraph(textItem(longText))] + const result = extractTitleFromBlocks(blocks as any) + expect(result.length).toBe(TITLE_MAX_LENGTH) + expect(result).toBe('a'.repeat(TITLE_MAX_LENGTH)) + }) + + it('returns empty string for whitespace-only first block', () => { + const blocks = [paragraph(textItem(' \n\t '))] + expect(extractTitleFromBlocks(blocks as any)).toBe('') + }) + + it('concatenates mixed text and link items in order', () => { + const blocks = [ + paragraph(textItem('Check '), linkItem('https://example.com', 'this link'), textItem(' now')) + ] + expect(extractTitleFromBlocks(blocks as any)).toBe('Check this link now') + }) +}) diff --git a/apps/desktop/src/renderer/src/lib/blocknote-title.ts b/apps/desktop/src/renderer/src/lib/blocknote-title.ts new file mode 100644 index 000000000..4da77ccdc --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/blocknote-title.ts @@ -0,0 +1,45 @@ +import type { Block } from '@blocknote/core' + +export const TITLE_MAX_LENGTH = 200 + +interface TextItem { + type: 'text' + text: string +} + +interface LinkItem { + type: 'link' + content: TextItem[] +} + +type InlineItem = TextItem | LinkItem + +function extractTextFromInlineContent(items: InlineItem[]): string { + let result = '' + for (const item of items) { + if (item.type === 'text') { + result += item.text + } else if (item.type === 'link' && Array.isArray(item.content)) { + for (const child of item.content) { + result += child.text + } + } + } + return result +} + +export function extractTitleFromBlocks(blocks: Block[]): string { + if (blocks.length === 0) return '' + + const firstBlock = blocks[0] + const { content } = firstBlock + + if (!Array.isArray(content)) return '' + + const raw = extractTextFromInlineContent(content as InlineItem[]) + const trimmed = raw.trim() + + if (trimmed.length === 0) return '' + + return trimmed.slice(0, TITLE_MAX_LENGTH) +} diff --git a/apps/desktop/src/renderer/src/lib/hugeicon-renderer.tsx b/apps/desktop/src/renderer/src/lib/hugeicon-renderer.tsx new file mode 100644 index 000000000..5710b61f5 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/hugeicon-renderer.tsx @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import type { IconSvgElement } from '@hugeicons/react' +import { CircleIcon } from '@hugeicons/core-free-icons' + +let allIconsPromise: Promise<Record<string, unknown>> | null = null +const iconCache = new Map<string, IconSvgElement>() + +export function loadAllIcons(): Promise<Record<string, unknown>> { + if (!allIconsPromise) { + allIconsPromise = import('@hugeicons/core-free-icons') + } + return allIconsPromise +} + +export function HugeIconByName({ + name, + className +}: { + name: string + className?: string +}): React.JSX.Element { + const cached = iconCache.get(name) + const [icon, setIcon] = useState<IconSvgElement | null>(cached ?? null) + + useEffect(() => { + if (cached) return + + let cancelled = false + loadAllIcons().then((mod) => { + if (cancelled) return + const resolved = mod[name] as IconSvgElement | undefined + if (resolved) { + iconCache.set(name, resolved) + setIcon(resolved) + } + }) + return () => { + cancelled = true + } + }, [name, cached]) + + if (!icon) { + return <HugeiconsIcon icon={CircleIcon} className={className} /> + } + + return <HugeiconsIcon icon={icon} className={className} /> +} diff --git a/apps/desktop/src/renderer/src/lib/icons/icon-map.ts b/apps/desktop/src/renderer/src/lib/icons/icon-map.ts index d3323ca83..35cf51a1e 100644 --- a/apps/desktop/src/renderer/src/lib/icons/icon-map.ts +++ b/apps/desktop/src/renderer/src/lib/icons/icon-map.ts @@ -2,6 +2,7 @@ import { // Direct matches AlertCircleIcon, AlignLeftIcon, + Archive03Icon, ArchiveIcon, ArrowDown01Icon, ArrowDownAZIcon, @@ -22,6 +23,7 @@ import { Clock01Icon, CloudIcon, CloudOffIcon, + CloudSavingDone01Icon, CodeIcon, CogIcon, CopyIcon, @@ -254,7 +256,9 @@ import { ChartDecreaseIcon, NotificationOff01Icon, DashedLineCircleIcon, - Progress03Icon + Progress03Icon, + Pdf01Icon, + ArrowTurnBackwardIcon } from '@hugeicons/core-free-icons' import { createIcon } from './create-icon' @@ -277,6 +281,7 @@ export const FileSearch = createIcon(FileSearchIcon) export const Files = createIcon(Files01Icon) export const FileInput = createIcon(FileInputIcon) export const FileWarning = createIcon(FileExclamationPointIcon) +export const FilePdf = createIcon(Pdf01Icon) export const FileType2 = createIcon(FileTypeIcon) export const FileIcon_ = File @@ -306,6 +311,7 @@ export const BookMarked = createIcon(BookBookmark01Icon) export const Box = createIcon(CubeIcon) export const Package = createIcon(PackageIcon) export const Archive = createIcon(ArchiveIcon) +export const Archive03 = createIcon(Archive03Icon) export const Inbox = createIcon(InboxIcon) export const Mail = createIcon(Mail01Icon) export const MailOpen = createIcon(MailOpenIcon) @@ -376,6 +382,7 @@ export const MoveDown = createIcon(NodeMoveDownIcon) export const Move = createIcon(MoveIcon) export const Forward = createIcon(Forward01Icon) export const ExternalLink = createIcon(LinkForwardIcon) +export const ArrowTurnBackward = createIcon(ArrowTurnBackwardIcon) // ── Aliases (Icon suffix variants used in codebase) ─ @@ -477,6 +484,7 @@ export const Database = createIcon(DatabaseIcon) export const Server = createIcon(ServerStack01Icon) export const Cloud = createIcon(CloudIcon) export const CloudOff = createIcon(CloudOffIcon) +export const CloudSavingDone = createIcon(CloudSavingDone01Icon) export const Wifi = createIcon(Wifi01Icon) export const Globe = createIcon(GlobeIcon) export const Smartphone = createIcon(SmartPhone01Icon) diff --git a/apps/desktop/src/renderer/src/lib/inbox-utils.test.ts b/apps/desktop/src/renderer/src/lib/inbox-utils.test.ts index 2cefc6ce4..993af66c9 100644 --- a/apps/desktop/src/renderer/src/lib/inbox-utils.test.ts +++ b/apps/desktop/src/renderer/src/lib/inbox-utils.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { InboxItem, InboxItemListItem } from '@/types' import { + formatCompactRelativeTime, groupItemsByTimePeriod, formatTimestamp, formatDuration, @@ -114,6 +115,56 @@ describe('inbox-utils', () => { expect(result).toHaveLength(1) expect(result[0].period).toBe('TODAY') }) + + describe('dateAccessor parameter', () => { + it('should use custom dateAccessor when provided', () => { + const items = [ + createItem('1', 5), // createdAt = 5 days ago (OLDER) + createItem('2', 5) // createdAt = 5 days ago (OLDER) + ] + const accessor = (_item: InboxItem) => new Date(2026, 0, 15) // today + const result = groupItemsByTimePeriod(items, accessor) + + expect(result).toHaveLength(1) + expect(result[0].period).toBe('TODAY') + expect(result[0].items).toHaveLength(2) + }) + + it('should group by accessor date, not createdAt', () => { + const items = [ + createItem('1', 0), // createdAt = today + createItem('2', 0) // createdAt = today + ] + const accessor = (_item: InboxItem) => new Date(2026, 0, 10) // 5 days ago → OLDER + const result = groupItemsByTimePeriod(items, accessor) + + expect(result).toHaveLength(1) + expect(result[0].period).toBe('OLDER') + }) + + it('should split items across groups via accessor', () => { + const items = [createItem('1', 0), createItem('2', 0), createItem('3', 0)] + const dates = [ + new Date(2026, 0, 15), // today + new Date(2026, 0, 14), // yesterday + new Date(2026, 0, 10) // older + ] + let callIndex = 0 + const accessor = (_item: InboxItem) => dates[callIndex++] + const result = groupItemsByTimePeriod(items, accessor) + + expect(result).toHaveLength(3) + expect(result[0].period).toBe('TODAY') + expect(result[1].period).toBe('YESTERDAY') + expect(result[2].period).toBe('OLDER') + }) + + it('should fall back to createdAt without dateAccessor', () => { + const items = [createItem('1', 0)] + const result = groupItemsByTimePeriod(items) + expect(result[0].period).toBe('TODAY') + }) + }) }) describe('formatTimestamp', () => { @@ -187,6 +238,131 @@ describe('inbox-utils', () => { }) }) + describe('formatCompactRelativeTime', () => { + const NOW_MS = new Date(2026, 0, 15, 14, 30).getTime() + + const minutesAgo = (n: number) => new Date(NOW_MS - n * 60_000) + const hoursAgo = (n: number) => new Date(NOW_MS - n * 3_600_000) + const daysAgo = (n: number) => new Date(NOW_MS - n * 86_400_000) + + describe('"now" — less than 1 minute', () => { + it('should return "now" for 0 seconds ago', () => { + expect(formatCompactRelativeTime(new Date(NOW_MS))).toBe('now') + }) + + it('should return "now" for 30 seconds ago', () => { + expect(formatCompactRelativeTime(new Date(NOW_MS - 30_000))).toBe('now') + }) + + it('should return "now" for 59.9 seconds ago', () => { + expect(formatCompactRelativeTime(new Date(NOW_MS - 59_999))).toBe('now') + }) + }) + + describe('minutes — 1m to 59m', () => { + it('should return "1m" at exactly 1 minute', () => { + expect(formatCompactRelativeTime(minutesAgo(1))).toBe('1m') + }) + + it('should return "30m"', () => { + expect(formatCompactRelativeTime(minutesAgo(30))).toBe('30m') + }) + + it('should return "59m"', () => { + expect(formatCompactRelativeTime(minutesAgo(59))).toBe('59m') + }) + }) + + describe('hours — 1h to 23h', () => { + it('should return "1h" at exactly 60 minutes', () => { + expect(formatCompactRelativeTime(minutesAgo(60))).toBe('1h') + }) + + it('should return "12h"', () => { + expect(formatCompactRelativeTime(hoursAgo(12))).toBe('12h') + }) + + it('should return "23h"', () => { + expect(formatCompactRelativeTime(hoursAgo(23))).toBe('23h') + }) + }) + + describe('days — 1d to 29d', () => { + it('should return "1d" at exactly 24 hours', () => { + expect(formatCompactRelativeTime(hoursAgo(24))).toBe('1d') + }) + + it('should return "15d"', () => { + expect(formatCompactRelativeTime(daysAgo(15))).toBe('15d') + }) + + it('should return "29d"', () => { + expect(formatCompactRelativeTime(daysAgo(29))).toBe('29d') + }) + }) + + describe('older than 30 days — formatted date', () => { + it('should return formatted date at exactly 30 days', () => { + const result = formatCompactRelativeTime(daysAgo(30)) + expect(result).toMatch(/Dec\s+16/) + }) + + it('should return formatted date at 90 days', () => { + const result = formatCompactRelativeTime(daysAgo(90)) + expect(result).toMatch(/Oct\s+17/) + }) + + it('should not return a "d" suffix', () => { + const result = formatCompactRelativeTime(daysAgo(30)) + expect(result).not.toMatch(/^\d+d$/) + }) + }) + + describe('boundary precision', () => { + it('59 minutes 59 seconds → still minutes', () => { + const almostHour = new Date(NOW_MS - 59 * 60_000 - 59_000) + expect(formatCompactRelativeTime(almostHour)).toBe('59m') + }) + + it('60 minutes exactly → hours', () => { + expect(formatCompactRelativeTime(minutesAgo(60))).toBe('1h') + }) + + it('23 hours 59 minutes → still hours', () => { + const almostDay = new Date(NOW_MS - 23 * 3_600_000 - 59 * 60_000) + expect(formatCompactRelativeTime(almostDay)).toBe('23h') + }) + + it('24 hours exactly → days', () => { + expect(formatCompactRelativeTime(hoursAgo(24))).toBe('1d') + }) + + it('29 days 23 hours → still days', () => { + const almost30 = new Date(NOW_MS - 29 * 86_400_000 - 23 * 3_600_000) + expect(formatCompactRelativeTime(almost30)).toBe('29d') + }) + + it('30 days exactly → formatted date', () => { + const result = formatCompactRelativeTime(daysAgo(30)) + expect(result).not.toMatch(/^\d+[mhd]$/) + }) + }) + + describe('input types', () => { + it('should accept Date object', () => { + expect(formatCompactRelativeTime(minutesAgo(5))).toBe('5m') + }) + + it('should accept ISO date string', () => { + expect(formatCompactRelativeTime(minutesAgo(5).toISOString())).toBe('5m') + }) + + it('should accept non-ISO date string', () => { + expect(formatCompactRelativeTime(hoursAgo(2).toString())).toBe('2h') + }) + }) + }) + describe('extractDomain', () => { it('should extract domain from simple URL', () => { expect(extractDomain('https://example.com')).toBe('example.com') diff --git a/apps/desktop/src/renderer/src/lib/inbox-utils.ts b/apps/desktop/src/renderer/src/lib/inbox-utils.ts index dbf88da24..7dac0f507 100644 --- a/apps/desktop/src/renderer/src/lib/inbox-utils.ts +++ b/apps/desktop/src/renderer/src/lib/inbox-utils.ts @@ -28,7 +28,8 @@ const getItemDate = (item: InboxItem | InboxItemListItem): Date => { // Helper to group items by time period export const groupItemsByTimePeriod = <T extends InboxItem | InboxItemListItem>( - items: T[] + items: T[], + dateAccessor?: (item: T) => Date ): GroupedItems<T>[] => { const now = new Date() const yesterday = new Date(now) @@ -41,7 +42,7 @@ export const groupItemsByTimePeriod = <T extends InboxItem | InboxItemListItem>( } items.forEach((item) => { - const itemDate = getItemDate(item) + const itemDate = dateAccessor ? dateAccessor(item) : getItemDate(item) if (isSameDay(itemDate, now)) { groups.TODAY.push(item) } else if (isSameDay(itemDate, yesterday)) { @@ -84,6 +85,22 @@ export const formatDuration = (seconds: number): string => { return `${mins}:${secs.toString().padStart(2, '0')}` } +// Compact relative time (Paper-style: "2m", "1h", "3d") +export const formatCompactRelativeTime = (date: Date | string): string => { + const d = typeof date === 'string' ? new Date(date) : date + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 1) return 'now' + if (diffMins < 60) return `${diffMins}m` + if (diffHours < 24) return `${diffHours}h` + if (diffDays < 30) return `${diffDays}d` + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + // Helper to extract domain from URL export const extractDomain = (url: string): string => { try { diff --git a/apps/desktop/src/renderer/src/lib/render-note-icon.tsx b/apps/desktop/src/renderer/src/lib/render-note-icon.tsx new file mode 100644 index 000000000..afe9d6b8d --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/render-note-icon.tsx @@ -0,0 +1,15 @@ +import { isIconValue, parseIconName } from '@/components/note/note-title/emoji-icon-utils' +import { HugeIconByName } from './hugeicon-renderer' + +export function NoteIconDisplay({ + value, + className +}: { + value: string + className?: string +}): React.JSX.Element { + if (isIconValue(value)) { + return <HugeIconByName name={parseIconName(value)} className={className} /> + } + return <span className={className}>{value}</span> +} diff --git a/apps/desktop/src/renderer/src/lib/shortcut-registry.test.ts b/apps/desktop/src/renderer/src/lib/shortcut-registry.test.ts new file mode 100644 index 000000000..df2d75e20 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/shortcut-registry.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from 'vitest' +import type { ShortcutBinding } from '@memry/contracts/settings-schemas' +import { + SHORTCUT_REGISTRY, + CATEGORY_ORDER, + formatBinding, + resolveBinding, + bindingsEqual, + findConflicts, + getGroupedShortcuts, + type ShortcutEntry +} from './shortcut-registry' + +describe('shortcut-registry', () => { + describe('SHORTCUT_REGISTRY data integrity', () => { + it('every entry has required fields', () => { + for (const entry of SHORTCUT_REGISTRY) { + expect(entry.id).toBeTruthy() + expect(entry.label).toBeTruthy() + expect(entry.description).toBeTruthy() + expect(entry.category).toBeTruthy() + expect(entry.defaultBinding).toBeDefined() + expect(entry.defaultBinding.key).toBeTruthy() + expect(entry.defaultBinding.modifiers).toBeDefined() + } + }) + + it('no duplicate IDs', () => { + const ids = SHORTCUT_REGISTRY.map((e) => e.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('all IDs follow dot-notation', () => { + for (const entry of SHORTCUT_REGISTRY) { + expect(entry.id).toMatch(/^[a-z]+\.[a-zA-Z]+$/) + } + }) + + it('all categories are in CATEGORY_ORDER', () => { + const cats = new Set(SHORTCUT_REGISTRY.map((e) => e.category)) + for (const cat of cats) { + expect(CATEGORY_ORDER).toContain(cat) + } + }) + + it('has entries in every declared category', () => { + for (const cat of CATEGORY_ORDER) { + const entries = SHORTCUT_REGISTRY.filter((e) => e.category === cat) + expect(entries.length).toBeGreaterThan(0) + } + }) + }) + + describe('formatBinding', () => { + it('formats single modifier + key', () => { + const binding: ShortcutBinding = { key: 'n', modifiers: { meta: true } } + const result = formatBinding(binding) + expect(result).toMatch(/N$/) + expect(result).toMatch(/⌘|Ctrl/) + }) + + it('formats multiple modifiers', () => { + const binding: ShortcutBinding = { key: 't', modifiers: { meta: true, shift: true } } + const result = formatBinding(binding) + expect(result).toContain('Shift') + expect(result).toMatch(/T$/) + }) + + it('formats special keys', () => { + const binding: ShortcutBinding = { key: 'Tab', modifiers: { ctrl: true } } + const result = formatBinding(binding) + expect(result).toContain('Ctrl') + expect(result).toContain('⇥') + }) + + it('uppercases regular keys', () => { + const binding: ShortcutBinding = { key: 'f', modifiers: { meta: true } } + const result = formatBinding(binding) + expect(result).toMatch(/F$/) + }) + + it('maps arrow keys to symbols', () => { + const binding: ShortcutBinding = { key: 'ArrowUp', modifiers: {} } + expect(formatBinding(binding)).toContain('↑') + }) + + it('maps Enter to ↩', () => { + const binding: ShortcutBinding = { key: 'Enter', modifiers: {} } + expect(formatBinding(binding)).toContain('↩') + }) + + it('maps Backspace to ⌫', () => { + const binding: ShortcutBinding = { key: 'Backspace', modifiers: {} } + expect(formatBinding(binding)).toContain('⌫') + }) + }) + + describe('resolveBinding', () => { + const entry: ShortcutEntry = { + id: 'test.shortcut', + label: 'Test', + description: 'Test shortcut', + category: 'Navigation', + defaultBinding: { key: 'n', modifiers: { meta: true } } + } + + it('returns default when no override', () => { + expect(resolveBinding(entry, {})).toEqual(entry.defaultBinding) + }) + + it('returns override when present', () => { + const override: ShortcutBinding = { key: 'x', modifiers: { meta: true, shift: true } } + expect(resolveBinding(entry, { 'test.shortcut': override })).toEqual(override) + }) + + it('ignores overrides for different IDs', () => { + const override: ShortcutBinding = { key: 'x', modifiers: { meta: true } } + expect(resolveBinding(entry, { 'other.shortcut': override })).toEqual(entry.defaultBinding) + }) + }) + + describe('bindingsEqual', () => { + it('returns true for identical bindings', () => { + const a: ShortcutBinding = { key: 'n', modifiers: { meta: true } } + const b: ShortcutBinding = { key: 'n', modifiers: { meta: true } } + expect(bindingsEqual(a, b)).toBe(true) + }) + + it('is case-insensitive on key', () => { + const a: ShortcutBinding = { key: 'N', modifiers: { meta: true } } + const b: ShortcutBinding = { key: 'n', modifiers: { meta: true } } + expect(bindingsEqual(a, b)).toBe(true) + }) + + it('returns false for different keys', () => { + const a: ShortcutBinding = { key: 'n', modifiers: { meta: true } } + const b: ShortcutBinding = { key: 'x', modifiers: { meta: true } } + expect(bindingsEqual(a, b)).toBe(false) + }) + + it('returns false for different modifiers', () => { + const a: ShortcutBinding = { key: 'n', modifiers: { meta: true } } + const b: ShortcutBinding = { key: 'n', modifiers: { meta: true, shift: true } } + expect(bindingsEqual(a, b)).toBe(false) + }) + + it('treats undefined and false modifiers as equal', () => { + const a: ShortcutBinding = { key: 'n', modifiers: { meta: true } } + const b: ShortcutBinding = { key: 'n', modifiers: { meta: true, shift: false, alt: false } } + expect(bindingsEqual(a, b)).toBe(true) + }) + }) + + describe('findConflicts', () => { + it('returns empty when no conflicts', () => { + const unique: ShortcutBinding = { + key: 'z', + modifiers: { meta: true, shift: true, alt: true } + } + expect(findConflicts('nav.newNote', unique, {})).toHaveLength(0) + }) + + it('detects conflict with existing default binding', () => { + const conflicting: ShortcutBinding = { key: 'f', modifiers: { meta: true } } + const conflicts = findConflicts('custom.id', conflicting, {}) + expect(conflicts.length).toBeGreaterThan(0) + expect(conflicts[0].conflictingId).toBe('nav.search') + expect(conflicts[0].conflictingLabel).toBe('Search') + }) + + it('excludes self from conflict check', () => { + const searchBinding = SHORTCUT_REGISTRY.find((e) => e.id === 'nav.search')!.defaultBinding + const conflicts = findConflicts('nav.search', searchBinding, {}) + expect(conflicts.every((c) => c.conflictingId !== 'nav.search')).toBe(true) + }) + + it('considers overrides when checking conflicts', () => { + const overrides: Record<string, ShortcutBinding> = { + 'nav.search': { key: 'z', modifiers: { meta: true, alt: true } } + } + const conflicts = findConflicts( + 'custom.id', + { key: 'z', modifiers: { meta: true, alt: true } }, + overrides + ) + expect(conflicts.some((c) => c.conflictingId === 'nav.search')).toBe(true) + }) + + it('does not conflict when override removes the collision', () => { + const overrides: Record<string, ShortcutBinding> = { + 'nav.search': { key: 'z', modifiers: { meta: true, alt: true } } + } + const conflicts = findConflicts( + 'custom.id', + { key: 'f', modifiers: { meta: true } }, + overrides + ) + expect(conflicts.every((c) => c.conflictingId !== 'nav.search')).toBe(true) + }) + }) + + describe('getGroupedShortcuts', () => { + it('returns a Map with all categories', () => { + const grouped = getGroupedShortcuts() + for (const cat of CATEGORY_ORDER) { + expect(grouped.has(cat)).toBe(true) + } + }) + + it('preserves CATEGORY_ORDER ordering', () => { + const grouped = getGroupedShortcuts() + const keys = [...grouped.keys()] + for (let i = 0; i < CATEGORY_ORDER.length; i++) { + expect(keys[i]).toBe(CATEGORY_ORDER[i]) + } + }) + + it('every entry is placed in its category', () => { + const grouped = getGroupedShortcuts() + let total = 0 + for (const entries of grouped.values()) { + total += entries.length + } + expect(total).toBe(SHORTCUT_REGISTRY.length) + }) + + it('entries within a category match their category field', () => { + const grouped = getGroupedShortcuts() + for (const [cat, entries] of grouped) { + for (const entry of entries) { + expect(entry.category).toBe(cat) + } + } + }) + }) +}) diff --git a/apps/desktop/src/renderer/src/lib/shortcut-registry.ts b/apps/desktop/src/renderer/src/lib/shortcut-registry.ts new file mode 100644 index 000000000..b6e15b5fc --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/shortcut-registry.ts @@ -0,0 +1,258 @@ +/** + * Shortcut Registry + * + * Central registry of all rebindable keyboard shortcuts with defaults, + * categories, and conflict detection. + */ + +import type { ShortcutBinding } from '@memry/contracts/settings-schemas' + +export interface ShortcutEntry { + id: string + label: string + description: string + category: string + defaultBinding: ShortcutBinding +} + +export interface ShortcutConflict { + conflictingId: string + conflictingLabel: string +} + +// ============================================================================ +// Platform detection +// ============================================================================ + +export const isMac = + typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC') + +// ============================================================================ +// Default shortcut registry +// ============================================================================ + +export const SHORTCUT_REGISTRY: ShortcutEntry[] = [ + // Navigation + { + id: 'nav.newNote', + label: 'New Note', + description: 'Create a new note', + category: 'Navigation', + defaultBinding: { key: 'n', modifiers: { meta: true } } + }, + { + id: 'nav.newTask', + label: 'New Task', + description: 'Create a new task', + category: 'Navigation', + defaultBinding: { key: 't', modifiers: { meta: true, shift: true } } + }, + { + id: 'nav.goToInbox', + label: 'Go to Inbox', + description: 'Navigate to the inbox', + category: 'Navigation', + defaultBinding: { key: 'i', modifiers: { meta: true, shift: true } } + }, + { + id: 'nav.goToNotes', + label: 'Go to Notes', + description: 'Navigate to notes', + category: 'Navigation', + defaultBinding: { key: 'e', modifiers: { meta: true, shift: true } } + }, + { + id: 'nav.goToTasks', + label: 'Go to Tasks', + description: 'Navigate to tasks', + category: 'Navigation', + defaultBinding: { key: 'k', modifiers: { meta: true, shift: true } } + }, + { + id: 'nav.search', + label: 'Search', + description: 'Open global search', + category: 'Navigation', + defaultBinding: { key: 'f', modifiers: { meta: true } } + }, + { + id: 'nav.settings', + label: 'Open Settings', + description: 'Open the settings panel', + category: 'Navigation', + defaultBinding: { key: ',', modifiers: { meta: true } } + }, + + // Tabs + { + id: 'tabs.closeTab', + label: 'Close Tab', + description: 'Close the current tab', + category: 'Tabs', + defaultBinding: { key: 'w', modifiers: { meta: true } } + }, + { + id: 'tabs.nextTab', + label: 'Next Tab', + description: 'Switch to the next tab', + category: 'Tabs', + defaultBinding: { key: 'Tab', modifiers: { ctrl: true } } + }, + { + id: 'tabs.prevTab', + label: 'Previous Tab', + description: 'Switch to the previous tab', + category: 'Tabs', + defaultBinding: { key: 'Tab', modifiers: { ctrl: true, shift: true } } + }, + { + id: 'tabs.reopenTab', + label: 'Reopen Last Tab', + description: 'Reopen the most recently closed tab', + category: 'Tabs', + defaultBinding: { key: 't', modifiers: { meta: true } } + }, + + // Editor + { + id: 'editor.save', + label: 'Save', + description: 'Save the current note', + category: 'Editor', + defaultBinding: { key: 's', modifiers: { meta: true } } + }, + { + id: 'editor.bold', + label: 'Bold', + description: 'Toggle bold formatting', + category: 'Editor', + defaultBinding: { key: 'b', modifiers: { meta: true } } + }, + { + id: 'editor.italic', + label: 'Italic', + description: 'Toggle italic formatting', + category: 'Editor', + defaultBinding: { key: 'i', modifiers: { meta: true } } + }, + { + id: 'editor.underline', + label: 'Underline', + description: 'Toggle underline formatting', + category: 'Editor', + defaultBinding: { key: 'u', modifiers: { meta: true } } + }, + + // View + { + id: 'view.toggleSidebar', + label: 'Toggle Sidebar', + description: 'Show or hide the sidebar', + category: 'View', + defaultBinding: { key: 's', modifiers: { meta: true, shift: true } } + }, + { + id: 'view.shortcuts', + label: 'Keyboard Shortcuts Help', + description: 'Show keyboard shortcuts reference', + category: 'View', + defaultBinding: { key: '/', modifiers: { meta: true } } + } +] + +// Category order for display +export const CATEGORY_ORDER = ['Navigation', 'Tabs', 'Editor', 'View'] + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Format a ShortcutBinding as a human-readable string (e.g., "⌘ Shift N") + */ +export function formatBinding(binding: ShortcutBinding): string { + const parts: string[] = [] + if (binding.modifiers.meta) parts.push(isMac ? '⌘' : 'Ctrl') + if (binding.modifiers.ctrl) parts.push('Ctrl') + if (binding.modifiers.alt) parts.push(isMac ? '⌥' : 'Alt') + if (binding.modifiers.shift) parts.push('Shift') + parts.push(formatKey(binding.key)) + return parts.join(' ') +} + +/** + * Format a raw key to display-friendly string + */ +function formatKey(key: string): string { + const map: Record<string, string> = { + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', + Enter: '↩', + Escape: 'Esc', + Backspace: '⌫', + Delete: '⌦', + Tab: '⇥', + Space: '␣' + } + return map[key] ?? key.toUpperCase() +} + +/** + * Resolve the effective binding for a shortcut (override takes precedence over default) + */ +export function resolveBinding( + entry: ShortcutEntry, + overrides: Record<string, ShortcutBinding> +): ShortcutBinding { + return overrides[entry.id] ?? entry.defaultBinding +} + +/** + * Check if two bindings are identical + */ +export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean { + return ( + a.key.toLowerCase() === b.key.toLowerCase() && + Boolean(a.modifiers.meta) === Boolean(b.modifiers.meta) && + Boolean(a.modifiers.ctrl) === Boolean(b.modifiers.ctrl) && + Boolean(a.modifiers.shift) === Boolean(b.modifiers.shift) && + Boolean(a.modifiers.alt) === Boolean(b.modifiers.alt) + ) +} + +/** + * Find conflicts: other shortcuts that use the same binding + */ +export function findConflicts( + id: string, + binding: ShortcutBinding, + overrides: Record<string, ShortcutBinding> +): ShortcutConflict[] { + return SHORTCUT_REGISTRY.filter((entry) => { + if (entry.id === id) return false + const effective = resolveBinding(entry, overrides) + return bindingsEqual(effective, binding) + }).map((entry) => ({ conflictingId: entry.id, conflictingLabel: entry.label })) +} + +/** + * Get shortcuts grouped by category in display order + */ +export function getGroupedShortcuts(): Map<string, ShortcutEntry[]> { + const grouped = new Map<string, ShortcutEntry[]>() + for (const cat of CATEGORY_ORDER) { + grouped.set(cat, []) + } + for (const entry of SHORTCUT_REGISTRY) { + const cat = entry.category + if (!grouped.has(cat)) grouped.set(cat, []) + grouped.get(cat)!.push(entry) + } + // Remove empty categories + for (const [key, entries] of grouped) { + if (entries.length === 0) grouped.delete(key) + } + return grouped +} diff --git a/apps/desktop/src/renderer/src/lib/stale-utils.test.ts b/apps/desktop/src/renderer/src/lib/stale-utils.test.ts deleted file mode 100644 index 5fa064253..000000000 --- a/apps/desktop/src/renderer/src/lib/stale-utils.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import type { InboxItem, InboxItemListItem } from '@/types' -import { - STALE_THRESHOLD_DAYS, - getDaysInInbox, - isStale, - formatAge, - getStaleItems, - getNonStaleItems, - getRandomNudgeMessage, - getNudgeMessage -} from './stale-utils' - -describe('stale-utils', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date(2026, 0, 15)) // January 15, 2026 - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('STALE_THRESHOLD_DAYS', () => { - it('should be 7 days', () => { - expect(STALE_THRESHOLD_DAYS).toBe(7) - }) - }) - - describe('getDaysInInbox', () => { - it('should return 0 for item created today', () => { - const item: InboxItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 15) - } - expect(getDaysInInbox(item)).toBe(0) - }) - - it('should return correct days for item created 5 days ago', () => { - const item: InboxItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 10) - } - expect(getDaysInInbox(item)).toBe(5) - }) - - it('should return correct days for item created 30 days ago', () => { - const item: InboxItem = { - id: '1', - title: 'Test', - createdAt: new Date(2025, 11, 16) // December 16, 2025 - } - expect(getDaysInInbox(item)).toBe(30) - }) - - it('should handle ISO string dates', () => { - const item: InboxItemListItem = { - id: '1', - title: 'Test', - createdAt: '2026-01-10T12:00:00.000Z' // Use noon UTC to avoid timezone edge cases - } - // The difference between Jan 10 noon UTC and Jan 15 midnight local time - // Floor of 4.5 days = 4 days - expect(getDaysInInbox(item)).toBe(4) - }) - - it('should handle Date objects in InboxItemListItem', () => { - const item: InboxItemListItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 8) - } - expect(getDaysInInbox(item)).toBe(7) - }) - }) - - describe('isStale', () => { - it('should return false for item created today', () => { - const item: InboxItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 15) - } - expect(isStale(item)).toBe(false) - }) - - it('should return false for item created 6 days ago (just under threshold)', () => { - const item: InboxItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 9) - } - expect(isStale(item)).toBe(false) - }) - - it('should return true for item created 7 days ago (at threshold)', () => { - const item: InboxItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 8) - } - expect(isStale(item)).toBe(true) - }) - - it('should return true for item created 30 days ago', () => { - const item: InboxItem = { - id: '1', - title: 'Test', - createdAt: new Date(2025, 11, 16) - } - expect(isStale(item)).toBe(true) - }) - - it('should use custom threshold when provided', () => { - const item: InboxItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 12) // 3 days ago - } - expect(isStale(item, 3)).toBe(true) - expect(isStale(item, 5)).toBe(false) - }) - - it('should use pre-computed isStale field from backend item when available', () => { - const item: InboxItemListItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 15), // today - isStale: true // backend says stale - } - expect(isStale(item)).toBe(true) - }) - - it('should use pre-computed isStale field even when false', () => { - const item: InboxItemListItem = { - id: '1', - title: 'Test', - createdAt: new Date(2026, 0, 1), // 14 days ago - isStale: false // backend says not stale - } - expect(isStale(item)).toBe(false) - }) - }) - - describe('formatAge', () => { - it('should format 0 days correctly', () => { - expect(formatAge(0)).toBe('0 days in inbox') - }) - - it('should format 1 day correctly', () => { - expect(formatAge(1)).toBe('1 days in inbox') - }) - - it('should format 7 days correctly', () => { - expect(formatAge(7)).toBe('7 days in inbox') - }) - - it('should format 13 days correctly (still in days range)', () => { - expect(formatAge(13)).toBe('13 days in inbox') - }) - - it('should format 14 days as weeks', () => { - expect(formatAge(14)).toBe('2 weeks in inbox') - }) - - it('should format 21 days as 3 weeks', () => { - expect(formatAge(21)).toBe('3 weeks in inbox') - }) - - it('should format 28 days as 4 weeks', () => { - expect(formatAge(28)).toBe('4 weeks in inbox') - }) - - it('should format 30 days as over a month', () => { - expect(formatAge(30)).toBe('Over a month in inbox') - }) - - it('should format 59 days as over a month', () => { - expect(formatAge(59)).toBe('Over a month in inbox') - }) - - it('should format 60 days as over 2 months', () => { - expect(formatAge(60)).toBe('Over 2 months in inbox') - }) - - it('should format 90 days as over 3 months', () => { - expect(formatAge(90)).toBe('Over 3 months in inbox') - }) - - it('should format 365 days as over 12 months', () => { - expect(formatAge(365)).toBe('Over 12 months in inbox') - }) - }) - - describe('getStaleItems', () => { - const freshItem: InboxItem = { - id: '1', - title: 'Fresh', - createdAt: new Date(2026, 0, 15) // today - } - - const staleItem: InboxItem = { - id: '2', - title: 'Stale', - createdAt: new Date(2026, 0, 1) // 14 days ago - } - - const borderlineItem: InboxItem = { - id: '3', - title: 'Borderline', - createdAt: new Date(2026, 0, 8) // exactly 7 days ago - } - - it('should return empty array when no items are stale', () => { - const items = [freshItem] - expect(getStaleItems(items)).toEqual([]) - }) - - it('should return only stale items', () => { - const items = [freshItem, staleItem] - const result = getStaleItems(items) - expect(result).toHaveLength(1) - expect(result[0].id).toBe('2') - }) - - it('should include borderline items (exactly at threshold)', () => { - const items = [freshItem, borderlineItem] - const result = getStaleItems(items) - expect(result).toHaveLength(1) - expect(result[0].id).toBe('3') - }) - - it('should work with custom threshold', () => { - const items = [freshItem, staleItem, borderlineItem] - const result = getStaleItems(items, 14) - expect(result).toHaveLength(1) - expect(result[0].id).toBe('2') - }) - - it('should return all items when all are stale', () => { - const items = [staleItem, borderlineItem] - const result = getStaleItems(items) - expect(result).toHaveLength(2) - }) - }) - - describe('getNonStaleItems', () => { - const freshItem: InboxItem = { - id: '1', - title: 'Fresh', - createdAt: new Date(2026, 0, 15) - } - - const staleItem: InboxItem = { - id: '2', - title: 'Stale', - createdAt: new Date(2026, 0, 1) - } - - it('should return all items when none are stale', () => { - const items = [freshItem] - const result = getNonStaleItems(items) - expect(result).toHaveLength(1) - }) - - it('should return only fresh items', () => { - const items = [freshItem, staleItem] - const result = getNonStaleItems(items) - expect(result).toHaveLength(1) - expect(result[0].id).toBe('1') - }) - - it('should return empty array when all items are stale', () => { - const items = [staleItem] - const result = getNonStaleItems(items) - expect(result).toEqual([]) - }) - - it('should work with custom threshold', () => { - const items = [freshItem, staleItem] - const result = getNonStaleItems(items, 30) - expect(result).toHaveLength(2) - }) - }) - - describe('getRandomNudgeMessage', () => { - it('should return a string', () => { - const message = getRandomNudgeMessage() - expect(typeof message).toBe('string') - expect(message.length).toBeGreaterThan(0) - }) - - it('should return one of the predefined messages', () => { - const validMessages = [ - 'These items are getting dusty.', - 'These have been waiting for a while.', - 'Some items could use your attention.', - 'A few things have been sitting here.', - 'Ready to clear some old items?' - ] - - // Run multiple times to increase confidence - for (let i = 0; i < 10; i++) { - const message = getRandomNudgeMessage() - expect(validMessages).toContain(message) - } - }) - }) - - describe('getNudgeMessage', () => { - const expectedMessages = [ - 'These items are getting dusty.', - 'These have been waiting for a while.', - 'Some items could use your attention.', - 'A few things have been sitting here.', - 'Ready to clear some old items?' - ] - - it('should return consistent message based on item count', () => { - const message1 = getNudgeMessage(5) - const message2 = getNudgeMessage(5) - expect(message1).toBe(message2) - }) - - it('should return first message for count 0', () => { - expect(getNudgeMessage(0)).toBe(expectedMessages[0]) - }) - - it('should return second message for count 1', () => { - expect(getNudgeMessage(1)).toBe(expectedMessages[1]) - }) - - it('should cycle through messages based on modulo', () => { - expect(getNudgeMessage(5)).toBe(expectedMessages[0]) // 5 % 5 = 0 - expect(getNudgeMessage(6)).toBe(expectedMessages[1]) // 6 % 5 = 1 - expect(getNudgeMessage(7)).toBe(expectedMessages[2]) // 7 % 5 = 2 - }) - - it('should handle large numbers correctly', () => { - expect(getNudgeMessage(100)).toBe(expectedMessages[0]) // 100 % 5 = 0 - expect(getNudgeMessage(103)).toBe(expectedMessages[3]) // 103 % 5 = 3 - }) - }) -}) diff --git a/apps/desktop/src/renderer/src/lib/stale-utils.ts b/apps/desktop/src/renderer/src/lib/stale-utils.ts deleted file mode 100644 index 36b71ce97..000000000 --- a/apps/desktop/src/renderer/src/lib/stale-utils.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { InboxItem, InboxItemListItem } from '@/types' - -// Default threshold for stale items (days) -export const STALE_THRESHOLD_DAYS = 7 - -/** - * Calculate how many days an item has been in the inbox - * Works with both InboxItem and InboxItemListItem (both have createdAt) - */ -export const getDaysInInbox = (item: InboxItem | InboxItemListItem): number => { - const now = new Date() - // Handle both Date objects and ISO strings - const createdAt = item.createdAt instanceof Date ? item.createdAt : new Date(item.createdAt) - const diffMs = now.getTime() - createdAt.getTime() - return Math.floor(diffMs / (1000 * 60 * 60 * 24)) -} - -/** - * Check if an item is stale (older than threshold) - * For backend items, prefer using the pre-computed isStale field - */ -export const isStale = ( - item: InboxItem | InboxItemListItem, - threshold: number = STALE_THRESHOLD_DAYS -): boolean => { - // Backend items have isStale pre-computed - if ('isStale' in item && typeof item.isStale === 'boolean') { - return item.isStale - } - return getDaysInInbox(item) >= threshold -} - -/** - * Format the age of an item for display - * - < 14 days: "X days in inbox" - * - 14-29 days: "X weeks in inbox" - * - 30-59 days: "Over a month in inbox" - * - 60+ days: "Over X months in inbox" - */ -export const formatAge = (days: number): string => { - if (days < 14) { - return `${days} days in inbox` - } - if (days < 30) { - const weeks = Math.floor(days / 7) - return `${weeks} week${weeks > 1 ? 's' : ''} in inbox` - } - if (days < 60) { - return 'Over a month in inbox' - } - const months = Math.floor(days / 30) - return `Over ${months} months in inbox` -} - -/** - * Filter items to only stale items (7+ days old) - * Works with both InboxItem and InboxItemListItem - */ -export const getStaleItems = <T extends InboxItem | InboxItemListItem>( - items: T[], - threshold: number = STALE_THRESHOLD_DAYS -): T[] => { - return items.filter((item) => isStale(item, threshold)) -} - -/** - * Filter items to only non-stale items (< 7 days old) - * Works with both InboxItem and InboxItemListItem - */ -export const getNonStaleItems = <T extends InboxItem | InboxItemListItem>( - items: T[], - threshold: number = STALE_THRESHOLD_DAYS -): T[] => { - return items.filter((item) => !isStale(item, threshold)) -} - -/** - * Get a random nudge message for the stale section - */ -const nudgeMessages = [ - 'These items are getting dusty.', - 'These have been waiting for a while.', - 'Some items could use your attention.', - 'A few things have been sitting here.', - 'Ready to clear some old items?' -] - -export const getRandomNudgeMessage = (): string => { - const index = Math.floor(Math.random() * nudgeMessages.length) - return nudgeMessages[index] -} - -/** - * Get a consistent nudge message (based on item count for stability) - */ -export const getNudgeMessage = (itemCount: number): string => { - const index = itemCount % nudgeMessages.length - return nudgeMessages[index] -} diff --git a/apps/desktop/src/renderer/src/lib/task-utils.test.ts b/apps/desktop/src/renderer/src/lib/task-utils.test.ts index f6221974a..da708d7b3 100644 --- a/apps/desktop/src/renderer/src/lib/task-utils.test.ts +++ b/apps/desktop/src/renderer/src/lib/task-utils.test.ts @@ -52,7 +52,6 @@ import { getDefaultDoneStatus, // Task Sorting (T077-T078) sortTasksByPriorityAndDate, - sortTasksForDay, sortTasksByTimeAndPriority, sortOverdueTasks, sortTasksAdvanced, @@ -62,8 +61,6 @@ import { // Calendar Helpers (T081) formatDateKey, parseDateKey, - getCalendarDays, - groupTasksByCalendarDate, // Task Filtering - Basic (T082) filterBySearch, filterByProjects, @@ -1422,99 +1419,6 @@ describe('Task Utils', () => { }) }) - describe('sortTasksForDay', () => { - it('should put timed tasks first, then untimed', () => { - const tasks = [ - createMockTask({ id: 't1', dueTime: null }), - createMockTask({ id: 't2', dueTime: '14:30' }), - createMockTask({ id: 't3', dueTime: null }) - ] - - const sorted = sortTasksForDay(tasks) - - expect(sorted[0].id).toBe('t2') - }) - - it('should sort timed tasks chronologically', () => { - const tasks = [ - createMockTask({ id: 't1', dueTime: '14:30' }), - createMockTask({ id: 't2', dueTime: '09:00' }), - createMockTask({ id: 't3', dueTime: '18:00' }), - createMockTask({ id: 't4', dueTime: '12:00' }) - ] - - const sorted = sortTasksForDay(tasks) - - expect(sorted.map((t: Task) => t.id)).toEqual(['t2', 't4', 't1', 't3']) - }) - - it('should sort untimed tasks by priority', () => { - const tasks = [ - createMockTask({ id: 't1', dueTime: null, priority: 'low' }), - createMockTask({ id: 't2', dueTime: null, priority: 'urgent' }), - createMockTask({ id: 't3', dueTime: null, priority: 'high' }) - ] - - const sorted = sortTasksForDay(tasks) - - expect(sorted.map((t: Task) => t.id)).toEqual(['t2', 't3', 't1']) - }) - - it('should sort by title when priority is equal (untimed)', () => { - const tasks = [ - createMockTask({ id: 't1', title: 'Zebra task', dueTime: null, priority: 'medium' }), - createMockTask({ id: 't2', title: 'Apple task', dueTime: null, priority: 'medium' }), - createMockTask({ id: 't3', title: 'Mango task', dueTime: null, priority: 'medium' }) - ] - - const sorted = sortTasksForDay(tasks) - - expect(sorted.map((t: Task) => t.id)).toEqual(['t2', 't3', 't1']) - }) - - it('should handle mixed timed and untimed tasks with varying priorities', () => { - const tasks = [ - createMockTask({ id: 't1', dueTime: null, priority: 'urgent' }), - createMockTask({ id: 't2', dueTime: '14:00', priority: 'low' }), - createMockTask({ id: 't3', dueTime: null, priority: 'low' }), - createMockTask({ id: 't4', dueTime: '09:00', priority: 'high' }) - ] - - const sorted = sortTasksForDay(tasks) - - expect(sorted.map((t: Task) => t.id)).toEqual(['t4', 't2', 't1', 't3']) - }) - - it('should handle empty array', () => { - const sorted = sortTasksForDay([]) - expect(sorted).toEqual([]) - }) - - it('should handle all tasks with same time (sort by priority)', () => { - const tasks = [ - createMockTask({ id: 't1', dueTime: '10:00', priority: 'low' }), - createMockTask({ id: 't2', dueTime: '10:00', priority: 'high' }), - createMockTask({ id: 't3', dueTime: '10:00', priority: 'medium' }) - ] - - const sorted = sortTasksForDay(tasks) - - expect(sorted.map((t: Task) => t.id)).toEqual(['t2', 't3', 't1']) - }) - - it('should handle edge times (midnight and end of day)', () => { - const tasks = [ - createMockTask({ id: 't1', dueTime: '23:59' }), - createMockTask({ id: 't2', dueTime: '00:00' }), - createMockTask({ id: 't3', dueTime: '12:00' }) - ] - - const sorted = sortTasksForDay(tasks) - - expect(sorted.map((t: Task) => t.id)).toEqual(['t2', 't3', 't1']) - }) - }) - describe('sortTasksByTimeAndPriority', () => { it('should put tasks with time before tasks without time', () => { const tasks = [ @@ -2301,191 +2205,6 @@ describe('Task Utils', () => { expect(date.getDate()).toBe(29) }) }) - - describe('getCalendarDays', () => { - it('should return calendar days for a month', () => { - const january2026 = new Date('2026-01-01') - const days = getCalendarDays(january2026) - - expect(days.length).toBeGreaterThan(0) - expect(days.length % 7).toBe(0) - }) - - it('should mark days in current month correctly', () => { - const january2026 = new Date('2026-01-15') - const days = getCalendarDays(january2026) - - const jan15 = days.find( - (d: { date: Date; isCurrentMonth: boolean }) => - d.date.getMonth() === 0 && d.date.getDate() === 15 && d.date.getFullYear() === 2026 - ) - expect(jan15?.isCurrentMonth).toBe(true) - }) - - it('should mark overflow days from previous month', () => { - const january2026 = new Date('2026-01-01') - const days = getCalendarDays(january2026, 0) - - expect(days[0].date.getDay()).toBe(0) - expect(days[0].isCurrentMonth).toBe(false) - }) - - it('should mark overflow days from next month', () => { - const january2026 = new Date('2026-01-31') - const days = getCalendarDays(january2026, 0) - - const lastDay = days[days.length - 1] - expect(lastDay.date.getDay()).toBe(6) - }) - - it('should mark today correctly', () => { - const currentMonth = new Date('2026-01-14') - const days = getCalendarDays(currentMonth) - - const today = days.find((d: { isToday: boolean }) => d.isToday) - expect(today).toBeDefined() - expect(today?.date.getDate()).toBe(14) - }) - - it('should mark weekends correctly', () => { - const january2026 = new Date('2026-01-01') - const days = getCalendarDays(january2026) - - const weekends = days.filter((d: { isWeekend: boolean }) => d.isWeekend) - weekends.forEach((d: { date: Date }) => { - expect([0, 6]).toContain(d.date.getDay()) - }) - }) - - it('should respect weekStartsOn = 0 (Sunday)', () => { - const january2026 = new Date('2026-01-01') - const days = getCalendarDays(january2026, 0) - - expect(days[0].date.getDay()).toBe(0) - }) - - it('should respect weekStartsOn = 1 (Monday)', () => { - const january2026 = new Date('2026-01-01') - const days = getCalendarDays(january2026, 1) - - expect(days[0].date.getDay()).toBe(1) - }) - - it('should include all days of the month', () => { - const january2026 = new Date('2026-01-15') - const days = getCalendarDays(january2026) - - for (let day = 1; day <= 31; day++) { - const found = days.some( - (d: { date: Date }) => - d.date.getMonth() === 0 && d.date.getDate() === day && d.date.getFullYear() === 2026 - ) - expect(found).toBe(true) - } - }) - - it('should handle February with 28 days', () => { - const february2026 = new Date('2026-02-15') - const days = getCalendarDays(february2026) - - for (let day = 1; day <= 28; day++) { - const found = days.some( - (d: { date: Date }) => - d.date.getMonth() === 1 && d.date.getDate() === day && d.date.getFullYear() === 2026 - ) - expect(found).toBe(true) - } - }) - }) - - describe('groupTasksByCalendarDate', () => { - it('should group tasks by date key within range', () => { - const tasks = [ - createMockTask({ id: 't1', dueDate: new Date('2026-01-14') }), - createMockTask({ id: 't2', dueDate: new Date('2026-01-15') }), - createMockTask({ id: 't3', dueDate: new Date('2026-01-14') }) - ] - - const start = new Date('2026-01-10') - const end = new Date('2026-01-20') - const grouped = groupTasksByCalendarDate(tasks, start, end) - - expect(grouped.get('2026-01-14')).toHaveLength(2) - expect(grouped.get('2026-01-15')).toHaveLength(1) - }) - - it('should exclude tasks outside date range', () => { - const tasks = [ - createMockTask({ id: 't1', dueDate: new Date('2026-01-05') }), - createMockTask({ id: 't2', dueDate: new Date('2026-01-15') }), - createMockTask({ id: 't3', dueDate: new Date('2026-01-25') }) - ] - - const start = new Date('2026-01-10') - const end = new Date('2026-01-20') - const grouped = groupTasksByCalendarDate(tasks, start, end) - - expect(grouped.get('2026-01-05')).toBeUndefined() - expect(grouped.get('2026-01-15')).toHaveLength(1) - expect(grouped.get('2026-01-25')).toBeUndefined() - }) - - it('should skip tasks without due date', () => { - const tasks = [ - createMockTask({ id: 't1', dueDate: null }), - createMockTask({ id: 't2', dueDate: new Date('2026-01-15') }) - ] - - const start = new Date('2026-01-10') - const end = new Date('2026-01-20') - const grouped = groupTasksByCalendarDate(tasks, start, end) - - let totalTasks = 0 - grouped.forEach((tasksForDay: Task[]) => { - totalTasks += tasksForDay.length - }) - expect(totalTasks).toBe(1) - }) - - it('should sort tasks within each day', () => { - const date = new Date('2026-01-15') - const tasks = [ - createMockTask({ id: 't1', dueDate: date, dueTime: '15:00' }), - createMockTask({ id: 't2', dueDate: date, dueTime: '09:00' }), - createMockTask({ id: 't3', dueDate: date, dueTime: '12:00' }) - ] - - const start = new Date('2026-01-10') - const end = new Date('2026-01-20') - const grouped = groupTasksByCalendarDate(tasks, start, end) - - const dayTasks = grouped.get('2026-01-15')! - expect(dayTasks.map((t: Task) => t.id)).toEqual(['t2', 't3', 't1']) - }) - - it('should include boundary dates (inclusive)', () => { - // Use local date constructors to avoid timezone issues - const tasks = [ - createMockTask({ id: 't1', dueDate: new Date(2026, 0, 10, 12) }), - createMockTask({ id: 't2', dueDate: new Date(2026, 0, 20, 12) }) - ] - - const start = startOfDay(new Date(2026, 0, 10)) - const end = endOfDay(new Date(2026, 0, 20)) - const grouped = groupTasksByCalendarDate(tasks, start, end) - - expect(grouped.get('2026-01-10')).toHaveLength(1) - expect(grouped.get('2026-01-20')).toHaveLength(1) - }) - - it('should return empty map for empty tasks', () => { - const start = new Date('2026-01-10') - const end = new Date('2026-01-20') - const grouped = groupTasksByCalendarDate([], start, end) - - expect(grouped.size).toBe(0) - }) - }) }) // ============================================================================ diff --git a/apps/desktop/src/renderer/src/lib/task-utils.ts b/apps/desktop/src/renderer/src/lib/task-utils.ts index 392640aba..24dd93174 100644 --- a/apps/desktop/src/renderer/src/lib/task-utils.ts +++ b/apps/desktop/src/renderer/src/lib/task-utils.ts @@ -438,17 +438,6 @@ export const groupTasksByStatus = ( // TASK GROUPING - BY COMPLETION DATE // ============================================================================ -// ============================================================================ -// CALENDAR HELPERS -// ============================================================================ - -export interface CalendarDay { - date: Date - isCurrentMonth: boolean - isToday: boolean - isWeekend: boolean -} - /** * Format date to yyyy-MM-dd key */ @@ -459,105 +448,6 @@ export const formatDateKey = (date: Date): string => { return `${year}-${month}-${day}` } -/** - * Build visible calendar days for a month (includes overflow days) - */ -export const getCalendarDays = (month: Date, weekStartsOn: 0 | 1 = 0): CalendarDay[] => { - const start = startOfWeek(startOfMonth(month), weekStartsOn) - const end = endOfWeek(endOfMonth(month), weekStartsOn) - - const days: CalendarDay[] = [] - let current = start - - while (current <= end) { - const dayDate = new Date(current) - days.push({ - date: dayDate, - isCurrentMonth: isSameMonth(dayDate, month), - isToday: isSameDay(dayDate, startOfDay(new Date())), - isWeekend: [0, 6].includes(dayDate.getDay()) - }) - current = addDays(current, 1) - } - - return days -} - -/** - * Convert HH:MM to minutes since midnight - */ -const timeToMinutes = (time: string | null): number | null => { - if (!time) return null - const [hoursStr, minutesStr] = time.split(':') - const hours = Number(hoursStr) - const minutes = Number(minutesStr) - if (Number.isNaN(hours) || Number.isNaN(minutes)) return null - return hours * 60 + minutes -} - -/** - * Sort tasks for a single day: - * 1) Timed tasks first (chronological) - * 2) Untimed tasks next (by priority) - * 3) Tie-breaker by title - */ -export const sortTasksForDay = (tasks: Task[]): Task[] => { - return [...tasks].sort((a, b) => { - const aMinutes = timeToMinutes(a.dueTime) - const bMinutes = timeToMinutes(b.dueTime) - - const aHasTime = aMinutes !== null - const bHasTime = bMinutes !== null - - // Timed before untimed - if (aHasTime && !bHasTime) return -1 - if (!aHasTime && bHasTime) return 1 - - // Both timed: chronological - if (aHasTime && bHasTime && aMinutes !== bMinutes) { - return aMinutes - bMinutes - } - - // Priority (lower order is higher priority) - const pa = priorityConfig[a.priority].order - const pb = priorityConfig[b.priority].order - if (pa !== pb) return pa - pb - - // Title - return a.title.localeCompare(b.title) - }) -} - -/** - * Group tasks by date key within a visible range - */ -export const groupTasksByCalendarDate = ( - tasks: Task[], - visibleStart: Date, - visibleEnd: Date -): Map<string, Task[]> => { - const map = new Map<string, Task[]>() - - tasks.forEach((task) => { - if (!task.dueDate) return - const taskDate = startOfDay(task.dueDate) - if (!isWithinInterval(taskDate, { start: visibleStart, end: visibleEnd })) return - - const key = formatDateKey(taskDate) - if (!map.has(key)) { - map.set(key, []) - } - map.get(key)!.push(task) - }) - - // Sort each bucket for consistent display - map.forEach((value, key) => { - map.set(key, sortTasksForDay(value)) - }) - - return map -} - // ============================================================================ // TASK FILTERING // ============================================================================ diff --git a/apps/desktop/src/renderer/src/lib/virtualized-tree-utils.ts b/apps/desktop/src/renderer/src/lib/virtualized-tree-utils.ts index c76bd0737..ede304f52 100644 --- a/apps/desktop/src/renderer/src/lib/virtualized-tree-utils.ts +++ b/apps/desktop/src/renderer/src/lib/virtualized-tree-utils.ts @@ -20,6 +20,7 @@ import type { NoteListItem } from '@/hooks/use-notes-query' export interface FolderNode { name: string path: string + icon?: string | null children: FolderNode[] notes: NoteListItem[] } diff --git a/apps/desktop/src/renderer/src/main.tsx b/apps/desktop/src/renderer/src/main.tsx index 4243b5f7a..9048f5802 100644 --- a/apps/desktop/src/renderer/src/main.tsx +++ b/apps/desktop/src/renderer/src/main.tsx @@ -2,6 +2,12 @@ import '@fontsource-variable/crimson-pro' import '@fontsource-variable/crimson-pro/wght-italic.css' import '@fontsource-variable/dm-sans' import '@fontsource-variable/dm-sans/wght-italic.css' +import '@fontsource-variable/geist' +import '@fontsource-variable/inter' +import '@fontsource/gelasio' +import '@fontsource/gelasio/400-italic.css' +import '@fontsource/gelasio/700.css' +import '@fontsource/gelasio/700-italic.css' import '@fontsource/instrument-serif' import '@fontsource/instrument-serif/400-italic.css' import '@fontsource-variable/jetbrains-mono' diff --git a/apps/desktop/src/renderer/src/pages/inbox.tsx b/apps/desktop/src/renderer/src/pages/inbox.tsx index 9dd3d5f9d..c44b429bd 100644 --- a/apps/desktop/src/renderer/src/pages/inbox.tsx +++ b/apps/desktop/src/renderer/src/pages/inbox.tsx @@ -1,13 +1,51 @@ -import { useState, useEffect, useCallback } from 'react' -import { ToastContainer } from '@/components/ui/toast' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { Check, Clock, Filter, Search, X } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' import { SRAnnouncer } from '@/components/sr-announcer' +import { PageToolbar, ToolbarButton } from '@/components/ui/page-toolbar' import { InboxSegmentControl, type InboxView } from '@/components/inbox/inbox-segment-control' +import { CaptureInput } from '@/components/capture-input' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuSeparator, + DropdownMenuLabel +} from '@/components/ui/dropdown-menu' import { useInboxNotifications } from '@/hooks/use-inbox-notifications' +import { useInboxList, useInboxSnoozed } from '@/hooks/use-inbox' +import type { InboxItemType } from '@memry/contracts/inbox-api' import { InboxListView } from './inbox/inbox-list-view' import { InboxHealthView } from './inbox/inbox-health-view' import { InboxArchivedView } from './inbox/inbox-archived-view' import { TriageView } from './inbox/triage-view' +const INBOX_ITEM_TYPES: InboxItemType[] = [ + 'link', + 'note', + 'image', + 'voice', + 'video', + 'clip', + 'pdf', + 'social', + 'reminder' +] + +const INBOX_TYPE_LABELS: Record<InboxItemType, string> = { + link: 'Links', + note: 'Notes', + image: 'Images', + voice: 'Voice', + video: 'Video', + clip: 'Clips', + pdf: 'PDFs', + social: 'Social', + reminder: 'Reminders' +} + interface InboxPageProps { className?: string } @@ -15,7 +53,36 @@ interface InboxPageProps { export function InboxPage({ className }: InboxPageProps): React.JSX.Element { const [currentView, setCurrentView] = useState<InboxView>('inbox') const [isTriageMode, setIsTriageMode] = useState(false) - const notifications = useInboxNotifications() + const [selectedTypes, setSelectedTypes] = useState<Set<InboxItemType>>(new Set()) + const [showSnoozedItems, setShowSnoozedItems] = useState(false) + const [isFilterOpen, setIsFilterOpen] = useState(false) + const [isArchivedSearchOpen, setIsArchivedSearchOpen] = useState(false) + const [archivedSearchQuery, setArchivedSearchQuery] = useState('') + const archivedSearchRef = useRef<HTMLInputElement>(null) + useInboxNotifications() + const { items } = useInboxList() + const { data: snoozedItems = [] } = useInboxSnoozed() + const snoozedCount = snoozedItems.length + + const itemCountsByType = useMemo(() => { + const counts: Record<InboxItemType, number> = { + link: 0, + note: 0, + image: 0, + voice: 0, + video: 0, + clip: 0, + pdf: 0, + social: 0, + reminder: 0 + } + items.forEach((item) => { + counts[item.type]++ + }) + return counts + }, [items]) + + const hasActiveFilters = selectedTypes.size > 0 const enterTriage = useCallback(() => setIsTriageMode(true), []) const exitTriage = useCallback(() => setIsTriageMode(false), []) @@ -39,6 +106,24 @@ export function InboxPage({ className }: InboxPageProps): React.JSX.Element { return () => window.removeEventListener('keydown', handler) }, [isTriageMode, enterTriage, exitTriage]) + const closeArchivedSearch = useCallback(() => { + setArchivedSearchQuery('') + setIsArchivedSearchOpen(false) + }, []) + + useEffect(() => { + if (isArchivedSearchOpen) { + requestAnimationFrame(() => archivedSearchRef.current?.focus()) + } + }, [isArchivedSearchOpen]) + + useEffect(() => { + if (currentView !== 'archived') { + setIsArchivedSearchOpen(false) + setArchivedSearchQuery('') + } + }, [currentView]) + useEffect(() => { const handler = (): void => enterTriage() window.addEventListener('memry:enter-triage', handler) @@ -48,27 +133,198 @@ export function InboxPage({ className }: InboxPageProps): React.JSX.Element { return ( <> {isTriageMode ? ( - <TriageView onExit={exitTriage} addToast={notifications.addToast} /> + <TriageView onExit={exitTriage} /> ) : ( <div className="flex h-full flex-col"> - <div className="flex shrink-0 items-center justify-center px-4 pt-3 pb-1"> + <PageToolbar className="px-2 py-1 min-h-[38px]"> <InboxSegmentControl value={currentView} onChange={setCurrentView} /> - </div> + + {currentView === 'inbox' && ( + <CaptureInput + compact + density="compact" + onCaptureSuccess={() => toast.success('Item captured')} + onCaptureError={(errorMsg) => toast.error(errorMsg)} + /> + )} + + {currentView === 'inbox' && items.length > 0 && ( + <button + type="button" + onClick={enterTriage} + title="Process inbox (Cmd+P)" + className="flex items-center shrink-0 rounded-[5px] py-1 px-2.5 gap-1.5 bg-amber-500/[0.08] border border-amber-500/20 text-amber-500 transition-colors hover:bg-amber-500/[0.12]" + > + <Check className="size-3" /> + <span className="text-[12px] leading-4 font-medium">Triage</span> + <span className="flex items-center justify-center rounded-[10px] py-px px-1.5 bg-amber-500/15 text-[11px] leading-3.5 font-semibold"> + {items.length} + </span> + </button> + )} + + {currentView === 'archived' && ( + <div + className={cn( + 'ml-auto flex items-center rounded-[5px] py-1 border overflow-hidden outline-none', + 'transition-[width] duration-150 ease-out', + isArchivedSearchOpen + ? 'w-52 border-transparent pl-2 pr-1.5 gap-1' + : 'w-[30px] border-border text-text-secondary hover:bg-surface-active/50 justify-center cursor-pointer' + )} + onClick={() => { + if (!isArchivedSearchOpen) setIsArchivedSearchOpen(true) + }} + role={!isArchivedSearchOpen ? 'button' : undefined} + tabIndex={!isArchivedSearchOpen ? 0 : undefined} + title={!isArchivedSearchOpen ? 'Search archived items' : undefined} + onKeyDown={(e) => { + if (!isArchivedSearchOpen && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + setIsArchivedSearchOpen(true) + } + }} + > + <Search className="size-3 shrink-0" /> + <input + ref={archivedSearchRef} + type="text" + value={archivedSearchQuery} + onChange={(e) => setArchivedSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') closeArchivedSearch() + }} + placeholder="Search..." + className={cn( + 'min-w-0 bg-transparent text-[12px] leading-4 outline-none border-none ring-0 shadow-none text-foreground placeholder:text-muted-foreground/40', + isArchivedSearchOpen ? 'flex-1' : 'w-0 opacity-0' + )} + tabIndex={isArchivedSearchOpen ? 0 : -1} + /> + {isArchivedSearchOpen && archivedSearchQuery && ( + <button + type="button" + onClick={(e) => { + e.stopPropagation() + setArchivedSearchQuery('') + archivedSearchRef.current?.focus() + }} + className="shrink-0 p-0.5 text-muted-foreground/40 hover:text-muted-foreground transition-colors" + > + <X size={10} /> + </button> + )} + </div> + )} + + {currentView === 'inbox' && ( + <> + <ToolbarButton + isActive={showSnoozedItems} + onClick={() => setShowSnoozedItems(!showSnoozedItems)} + title={ + showSnoozedItems + ? 'Hide snoozed items' + : `Show snoozed items${snoozedCount > 0 ? ` (${snoozedCount})` : ''}` + } + > + <Clock className="size-3" /> + {snoozedCount > 0 && ( + <span + className={cn( + 'flex items-center justify-center size-[14px] rounded-full text-[9px] font-bold', + showSnoozedItems + ? 'bg-foreground text-background' + : 'bg-foreground/15 text-text-secondary' + )} + > + {snoozedCount} + </span> + )} + </ToolbarButton> + + <DropdownMenu open={isFilterOpen} onOpenChange={setIsFilterOpen}> + <DropdownMenuTrigger asChild> + <ToolbarButton + isActive={isFilterOpen || hasActiveFilters} + title={ + hasActiveFilters + ? `Filtering by ${selectedTypes.size} type${selectedTypes.size > 1 ? 's' : ''}` + : 'Filter by type' + } + > + <Filter className="size-3" /> + <span className="text-[11px] leading-3.5">Filter</span> + {hasActiveFilters && ( + <span className="flex items-center justify-center size-[14px] rounded-full bg-foreground text-background text-[9px] font-bold"> + {selectedTypes.size} + </span> + )} + </ToolbarButton> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + <DropdownMenuLabel className="text-xs text-muted-foreground/70"> + Filter by type + </DropdownMenuLabel> + <DropdownMenuSeparator /> + {INBOX_ITEM_TYPES.map((type) => { + const count = itemCountsByType[type] + return ( + <DropdownMenuCheckboxItem + key={type} + checked={selectedTypes.has(type)} + onCheckedChange={(checked) => { + setSelectedTypes((prev) => { + const next = new Set(prev) + if (checked) next.add(type) + else next.delete(type) + return next + }) + }} + onSelect={(e) => e.preventDefault()} + disabled={count === 0} + className={cn(count === 0 && 'opacity-50')} + > + <span className="flex-1">{INBOX_TYPE_LABELS[type]}</span> + <span className="text-xs text-muted-foreground/60 ml-2">{count}</span> + </DropdownMenuCheckboxItem> + ) + })} + {hasActiveFilters && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuCheckboxItem + checked={false} + onCheckedChange={() => setSelectedTypes(new Set())} + onSelect={(e) => e.preventDefault()} + className="text-muted-foreground/70" + > + Clear all + </DropdownMenuCheckboxItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + </> + )} + </PageToolbar> + <div className="min-h-0 flex-1"> {currentView === 'inbox' && ( <InboxListView - notifications={notifications} className={className} - onEnterTriage={enterTriage} + selectedTypes={selectedTypes} + showSnoozedItems={showSnoozedItems} /> )} - {currentView === 'archived' && <InboxArchivedView className={className} />} + {currentView === 'archived' && ( + <InboxArchivedView className={className} searchQuery={archivedSearchQuery} /> + )} {currentView === 'insights' && <InboxHealthView className={className} />} </div> </div> )} - <ToastContainer toasts={notifications.toasts} onDismiss={notifications.removeToast} /> <SRAnnouncer /> </> ) diff --git a/apps/desktop/src/renderer/src/pages/inbox/inbox-archived-view.tsx b/apps/desktop/src/renderer/src/pages/inbox/inbox-archived-view.tsx index 3ce460ddf..787f397c0 100644 --- a/apps/desktop/src/renderer/src/pages/inbox/inbox-archived-view.tsx +++ b/apps/desktop/src/renderer/src/pages/inbox/inbox-archived-view.tsx @@ -1,9 +1,10 @@ -import { InboxArchivedView as ArchivedViewComponent } from '@/components/inbox/inbox-archived-view' +import { + InboxArchivedView as ArchivedViewComponent, + type InboxArchivedViewProps +} from '@/components/inbox/inbox-archived-view' -export interface InboxArchivedViewProps { - className?: string -} +export type { InboxArchivedViewProps } -export function InboxArchivedView({ className }: InboxArchivedViewProps): React.JSX.Element { - return <ArchivedViewComponent className={className} /> +export function InboxArchivedView(props: InboxArchivedViewProps): React.JSX.Element { + return <ArchivedViewComponent {...props} /> } diff --git a/apps/desktop/src/renderer/src/pages/inbox/inbox-health-view.tsx b/apps/desktop/src/renderer/src/pages/inbox/inbox-health-view.tsx index c8cf35215..5a55905fc 100644 --- a/apps/desktop/src/renderer/src/pages/inbox/inbox-health-view.tsx +++ b/apps/desktop/src/renderer/src/pages/inbox/inbox-health-view.tsx @@ -1,485 +1,388 @@ -import { useState, useMemo } from 'react' -import { AlertTriangle, Flame, Archive, ArrowRight, Inbox, Clock, Zap, Moon } from '@/lib/icons' -import { useInboxStats, useInboxBankruptcy, useInboxFilingHistory } from '@/hooks/use-inbox' -import { InboxFilingHistoryList } from '@/components/inbox/inbox-filing-history' +import { useMemo } from 'react' +import { + ArrowRight, + Link2, + Mic, + StickyNote, + Paperclip, + Image, + MessageCircle, + File, + Bell, + HelpCircle, + CheckCircle +} from '@/lib/icons' +import type { AppIcon } from '@/lib/icons' +import { useInboxStats, useInboxFilingHistory, useInboxPatterns } from '@/hooks/use-inbox' import { cn } from '@/lib/utils' +import type { InboxCapturePattern, InboxFilingHistoryEntry } from '../../../../preload/index.d' export interface InboxHealthViewProps { className?: string } -// --------------------------------------------------------------------------- -// Arc Gauge — SVG ratio visualization -// --------------------------------------------------------------------------- - -function ArcGauge({ - ratio, - captured, - processed -}: { - ratio: number - captured: number - processed: number -}) { - const clampedRatio = Math.min(ratio, 8) - const progress = Math.min(clampedRatio / 5, 1) - const isHealthy = ratio <= 2 - const isWarning = ratio > 2 && ratio <= 3 - const isDanger = ratio > 3 - - const size = 160 - const stroke = 10 - const r = (size - stroke) / 2 - const cx = size / 2 - const cy = size / 2 - - const startAngle = 135 - const endAngle = 405 - const totalAngle = endAngle - startAngle - const valueAngle = startAngle + totalAngle * progress - - const toRad = (deg: number) => ((deg - 90) * Math.PI) / 180 - const arcPath = (start: number, end: number) => { - const s = { x: cx + r * Math.cos(toRad(start)), y: cy + r * Math.sin(toRad(start)) } - const e = { x: cx + r * Math.cos(toRad(end)), y: cy + r * Math.sin(toRad(end)) } - const large = end - start > 180 ? 1 : 0 - return `M ${s.x} ${s.y} A ${r} ${r} 0 ${large} 1 ${e.x} ${e.y}` - } - - const strokeColor = isDanger - ? 'stroke-red-500 dark:stroke-red-400' - : isWarning - ? 'stroke-amber-500 dark:stroke-amber-400' - : 'stroke-emerald-500 dark:stroke-emerald-400' +const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const +const HEATMAP_HOURS = [6, 8, 10, 12, 14, 16, 18, 20, 22] as const + +const TYPE_ICONS: Record<string, AppIcon> = { + link: Link2, + voice: Mic, + note: StickyNote, + clip: Paperclip, + image: Image, + social: MessageCircle, + pdf: File, + reminder: Bell +} - const textColor = isDanger - ? 'text-red-600 dark:text-red-400' - : isWarning - ? 'text-amber-600 dark:text-amber-400' - : 'text-emerald-600 dark:text-emerald-400' +const TYPE_BAR_COLORS: Record<string, string> = { + link: 'bg-indigo-500 dark:bg-indigo-400', + voice: 'bg-accent-orange', + note: 'bg-muted-foreground/60', + clip: 'bg-accent-purple', + image: 'bg-accent-green', + social: 'bg-accent-cyan', + pdf: 'bg-rose-500 dark:bg-rose-400', + reminder: 'bg-amber-500 dark:bg-amber-400' +} - return ( - <div className="flex flex-col items-center"> - <svg - width={size} - height={size - 20} - viewBox={`0 0 ${size} ${size - 10}`} - className="overflow-visible" - > - <path - d={arcPath(startAngle, endAngle)} - fill="none" - className="stroke-border/40" - strokeWidth={stroke} - strokeLinecap="round" - /> - {progress > 0 && ( - <path - d={arcPath(startAngle, valueAngle)} - fill="none" - className={strokeColor} - strokeWidth={stroke} - strokeLinecap="round" - style={{ - filter: isDanger ? 'drop-shadow(0 0 6px rgba(239,68,68,0.3))' : undefined - }} - /> - )} - <text - x={cx} - y={cy - 6} - textAnchor="middle" - className={cn('font-display text-[42px] font-bold', textColor)} - fill="currentColor" - > - {ratio > 0 ? `${ratio}` : '0'} - </text> - <text - x={cx} - y={cy + 16} - textAnchor="middle" - className="fill-muted-foreground font-serif text-[13px]" - fill="currentColor" - > - : 1 ratio - </text> - </svg> - <div className="mt-1 flex items-center gap-4 text-xs"> - <span className="text-muted-foreground"> - <strong className="text-foreground font-display tabular-nums">{captured}</strong> in - </span> - <span className="bg-border/60 h-3 w-px" /> - <span className="text-muted-foreground"> - <strong className="text-foreground font-display tabular-nums">{processed}</strong> out - </span> - </div> - </div> - ) +const TYPE_ICON_COLORS: Record<string, string> = { + link: 'text-indigo-500 dark:text-indigo-400', + voice: 'text-accent-orange', + note: 'text-muted-foreground', + clip: 'text-accent-purple', + image: 'text-accent-green', + social: 'text-accent-cyan', + pdf: 'text-rose-500 dark:text-rose-400', + reminder: 'text-amber-500 dark:text-amber-400' } -// --------------------------------------------------------------------------- -// Age Strata — vertical layered visualization -// --------------------------------------------------------------------------- +function formatAvgTime(minutes: number): string { + if (minutes <= 0) return '—' + if (minutes < 60) return `${Math.round(minutes)}m` + if (minutes < 1440) return `${(minutes / 60).toFixed(1)}h` + return `${(minutes / 1440).toFixed(1)}d` +} -function AgeStrata({ fresh, aging, stale }: { fresh: number; aging: number; stale: number }) { - const total = fresh + aging + stale +function timeAgo(date: Date): string { + const ms = Date.now() - date.getTime() + const mins = Math.floor(ms / 60_000) + if (mins < 1) return 'now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + return `${days}d ago` +} - if (total === 0) { - return ( - <div className="flex h-full items-center justify-center"> - <p className="text-muted-foreground font-serif text-sm italic">Inbox clear</p> - </div> - ) +function computePeakInfo(heatmap: number[][]): string { + const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + let maxVal = 0 + let peakHour = 0 + let peakDay = 0 + + heatmap.forEach((hourRow, hour) => { + hourRow.forEach((count, day) => { + if (count > maxVal) { + maxVal = count + peakHour = hour + peakDay = day + } + }) + }) + + if (maxVal === 0) return 'No captures yet' + + const fmtH = (h: number) => { + if (h === 0 || h === 24) return '12 AM' + if (h < 12) return `${h} AM` + if (h === 12) return '12 PM' + return `${h - 12} PM` } - const layers = [ - { - label: 'Fresh', - count: fresh, - range: '<3d', - color: 'bg-emerald-500/60 dark:bg-emerald-400/50', - dot: 'bg-emerald-500' - }, - { - label: 'Aging', - count: aging, - range: '3–7d', - color: 'bg-amber-500/50 dark:bg-amber-400/40', - dot: 'bg-amber-500' - }, - { - label: 'Stale', - count: stale, - range: '>7d', - color: 'bg-red-500/40 dark:bg-red-400/30', - dot: 'bg-red-500' - } - ] - - return ( - <div className="flex flex-col gap-2"> - {layers.map((layer) => { - const pct = total > 0 ? (layer.count / total) * 100 : 0 - return ( - <div key={layer.label} className="group flex items-center gap-3"> - <div className="w-20 flex-shrink-0"> - <div className="flex items-center gap-1.5"> - <span className={cn('size-1.5 rounded-full', layer.dot)} /> - <span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider"> - {layer.label} - </span> - </div> - <span className="text-muted-foreground/50 ml-3 text-[10px]">{layer.range}</span> - </div> - <div className="relative h-7 min-w-0 flex-1 overflow-hidden rounded"> - <div - className={cn( - 'absolute inset-y-0 left-0 rounded transition-all duration-700 ease-out', - layer.color - )} - style={{ width: `${Math.max(pct, 2)}%` }} - /> - <span className="relative z-10 flex h-full items-center px-2.5 text-xs font-bold tabular-nums"> - {layer.count} - </span> - </div> - </div> - ) - })} - <div className="text-muted-foreground/60 mt-1 text-right text-[10px] tabular-nums"> - {total} pending - </div> - </div> - ) + return `Peak: ${dayNames[peakDay]} ${fmtH(peakHour)}\u2013${fmtH(Math.min(peakHour + 2, 24))}` } // --------------------------------------------------------------------------- -// Streak dots — visual habit tracker +// Stat Card // --------------------------------------------------------------------------- -function StreakDots({ streak }: { streak: number }) { - const dots = useMemo(() => { - const filled = Math.min(streak, 14) - const empty = 14 - filled - return { filled, empty } - }, [streak]) - +function StatCard({ + label, + value, + subValue, + subColor = 'text-text-tertiary', + borderColor = 'border-border/50' +}: { + label: string + value: string | number + subValue: string + subColor?: string + borderColor?: string +}): React.JSX.Element { return ( - <div className="flex items-center gap-3"> - <Flame - className={cn( - 'size-5 shrink-0', - streak > 0 ? 'text-orange-500' : 'text-muted-foreground/30' - )} - /> - <div className="flex min-w-0 flex-1 items-center gap-[3px]"> - {Array.from({ length: dots.filled }, (_, i) => ( - <div - key={`f-${i}`} - className="size-2 rounded-full bg-orange-500/80 dark:bg-orange-400/70" - style={{ animationDelay: `${i * 40}ms` }} - /> - ))} - {Array.from({ length: dots.empty }, (_, i) => ( - <div key={`e-${i}`} className="bg-border/50 size-2 rounded-full" /> - ))} + <div + className={cn('flex flex-col grow basis-0 rounded-[10px] gap-1.5 border p-4', borderColor)} + > + <div className="uppercase tracking-[0.04em] text-text-tertiary font-sans text-[11px]/3.5"> + {label} </div> - <div className="shrink-0 text-right"> - <span className="font-display text-lg font-bold tabular-nums leading-none">{streak}</span> - <span className="text-muted-foreground ml-1 text-[10px]"> - {streak === 1 ? 'day' : 'days'} + <div className="flex items-baseline gap-1.5"> + <span className="text-foreground font-sans font-semibold text-[28px]/8 tabular-nums"> + {value} </span> + {subValue && <span className={cn('font-sans text-[11px]/3.5', subColor)}>{subValue}</span>} </div> </div> ) } // --------------------------------------------------------------------------- -// Collector warning — editorial pull-quote +// Capture Heatmap // --------------------------------------------------------------------------- -function CollectorWarning({ - ratio, - oldestDays, - onDeclare +function intensityToAlpha(intensity: number): string { + if (intensity <= 0) return '0D' + if (intensity < 0.1) return '1A' + if (intensity < 0.2) return '26' + if (intensity < 0.3) return '40' + if (intensity < 0.4) return '59' + if (intensity < 0.5) return '73' + if (intensity < 0.6) return '8C' + if (intensity < 0.7) return '99' + if (intensity < 0.8) return 'B3' + if (intensity < 0.9) return 'CC' + return 'E6' +} + +function CaptureHeatmap({ + patterns }: { - ratio: number - oldestDays: number - onDeclare: () => void -}) { - if (ratio <= 3 && oldestDays < 21) return null - - const message = - ratio > 3 - ? "You're collecting faster than processing" - : `Your oldest item is ${oldestDays} days old` - - const detail = - ratio > 3 - ? `At ${ratio}:1 this week, your inbox is growing. Triage or declare bankruptcy.` - : 'Items lose context as they age. Archive what you no longer need.' + patterns: InboxCapturePattern | undefined +}): React.JSX.Element { + const heatmap = patterns?.timeHeatmap + const hasData = Array.isArray(heatmap) && heatmap.length > 0 + + const { maxCount, peakText } = useMemo(() => { + if (!hasData) return { maxCount: 0, peakText: 'No captures yet' } + + let max = 0 + for (let day = 0; day < 7; day++) { + for (const hour of HEATMAP_HOURS) { + const val = (heatmap![hour]?.[day] ?? 0) + (heatmap![hour + 1]?.[day] ?? 0) + if (val > max) max = val + } + } + + return { maxCount: max, peakText: computePeakInfo(heatmap!) } + }, [heatmap, hasData]) return ( - <div className="relative overflow-hidden rounded-xl border border-amber-600/20 dark:border-amber-400/15"> - <div className="absolute inset-y-0 left-0 w-1 bg-amber-500 dark:bg-amber-400" /> - <div className="bg-amber-500/[0.04] px-5 py-4 pl-6"> - <div className="flex items-start gap-3"> - <AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" /> - <div className="flex-1"> - <p className="font-serif text-sm font-semibold text-amber-800 dark:text-amber-300"> - {message} - </p> - <p className="text-muted-foreground mt-1 text-xs leading-relaxed">{detail}</p> + <div className="flex flex-col grow basis-0 rounded-[10px] gap-3.5 border border-border/50 p-4"> + <div className="text-muted-foreground font-sans font-medium text-xs/4">Capture Activity</div> + <div className="[font-synthesis:none] flex gap-1.5 antialiased text-xs/4"> + <div className="flex flex-col pt-4 gap-0.75"> + {DAYS.map((day) => ( + <div + key={day} + className="h-3 inline-block text-[#50505A] font-sans shrink-0 text-[9px]/3" + > + {day} + </div> + ))} + </div> + <div className="flex flex-col gap-0.75"> + <div className="flex h-3 gap-0.75 shrink-0"> + {HEATMAP_HOURS.map((hour) => ( + <div + key={hour} + className="w-3 text-center inline-block text-[#50505A] font-sans shrink-0 text-[9px]/3" + > + {hour} + </div> + ))} </div> + {DAYS.map((_, dayIdx) => ( + <div key={dayIdx} className="flex gap-0.75"> + {HEATMAP_HOURS.map((hour) => { + const val = (heatmap?.[hour]?.[dayIdx] ?? 0) + (heatmap?.[hour + 1]?.[dayIdx] ?? 0) + const intensity = maxCount > 0 ? val / maxCount : 0 + return ( + <div + key={hour} + className="rounded-xs shrink-0 size-3" + style={{ backgroundColor: `#E8A44A${intensityToAlpha(intensity)}` }} + title={`${val} captures`} + /> + ) + })} + </div> + ))} </div> - <button - onClick={onDeclare} - className="ml-7 mt-3 inline-flex items-center gap-1.5 rounded-md bg-amber-600/10 px-3 py-1.5 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-600/20 dark:text-amber-300 dark:hover:bg-amber-400/15" - > - <Archive className="size-3" /> - Declare Bankruptcy - <ArrowRight className="size-3" /> - </button> </div> + <div className="text-text-tertiary font-sans text-[10px]/3.5">{peakText}</div> </div> ) } // --------------------------------------------------------------------------- -// Bankruptcy dialog +// Type Distribution // --------------------------------------------------------------------------- -function BankruptcyDialog({ - oldestDays, - onConfirm, - onCancel, - isPending +function TypeDistribution({ + itemsByType }: { - oldestDays: number - onConfirm: (days: number) => void - onCancel: () => void - isPending: boolean -}) { - const presets = [ - { label: '2 weeks', days: 14 }, - { label: '1 month', days: 30 }, - { label: '3 months', days: 90 } - ].filter((p) => p.days <= oldestDays) - - const [selectedDays, setSelectedDays] = useState(presets[0]?.days ?? 14) + itemsByType: Record<string, number> +}): React.JSX.Element { + const sortedTypes = useMemo( + () => + Object.entries(itemsByType) + .filter(([, count]) => count > 0) + .sort(([, a], [, b]) => b - a), + [itemsByType] + ) - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> - <div - className="bg-card mx-4 w-full max-w-sm rounded-2xl border shadow-2xl" - style={{ boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)' }} - > - <div className="border-b p-5"> - <div className="flex items-center gap-3"> - <div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10"> - <Archive className="size-4 text-amber-600 dark:text-amber-400" /> - </div> - <div> - <h3 className="font-serif text-base font-semibold">Inbox Bankruptcy</h3> - <p className="text-muted-foreground text-xs">Archive old unfiled items</p> - </div> - </div> - </div> + const maxCount = sortedTypes.length > 0 ? sortedTypes[0][1] : 0 - <div className="p-5"> - <p className="text-muted-foreground mb-4 text-xs leading-relaxed"> - Move all unfiled items older than the threshold to Archive. This is reversible. - </p> - - <div className="flex gap-2"> - {presets.map((p) => ( - <button - key={p.days} - onClick={() => setSelectedDays(p.days)} - className={cn( - 'flex-1 rounded-lg border px-3 py-2.5 text-sm font-medium transition-all', - selectedDays === p.days - ? 'border-amber-500/50 bg-amber-500/10 text-amber-700 dark:border-amber-400/40 dark:text-amber-300' - : 'border-border hover:bg-accent' - )} - > - {p.label} - </button> - ))} - </div> - </div> + if (sortedTypes.length === 0) { + return ( + <div className="flex flex-col grow basis-0 rounded-[10px] border border-border/50 p-4 items-center justify-center min-h-[180px]"> + <span className="text-muted-foreground font-serif text-sm italic">No items yet</span> + </div> + ) + } - <div className="flex justify-end gap-2 border-t px-5 py-3"> - <button - onClick={onCancel} - disabled={isPending} - className="text-muted-foreground hover:text-foreground rounded-lg px-4 py-2 text-sm transition-colors" - > - Cancel - </button> - <button - onClick={() => onConfirm(selectedDays)} - disabled={isPending} - className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50 dark:bg-amber-500 dark:hover:bg-amber-600" - > - {isPending ? 'Archiving...' : `Archive >${selectedDays}d`} - </button> - </div> + return ( + <div className="flex flex-col grow basis-0 rounded-[10px] gap-3.5 border border-border/50 p-4"> + <div className="text-muted-foreground font-sans font-medium text-xs/4">By Type</div> + <div className="flex flex-col gap-2.5"> + {sortedTypes.map(([type, count]) => { + const pct = maxCount > 0 ? (count / maxCount) * 100 : 0 + const barColor = TYPE_BAR_COLORS[type] ?? 'bg-muted-foreground/40' + const label = type.charAt(0).toUpperCase() + type.slice(1) + + return ( + <div key={type} className="flex items-center gap-2.5"> + <div className="w-[50px] shrink-0 text-muted-foreground font-sans text-[11px]/3.5"> + {label} + </div> + <div className="flex grow h-2 rounded-sm overflow-clip bg-muted/30"> + <div + className={cn('h-2 rounded-sm transition-all duration-500 ease-out', barColor)} + style={{ width: `${Math.max(pct, 4)}%` }} + /> + </div> + <div className="w-5 shrink-0 text-right text-text-tertiary font-sans text-[11px]/3.5 tabular-nums"> + {count} + </div> + </div> + ) + })} </div> </div> ) } // --------------------------------------------------------------------------- -// Stat pill — compact inline stat +// Filing Row // --------------------------------------------------------------------------- -function StatPill({ - icon: Icon, - label, - value -}: { - icon: typeof Inbox - label: string - value: string | number -}) { +function FilingRow({ item }: { item: InboxFilingHistoryEntry }): React.JSX.Element { + const isLinked = item.filedAction === 'linked' + const Icon = isLinked ? CheckCircle : (TYPE_ICONS[item.itemType] ?? HelpCircle) + const iconColor = isLinked + ? 'text-indigo-500 dark:text-indigo-400' + : (TYPE_ICON_COLORS[item.itemType] ?? 'text-muted-foreground') + return ( - <div className="flex items-center gap-2.5 rounded-lg border border-border/40 bg-card px-3 py-2"> - <Icon className="text-muted-foreground/60 size-3.5" /> - <div className="flex items-baseline gap-1.5"> - <span className="font-display text-base font-bold tabular-nums leading-none">{value}</span> - <span className="text-muted-foreground text-[10px] uppercase tracking-wider">{label}</span> + <div className="flex items-center rounded-md py-1.5 px-3 gap-2.5 hover:bg-surface-active/50 transition-colors"> + <Icon className={cn('size-3 shrink-0', iconColor)} /> + <div className="grow overflow-clip min-w-0"> + <span className="text-foreground font-sans text-xs/4 line-clamp-1"> + {item.itemTitle || 'Untitled'} + </span> </div> + <ArrowRight className="size-2.5 shrink-0 text-text-tertiary" /> + <span className="shrink-0 text-text-tertiary font-sans text-[11px]/3.5 truncate max-w-[160px]"> + {isLinked ? 'Converted to task' : item.filedTo} + </span> + <span className="shrink-0 text-text-tertiary font-sans text-[11px]/3.5 tabular-nums"> + {timeAgo(new Date(item.filedAt))} + </span> </div> ) } // --------------------------------------------------------------------------- -// Main health view +// Main // --------------------------------------------------------------------------- export function InboxHealthView({ className }: InboxHealthViewProps): React.JSX.Element { const { stats, isLoading } = useInboxStats() const { data: historyData } = useInboxFilingHistory() - const bankruptcy = useInboxBankruptcy() - const [showBankruptcy, setShowBankruptcy] = useState(false) + const { data: patterns } = useInboxPatterns() if (isLoading || !stats) { return ( <div className={cn('flex h-64 items-center justify-center', className)}> - <div className="size-6 animate-spin rounded-full border-2 border-amber-500/30 border-t-amber-500" /> + <div className="size-6 animate-spin rounded-full border-2 border-accent-orange/30 border-t-accent-orange" /> </div> ) } - const filingHistory = historyData?.entries ?? [] + const filingHistory = historyData?.entries?.slice(0, 6) ?? [] + const processRate = + stats.capturedThisWeek > 0 + ? Math.round((stats.processedThisWeek / stats.capturedThisWeek) * 100) + : 0 return ( - <div className={cn('mx-auto max-w-2xl space-y-6 px-6 py-6 pb-12', className)}> - {/* Section: Pulse */} - <div className="fade-in-up stagger-1"> - <div className="journal-section-label mb-3">Weekly Pulse</div> - <div className="grid grid-cols-[auto_1fr] gap-6 rounded-xl border border-border/50 bg-card p-5"> - <ArcGauge - ratio={stats.captureProcessRatio} - captured={stats.capturedThisWeek} - processed={stats.processedThisWeek} - /> - <div className="flex flex-col justify-between py-1"> - <div className="flex flex-wrap gap-2"> - <StatPill icon={Inbox} label="pending" value={stats.totalItems} /> - <StatPill icon={Zap} label="today" value={stats.processedToday} /> - <StatPill - icon={Clock} - label="avg" - value={stats.avgTimeToProcess > 0 ? `${Math.round(stats.avgTimeToProcess)}m` : '—'} - /> - <StatPill icon={Moon} label="snoozed" value={stats.snoozedCount} /> - </div> - <StreakDots streak={stats.currentStreak} /> - </div> - </div> - </div> - - {/* Section: Age */} - <div className="fade-in-up stagger-2"> - <div className="journal-section-label mb-3">Item Age</div> - <div className="rounded-xl border border-border/50 bg-card p-5"> - <AgeStrata - fresh={stats.ageDistribution.fresh} - aging={stats.ageDistribution.aging} - stale={stats.ageDistribution.stale} - /> - </div> - </div> - - {/* Section: Warning */} - <div className="fade-in-up stagger-3"> - <CollectorWarning - ratio={stats.captureProcessRatio} - oldestDays={stats.oldestItemDays} - onDeclare={() => setShowBankruptcy(true)} + <div className={cn('flex flex-col grow overflow-y-auto antialiased', className)}> + <div className="fade-in-up stagger-1 flex shrink-0 pt-6 gap-3 px-6"> + <StatCard + label="Captured" + value={stats.totalItems} + subValue={`+${stats.capturedThisWeek} this week`} + subColor="text-accent-green" + /> + <StatCard + label="Processed" + value={stats.processedThisWeek} + subValue={`${processRate}% rate`} + /> + <StatCard + label="Stale" + value={stats.staleCount} + subValue={stats.staleCount > 0 ? 'needs attention' : 'all clear'} + subColor={stats.staleCount > 0 ? 'text-destructive' : 'text-accent-green'} + borderColor={stats.staleCount > 0 ? 'border-destructive/15' : 'border-border/50'} + /> + <StatCard + label="Avg Time to File" + value={formatAvgTime(stats.avgTimeToProcess)} + subValue="" /> </div> - {/* Section: History */} - <div className="fade-in-up stagger-4"> - <div className="journal-section-label mb-3">Recent Activity</div> - <InboxFilingHistoryList items={filingHistory} /> + <div className="fade-in-up stagger-2 flex shrink-0 pt-4 gap-3 px-6"> + <CaptureHeatmap patterns={patterns} /> + <TypeDistribution itemsByType={stats.itemsByType} /> </div> - {showBankruptcy && ( - <BankruptcyDialog - oldestDays={stats.oldestItemDays} - isPending={bankruptcy.isPending} - onCancel={() => setShowBankruptcy(false)} - onConfirm={(days) => { - bankruptcy.mutate(days, { - onSuccess: () => setShowBankruptcy(false) - }) - }} - /> - )} + <div className="fade-in-up stagger-3 flex flex-col shrink-0 pt-4 gap-3 px-6 pb-6"> + <div className="text-muted-foreground font-sans font-medium text-xs/4">Recent Filings</div> + {filingHistory.length > 0 ? ( + <div className="flex flex-col gap-0.5"> + {filingHistory.map((item) => ( + <FilingRow key={item.id} item={item} /> + ))} + </div> + ) : ( + <div className="py-6 text-center text-muted-foreground font-serif text-sm italic"> + No items filed yet + </div> + )} + </div> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/inbox/inbox-list-view.tsx b/apps/desktop/src/renderer/src/pages/inbox/inbox-list-view.tsx index 98b83ded8..d72e5f410 100644 --- a/apps/desktop/src/renderer/src/pages/inbox/inbox-list-view.tsx +++ b/apps/desktop/src/renderer/src/pages/inbox/inbox-list-view.tsx @@ -1,18 +1,10 @@ import { useState, useCallback, useMemo, useEffect } from 'react' import { extractErrorMessage } from '@/lib/ipc-error' -import { Check, Loader2, AlertCircle, Clock, Filter, Play } from '@/lib/icons' +import { Check, Loader2, AlertCircle } from '@/lib/icons' import { useQueryClient } from '@tanstack/react-query' import { useTabs } from '@/contexts/tabs' import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuCheckboxItem, - DropdownMenuSeparator, - DropdownMenuLabel -} from '@/components/ui/dropdown-menu' import { ListView } from '@/components/list-view' import { InboxDetailPanel } from '@/components/inbox-detail' import { BulkActionBar, type ClusterSuggestion } from '@/components/bulk/bulk-action-bar' @@ -21,11 +13,9 @@ import { BulkTagPopover } from '@/components/bulk/bulk-tag-popover' import { ArchiveConfirmationDialog } from '@/components/bulk/archive-confirmation-dialog' import { EmptyState } from '@/components/empty-state/empty-state' import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal' -import { CaptureInput } from '@/components/capture-input' import { inboxService } from '@/services/inbox-service' import type { ReminderMetadata, InboxItemType } from '@memry/contracts/inbox-api' import { detectClusters, getClusterKey } from '@/lib/ai-clustering' -import { getStaleItems, getNonStaleItems } from '@/lib/stale-utils' import { cn } from '@/lib/utils' import { isInputFocused } from '@/hooks/use-keyboard-shortcuts' import { DENSITY_CONFIG } from '@/hooks/use-display-density' @@ -35,63 +25,33 @@ import { useArchiveInboxItem, useBulkArchiveInboxItems, useFileInboxItem, - useInboxSnoozed, useInboxStats, - useInboxFilingHistory, inboxKeys } from '@/hooks/use-inbox' import { useUndoableAction } from '@/hooks/use-undoable-action' import { notesKeys } from '@/hooks/use-notes-query' import { useInboxKeyboard } from '@/hooks/use-inbox-keyboard' -import type { UseInboxNotificationsResult } from '@/hooks/use-inbox-notifications' - -const INBOX_ITEM_TYPES: InboxItemType[] = [ - 'link', - 'note', - 'image', - 'voice', - 'video', - 'clip', - 'pdf', - 'social', - 'reminder' -] - -const INBOX_TYPE_LABELS: Record<InboxItemType, string> = { - link: 'Links', - note: 'Notes', - image: 'Images', - voice: 'Voice', - video: 'Video', - clip: 'Clips', - pdf: 'PDFs', - social: 'Social', - reminder: 'Reminders' -} +import { toast } from 'sonner' const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'] export interface InboxListViewProps { - notifications: UseInboxNotificationsResult className?: string - onEnterTriage?: () => void + selectedTypes: Set<InboxItemType> + showSnoozedItems: boolean } export function InboxListView({ - notifications, className, - onEnterTriage + selectedTypes, + showSnoozedItems }: InboxListViewProps): React.JSX.Element { - const { addToast } = notifications const queryClient = useQueryClient() const { openTab } = useTabs() const density = 'compact' const densityConfig = DENSITY_CONFIG.compact - // Local UI state (declared before hooks that depend on them) - const [showSnoozedItems, setShowSnoozedItems] = useState(false) - // Data hooks const { items: backendItems, @@ -99,13 +59,10 @@ export function InboxListView({ error, refetch } = useInboxList({ includeSnoozed: showSnoozedItems }) - const { data: snoozedItems = [] } = useInboxSnoozed() - const snoozedCount = snoozedItems.length const fileItemMutation = useFileInboxItem() const archiveItemMutation = useArchiveInboxItem() const bulkArchiveMutation = useBulkArchiveInboxItems() - const { archiveWithUndo } = useUndoableAction(addToast) - const [selectedTypes, setSelectedTypes] = useState<Set<InboxItemType>>(new Set()) + const { archiveWithUndo } = useUndoableAction() const [pendingArchiveIds, setPendingArchiveIds] = useState<Set<string>>(new Set()) const [exitingItemIds, setExitingItemIds] = useState<Set<string>>(new Set()) const [isEmptyStateExiting] = useState(false) @@ -134,29 +91,11 @@ export function InboxListView({ }) }, [backendItems, pendingArchiveIds, selectedTypes]) - const itemCountsByType = useMemo(() => { - const counts: Record<InboxItemType, number> = { - link: 0, - note: 0, - image: 0, - voice: 0, - video: 0, - clip: 0, - pdf: 0, - social: 0, - reminder: 0 - } - backendItems.forEach((item) => { - if (!pendingArchiveIds.has(item.id)) counts[item.type]++ - }) - return counts - }, [backendItems, pendingArchiveIds]) - // Empty state data const { stats: inboxStats } = useInboxStats() - const { data: filingHistoryData } = useInboxFilingHistory() const itemsProcessedToday = inboxStats?.processedToday ?? 0 - const hasFilingHistory = (filingHistoryData?.entries?.length ?? 0) > 0 + const processedThisWeek = inboxStats?.processedThisWeek ?? 0 + const currentStreak = inboxStats?.currentStreak ?? 0 // Sync empty state useEffect(() => { @@ -193,9 +132,6 @@ export function InboxListView({ return suggestion }, [selectedItems, items, dismissedSuggestionKeys]) - const staleItems = useMemo(() => getStaleItems(items), [items]) - const nonStaleItems = useMemo(() => getNonStaleItems(items), [items]) - // === OPTIMISTIC ARCHIVE HELPER === const archiveWithAnimation = useCallback( async (id: string, nextFocusId?: string | null): Promise<void> => { @@ -233,11 +169,11 @@ export function InboxListView({ next.delete(id) return next }) - addToast({ message: 'Failed to archive item', type: 'error' }) + toast.error('Failed to archive item') } }, 200) }, - [items, addToast, activeDetailItemId, archiveWithUndo] + [items, activeDetailItemId, archiveWithUndo] ) // === KEYBOARD SHORTCUTS === @@ -249,14 +185,11 @@ export function InboxListView({ isInBulkMode, focusedItemId, items, - staleItems, - nonStaleItems, onOpenShortcutsModal: () => setIsShortcutsModalOpen(true), onRefresh: () => refetch(), onArchiveFocusedItem: (itemId, nextItemId) => archiveWithAnimation(itemId, nextItemId), onOpenBulkArchiveDialog: () => setIsArchiveDialogOpen(true), - onOpenSourceUrl: (url) => window.open(url, '_blank', 'noopener,noreferrer'), - addToast + onOpenSourceUrl: (url) => window.open(url, '_blank', 'noopener,noreferrer') }) // === HANDLERS === @@ -307,15 +240,13 @@ export function InboxListView({ queryClient.invalidateQueries({ queryKey: notesKeys.note(noteId) }) }) } - addToast({ - message: - linkedNoteIds.length > 1 - ? `Linked to ${linkedNoteIds.length} notes` - : linkedNoteIds.length === 1 - ? 'Linked to note' - : `Filed to ${folderId || 'Notes'}`, - type: 'success' - }) + toast.success( + linkedNoteIds.length > 1 + ? `Linked to ${linkedNoteIds.length} notes` + : linkedNoteIds.length === 1 + ? 'Linked to note' + : `Filed to ${folderId || 'Notes'}` + ) } else { throw new Error(result.error || 'Failed to file') } @@ -325,11 +256,11 @@ export function InboxListView({ next.delete(itemId) return next }) - addToast({ message: extractErrorMessage(error, 'Failed to file item'), type: 'error' }) + toast.error(extractErrorMessage(error, 'Failed to file item')) } }, 200) }, - [items, addToast, fileItemMutation, queryClient] + [items, fileItemMutation, queryClient] ) const handleQuickFile = useCallback( @@ -364,7 +295,7 @@ export function InboxListView({ if (result.success) { queryClient.invalidateQueries({ queryKey: inboxKeys.lists() }) - addToast({ message: `Filed to ${folderId || 'Notes'}`, type: 'success' }) + toast.success(`Filed to ${folderId || 'Notes'}`) } else { throw new Error(result.error || 'Failed to file') } @@ -374,11 +305,11 @@ export function InboxListView({ next.delete(itemId) return next }) - addToast({ message: extractErrorMessage(error, 'Failed to file item'), type: 'error' }) + toast.error(extractErrorMessage(error, 'Failed to file item')) } }, 200) }, - [items, addToast, fileItemMutation, queryClient] + [items, fileItemMutation, queryClient] ) const openReminderTarget = useCallback( @@ -434,11 +365,6 @@ export function InboxListView({ const item = items.find((i) => i.id === id) if (!item) return - if (item.type === 'reminder') { - openReminderTarget(item) - return - } - if (isDetailPanelOpen && activeDetailItemId === id) { setActiveDetailItemId(null) } else { @@ -446,7 +372,7 @@ export function InboxListView({ setFocusedItemId(id) } }, - [isDetailPanelOpen, activeDetailItemId, items, openReminderTarget] + [isDetailPanelOpen, activeDetailItemId, items] ) const handleFocusedItemChange = useCallback( @@ -506,7 +432,7 @@ export function InboxListView({ hour: 'numeric', minute: '2-digit' }) - addToast({ message: `Snoozed until ${timeString}`, type: 'success' }) + toast.success(`Snoozed until ${timeString}`) } else { throw new Error(result.error || 'Failed to snooze') } @@ -516,11 +442,11 @@ export function InboxListView({ next.delete(id) return next }) - addToast({ message: extractErrorMessage(error, 'Failed to snooze item'), type: 'error' }) + toast.error(extractErrorMessage(error, 'Failed to snooze item')) } }, 200) }, - [items, addToast, activeDetailItemId, queryClient] + [items, activeDetailItemId, queryClient] ) // === BULK HANDLERS === @@ -543,16 +469,10 @@ export function InboxListView({ if (result.success) { queryClient.invalidateQueries({ queryKey: inboxKeys.lists() }) - addToast({ - message: `Filed ${itemIds.length} items to ${folderId || 'Notes'}`, - type: 'success' - }) + toast.success(`Filed ${itemIds.length} items to ${folderId || 'Notes'}`) } else if (result.errors.length > 0) { queryClient.invalidateQueries({ queryKey: inboxKeys.lists() }) - addToast({ - message: `Filed ${result.processedCount} of ${itemIds.length} items`, - type: 'success' - }) + toast.success(`Filed ${result.processedCount} of ${itemIds.length} items`) } else { throw new Error('Failed to file items') } @@ -562,10 +482,10 @@ export function InboxListView({ itemIds.forEach((id) => next.delete(id)) return next }) - addToast({ message: extractErrorMessage(error, 'Failed to file items'), type: 'error' }) + toast.error(extractErrorMessage(error, 'Failed to file items')) } }, - [addToast, queryClient] + [queryClient] ) const handleBulkTagApply = useCallback( @@ -575,18 +495,17 @@ export function InboxListView({ const result = await window.api.inbox.bulkTag({ itemIds, tags }) if (result.success || result.processedCount > 0) { queryClient.invalidateQueries({ queryKey: inboxKeys.lists() }) - addToast({ - message: `Applied ${tags.length} tag${tags.length !== 1 ? 's' : ''} to ${result.processedCount} item${result.processedCount !== 1 ? 's' : ''}`, - type: 'success' - }) + toast.success( + `Applied ${tags.length} tag${tags.length !== 1 ? 's' : ''} to ${result.processedCount} item${result.processedCount !== 1 ? 's' : ''}` + ) } else { throw new Error('Failed to apply tags') } } catch (error) { - addToast({ message: extractErrorMessage(error, 'Failed to apply tags'), type: 'error' }) + toast.error(extractErrorMessage(error, 'Failed to apply tags')) } }, - [selectedItemIds, queryClient, addToast] + [selectedItemIds, queryClient] ) const handleBulkArchiveConfirm = useCallback((): void => { @@ -613,20 +532,17 @@ export function InboxListView({ try { await bulkArchiveMutation.mutateAsync({ itemIds: idsToArchive }) - addToast({ - message: `Archived ${idsToArchive.length} item${idsToArchive.length !== 1 ? 's' : ''}`, - type: 'success' - }) + toast.success(`Archived ${idsToArchive.length} item${idsToArchive.length !== 1 ? 's' : ''}`) } catch { setPendingArchiveIds((prev) => { const next = new Set(prev) idsToArchive.forEach((id) => next.delete(id)) return next }) - addToast({ message: 'Failed to archive items', type: 'error' }) + toast.error('Failed to archive items') } }, 200) - }, [selectedItemIds, items, activeDetailItemId, addToast, bulkArchiveMutation]) + }, [selectedItemIds, items, activeDetailItemId, bulkArchiveMutation]) const handleAddSuggestionToSelection = useCallback((): void => { if (!aiSuggestion) return @@ -681,10 +597,9 @@ export function InboxListView({ hour: 'numeric', minute: '2-digit' }) - addToast({ - message: `Snoozed ${result.processedCount} item${result.processedCount !== 1 ? 's' : ''} until ${timeString}`, - type: 'success' - }) + toast.success( + `Snoozed ${result.processedCount} item${result.processedCount !== 1 ? 's' : ''} until ${timeString}` + ) } else { throw new Error('Failed to snooze items') } @@ -694,105 +609,46 @@ export function InboxListView({ idsToSnooze.forEach((id) => next.delete(id)) return next }) - addToast({ - message: extractErrorMessage(error, 'Failed to snooze items'), - type: 'error' - }) + toast.error(extractErrorMessage(error, 'Failed to snooze items')) } }, 200) }, - [selectedItemIds, items, activeDetailItemId, addToast, queryClient] + [selectedItemIds, items, activeDetailItemId, queryClient] ) - // === STALE ITEMS HANDLERS === - - const handleFileAllStaleToUnsorted = useCallback((): void => { - if (staleItems.length === 0) return - - const staleIds = staleItems.map((i) => i.id) - setExitingItemIds(new Set(staleIds)) - - setTimeout(async () => { - setPendingArchiveIds((prev) => { - const next = new Set(prev) - staleIds.forEach((id) => next.add(id)) - return next - }) - setExitingItemIds(new Set()) - setSelectedItemIds((prev) => { - const next = new Set(prev) - staleItems.forEach((item) => next.delete(item.id)) - return next - }) - - try { - const result = await window.api.inbox.fileAllStale() - if (result.success || result.processedCount > 0) { - queryClient.invalidateQueries({ queryKey: inboxKeys.lists() }) - addToast({ - message: `Filed ${result.processedCount} stale items to Unsorted`, - type: 'success' - }) - } else { - throw new Error('Failed to file stale items') - } - } catch (error) { - setPendingArchiveIds((prev) => { - const next = new Set(prev) - staleIds.forEach((id) => next.delete(id)) - return next - }) - addToast({ - message: extractErrorMessage(error, 'Failed to file stale items'), - type: 'error' - }) - } - }, 200) - }, [staleItems, addToast, queryClient]) - - const handleReviewStaleItems = useCallback((): void => { - if (staleItems.length === 0) return - const staleIds = new Set(staleItems.map((i) => i.id)) - setSelectedItemIds(staleIds) - if (staleItems[0]) setFocusedItemId(staleItems[0].id) - }, [staleItems]) - // === IMAGE CAPTURE HANDLERS === - const handleImageCapture = useCallback( - async (file: File): Promise<void> => { - if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { - addToast({ message: `Unsupported image type: ${file.type}`, type: 'error' }) - return - } + const handleImageCapture = useCallback(async (file: File): Promise<void> => { + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + toast.error(`Unsupported image type: ${file.type}`) + return + } - const MAX_SIZE = 50 * 1024 * 1024 - if (file.size > MAX_SIZE) { - addToast({ message: 'Image too large (max 50MB)', type: 'error' }) - return - } + const MAX_SIZE = 50 * 1024 * 1024 + if (file.size > MAX_SIZE) { + toast.error('Image too large (max 50MB)') + return + } - setIsCapturingImage(true) - try { - const arrayBuffer = await file.arrayBuffer() - const result = await inboxService.captureImage({ - data: arrayBuffer, - filename: file.name, - mimeType: file.type - }) - if (result.success) { - addToast({ message: 'Image captured', type: 'success' }) - } else { - throw new Error(result.error || 'Failed to capture image') - } - } catch (error) { - addToast({ message: extractErrorMessage(error, 'Failed to capture image'), type: 'error' }) - } finally { - setIsCapturingImage(false) + setIsCapturingImage(true) + try { + const arrayBuffer = await file.arrayBuffer() + const result = await inboxService.captureImage({ + data: arrayBuffer, + filename: file.name, + mimeType: file.type + }) + if (result.success) { + toast.success('Image captured') + } else { + throw new Error(result.error || 'Failed to capture image') } - }, - [addToast] - ) + } catch (error) { + toast.error(extractErrorMessage(error, 'Failed to capture image')) + } finally { + setIsCapturingImage(false) + } + }, []) const handleDragOver = useCallback((e: React.DragEvent): void => { e.preventDefault() @@ -850,273 +706,151 @@ export function InboxListView({ // === RENDER === return ( - <div - className={cn( - 'flex flex-col h-full relative', - densityConfig.pagePadding, - isDraggingOver && 'ring-2 ring-primary/50 ring-inset bg-primary/5', - className - )} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - {isDraggingOver && ( - <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm pointer-events-none"> - <div className="flex flex-col items-center gap-3 p-8 rounded-xl border-2 border-dashed border-primary/50 bg-background/90"> - <div className="size-12 rounded-full bg-primary/10 flex items-center justify-center"> - <svg - className="size-6 text-primary" - fill="none" - viewBox="0 0 24 24" - stroke="currentColor" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" - /> - </svg> + <div className={cn('flex h-full overflow-hidden', className)}> + <div + className={cn( + 'flex flex-col flex-1 min-w-0 h-full relative', + 'px-4 lg:px-6 pt-3 pb-4 lg:pb-6', + isDraggingOver && 'ring-2 ring-primary/50 ring-inset bg-primary/5' + )} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + {isDraggingOver && ( + <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm pointer-events-none"> + <div className="flex flex-col items-center gap-3 p-8 rounded-xl border-2 border-dashed border-primary/50 bg-background/90"> + <div className="size-12 rounded-full bg-primary/10 flex items-center justify-center"> + <svg + className="size-6 text-primary" + fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" + /> + </svg> + </div> + <p className="text-sm font-medium text-foreground">Drop image to capture</p> + <p className="text-xs text-muted-foreground">PNG, JPEG, GIF, WebP, SVG</p> </div> - <p className="text-sm font-medium text-foreground">Drop image to capture</p> - <p className="text-xs text-muted-foreground">PNG, JPEG, GIF, WebP, SVG</p> </div> - </div> - )} + )} - {isCapturingImage && ( - <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm pointer-events-none"> - <div className="flex flex-col items-center gap-3"> - <Loader2 className="size-8 text-primary animate-spin" /> - <p className="text-sm text-muted-foreground">Capturing image...</p> + {isCapturingImage && ( + <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm pointer-events-none"> + <div className="flex flex-col items-center gap-3"> + <Loader2 className="size-8 text-primary animate-spin" /> + <p className="text-sm text-muted-foreground">Capturing image...</p> + </div> </div> - </div> - )} - - {/* Header */} - <header className={cn('relative', densityConfig.headerMargin)}> - <div className="relative z-10"> - {isInBulkMode ? ( - <div className="flex items-start justify-between gap-6"> - <div> - <div className="flex items-center gap-3 mb-1"> - <Check className="size-5 text-amber-600 dark:text-amber-400" aria-hidden="true" /> - <h1 className="font-display text-2xl lg:text-3xl font-normal tracking-tight text-foreground/90"> - {selectedCount} selected - </h1> - </div> - <p className="font-serif text-sm text-muted-foreground/70 tracking-wide pl-8"> - Ready to process - </p> + )} + + {/* Bulk selection header */} + {isInBulkMode && ( + <header className={cn('relative', densityConfig.headerMargin)}> + <div className="flex items-center justify-between gap-6"> + <div className="flex items-center gap-3"> + <Check className="size-4 text-amber-600 dark:text-amber-400" aria-hidden="true" /> + <span className="text-sm font-medium text-foreground"> + {selectedCount} selected + </span> </div> <Button variant="ghost" size="sm" onClick={handleDeselectAll} - className={cn( - 'text-muted-foreground/60 hover:text-foreground', - 'hover:bg-foreground/5' - )} + className="text-muted-foreground/60 hover:text-foreground hover:bg-foreground/5" > Deselect all </Button> </div> - ) : ( - <div className="flex items-start justify-between gap-4"> - <div className="flex-1"> - <CaptureInput - density={density} - onCaptureSuccess={() => addToast({ message: 'Item captured', type: 'success' })} - onCaptureError={(errorMsg) => addToast({ message: errorMsg, type: 'error' })} - /> - </div> - <div className="flex items-center gap-2 mt-1.5"> - {onEnterTriage && nonStaleItems.length > 0 && ( - <Button - variant="ghost" - size="sm" - onClick={onEnterTriage} - className="text-muted-foreground/60 hover:text-foreground hover:bg-foreground/5 gap-1.5" - title="Process inbox (Cmd+P)" - > - <Play className="size-3.5" /> - <span className="text-xs">Process</span> - </Button> - )} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="icon" - className={cn( - 'size-8 relative', - 'text-muted-foreground/60 hover:text-foreground', - 'hover:bg-foreground/5', - selectedTypes.size > 0 && 'text-amber-600 dark:text-amber-400' - )} - title={ - selectedTypes.size > 0 - ? `Filtering by ${selectedTypes.size} type${selectedTypes.size > 1 ? 's' : ''}` - : 'Filter by type' - } - > - <Filter className="size-4" /> - {selectedTypes.size > 0 && ( - <span className="absolute -top-1 -right-1 size-4 text-[10px] font-medium bg-amber-600 dark:bg-amber-500 text-white rounded-full flex items-center justify-center"> - {selectedTypes.size} - </span> - )} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-48"> - <DropdownMenuLabel className="text-xs text-muted-foreground/70"> - Filter by type - </DropdownMenuLabel> - <DropdownMenuSeparator /> - {INBOX_ITEM_TYPES.map((type) => { - const count = itemCountsByType[type] - return ( - <DropdownMenuCheckboxItem - key={type} - checked={selectedTypes.has(type)} - onCheckedChange={(checked) => { - setSelectedTypes((prev) => { - const next = new Set(prev) - if (checked) next.add(type) - else next.delete(type) - return next - }) - }} - onSelect={(e) => e.preventDefault()} - disabled={count === 0} - className={cn(count === 0 && 'opacity-50')} - > - <span className="flex-1">{INBOX_TYPE_LABELS[type]}</span> - <span className="text-xs text-muted-foreground/60 ml-2">{count}</span> - </DropdownMenuCheckboxItem> - ) - })} - {selectedTypes.size > 0 && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuCheckboxItem - checked={false} - onCheckedChange={() => setSelectedTypes(new Set())} - onSelect={(e) => e.preventDefault()} - className="text-muted-foreground/70" - > - Clear all - </DropdownMenuCheckboxItem> - </> - )} - </DropdownMenuContent> - </DropdownMenu> - - <Button - variant="ghost" - size="icon" - onClick={() => setShowSnoozedItems(!showSnoozedItems)} - className={cn( - 'size-8 relative', - 'text-muted-foreground/60 hover:text-foreground', - 'hover:bg-foreground/5', - showSnoozedItems && 'bg-amber-500/10 text-amber-600 dark:text-amber-400' - )} - title={ - showSnoozedItems - ? 'Hide snoozed items' - : `Show snoozed items${snoozedCount > 0 ? ` (${snoozedCount})` : ''}` - } - > - <Clock className="size-4" /> - {snoozedCount > 0 && ( - <span className="absolute -top-1 -right-1 size-4 text-[10px] font-medium bg-amber-600 dark:bg-amber-500 text-white rounded-full flex items-center justify-center"> - {snoozedCount} - </span> - )} - </Button> - </div> + </header> + )} + + {/* Content */} + <div className={cn('flex-1 overflow-y-auto', isInBulkMode && 'pb-32')}> + {isLoading ? ( + <div className="flex flex-col items-center justify-center h-64 gap-4"> + <Loader2 className="size-8 text-muted-foreground/50 animate-spin" /> + <p className="text-sm text-muted-foreground/60 font-serif">Loading inbox...</p> </div> + ) : error ? ( + <div className="flex flex-col items-center justify-center h-64 gap-4"> + <AlertCircle className="size-8 text-destructive/60" /> + <p className="text-sm text-destructive/80 font-serif">Failed to load inbox</p> + <Button variant="outline" size="sm" onClick={() => refetch()}> + Try again + </Button> + </div> + ) : showEmptyState ? ( + <EmptyState + itemsProcessedToday={itemsProcessedToday} + processedThisWeek={processedThisWeek} + currentStreak={currentStreak} + isExiting={isEmptyStateExiting} + /> + ) : ( + <ListView + items={items} + selectedItemIds={selectedItemIds} + exitingItemIds={exitingItemIds} + density={density} + onPreview={handlePreview} + onArchive={handleArchive} + onSnooze={handleSnooze} + onQuickFile={handleQuickFile} + onSelectionChange={handleSelectionChange} + focusedItemId={focusedItemId} + onFocusedItemChange={handleFocusedItemChange} + isPreviewOpen={isDetailPanelOpen} + /> )} </div> - </header> - - {/* Content */} - <div className={cn('flex-1 overflow-y-auto', isInBulkMode && 'pb-32')}> - {isLoading ? ( - <div className="flex flex-col items-center justify-center h-64 gap-4"> - <Loader2 className="size-8 text-muted-foreground/50 animate-spin" /> - <p className="text-sm text-muted-foreground/60 font-serif">Loading inbox...</p> - </div> - ) : error ? ( - <div className="flex flex-col items-center justify-center h-64 gap-4"> - <AlertCircle className="size-8 text-destructive/60" /> - <p className="text-sm text-destructive/80 font-serif">Failed to load inbox</p> - <Button variant="outline" size="sm" onClick={() => refetch()}> - Try again - </Button> - </div> - ) : showEmptyState ? ( - <EmptyState - itemsProcessedToday={itemsProcessedToday} - hasFilingHistory={hasFilingHistory} - isExiting={isEmptyStateExiting} - /> - ) : ( - <ListView - items={nonStaleItems} - staleItems={staleItems} - selectedItemIds={selectedItemIds} - exitingItemIds={exitingItemIds} - density={density} - onPreview={handlePreview} - onArchive={handleArchive} - onSnooze={handleSnooze} - onQuickFile={handleQuickFile} - onSelectionChange={handleSelectionChange} - onFileAllStale={handleFileAllStaleToUnsorted} - onReviewStale={handleReviewStaleItems} - focusedItemId={focusedItemId} - onFocusedItemChange={handleFocusedItemChange} - isPreviewOpen={isDetailPanelOpen} - /> - )} - </div> - - {/* Bulk & Detail components */} - <BulkActionBar - selectedCount={selectedCount} - onFileAll={() => setIsBulkFilePanelOpen(true)} - onTagAll={() => setIsBulkTagPopoverOpen(true)} - onArchiveAll={() => setIsArchiveDialogOpen(true)} - onSnoozeAll={handleBulkSnoozeAll} - aiSuggestion={aiSuggestion} - onAddSuggestionToSelection={handleAddSuggestionToSelection} - onDismissSuggestion={handleDismissSuggestion} - /> - <BulkFilePanel - isOpen={isBulkFilePanelOpen} - items={selectedItems} - onClose={() => setIsBulkFilePanelOpen(false)} - onFile={handleBulkFileComplete} - /> - - <BulkTagPopover - isOpen={isBulkTagPopoverOpen} - itemCount={selectedCount} - trigger={<span />} - onOpenChange={setIsBulkTagPopoverOpen} - onApplyTags={handleBulkTagApply} - /> - - <ArchiveConfirmationDialog - isOpen={isArchiveDialogOpen} - itemCount={selectedCount} - onConfirm={handleBulkArchiveConfirm} - onCancel={() => setIsArchiveDialogOpen(false)} - /> + {/* Bulk & Detail components */} + <BulkActionBar + selectedCount={selectedCount} + onFileAll={() => setIsBulkFilePanelOpen(true)} + onTagAll={() => setIsBulkTagPopoverOpen(true)} + onArchiveAll={() => setIsArchiveDialogOpen(true)} + onSnoozeAll={handleBulkSnoozeAll} + aiSuggestion={aiSuggestion} + onAddSuggestionToSelection={handleAddSuggestionToSelection} + onDismissSuggestion={handleDismissSuggestion} + /> + + <BulkFilePanel + isOpen={isBulkFilePanelOpen} + items={selectedItems} + onClose={() => setIsBulkFilePanelOpen(false)} + onFile={handleBulkFileComplete} + /> + + <BulkTagPopover + isOpen={isBulkTagPopoverOpen} + itemCount={selectedCount} + trigger={<span />} + onOpenChange={setIsBulkTagPopoverOpen} + onApplyTags={handleBulkTagApply} + /> + + <ArchiveConfirmationDialog + isOpen={isArchiveDialogOpen} + itemCount={selectedCount} + onConfirm={handleBulkArchiveConfirm} + onCancel={() => setIsArchiveDialogOpen(false)} + /> + + <KeyboardShortcutsModal + isOpen={isShortcutsModalOpen} + onClose={() => setIsShortcutsModalOpen(false)} + /> + </div> <InboxDetailPanel isOpen={isDetailPanelOpen} @@ -1126,11 +860,6 @@ export function InboxListView({ onFile={handleFilingComplete} onArchive={handleArchive} /> - - <KeyboardShortcutsModal - isOpen={isShortcutsModalOpen} - onClose={() => setIsShortcutsModalOpen(false)} - /> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/inbox/triage-view.tsx b/apps/desktop/src/renderer/src/pages/inbox/triage-view.tsx index 13c87b354..96579d46f 100644 --- a/apps/desktop/src/renderer/src/pages/inbox/triage-view.tsx +++ b/apps/desktop/src/renderer/src/pages/inbox/triage-view.tsx @@ -5,32 +5,42 @@ import { useUndoableAction } from '@/hooks/use-undoable-action' import { useTabs } from '@/contexts/tabs' import { TriageProgress } from '@/components/inbox/triage-progress' import { TriageItemCard } from '@/components/inbox/triage-item-card' -import { TriageActionBar } from '@/components/inbox/triage-action-bar' +import { TriageActionBar, type ActivePicker } from '@/components/inbox/triage-action-bar' import { TriageComplete } from '@/components/inbox/triage-complete' +import { TriageSnoozePicker } from '@/components/inbox/triage-snooze-picker' import { StreakBadge } from '@/components/inbox/streak-badge' +import { FilingSection, useFilingState } from '@/components/inbox-detail/filing-section' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -import { Loader2 } from '@/lib/icons' -import type { Toast } from '@/components/ui/toast' +import { Loader2, X, Check } from '@/lib/icons' +import { createLogger } from '@/lib/logger' +import { extractErrorMessage } from '@/lib/ipc-error' +import { toast } from 'sonner' import type { FileItemInput, SnoozeInput } from '@/services/inbox-service' import type { ReminderMetadata } from '@memry/contracts/inbox-api' +const log = createLogger('Component:TriageView') + type SlideDirection = 'left' | 'right' | null interface TriageViewProps { onExit: () => void - addToast: (toast: Omit<Toast, 'id'>) => void } -export function TriageView({ onExit, addToast }: TriageViewProps): React.JSX.Element | null { +export function TriageView({ onExit }: TriageViewProps): React.JSX.Element | null { const { state, actions } = useTriageQueue() const { stats } = useInboxStats() - const { archiveWithUndo } = useUndoableAction(addToast) + const { archiveWithUndo } = useUndoableAction() const { openTab } = useTabs() const [slideDir, setSlideDir] = useState<SlideDirection>(null) const [isAnimating, setIsAnimating] = useState(false) const [showComplete, setShowComplete] = useState(false) + const [activePicker, setActivePicker] = useState<ActivePicker>(null) const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined) + const { selectedFolder, tags, linkedNotes, setSelectedFolder, setTags, setLinkedNotes, canFile } = + useFilingState({ item: state.currentItem, isOpen: activePicker === 'file' }) + useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) @@ -49,17 +59,21 @@ export function TriageView({ onExit, addToast }: TriageViewProps): React.JSX.Ele } }, [state.isLoading, state.isComplete, state.completedCount]) + const closePicker = useCallback(() => setActivePicker(null), []) + const animateAndAct = useCallback( (direction: SlideDirection, action: () => Promise<void> | void) => { if (isAnimating) return setIsAnimating(true) setSlideDir(direction) + setActivePicker(null) timeoutRef.current = setTimeout(async () => { try { await action() } catch (err) { - console.error('Triage action failed:', err) + log.error('Triage action failed:', err) + toast.error(extractErrorMessage(err, 'Action failed')) } finally { setSlideDir(null) setIsAnimating(false) @@ -98,6 +112,27 @@ export function TriageView({ onExit, addToast }: TriageViewProps): React.JSX.Ele [animateAndAct, actions.defer] ) + const handleFileSubmit = useCallback((): void => { + if (!selectedFolder || !state.currentItem) return + const linkedNoteIds = linkedNotes.map((n) => n.id) + handleFile({ + itemId: state.currentItem.id, + destination: + linkedNoteIds.length > 0 + ? { type: 'note', noteIds: linkedNoteIds, path: selectedFolder.path || '' } + : { type: 'folder', path: selectedFolder.path || '' }, + tags: tags.length > 0 ? tags : undefined + }) + }, [selectedFolder, linkedNotes, tags, state.currentItem, handleFile]) + + const handleSnoozeSelect = useCallback( + (snoozeUntil: string): void => { + if (!state.currentItem) return + handleDefer({ itemId: state.currentItem.id, snoozeUntil }) + }, + [state.currentItem, handleDefer] + ) + const handleOpenTarget = useCallback(() => { const item = state.currentItem if (!item || item.type !== 'reminder' || !item.metadata) return @@ -166,45 +201,96 @@ export function TriageView({ onExit, addToast }: TriageViewProps): React.JSX.Ele const streak = stats?.currentStreak ?? 0 return ( - <div className="flex flex-1 flex-col"> - <div className="flex items-center"> - <div className="flex-1"> - <TriageProgress - current={state.currentIndex} - total={state.totalItems} - completed={state.completedCount} - /> + <div className="flex flex-1 flex-col bg-background"> + <header className="flex shrink-0 items-center justify-between px-8 py-5"> + <div className="flex items-center gap-3"> + <button + type="button" + onClick={onExit} + className="text-muted-foreground transition-colors hover:text-foreground" + aria-label="Exit triage mode" + > + <X className="size-4" /> + </button> + <span className="text-sm font-medium text-foreground">Triage Mode</span> + <span className="text-xs text-text-tertiary">Esc to exit</span> </div> - {streak > 0 && ( - <div className="pr-6"> - <StreakBadge streak={streak} /> - </div> - )} + <div className="flex items-center gap-3"> + {streak > 0 && <StreakBadge streak={streak} />} + <span className="text-xs tabular-nums text-muted-foreground"> + {state.currentIndex + 1} of {state.totalItems} + </span> + </div> + </header> + + <div className="shrink-0 px-8"> + <TriageProgress completed={state.completedCount} total={state.totalItems} /> </div> - <div className="flex-1 overflow-y-auto"> - <div - key={state.currentItem.id} - className={cn( - 'transition-[transform,opacity] duration-250 ease-out will-change-[transform,opacity]', - slideDir === 'left' && '-translate-x-full scale-95 opacity-0', - slideDir === 'right' && 'translate-x-full scale-95 opacity-0', - !slideDir && 'animate-in fade-in slide-in-from-bottom-3 duration-300' + <div className="flex flex-1 items-center justify-center overflow-y-auto p-8"> + <div className="flex items-start justify-center gap-6"> + <div + key={state.currentItem.id} + className={cn( + 'shrink-0 transition-all duration-300 ease-out', + slideDir === 'left' && '-translate-x-full scale-95 opacity-0', + slideDir === 'right' && 'translate-x-full scale-95 opacity-0', + !slideDir && 'animate-in fade-in slide-in-from-bottom-3 duration-300' + )} + > + <TriageItemCard item={state.currentItem} /> + </div> + + {activePicker === 'file' && ( + <div className="w-[320px] shrink-0 animate-in fade-in slide-in-from-right-4 duration-300"> + <div className="overflow-hidden rounded-xl border border-foreground/[0.08] bg-card"> + <FilingSection + item={state.currentItem} + selectedFolder={selectedFolder} + tags={tags} + linkedNotes={linkedNotes} + onFolderSelect={setSelectedFolder} + onTagsChange={setTags} + onLinkedNotesChange={setLinkedNotes} + /> + <div className="flex items-center gap-2 border-t border-border px-5 py-3"> + <Button + size="sm" + onClick={handleFileSubmit} + disabled={!canFile} + className="flex-1 border-0 bg-tint text-tint-foreground hover:bg-tint-hover" + > + <Check className="mr-1.5 size-4" aria-hidden="true" /> + File + </Button> + <Button + variant="ghost" + size="sm" + onClick={closePicker} + className="text-muted-foreground" + > + Cancel + </Button> + </div> + </div> + </div> + )} + + {activePicker === 'snooze' && ( + <div className="w-[280px] shrink-0 animate-in fade-in slide-in-from-right-4 duration-300"> + <TriageSnoozePicker onSelect={handleSnoozeSelect} onCancel={closePicker} /> + </div> )} - > - <TriageItemCard item={state.currentItem} /> </div> </div> <TriageActionBar - itemId={state.currentItem.id} itemType={state.currentItem.type} + activePicker={activePicker} + onPickerChange={setActivePicker} onDiscard={handleDiscard} onConvertToTask={handleConvertToTask} onExpandToNote={handleExpandToNote} - onFile={handleFile} - onDefer={handleDefer} - onDismissReminder={handleDiscard} onOpenTarget={handleOpenTarget} disabled={isAnimating} /> diff --git a/apps/desktop/src/renderer/src/pages/journal.tsx b/apps/desktop/src/renderer/src/pages/journal.tsx index 4d5c88556..819ac4f18 100644 --- a/apps/desktop/src/renderer/src/pages/journal.tsx +++ b/apps/desktop/src/renderer/src/pages/journal.tsx @@ -32,7 +32,7 @@ import { useNoteTagsQuery } from '@/hooks/use-notes-query' import { usePropertySection } from '@/hooks/use-property-section' import { useTemplates } from '@/hooks/use-templates' import { useJournalSettings } from '@/hooks/use-journal-settings' -import { useNoteEditorSettings } from '@/hooks/use-note-editor-settings' +import { useEditorSettings } from '@/hooks/use-editor-settings' import { ExportDialog } from '@/components/note/export-dialog' import { VersionHistory } from '@/components/note/version-history' import { toast } from 'sonner' @@ -157,7 +157,7 @@ export function JournalPage({ className }: JournalPageProps): React.JSX.Element } = useJournalSettings() // Editor settings - const { settings: editorSettings } = useNoteEditorSettings() + const { settings: editorSettings } = useEditorSettings() // Bookmark state - use entry.id (e.g., "j2026-01-13") to match notes_cache lookup const { isBookmarked, toggle: toggleBookmark } = useIsBookmarked('journal', entry?.id ?? '') @@ -796,7 +796,7 @@ export function JournalPage({ className }: JournalPageProps): React.JSX.Element }} > {entryError && ( - <div className="mb-4 px-4 py-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm"> + <div className="mb-4 px-4 py-3 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm"> <span className="font-medium">Error:</span> {entryError} </div> )} @@ -817,13 +817,13 @@ export function JournalPage({ className }: JournalPageProps): React.JSX.Element <button onClick={() => setShowTemplateSelector(true)} className={cn( - 'w-full flex items-center gap-3 px-4 py-3 rounded-lg', + 'w-full flex items-center gap-3 px-4 py-3 rounded-md', 'border border-dashed border-amber-300/50 dark:border-amber-700/50', 'bg-gradient-to-r from-amber-50/50 to-orange-50/30 dark:from-amber-950/20 dark:to-orange-950/10', 'hover:border-amber-400/60 dark:hover:border-amber-600/60 transition-all duration-200 text-left group' )} > - <div className="w-9 h-9 rounded-lg bg-gradient-to-br from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/30 flex items-center justify-center border border-amber-200/50 dark:border-amber-800/30 shadow-sm"> + <div className="w-9 h-9 rounded-md bg-gradient-to-br from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/30 flex items-center justify-center border border-amber-200/50 dark:border-amber-800/30 shadow-sm"> <FileText className="w-4 h-4 text-amber-700 dark:text-amber-400" /> </div> <div className="flex-1"> diff --git a/apps/desktop/src/renderer/src/pages/note.tsx b/apps/desktop/src/renderer/src/pages/note.tsx index 6b4831949..508c346de 100644 --- a/apps/desktop/src/renderer/src/pages/note.tsx +++ b/apps/desktop/src/renderer/src/pages/note.tsx @@ -42,7 +42,7 @@ import { import { toast } from 'sonner' import { registerPendingSave, unregisterPendingSave } from '@/lib/save-registry' import { useIsBookmarked } from '@/hooks/use-bookmarks' -import { useNoteEditorSettings } from '@/hooks/use-note-editor-settings' +import { useEditorSettings } from '@/hooks/use-editor-settings' import { extractErrorMessage } from '@/lib/ipc-error' import { createLogger } from '@/lib/logger' import { LocalGraphPanel } from '@/components/graph/local-graph-panel' @@ -171,8 +171,15 @@ export function NotePage({ noteId }: NotePageProps) { // Bookmark state const { isBookmarked, toggle: toggleBookmark } = useIsBookmarked('note', noteId ?? '') - // Editor settings (toolbar mode) - const { settings: editorSettings } = useNoteEditorSettings() + // Editor settings (toolbar mode, width, spellCheck, autoSaveDelay, showWordCount) + const { settings: editorSettings } = useEditorSettings() + + const editorWidthClass = + { + narrow: 'max-w-2xl', + medium: 'max-w-3xl', + wide: 'max-w-5xl' + }[editorSettings.width] ?? 'max-w-3xl' // Find in page (Cmd+F) const editorContainerRef = useRef<HTMLDivElement>(null) @@ -420,7 +427,7 @@ export function NotePage({ noteId }: NotePageProps) { clearTimeout(saveTimeoutRef.current) } - // Debounce save (500ms) + // Debounce save (configurable via editor settings, default 1000ms) saveTimeoutRef.current = setTimeout(async () => { isSavingRef.current = true try { @@ -435,9 +442,17 @@ export function NotePage({ noteId }: NotePageProps) { } finally { isSavingRef.current = false } - }, 500) + }, editorSettings.autoSaveDelay) }, - [noteId, note, updateNote.mutateAsync, isDeleted, isLocalGraphOpen, queryClient] + [ + noteId, + note, + updateNote.mutateAsync, + isDeleted, + isLocalGraphOpen, + queryClient, + editorSettings.autoSaveDelay + ] ) const handleContentChange = useCallback((_blocks: Block[]) => { @@ -829,10 +844,10 @@ export function NotePage({ noteId }: NotePageProps) { noteEmoji={note.emoji ?? null} /> } - stats={documentStats} + stats={editorSettings.showWordCount ? documentStats : undefined} > {/* Note content */} - <div className="flex flex-col gap-6"> + <div className={cn('flex flex-col gap-6 mx-auto w-full', editorWidthClass)}> {/* Title + Tags */} <div className="flex flex-col gap-4"> <NoteTitle @@ -902,6 +917,7 @@ export function NotePage({ noteId }: NotePageProps) { contentType="markdown" placeholder="Start writing, or press '/' for commands..." stickyToolbar={editorSettings.toolbarMode === 'sticky'} + spellCheck={editorSettings.spellCheck} onContentChange={handleContentChange} onMarkdownChange={handleMarkdownChange} onHeadingsChange={handleHeadingsChange} diff --git a/apps/desktop/src/renderer/src/pages/settings.tsx b/apps/desktop/src/renderer/src/pages/settings.tsx index be9007e97..9f343bc56 100644 --- a/apps/desktop/src/renderer/src/pages/settings.tsx +++ b/apps/desktop/src/renderer/src/pages/settings.tsx @@ -2,17 +2,17 @@ import { useState, useEffect } from 'react' import { ScrollArea } from '@/components/ui/scroll-area' import { FileText, - ChevronRight, Settings as SettingsIcon, FolderOpen, Palette, BookOpen, Brain, - Cloud, PenLine, Plug, Tags, - ListChecks + ListChecks, + Key, + User } from '@/lib/icons' import { cn } from '@/lib/utils' import { GeneralSettings } from './settings/general-section' @@ -22,10 +22,11 @@ import { JournalSettings } from './settings/journal-section' import { VaultSettings } from './settings/vault-section' import { AppearanceSettings } from './settings/appearance-section' import { AISettings } from './settings/ai-section' -import { SyncSettings } from './settings/sync-section' import { IntegrationsSettings } from './settings/integrations-section' import { TagsSettings } from './settings/tags-section' import { TasksSettings } from './settings/tasks-section' +import { ShortcutsSettings } from './settings/shortcuts-section' +import { AccountSettings } from './settings/account-section' type SettingsSection = | 'general' @@ -36,14 +37,15 @@ type SettingsSection = | 'vault' | 'appearance' | 'ai' - | 'sync' | 'integrations' | 'tags' + | 'shortcuts' + | 'account' export function SettingsPage() { const [activeSection, setActiveSection] = useState<SettingsSection>(() => { const saved = localStorage.getItem('memry_settings_section') - return (saved as SettingsSection) || 'templates' + return (saved as SettingsSection) || 'general' }) useEffect(() => { @@ -62,84 +64,98 @@ export function SettingsPage() { return ( <div className="h-full flex"> - {/* Sidebar */} - <div className="w-48 border-r bg-muted/30 flex-shrink-0"> - <div className="p-4"> - <h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider"> + <div className="w-60 shrink-0 pt-5 pb-4 bg-sidebar border-r border-border text-xs/4 font-[family-name:var(--font-sans)]"> + <div className="flex items-center pb-4 px-5"> + <span className="text-sm/4.5 font-semibold text-foreground tracking-[-0.01em]"> Settings - </h2> + </span> </div> - <nav className="px-2"> + + <SettingsNavGroup label="Workspace"> + <SettingsNavItem + icon={<User className="w-3.5 h-3.5" />} + label="Account" + isActive={activeSection === 'account'} + onClick={() => setActiveSection('account')} + /> <SettingsNavItem - icon={<SettingsIcon className="w-4 h-4" />} + icon={<SettingsIcon className="w-3.5 h-3.5" />} label="General" isActive={activeSection === 'general'} onClick={() => setActiveSection('general')} /> <SettingsNavItem - icon={<FileText className="w-4 h-4" />} + icon={<FileText className="w-3.5 h-3.5" />} label="Templates" isActive={activeSection === 'templates'} onClick={() => setActiveSection('templates')} /> <SettingsNavItem - icon={<PenLine className="w-4 h-4" />} + icon={<PenLine className="w-3.5 h-3.5" />} label="Editor" isActive={activeSection === 'editor'} onClick={() => setActiveSection('editor')} /> <SettingsNavItem - icon={<BookOpen className="w-4 h-4" />} + icon={<BookOpen className="w-3.5 h-3.5" />} label="Journal" isActive={activeSection === 'journal'} onClick={() => setActiveSection('journal')} /> <SettingsNavItem - icon={<ListChecks className="w-4 h-4" />} + icon={<ListChecks className="w-3.5 h-3.5" />} label="Tasks" isActive={activeSection === 'tasks'} onClick={() => setActiveSection('tasks')} /> + </SettingsNavGroup> + + <SettingsNavGroup label="Preferences"> <SettingsNavItem - icon={<FolderOpen className="w-4 h-4" />} - label="Vault" - isActive={activeSection === 'vault'} - onClick={() => setActiveSection('vault')} - /> - <SettingsNavItem - icon={<Palette className="w-4 h-4" />} + icon={<Palette className="w-3.5 h-3.5" />} label="Appearance" isActive={activeSection === 'appearance'} onClick={() => setActiveSection('appearance')} /> <SettingsNavItem - icon={<Brain className="w-4 h-4" />} + icon={<Key className="w-3.5 h-3.5" />} + label="Shortcuts" + isActive={activeSection === 'shortcuts'} + onClick={() => setActiveSection('shortcuts')} + /> + </SettingsNavGroup> + + <SettingsNavGroup label="Services"> + <SettingsNavItem + icon={<Brain className="w-3.5 h-3.5" />} label="AI Assistant" isActive={activeSection === 'ai'} onClick={() => setActiveSection('ai')} /> <SettingsNavItem - icon={<Cloud className="w-4 h-4" />} - label="Sync" - isActive={activeSection === 'sync'} - onClick={() => setActiveSection('sync')} - /> - <SettingsNavItem - icon={<Plug className="w-4 h-4" />} + icon={<Plug className="w-3.5 h-3.5" />} label="Integrations" isActive={activeSection === 'integrations'} onClick={() => setActiveSection('integrations')} /> + </SettingsNavGroup> + + <SettingsNavGroup label="Data"> + <SettingsNavItem + icon={<FolderOpen className="w-3.5 h-3.5" />} + label="Vault" + isActive={activeSection === 'vault'} + onClick={() => setActiveSection('vault')} + /> <SettingsNavItem - icon={<Tags className="w-4 h-4" />} + icon={<Tags className="w-3.5 h-3.5" />} label="Tags" isActive={activeSection === 'tags'} onClick={() => setActiveSection('tags')} /> - </nav> + </SettingsNavGroup> </div> - {/* Content */} <div className="flex-1 overflow-hidden"> <ScrollArea className="h-full"> <div className="p-6 max-w-3xl mx-auto"> @@ -151,9 +167,10 @@ export function SettingsPage() { {activeSection === 'vault' && <VaultSettings />} {activeSection === 'appearance' && <AppearanceSettings />} {activeSection === 'ai' && <AISettings />} - {activeSection === 'sync' && <SyncSettings />} {activeSection === 'integrations' && <IntegrationsSettings />} {activeSection === 'tags' && <TagsSettings />} + {activeSection === 'shortcuts' && <ShortcutsSettings />} + {activeSection === 'account' && <AccountSettings />} </div> </ScrollArea> </div> @@ -161,6 +178,17 @@ export function SettingsPage() { ) } +function SettingsNavGroup({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <div className="flex flex-col mt-4 first:mt-0 px-2 gap-px"> + <span className="uppercase pb-1.5 px-3 text-[11px]/3.5 font-medium tracking-[0.05em] text-muted-foreground/60"> + {label} + </span> + {children} + </div> + ) +} + interface SettingsNavItemProps { icon: React.ReactNode label: string @@ -174,14 +202,15 @@ function SettingsNavItem({ icon, label, isActive, onClick }: SettingsNavItemProp type="button" onClick={onClick} className={cn( - 'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors', - 'hover:bg-accent/50', - isActive && 'bg-accent text-accent-foreground' + 'relative flex items-center h-7 shrink-0 rounded-[5px] px-3 transition-colors', + isActive ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50' )} > - {icon} - <span>{label}</span> - {isActive && <ChevronRight className="w-4 h-4 ml-auto" />} + <span className="shrink-0">{icon}</span> + <span className="pl-2 text-[13px]/4 font-medium">{label}</span> + {isActive && ( + <span className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-[var(--tint)] rounded-r-sm" /> + )} </button> ) } diff --git a/apps/desktop/src/renderer/src/pages/settings/account-section.tsx b/apps/desktop/src/renderer/src/pages/settings/account-section.tsx new file mode 100644 index 000000000..a60852282 --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/settings/account-section.tsx @@ -0,0 +1,290 @@ +import { useState, useEffect, useCallback } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Switch } from '@/components/ui/switch' +import { RefreshCw } from '@/lib/icons' +import { toast } from 'sonner' +import { extractErrorMessage } from '@/lib/ipc-error' +import { useAuth } from '@/contexts/auth-context' +import { useSync } from '@/contexts/sync-context' +import { useSyncStatus } from '@/hooks/use-sync-status' +import { SetupWizard } from './setup-wizard' +import { QrLinking } from '@/components/sync/qr-linking' +import { LinkingApprovalDialog } from '@/components/sync/linking-approval-dialog' +import { DeviceList } from '@/components/sync/device-list' +import { KeyRotationWizard } from '@/components/sync/key-rotation-wizard' +import { RecoveryKeyDialog } from '@/components/settings/recovery-key-dialog' +import type { StorageBreakdownResult } from '@memry/contracts/ipc-sync-ops' +import { + SettingsHeader, + SettingsGroup, + SettingRow, + ACCENT_SWITCH +} from '@/components/settings/settings-primitives' + +function formatBytes(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` +} + +const STORAGE_COLORS: Record<string, string> = { + notes: '#6366f1', + attachments: '#f97316', + crdt: '#22c55e', + other: '#8c8c8c' +} + +export function AccountSettings() { + const { state, logout } = useAuth() + const { linkingRequest, clearLinkingRequest } = useSync() + const syncStatus = useSyncStatus() + const [storage, setStorage] = useState<StorageBreakdownResult | null>(null) + const [showSignOutDialog, setShowSignOutDialog] = useState(false) + const [signingOut, setSigningOut] = useState(false) + const [showLinkingQr, setShowLinkingQr] = useState(false) + const [showRotationWizard, setShowRotationWizard] = useState(false) + const [showRecoveryKey, setShowRecoveryKey] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + + const loadStorage = useCallback(async () => { + if (state.status !== 'authenticated') return + setIsRefreshing(true) + try { + const result = await window.api.syncOps.getStorageBreakdown() + setStorage(result) + } catch { + /* storage is non-critical */ + } finally { + setIsRefreshing(false) + } + }, [state.status]) + + useEffect(() => { + loadStorage() + }, [loadStorage]) + + const handleSignOut = useCallback(async () => { + setSigningOut(true) + try { + await logout() + toast.success('Signed out successfully') + } catch (error: unknown) { + toast.error(extractErrorMessage(error, 'Failed to sign out')) + } finally { + setSigningOut(false) + setShowSignOutDialog(false) + } + }, [logout]) + + if (state.status === 'checking') { + return ( + <div className="flex flex-col antialiased"> + <SettingsHeader title="Account" subtitle="Loading..." /> + </div> + ) + } + + if (state.status !== 'authenticated') { + return ( + <div className="flex flex-col items-center antialiased text-xs/4"> + <div className="w-full max-w-sm"> + <SetupWizard /> + </div> + </div> + ) + } + + const email = state.email + const initial = (email ?? 'U').charAt(0).toUpperCase() + const isSyncActive = syncStatus.status !== 'paused' + const isToggleDisabled = syncStatus.status === 'syncing' || syncStatus.status === 'offline' + + return ( + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader title="Account" subtitle="Your account, sync, and security" /> + + <SettingsGroup label="Identity"> + <div className="flex items-center gap-3 h-14 py-3 px-4"> + <div + className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-white text-sm font-semibold" + style={{ backgroundColor: 'var(--tint)' }} + > + {initial} + </div> + <div className="flex flex-col gap-px min-w-0"> + <span className="font-medium text-[13px]/4 text-foreground truncate"> + {email ?? 'Unknown'} + </span> + <span className="text-xs/4 text-muted-foreground">Pro plan</span> + </div> + </div> + </SettingsGroup> + + <SettingsGroup label="Sync"> + <div className="flex items-center justify-between h-11 px-4 shrink-0"> + <div className="flex items-center gap-2"> + <div className={`shrink-0 rounded-sm size-2 ${syncStatus.dotColor}`} /> + <div className="flex flex-col gap-px"> + <span className="font-medium text-[13px]/4 text-foreground">{syncStatus.label}</span> + <span className="text-xs/4 text-muted-foreground"> + Last synced {syncStatus.lastSyncLabel} + {syncStatus.pendingCount > 0 && ` · ${syncStatus.pendingCount} pending`} + </span> + </div> + </div> + <Switch + checked={isSyncActive} + disabled={isToggleDisabled} + onCheckedChange={(checked) => void (checked ? syncStatus.resume() : syncStatus.pause())} + className={ACCENT_SWITCH} + /> + </div> + </SettingsGroup> + + {storage && ( + <SettingsGroup label="Storage"> + <div className="py-3 px-4 space-y-3"> + <div className="flex items-center justify-between"> + <span className="font-semibold text-[13px]/4 text-foreground"> + {formatBytes(storage.used)} of {formatBytes(storage.limit)} used + </span> + <Button + variant="ghost" + size="sm" + onClick={() => void loadStorage()} + disabled={isRefreshing} + className="h-7 w-7 p-0" + > + <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} /> + </Button> + </div> + + <div className="h-2 rounded-full bg-muted overflow-hidden flex"> + {Object.entries(storage.breakdown).map(([key, bytes]) => { + const pct = storage.limit > 0 ? (bytes / storage.limit) * 100 : 0 + return ( + <div + key={key} + className="h-full first:rounded-l-full last:rounded-r-full" + style={{ + width: `${pct}%`, + backgroundColor: STORAGE_COLORS[key] ?? '#8c8c8c' + }} + /> + ) + })} + </div> + + <div className="flex items-center gap-4 flex-wrap"> + {Object.entries(storage.breakdown).map(([key, bytes]) => ( + <div key={key} className="flex items-center gap-1.5"> + <span + className="w-2 h-2 rounded-full shrink-0" + style={{ backgroundColor: STORAGE_COLORS[key] ?? '#8c8c8c' }} + /> + <span className="text-xs/4 text-muted-foreground capitalize">{key}</span> + </div> + ))} + </div> + </div> + </SettingsGroup> + )} + + <SettingsGroup label="Devices"> + <DeviceList onLinkDevice={() => setShowLinkingQr(true)} /> + </SettingsGroup> + + <SettingsGroup label="Security"> + <SettingRow label="Recovery Key" description="View your recovery key for data access"> + <Button + variant="outline" + size="sm" + onClick={() => setShowRecoveryKey(true)} + className="h-7 px-3 text-xs/4" + > + View Key + </Button> + </SettingRow> + + <SettingRow + label="Rotate Encryption Keys" + description="Generate new keys and re-encrypt all data" + > + <Button + variant="outline" + size="sm" + onClick={() => setShowRotationWizard(true)} + className="h-7 px-3 text-xs/4" + > + Rotate + </Button> + </SettingRow> + </SettingsGroup> + + <SettingsGroup> + <SettingRow label="Sign Out" description="Disconnect this device from sync"> + <Button + variant="outline" + size="sm" + onClick={() => setShowSignOutDialog(true)} + className="h-7 px-3 text-xs/4 text-destructive border-destructive/30 hover:bg-destructive/10" + > + Sign Out + </Button> + </SettingRow> + </SettingsGroup> + + <AlertDialog open={showSignOutDialog} onOpenChange={setShowSignOutDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Sign out of sync?</AlertDialogTitle> + <AlertDialogDescription> + Sync will stop and encryption keys will be removed from this device. Your notes will + remain on this device. You'll need your recovery phrase to set up sync again. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={signingOut}>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={handleSignOut} + disabled={signingOut} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {signingOut ? 'Signing out...' : 'Sign out'} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + <KeyRotationWizard open={showRotationWizard} onOpenChange={setShowRotationWizard} /> + <RecoveryKeyDialog open={showRecoveryKey} onOpenChange={setShowRecoveryKey} /> + + <Dialog open={showLinkingQr} onOpenChange={setShowLinkingQr}> + <DialogContent className="sm:max-w-[400px] rounded-xl"> + <QrLinking onCancel={() => setShowLinkingQr(false)} /> + </DialogContent> + </Dialog> + + <LinkingApprovalDialog + open={!!linkingRequest} + event={linkingRequest} + onApprove={() => { + clearLinkingRequest() + toast.success('Device linked successfully') + }} + onReject={clearLinkingRequest} + /> + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/pages/settings/ai-inline-section.tsx b/apps/desktop/src/renderer/src/pages/settings/ai-inline-section.tsx index 67cf7c232..10447dee8 100644 --- a/apps/desktop/src/renderer/src/pages/settings/ai-inline-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/ai-inline-section.tsx @@ -1,9 +1,7 @@ import { useState, useCallback, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' import { Select, SelectContent, @@ -11,19 +9,26 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' -import { Sparkles, Info, Loader2, CheckCircle, XCircle, Eye, EyeOff } from '@/lib/icons' +import { Loader2, CheckCircle, XCircle, Eye, EyeOff } from '@/lib/icons' import { toast } from 'sonner' import { extractErrorMessage } from '@/lib/ipc-error' import { createLogger } from '@/lib/logger' import type { AIInlineSettings } from '@memry/contracts/ai-inline-channels' import { AI_INLINE_SETTINGS_DEFAULTS } from '@memry/contracts/ai-inline-channels' +import { + SettingsGroup, + SettingRow, + SettingRowTall, + ACCENT_SWITCH, + COMPACT_SELECT +} from '@/components/settings/settings-primitives' const log = createLogger('Page:Settings:AIInline') const PROVIDER_OPTIONS = [ - { value: 'ollama', label: 'Ollama (Local)', description: 'Free, runs on your device' }, - { value: 'openai', label: 'OpenAI', description: 'GPT-4, GPT-4o, etc.' }, - { value: 'anthropic', label: 'Anthropic', description: 'Claude Sonnet, Opus, etc.' } + { value: 'ollama', label: 'Ollama (Local)' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'anthropic', label: 'Anthropic' } ] as const const MODEL_PRESETS: Record<string, string[]> = { @@ -132,14 +137,11 @@ export function AIInlineSettings(): React.JSX.Element { if (isLoading) { return ( - <div className="space-y-4"> - <div> - <h4 className="text-base font-semibold flex items-center gap-2"> - <Sparkles className="w-4 h-4" /> - Inline AI Editing - </h4> - <p className="text-sm text-muted-foreground">Loading...</p> - </div> + <div className="pb-6"> + <h4 className="uppercase pb-2 text-muted-foreground font-medium text-[11px]/3.5 tracking-[0.05em]"> + Inline AI Editing + </h4> + <p className="text-xs/4 text-muted-foreground">Loading...</p> </div> ) } @@ -148,157 +150,117 @@ export function AIInlineSettings(): React.JSX.Element { const models = MODEL_PRESETS[settings.provider] ?? [] return ( - <div className="space-y-4"> - <div> - <h4 className="text-base font-semibold flex items-center gap-2"> - <Sparkles className="w-4 h-4" /> - Inline AI Editing - </h4> - <p className="text-sm text-muted-foreground"> - Select text in the editor to access AI commands like rewrite, summarize, and translate. - </p> - </div> - - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="ai-inline-enabled">Enable Inline AI</Label> - <p className="text-sm text-muted-foreground">Show AI menu when editing notes</p> - </div> + <SettingsGroup label="Inline AI Editing"> + <SettingRow label="Enable Inline AI" description="Show AI menu when editing notes"> <Switch - id="ai-inline-enabled" checked={settings.enabled} onCheckedChange={handleToggleEnabled} + className={ACCENT_SWITCH} /> - </div> + </SettingRow> {settings.enabled && ( <> - <Separator /> + <SettingRow label="Provider" description="AI service for text operations"> + <Select value={settings.provider} onValueChange={handleProviderChange}> + <SelectTrigger className={COMPACT_SELECT}> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {PROVIDER_OPTIONS.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </SettingRow> - <div className="space-y-4"> - <div className="space-y-2"> - <Label>Provider</Label> - <Select value={settings.provider} onValueChange={handleProviderChange}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {PROVIDER_OPTIONS.map((opt) => ( - <SelectItem key={opt.value} value={opt.value}> - <div className="flex flex-col"> - <span>{opt.label}</span> - <span className="text-xs text-muted-foreground">{opt.description}</span> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - </div> + <SettingRow label="Model" description="Language model for rewrite and summarize"> + <Select value={settings.model} onValueChange={(model) => void updateSetting({ model })}> + <SelectTrigger className={COMPACT_SELECT}> + <SelectValue placeholder="Select a model" /> + </SelectTrigger> + <SelectContent> + {models.map((model) => ( + <SelectItem key={model} value={model}> + {model} + </SelectItem> + ))} + </SelectContent> + </Select> + </SettingRow> - <div className="space-y-2"> - <Label>Model</Label> - <Select - value={settings.model} - onValueChange={(model) => void updateSetting({ model })} - > - <SelectTrigger> - <SelectValue placeholder="Select a model" /> - </SelectTrigger> - <SelectContent> - {models.map((model) => ( - <SelectItem key={model} value={model}> - {model} - </SelectItem> - ))} - </SelectContent> - </Select> - <p className="text-xs text-muted-foreground"> - {settings.provider === 'ollama' - ? 'Make sure this model is pulled in Ollama first' - : 'Choose the model for AI editing'} - </p> - </div> - - {needsApiKey && ( - <div className="space-y-2"> - <Label>API Key</Label> - <div className="flex gap-2"> - <div className="relative flex-1"> - <Input - type={showApiKey ? 'text' : 'password'} - value={settings.apiKey} - onChange={(e) => setSettings((prev) => ({ ...prev, apiKey: e.target.value }))} - onBlur={() => void updateSetting({ apiKey: settings.apiKey })} - placeholder={`Enter ${settings.provider === 'openai' ? 'OpenAI' : 'Anthropic'} API key`} - /> - </div> - <Button - variant="ghost" - size="icon" - onClick={() => setShowApiKey((v) => !v)} - tabIndex={-1} - > - {showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} - </Button> - </div> - <p className="text-xs text-muted-foreground"> - Stored securely in your local vault. Never sent anywhere except the provider. - </p> - </div> - )} - - {settings.provider === 'ollama' && ( - <div className="space-y-2"> - <Label>Ollama URL</Label> + {needsApiKey && ( + <SettingRowTall label="API Key" description="Stored locally, sent only to the provider"> + <div className="flex gap-2"> <Input - value={settings.baseUrl} - onChange={(e) => setSettings((prev) => ({ ...prev, baseUrl: e.target.value }))} - onBlur={() => void updateSetting({ baseUrl: settings.baseUrl })} - placeholder="http://localhost:11434/v1" + type={showApiKey ? 'text' : 'password'} + value={settings.apiKey} + onChange={(e) => setSettings((prev) => ({ ...prev, apiKey: e.target.value }))} + onBlur={() => void updateSetting({ apiKey: settings.apiKey })} + placeholder={`Enter ${settings.provider === 'openai' ? 'OpenAI' : 'Anthropic'} API key`} + className="flex-1 h-7 text-xs/4" /> + <Button + variant="ghost" + size="sm" + onClick={() => setShowApiKey((v) => !v)} + tabIndex={-1} + className="h-7 w-7 p-0" + > + {showApiKey ? ( + <EyeOff className="w-3.5 h-3.5" /> + ) : ( + <Eye className="w-3.5 h-3.5" /> + )} + </Button> </div> - )} + </SettingRowTall> + )} - <div className="flex items-center gap-3"> - <Button - variant="outline" - onClick={handleTestConnection} - disabled={isTesting || (needsApiKey && !settings.apiKey)} - > - {isTesting ? ( - <> - <Loader2 className="w-4 h-4 animate-spin mr-2" /> - Testing... - </> - ) : ( - 'Test Connection' - )} - </Button> - {serverPort && ( - <span className="flex items-center gap-1 text-sm text-green-600"> - <CheckCircle className="w-4 h-4" /> - Active on port {serverPort} - </span> - )} - {!serverPort && !isTesting && ( - <span className="flex items-center gap-1 text-sm text-muted-foreground"> - <XCircle className="w-4 h-4" /> - Not connected - </span> + {settings.provider === 'ollama' && ( + <SettingRowTall label="Ollama URL" description="Local server address"> + <Input + value={settings.baseUrl} + onChange={(e) => setSettings((prev) => ({ ...prev, baseUrl: e.target.value }))} + onBlur={() => void updateSetting({ baseUrl: settings.baseUrl })} + placeholder="http://localhost:11434/v1" + className="h-7 text-xs/4" + /> + </SettingRowTall> + )} + + <div className="flex items-center justify-between h-11 py-3 px-4 shrink-0"> + <div className="flex items-center gap-2 min-w-0"> + {serverPort ? ( + <> + <span className="w-2 h-2 rounded-full bg-green-500 shrink-0" /> + <span className="text-[13px]/4 font-medium text-foreground">Connection</span> + <span className="text-xs/4 text-muted-foreground"> + Active on port {serverPort} + </span> + </> + ) : ( + <> + <span className="w-2 h-2 rounded-full bg-muted-foreground/40 shrink-0" /> + <span className="text-[13px]/4 font-medium text-foreground">Connection</span> + <span className="text-xs/4 text-muted-foreground">Not connected</span> + </> )} </div> - </div> - - <div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 text-sm"> - <Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> - <p className="text-muted-foreground"> - {settings.provider === 'ollama' - ? 'Ollama runs entirely on your device. Install it from ollama.com and pull a model (e.g. ollama pull llama3.2).' - : `API calls are sent directly to ${settings.provider === 'openai' ? 'OpenAI' : 'Anthropic'}. Usage is billed by the provider.`} - </p> + <Button + variant="outline" + size="sm" + onClick={handleTestConnection} + disabled={isTesting || (needsApiKey && !settings.apiKey)} + className="h-7 px-3 text-xs/4" + > + {isTesting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Test'} + </Button> </div> </> )} - </div> + </SettingsGroup> ) } diff --git a/apps/desktop/src/renderer/src/pages/settings/ai-section.tsx b/apps/desktop/src/renderer/src/pages/settings/ai-section.tsx index 3c013f5ba..bddac35a7 100644 --- a/apps/desktop/src/renderer/src/pages/settings/ai-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/ai-section.tsx @@ -1,13 +1,17 @@ import { useState, useCallback, useEffect } from 'react' import { Button } from '@/components/ui/button' -import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { Brain, Info, Loader2, CheckCircle, XCircle, RefreshCw } from '@/lib/icons' +import { Brain, Loader2, CheckCircle, XCircle, RefreshCw } from '@/lib/icons' import { toast } from 'sonner' import { extractErrorMessage } from '@/lib/ipc-error' import { createLogger } from '@/lib/logger' import { AIInlineSettings as AIInlineSettingsPanel } from './ai-inline-section' +import { + SettingsHeader, + SettingsGroup, + SettingRow, + ACCENT_SWITCH +} from '@/components/settings/settings-primitives' const log = createLogger('Page:Settings:AI') @@ -139,104 +143,92 @@ export function AISettings() { if (isLoading) { return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">AI Assistant</h3> - <p className="text-sm text-muted-foreground">Loading settings...</p> - </div> + <div className="flex flex-col antialiased"> + <SettingsHeader title="AI Assistant" subtitle="Loading settings..." /> </div> ) } return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">AI Assistant</h3> - <p className="text-sm text-muted-foreground"> - Configure AI-powered features like smart filing suggestions. All AI processing runs - locally on your device. - </p> - </div> - - <Separator /> - - {/* Enable/Disable AI */} - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="ai-enabled">Enable AI Features</Label> - <p className="text-sm text-muted-foreground"> - Use AI to suggest folders and tags when filing items - </p> - </div> - <Switch id="ai-enabled" checked={settings.enabled} onCheckedChange={handleToggleEnabled} /> - </div> - - <Separator /> + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader + title="AI Assistant" + subtitle="All AI processing runs locally on your device" + /> - {/* Local Model Status */} - <div className="space-y-4"> - <div> - <Label>Local Embedding Model</Label> - <p className="text-sm text-muted-foreground mt-1"> - Embeddings are generated locally using the all-MiniLM-L6-v2 model. No data is sent to - external servers. - </p> - </div> + <SettingsGroup> + <SettingRow + label="Enable AI Features" + description="Smart filing suggestions and note connections" + > + <Switch + checked={settings.enabled} + onCheckedChange={handleToggleEnabled} + className={ACCENT_SWITCH} + /> + </SettingRow> + </SettingsGroup> - {/* Model Info Card */} - <div className="rounded-lg border p-4 space-y-3"> + <SettingsGroup label="Local Embedding Model"> + <div className="py-3 px-4 space-y-3"> <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> - <Brain className="w-5 h-5 text-muted-foreground" /> - <span className="font-medium">{modelStatus?.name || 'all-MiniLM-L6-v2'}</span> - </div> - <div className="flex items-center gap-2"> + <Brain className="w-4 h-4 text-muted-foreground" /> + <span className="font-medium text-[13px]/4"> + {modelStatus?.name || 'all-MiniLM-L6-v2'} + </span> {modelStatus?.loaded ? ( - <span className="flex items-center gap-1 text-sm text-green-600"> - <CheckCircle className="w-4 h-4" /> + <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]/3 font-medium bg-green-500/15 text-green-600"> Loaded </span> ) : modelStatus?.loading || isLoadingModel ? ( - <span className="flex items-center gap-1 text-sm text-amber-600"> - <Loader2 className="w-4 h-4 animate-spin" /> - Loading... + <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]/3 font-medium bg-amber-500/15 text-amber-600"> + <Loader2 className="w-3 h-3 animate-spin" /> + Loading </span> - ) : ( - <span className="flex items-center gap-1 text-sm text-muted-foreground"> - <XCircle className="w-4 h-4" /> - Not loaded - </span> - )} + ) : null} </div> </div> - <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="text-xs/4 text-muted-foreground"> + ~23MB · Cached locally · All on-device + </div> + + <div className="flex gap-6"> <div> - <span className="text-muted-foreground">Dimensions:</span> - <span className="ml-2">{modelStatus?.dimension || 384}</span> + <span className="uppercase text-[10px]/3 font-medium tracking-[0.05em] text-muted-foreground"> + Dimensions + </span> + <p className="text-[13px]/4 font-semibold text-foreground"> + {modelStatus?.dimension || 384} + </p> </div> <div> - <span className="text-muted-foreground">Embeddings:</span> - <span className="ml-2">{modelStatus?.embeddingCount ?? 0}</span> + <span className="uppercase text-[10px]/3 font-medium tracking-[0.05em] text-muted-foreground"> + Embeddings + </span> + <p className="text-[13px]/4 font-semibold text-foreground"> + {(modelStatus?.embeddingCount ?? 0).toLocaleString()} + </p> </div> </div> {modelStatus?.error && ( - <div className="text-sm text-red-600 flex items-center gap-1"> - <XCircle className="w-4 h-4" /> + <div className="text-xs text-destructive flex items-center gap-1"> + <XCircle className="w-3.5 h-3.5" /> {modelStatus.error} </div> )} {!modelStatus?.loaded && !isLoadingModel && ( - <Button onClick={handleLoadModel} className="w-full"> + <Button onClick={handleLoadModel} size="sm" className="w-full"> Download & Load Model </Button> )} {isLoadingModel && reindexProgress && ( - <div className="space-y-2"> - <div className="flex justify-between text-sm text-muted-foreground"> + <div className="space-y-1.5"> + <div className="flex justify-between text-[10px]/3 text-muted-foreground"> <span> {reindexProgress.phase === 'downloading' ? 'Downloading model...' @@ -244,62 +236,40 @@ export function AISettings() { </span> <span>{Math.round(reindexProgress.current)}%</span> </div> - <div className="h-2 bg-muted rounded-full overflow-hidden"> + <div className="h-1.5 bg-muted rounded-full overflow-hidden"> <div - className="h-full bg-primary transition-all duration-300" + className="h-full bg-[var(--tint)] transition-all duration-300 rounded-full" style={{ width: `${reindexProgress.current}%` }} /> </div> </div> )} </div> + </SettingsGroup> - {/* Info hint */} - <div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 text-sm"> - <Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> - <p className="text-muted-foreground"> - The model (~23MB) will be downloaded once and cached locally. All embedding generation - happens on your device for complete privacy. - </p> - </div> - </div> - - <Separator /> - - {/* Reindex Embeddings */} - <div className="space-y-4"> - <div> - <Label>Embedding Index</Label> - <p className="text-sm text-muted-foreground mt-1"> - Rebuild the AI embeddings index for all notes. This enables better similarity matching - for filing suggestions. - </p> - </div> - - <Button - variant="outline" - onClick={handleReindexEmbeddings} - disabled={isReindexing || !modelStatus?.loaded || !settings.enabled} - > - {isReindexing ? ( - <> - <Loader2 className="w-4 h-4 animate-spin mr-2" /> - Reindexing... - </> - ) : ( - <> - <RefreshCw className="w-4 h-4 mr-2" /> - Rebuild Index - </> - )} - </Button> - + <SettingsGroup label="Embedding Index"> + <SettingRow label="Rebuild Index" description="Regenerate embeddings for all notes"> + <Button + variant="outline" + size="sm" + onClick={handleReindexEmbeddings} + disabled={isReindexing || !modelStatus?.loaded || !settings.enabled} + className="gap-1.5" + > + {isReindexing ? ( + <Loader2 className="w-3.5 h-3.5 animate-spin" /> + ) : ( + <RefreshCw className="w-3.5 h-3.5" /> + )} + Rebuild + </Button> + </SettingRow> {isReindexing && reindexProgress && reindexProgress.phase !== 'downloading' && reindexProgress.phase !== 'loading' && ( - <div className="space-y-2"> - <div className="flex justify-between text-sm text-muted-foreground"> + <div className="px-4 pb-3 space-y-1.5"> + <div className="flex justify-between text-[10px]/3 text-muted-foreground"> <span> {reindexProgress.phase === 'scanning' ? 'Scanning notes...' @@ -311,9 +281,9 @@ export function AISettings() { {reindexProgress.current} / {reindexProgress.total} </span> </div> - <div className="h-2 bg-muted rounded-full overflow-hidden"> + <div className="h-1.5 bg-muted rounded-full overflow-hidden"> <div - className="h-full bg-primary transition-all duration-300" + className="h-full bg-[var(--tint)] transition-all duration-300 rounded-full" style={{ width: `${reindexProgress.total > 0 ? (reindexProgress.current / reindexProgress.total) * 100 : 0}%` }} @@ -321,18 +291,7 @@ export function AISettings() { </div> </div> )} - - {/* Info hint */} - <div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 text-sm"> - <Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> - <p className="text-muted-foreground"> - The embedding index is built automatically when notes are created or modified. Use this - button to rebuild from scratch if suggestions seem inaccurate. - </p> - </div> - </div> - - <Separator /> + </SettingsGroup> <AIInlineSettingsPanel /> </div> diff --git a/apps/desktop/src/renderer/src/pages/settings/appearance-section.tsx b/apps/desktop/src/renderer/src/pages/settings/appearance-section.tsx index 4c1bcc7a9..5e25f4577 100644 --- a/apps/desktop/src/renderer/src/pages/settings/appearance-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/appearance-section.tsx @@ -1,5 +1,4 @@ -import { useCallback, useState } from 'react' -import { Separator } from '@/components/ui/separator' +import { type ComponentType, Fragment, useCallback, useState } from 'react' import { Select, SelectContent, @@ -7,13 +6,17 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' -import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' -import { Sun, Moon, Monitor, Check, ALargeSmall, FileText } from '@/lib/icons' +import { Sun, Moon, Monitor, FileText } from '@/lib/icons' import { useGeneralSettings } from '@/hooks/use-general-settings' import { toast } from 'sonner' import { cn } from '@/lib/utils' +import { + SettingsHeader, + SettingsGroup, + SettingRow, + COMPACT_SELECT +} from '@/components/settings/settings-primitives' const ACCENT_PRESETS = [ { value: '#6366f1', label: 'Indigo' }, @@ -28,6 +31,71 @@ const ACCENT_PRESETS = [ const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/ +interface SegmentOption { + value: string + label: string + icon?: ComponentType<{ className?: string }> +} + +function SegmentedControl({ + options, + value, + onValueChange, + ariaLabel +}: { + options: readonly SegmentOption[] + value: string + onValueChange: (v: string) => void + ariaLabel: string +}) { + return ( + <div + role="group" + aria-label={ariaLabel} + className="flex items-center shrink-0 rounded-lg overflow-hidden border border-border" + > + {options.map((opt, i) => { + const isActive = value === opt.value + const prevActive = i > 0 && value === options[i - 1].value + const Icon = opt.icon + + return ( + <Fragment key={opt.value}> + {i > 0 && !isActive && !prevActive && <div className="w-px h-5 bg-border shrink-0" />} + <button + type="button" + aria-pressed={isActive} + onClick={() => onValueChange(opt.value)} + className={cn( + 'flex items-center gap-1.5 py-1.5 px-3 text-xs transition-colors cursor-pointer', + isActive + ? 'bg-tint text-tint-foreground font-semibold' + : 'bg-foreground/[0.04] text-muted-foreground hover:text-foreground' + )} + > + {Icon && <Icon className="size-3" />} + {opt.label} + </button> + </Fragment> + ) + })} + </div> + ) +} + +const THEME_OPTIONS: SegmentOption[] = [ + { value: 'light', label: 'Warm', icon: Sun }, + { value: 'white', label: 'White', icon: FileText }, + { value: 'dark', label: 'Dark', icon: Moon }, + { value: 'system', label: 'System', icon: Monitor } +] + +const FONT_SIZE_OPTIONS: SegmentOption[] = [ + { value: 'small', label: 'S' }, + { value: 'medium', label: 'M' }, + { value: 'large', label: 'L' } +] + export function AppearanceSettings() { const { settings, isLoading, updateSettings } = useGeneralSettings() const [customHex, setCustomHex] = useState('') @@ -69,7 +137,14 @@ export function AppearanceSettings() { const handleFontFamilyChange = useCallback( async (value: string) => { - const fontFamily = value as 'system' | 'serif' | 'sans-serif' | 'monospace' + const fontFamily = value as + | 'system' + | 'serif' + | 'sans-serif' + | 'monospace' + | 'gelasio' + | 'geist' + | 'inter' const success = await updateSettings({ fontFamily }) if (!success) toast.error('Failed to update font family') }, @@ -78,175 +153,103 @@ export function AppearanceSettings() { if (isLoading) { return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Appearance</h3> - <p className="text-sm text-muted-foreground">Loading settings...</p> - </div> + <div className="flex flex-col antialiased"> + <SettingsHeader title="Appearance" subtitle="Loading settings..." /> </div> ) } return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Appearance</h3> - <p className="text-sm text-muted-foreground">Customize the look and feel</p> - </div> + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader title="Appearance" subtitle="Customize the look and feel" /> - <Separator /> - - {/* Theme */} - <div className="space-y-6"> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Theme - </h4> - - <div className="space-y-2"> - <Label>Color Mode</Label> - <p className="text-sm text-muted-foreground"> - Choose a color mode or follow your system preference - </p> - <ToggleGroup - type="single" + <SettingsGroup label="Theme"> + <SettingRow label="Color Mode" description="Choose your preferred theme"> + <SegmentedControl + options={THEME_OPTIONS} value={settings.theme} onValueChange={handleThemeChange} - className="justify-start" - > - <ToggleGroupItem value="light" aria-label="Light theme" className="gap-2 px-4"> - <Sun className="w-4 h-4" /> - Warm - </ToggleGroupItem> - <ToggleGroupItem value="white" aria-label="White theme" className="gap-2 px-4"> - <FileText className="w-4 h-4" /> - White - </ToggleGroupItem> - <ToggleGroupItem value="dark" aria-label="Dark theme" className="gap-2 px-4"> - <Moon className="w-4 h-4" /> - Dark - </ToggleGroupItem> - <ToggleGroupItem value="system" aria-label="System theme" className="gap-2 px-4"> - <Monitor className="w-4 h-4" /> - System - </ToggleGroupItem> - </ToggleGroup> - </div> - </div> - - <Separator /> - - {/* Accent Color */} - <div className="space-y-6"> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Accent Color - </h4> - - <div className="space-y-3"> - <Label>Pick an accent color</Label> - <div className="flex flex-wrap gap-2"> + ariaLabel="Color mode" + /> + </SettingRow> + </SettingsGroup> + + <SettingsGroup label="Accent Color"> + <div className="flex items-center justify-between py-3.5 px-4"> + <span className="font-medium text-[13px]/4 text-foreground">Pick an accent color</span> + <div className="flex items-center shrink-0 gap-2"> {ACCENT_PRESETS.map((preset) => ( <button key={preset.value} type="button" onClick={() => void handleAccentChange(preset.value)} - className={cn( - 'w-8 h-8 rounded-full transition-all duration-150 relative', - 'hover:scale-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', - settings.accentColor === preset.value && - 'ring-2 ring-offset-2 ring-offset-background ring-foreground/50' - )} - style={{ backgroundColor: preset.value }} + className="size-6 rounded-xl shrink-0 transition-all duration-150 cursor-pointer hover:scale-110 focus-visible:outline-none" + style={{ + backgroundColor: preset.value, + boxShadow: + settings.accentColor === preset.value + ? `var(--background) 0px 0px 0px 2px, ${preset.value}80 0px 0px 0px 3.5px` + : 'none' + }} title={preset.label} - > - {settings.accentColor === preset.value && ( - <Check className="w-4 h-4 text-white absolute inset-0 m-auto drop-shadow-sm" /> - )} - </button> + /> ))} </div> + </div> - <div className="flex items-center gap-2 max-w-xs"> + <SettingRow label="Custom Color" description="Enter a hex value"> + <div className="flex items-center shrink-0 gap-2"> <Input - placeholder="#hex" - value={customHex} + placeholder="#000000" + value={customHex || settings.accentColor} onChange={(e) => setCustomHex(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCustomHexSubmit()} - className="w-28 font-mono text-sm" + onFocus={() => { + if (!customHex) setCustomHex(settings.accentColor) + }} + onBlur={() => { + if (customHex === settings.accentColor) setCustomHex('') + }} + className="w-24 h-7 font-mono text-xs bg-muted/50 border-border" maxLength={7} /> - {customHex && HEX_COLOR_REGEX.test(customHex) && ( - <button - type="button" - onClick={handleCustomHexSubmit} - className="w-8 h-8 rounded-full border-2 border-border shrink-0 transition-transform hover:scale-110" - style={{ backgroundColor: customHex }} - title="Apply custom color" - /> - )} - {settings.accentColor && - !ACCENT_PRESETS.some((p) => p.value === settings.accentColor) && ( - <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> - <div - className="w-4 h-4 rounded-full ring-1 ring-border" - style={{ backgroundColor: settings.accentColor }} - /> - <span className="font-mono">{settings.accentColor}</span> - </div> - )} + <div + className="size-5 rounded-[10px] shrink-0" + style={{ + backgroundColor: HEX_COLOR_REGEX.test(customHex) ? customHex : settings.accentColor + }} + /> </div> - </div> - </div> - - <Separator /> + </SettingRow> + </SettingsGroup> - {/* Typography */} - <div className="space-y-6"> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Typography - </h4> - - <div className="space-y-2"> - <Label>Font Size</Label> - <p className="text-sm text-muted-foreground">Adjust the base text size across the app</p> - <ToggleGroup - type="single" + <SettingsGroup label="Typography"> + <SettingRow label="Font Size" description="Adjust the base text size"> + <SegmentedControl + options={FONT_SIZE_OPTIONS} value={settings.fontSize} onValueChange={handleFontSizeChange} - className="justify-start" - > - <ToggleGroupItem value="small" aria-label="Small font size" className="gap-2 px-4"> - <ALargeSmall className="w-3.5 h-3.5" /> - Small - </ToggleGroupItem> - <ToggleGroupItem value="medium" aria-label="Medium font size" className="gap-2 px-4"> - <ALargeSmall className="w-4 h-4" /> - Medium - </ToggleGroupItem> - <ToggleGroupItem value="large" aria-label="Large font size" className="gap-2 px-4"> - <ALargeSmall className="w-5 h-5" /> - Large - </ToggleGroupItem> - </ToggleGroup> - </div> + ariaLabel="Font size" + /> + </SettingRow> - <div className="space-y-2"> - <Label>Font Family</Label> - <p className="text-sm text-muted-foreground"> - Choose the primary typeface for the interface - </p> + <SettingRow label="Font Family" description="Primary typeface for the interface"> <Select value={settings.fontFamily} onValueChange={handleFontFamilyChange}> - <SelectTrigger className="w-full max-w-xs"> + <SelectTrigger className={COMPACT_SELECT}> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="system">System Default</SelectItem> - <SelectItem value="sans-serif">Sans-serif (System)</SelectItem> + <SelectItem value="sans-serif">Sans-serif</SelectItem> <SelectItem value="serif">Serif (Crimson Pro)</SelectItem> + <SelectItem value="gelasio">Gelasio</SelectItem> + <SelectItem value="geist">Geist</SelectItem> + <SelectItem value="inter">Inter</SelectItem> <SelectItem value="monospace">Monospace</SelectItem> </SelectContent> </Select> - </div> - </div> + </SettingRow> + </SettingsGroup> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/settings/editor-section.tsx b/apps/desktop/src/renderer/src/pages/settings/editor-section.tsx index 68a14493a..91cb19b54 100644 --- a/apps/desktop/src/renderer/src/pages/settings/editor-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/editor-section.tsx @@ -1,82 +1,145 @@ import { useCallback } from 'react' -import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { Info } from '@/lib/icons' -import { useNoteEditorSettings } from '@/hooks/use-note-editor-settings' +import { Slider } from '@/components/ui/slider' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { useEditorSettings } from '@/hooks/use-editor-settings' import { toast } from 'sonner' +import { + SettingsHeader, + SettingsGroup, + SettingRow, + SettingRowTall, + ACCENT_SWITCH, + COMPACT_SELECT +} from '@/components/settings/settings-primitives' export function EditorSettings() { - const { settings, isLoading, setToolbarMode } = useNoteEditorSettings() + const { settings, isLoading, updateSettings } = useEditorSettings() + + const handleWidthChange = useCallback( + async (value: string) => { + const success = await updateSettings({ width: value as 'narrow' | 'medium' | 'wide' }) + if (!success) toast.error('Failed to update editor width') + }, + [updateSettings] + ) const handleToolbarModeChange = useCallback( async (enabled: boolean) => { - const newMode = enabled ? 'sticky' : 'floating' - const success = await setToolbarMode(newMode) - if (success) { - toast.success( - enabled ? 'Sticky toolbar enabled' : 'Floating toolbar enabled (shows on text selection)' - ) - } else { - toast.error('Failed to update setting') - } + const success = await updateSettings({ toolbarMode: enabled ? 'sticky' : 'floating' }) + if (!success) toast.error('Failed to update toolbar mode') + }, + [updateSettings] + ) + + const handleSpellCheckChange = useCallback( + async (enabled: boolean) => { + const success = await updateSettings({ spellCheck: enabled }) + if (!success) toast.error('Failed to update spell check') + }, + [updateSettings] + ) + + const handleAutoSaveDelayChange = useCallback( + async (value: number[]) => { + const success = await updateSettings({ autoSaveDelay: value[0] }) + if (!success) toast.error('Failed to update auto-save delay') + }, + [updateSettings] + ) + + const handleWordCountChange = useCallback( + async (enabled: boolean) => { + const success = await updateSettings({ showWordCount: enabled }) + if (!success) toast.error('Failed to update word count display') }, - [setToolbarMode] + [updateSettings] ) if (isLoading) { return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Editor</h3> - <p className="text-sm text-muted-foreground">Loading settings...</p> - </div> + <div className="flex flex-col antialiased"> + <SettingsHeader title="Editor" subtitle="Loading settings..." /> </div> ) } - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Editor</h3> - <p className="text-sm text-muted-foreground">Note editor settings and preferences</p> - </div> + const autoSaveSeconds = Math.round(settings.autoSaveDelay / 1000) - <Separator /> + return ( + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader title="Editor" subtitle="Note editor settings and preferences" /> - {/* Toolbar Mode Section */} - <div className="space-y-6"> - <div> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Toolbar - </h4> - </div> + <SettingsGroup label="Layout"> + <SettingRow label="Editor Width" description="Maximum width of the writing area"> + <Select value={settings.width} onValueChange={handleWidthChange}> + <SelectTrigger className={COMPACT_SELECT}> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="narrow">Narrow</SelectItem> + <SelectItem value="medium">Medium</SelectItem> + <SelectItem value="wide">Wide</SelectItem> + </SelectContent> + </Select> + </SettingRow> + </SettingsGroup> - {/* Sticky Toolbar Toggle */} - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="sticky-toolbar">Sticky Formatting Toolbar</Label> - <p className="text-sm text-muted-foreground"> - Always show the formatting toolbar above the editor instead of on text selection - </p> - </div> + <SettingsGroup label="Toolbar"> + <SettingRow + label="Sticky Formatting Toolbar" + description="Always show toolbar above the editor" + > <Switch - id="sticky-toolbar" checked={settings.toolbarMode === 'sticky'} onCheckedChange={handleToolbarModeChange} + className={ACCENT_SWITCH} /> - </div> + </SettingRow> + </SettingsGroup> - {/* Info hint */} - <div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 text-sm"> - <Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> - <p className="text-muted-foreground"> - When disabled (floating mode), the formatting toolbar appears only when you select text. - Enable sticky mode to always have quick access to Bold, Italic, and other formatting - options. - </p> - </div> - </div> + <SettingsGroup label="Writing"> + <SettingRow label="Spell Check" description="Underline misspelled words while typing"> + <Switch + checked={settings.spellCheck} + onCheckedChange={handleSpellCheckChange} + className={ACCENT_SWITCH} + /> + </SettingRow> + + <SettingRowTall + label="Auto-Save Delay" + description="Wait time after typing stops before saving" + > + <div className="flex items-center gap-3"> + <Slider + min={0} + max={30000} + step={1000} + value={[settings.autoSaveDelay]} + onValueCommit={handleAutoSaveDelayChange} + className="flex-1 max-w-xs" + /> + <span className="text-xs/4 font-medium text-muted-foreground w-8 text-right shrink-0"> + {autoSaveSeconds === 0 ? '0s' : `${autoSaveSeconds}s`} + </span> + </div> + </SettingRowTall> + + <SettingRow label="Word Count" description="Show word count in the editor footer"> + <Switch + checked={settings.showWordCount} + onCheckedChange={handleWordCountChange} + className={ACCENT_SWITCH} + /> + </SettingRow> + </SettingsGroup> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/settings/general-section.tsx b/apps/desktop/src/renderer/src/pages/settings/general-section.tsx index b740326c2..be595a427 100644 --- a/apps/desktop/src/renderer/src/pages/settings/general-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/general-section.tsx @@ -1,5 +1,4 @@ import { useCallback } from 'react' -import { Separator } from '@/components/ui/separator' import { Select, SelectContent, @@ -8,12 +7,17 @@ import { SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { Info } from '@/lib/icons' import { useTabPreferences } from '@/hooks/use-tab-preferences' import { useGeneralSettings } from '@/hooks/use-general-settings' import { useTabs } from '@/contexts/tabs' import { toast } from 'sonner' +import { + SettingsHeader, + SettingsGroup, + SettingRow, + ACCENT_SWITCH, + COMPACT_SELECT +} from '@/components/settings/settings-primitives' export function GeneralSettings() { const { @@ -43,7 +47,6 @@ export function GeneralSettings() { const success = await updateTabSettings({ previewMode: enabled }) if (success) { updateContextSettings({ previewMode: enabled }) - toast.success(enabled ? 'Preview mode enabled' : 'Preview mode disabled') } else { toast.error('Failed to update setting') } @@ -56,7 +59,6 @@ export function GeneralSettings() { const success = await updateTabSettings({ restoreSessionOnStart: enabled }) if (success) { updateContextSettings({ restoreSessionOnStart: enabled }) - toast.success(enabled ? 'Session will be restored on start' : 'Session restore disabled') } else { toast.error('Failed to update setting') } @@ -69,7 +71,6 @@ export function GeneralSettings() { const success = await updateTabSettings({ tabCloseButton: value }) if (success) { updateContextSettings({ tabCloseButton: value }) - toast.success('Close button visibility updated') } else { toast.error('Failed to update setting') } @@ -79,86 +80,52 @@ export function GeneralSettings() { if (isLoading) { return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">General</h3> - <p className="text-sm text-muted-foreground">Loading settings...</p> - </div> + <div className="flex flex-col antialiased"> + <SettingsHeader title="General" subtitle="Loading settings..." /> </div> ) } return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">General</h3> - <p className="text-sm text-muted-foreground">General application settings</p> - </div> - - <Separator /> + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader title="General" subtitle="Application startup and tab behavior" /> - {/* Startup */} - <div className="space-y-6"> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Startup - </h4> - - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="start-on-boot">Launch at Login</Label> - <p className="text-sm text-muted-foreground"> - Automatically start Memry when you log in to your computer - </p> - </div> + <SettingsGroup label="Startup"> + <SettingRow label="Launch at Login" description="Start Memry when you log in"> <Switch - id="start-on-boot" checked={generalSettings.startOnBoot} onCheckedChange={handleStartOnBootChange} + className={ACCENT_SWITCH} /> - </div> - </div> - - <Separator /> - - {/* Tab Behavior */} - <div className="space-y-6"> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Tab Behavior - </h4> - - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="preview-mode">Preview Mode</Label> - <p className="text-sm text-muted-foreground"> - Single-click opens a preview tab, double-click opens permanently - </p> - </div> + </SettingRow> + </SettingsGroup> + + <SettingsGroup label="Tab Behavior"> + <SettingRow + label="Preview Mode" + description="Single-click opens preview, double-click keeps open" + > <Switch - id="preview-mode" checked={tabSettings.previewMode} onCheckedChange={handlePreviewModeChange} + className={ACCENT_SWITCH} /> - </div> + </SettingRow> - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="restore-session">Restore Session on Start</Label> - <p className="text-sm text-muted-foreground"> - Reopen your tabs from your last session when the app starts - </p> - </div> + <SettingRow + label="Restore Session on Start" + description="Reopen tabs from your last session" + > <Switch - id="restore-session" checked={tabSettings.restoreSessionOnStart} onCheckedChange={handleRestoreSessionChange} + className={ACCENT_SWITCH} /> - </div> + </SettingRow> - <div className="space-y-2"> - <Label>Tab Close Button</Label> - <p className="text-sm text-muted-foreground">When to show the close button on tabs</p> + <SettingRow label="Tab Close Button" description="When to show the close button"> <Select value={tabSettings.tabCloseButton} onValueChange={handleCloseButtonChange}> - <SelectTrigger className="w-full max-w-xs"> + <SelectTrigger className={COMPACT_SELECT}> <SelectValue /> </SelectTrigger> <SelectContent> @@ -167,16 +134,8 @@ export function GeneralSettings() { <SelectItem value="active">Only on active tab</SelectItem> </SelectContent> </Select> - </div> - - <div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 text-sm"> - <Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> - <p className="text-muted-foreground"> - Tab settings take effect immediately. Preview mode is useful for quickly browsing items - - single-click to preview, double-click to keep open. - </p> - </div> - </div> + </SettingRow> + </SettingsGroup> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/settings/integrations-section.tsx b/apps/desktop/src/renderer/src/pages/settings/integrations-section.tsx index 14d795055..815297473 100644 --- a/apps/desktop/src/renderer/src/pages/settings/integrations-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/integrations-section.tsx @@ -1,15 +1,13 @@ import { IntegrationList } from '@/components/settings/integration-list' +import { SettingsHeader } from '@/components/settings/settings-primitives' export function IntegrationsSettings() { return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Integrations</h3> - <p className="text-sm text-muted-foreground"> - Connect external services to enrich your workflow - </p> - </div> - + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader + title="Integrations" + subtitle="Connect external services to enrich your workflow" + /> <IntegrationList /> </div> ) diff --git a/apps/desktop/src/renderer/src/pages/settings/journal-section.tsx b/apps/desktop/src/renderer/src/pages/settings/journal-section.tsx index 665fb1a05..57665e5d2 100644 --- a/apps/desktop/src/renderer/src/pages/settings/journal-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/journal-section.tsx @@ -1,5 +1,4 @@ import { useCallback } from 'react' -import { Separator } from '@/components/ui/separator' import { Select, SelectContent, @@ -8,11 +7,17 @@ import { SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { Info, Lock } from '@/lib/icons' +import { Lock } from '@/lib/icons' import { useTemplates } from '@/hooks/use-templates' import { useJournalSettings } from '@/hooks/use-journal-settings' import { toast } from 'sonner' +import { + SettingsHeader, + SettingsGroup, + SettingRow, + ACCENT_SWITCH, + COMPACT_SELECT +} from '@/components/settings/settings-primitives' export function JournalSettings() { const { templates, isLoading: isLoadingTemplates } = useTemplates() @@ -39,11 +44,7 @@ export function JournalSettings() { const handleShowScheduleChange = useCallback( async (checked: boolean) => { const success = await updateSettings({ showSchedule: checked }) - if (success) { - toast.success(checked ? 'Schedule section shown' : 'Schedule section hidden') - } else { - toast.error('Failed to update setting') - } + if (!success) toast.error('Failed to update setting') }, [updateSettings] ) @@ -51,11 +52,7 @@ export function JournalSettings() { const handleShowTasksChange = useCallback( async (checked: boolean) => { const success = await updateSettings({ showTasks: checked }) - if (success) { - toast.success(checked ? 'Tasks section shown' : 'Tasks section hidden') - } else { - toast.error('Failed to update setting') - } + if (!success) toast.error('Failed to update setting') }, [updateSettings] ) @@ -63,11 +60,7 @@ export function JournalSettings() { const handleShowAIConnectionsChange = useCallback( async (checked: boolean) => { const success = await updateSettings({ showAIConnections: checked }) - if (success) { - toast.success(checked ? 'AI Connections shown' : 'AI Connections hidden') - } else { - toast.error('Failed to update setting') - } + if (!success) toast.error('Failed to update setting') }, [updateSettings] ) @@ -75,11 +68,7 @@ export function JournalSettings() { const handleShowStatsFooterChange = useCallback( async (checked: boolean) => { const success = await updateSettings({ showStatsFooter: checked }) - if (success) { - toast.success(checked ? 'Stats footer shown' : 'Stats footer hidden') - } else { - toast.error('Failed to update setting') - } + if (!success) toast.error('Failed to update setting') }, [updateSettings] ) @@ -90,171 +79,86 @@ export function JournalSettings() { if (isLoadingSettings) { return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Journal</h3> - <p className="text-sm text-muted-foreground">Loading settings...</p> - </div> + <div className="flex flex-col antialiased"> + <SettingsHeader title="Journal" subtitle="Loading settings..." /> </div> ) } return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Journal</h3> - <p className="text-sm text-muted-foreground">Journal settings and preferences</p> - </div> - - <Separator /> - - {/* Default Template Setting */} - <div className="space-y-4"> - <div> - <label htmlFor="default-template" className="text-sm font-medium"> - Default Template - </label> - <p className="text-sm text-muted-foreground mt-1"> - New journal entries will start with this template. You can always change it when - creating an entry. - </p> - </div> - - <Select - value={settings.defaultTemplate ?? 'none'} - onValueChange={handleTemplateChange} - disabled={isLoadingTemplates || isLoadingSettings} - > - <SelectTrigger id="default-template" className="w-full max-w-xs"> - <SelectValue placeholder="Select a template"> - {isLoadingSettings - ? 'Loading...' - : settings.defaultTemplate - ? (defaultTemplateName ?? 'Unknown template') - : 'None (ask each time)'} - </SelectValue> - </SelectTrigger> - <SelectContent> - <SelectItem value="none"> - <span className="flex items-center gap-2">None (ask each time)</span> - </SelectItem> - {templates.map((template) => ( - <SelectItem key={template.id} value={template.id}> - <span className="flex items-center gap-2"> - {template.icon && <span>{template.icon}</span>} - {template.name} - {template.isBuiltIn && <Lock className="w-3 h-3 text-muted-foreground ml-1" />} - </span> - </SelectItem> - ))} - </SelectContent> - </Select> - - {/* Info hint */} - <div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 text-sm"> - <Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> - <p className="text-muted-foreground"> - When a default template is set, new journal entries will be created with the template - content automatically. A small indicator will appear letting you change the template or - start blank. - </p> - </div> - </div> - - <Separator /> - - {/* Sidebar Visibility Section */} - <div className="space-y-6"> - <div> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Sidebar Visibility - </h4> - <p className="text-sm text-muted-foreground mt-1"> - Choose which sections to display in the journal sidebar. The calendar is always visible. - </p> - </div> - - {/* Show Schedule Toggle */} - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="show-schedule">Show Schedule</Label> - <p className="text-sm text-muted-foreground"> - Display today's events and calendar schedule - </p> - </div> + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader title="Journal" subtitle="Journal settings and preferences" /> + + <SettingsGroup label="Default Template"> + <SettingRow label="Template" description="New entries start with this template"> + <Select + value={settings.defaultTemplate ?? 'none'} + onValueChange={handleTemplateChange} + disabled={isLoadingTemplates || isLoadingSettings} + > + <SelectTrigger className={COMPACT_SELECT}> + <SelectValue placeholder="Select a template"> + {isLoadingSettings + ? 'Loading...' + : settings.defaultTemplate + ? (defaultTemplateName ?? 'Unknown template') + : 'None'} + </SelectValue> + </SelectTrigger> + <SelectContent> + <SelectItem value="none">None (ask each time)</SelectItem> + {templates.map((template) => ( + <SelectItem key={template.id} value={template.id}> + <span className="flex items-center gap-2"> + {template.icon && <span>{template.icon}</span>} + {template.name} + {template.isBuiltIn && <Lock className="w-3 h-3 text-muted-foreground ml-1" />} + </span> + </SelectItem> + ))} + </SelectContent> + </Select> + </SettingRow> + </SettingsGroup> + + <SettingsGroup label="Sidebar Visibility"> + <SettingRow label="Show Schedule" description="Display today's events and calendar"> <Switch - id="show-schedule" checked={settings.showSchedule} onCheckedChange={handleShowScheduleChange} + className={ACCENT_SWITCH} /> - </div> + </SettingRow> - {/* Show Tasks Toggle */} - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="show-tasks">Show Tasks</Label> - <p className="text-sm text-muted-foreground">Display tasks due on the selected day</p> - </div> + <SettingRow label="Show Tasks" description="Display tasks due on the selected day"> <Switch - id="show-tasks" checked={settings.showTasks} onCheckedChange={handleShowTasksChange} + className={ACCENT_SWITCH} /> - </div> + </SettingRow> - {/* Show AI Connections Toggle */} - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="show-ai-connections">Show AI Connections</Label> - <p className="text-sm text-muted-foreground"> - Display AI-powered connections to related entries and notes - </p> - </div> + <SettingRow + label="Show AI Connections" + description="Display AI-powered connections to related entries" + > <Switch - id="show-ai-connections" checked={settings.showAIConnections} onCheckedChange={handleShowAIConnectionsChange} + className={ACCENT_SWITCH} /> - </div> - - {/* Info hint */} - <div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 text-sm"> - <Info className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> - <p className="text-muted-foreground"> - The mini calendar at the top of the sidebar is always visible for quick navigation. - These settings only affect the additional panels below it. - </p> - </div> - </div> + </SettingRow> + </SettingsGroup> - <Separator /> - - {/* Footer Section */} - <div className="space-y-6"> - <div> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Footer - </h4> - <p className="text-sm text-muted-foreground mt-1"> - Display document statistics at the bottom of journal entries. - </p> - </div> - - {/* Show Stats Footer Toggle */} - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label htmlFor="show-stats-footer">Show Stats Footer</Label> - <p className="text-sm text-muted-foreground"> - Display word count, reading time, and timestamps at the bottom - </p> - </div> + <SettingsGroup label="Footer"> + <SettingRow label="Show Stats Footer" description="Word count, reading time, timestamps"> <Switch - id="show-stats-footer" checked={settings.showStatsFooter} onCheckedChange={handleShowStatsFooterChange} + className={ACCENT_SWITCH} /> - </div> - </div> + </SettingRow> + </SettingsGroup> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/settings/setup-wizard.tsx b/apps/desktop/src/renderer/src/pages/settings/setup-wizard.tsx index 602d7e454..6e51fd0f5 100644 --- a/apps/desktop/src/renderer/src/pages/settings/setup-wizard.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/setup-wizard.tsx @@ -17,11 +17,11 @@ const STEP_MAP: Record<WizardStep, number> = { idle: 0, 'sign-in': 0, 'otp-verification': 1, - 'recovery-display': 2, - 'recovery-confirm': 2, - 'recovery-input': 2, + 'recovery-display': 1, + 'recovery-confirm': 1, + 'recovery-input': 1, 'linking-choice': 2, - 'linking-scan': 2, + 'linking-scan': 1, 'linking-pending': 2 } @@ -195,16 +195,18 @@ export function SetupWizard(): React.JSX.Element { const currentStepIndex = STEP_MAP[wizardStep] return ( - <div className="space-y-8" ref={containerRef}> + <div className="flex flex-col" ref={containerRef}> <WizardProgress currentStep={currentStepIndex} /> {wizardStep === 'sign-in' && ( - <div className="wizard-step-enter space-y-6"> - <div className="space-y-2"> - <h3 className="font-display text-2xl tracking-tight">Set up Sync</h3> - <p className="font-serif text-[15px] text-muted-foreground leading-relaxed"> + <div className="wizard-step-enter space-y-6 text-center"> + <div className="flex flex-col pb-7 gap-1.5"> + <div className="tracking-[-0.02em] font-semibold text-xl/6.5 text-foreground"> + Set up Sync + </div> + <div className="text-[13px]/4.5 text-muted-foreground"> Create an account to sync your data across devices with end-to-end encryption. - </p> + </div> </div> <EmailEntryForm onSubmit={handleEmailSubmit} isLoading={isLoading} error={wizardError} /> @@ -214,7 +216,7 @@ export function SetupWizard(): React.JSX.Element { <div className="w-full h-px bg-gradient-to-r from-transparent via-border to-transparent" /> </div> <div className="relative flex justify-center"> - <span className="bg-background px-3 text-[10px] font-semibold tracking-widest uppercase text-muted-foreground/60"> + <span className="bg-background px-3 uppercase tracking-[0.05em] font-medium text-[10px]/3.5 text-muted-foreground/50"> or </span> </div> @@ -311,11 +313,12 @@ function LinkingChoiceStep({ }): React.JSX.Element { return ( <div className="wizard-step-enter space-y-6"> - <div className="space-y-2"> - <h3 className="font-display text-2xl tracking-tight">Link this device</h3> - <p className="font-serif text-[15px] text-muted-foreground leading-relaxed"> - This account already exists. Transfer your encryption keys from another device or restore - from your recovery phrase. + <div className="space-y-1.5"> + <h3 className="font-semibold text-base/5 tracking-[-0.01em] text-foreground"> + Link this device + </h3> + <p className="text-xs/4 text-muted-foreground"> + Transfer encryption keys from another device or restore from your recovery phrase. </p> </div> @@ -325,8 +328,8 @@ function LinkingChoiceStep({ onClick={onChooseQr} className="w-full flex items-center gap-4 p-4 rounded-xl border border-border bg-background hover:bg-muted/50 transition-colors text-left group" > - <div className="w-11 h-11 rounded-xl bg-amber-500/10 dark:bg-amber-400/10 flex items-center justify-center flex-shrink-0"> - <QrCode className="w-5 h-5 text-amber-700 dark:text-amber-400" /> + <div className="w-11 h-11 rounded-xl bg-[var(--tint)]/10 flex items-center justify-center flex-shrink-0"> + <QrCode className="w-5 h-5 text-[var(--tint)]" /> </div> <div className="flex-1 min-w-0"> <p className="text-sm font-medium group-hover:text-foreground transition-colors"> @@ -361,32 +364,27 @@ function LinkingChoiceStep({ } function WizardProgress({ currentStep }: { currentStep: number }): React.JSX.Element { - const progress = (currentStep / (STEPS.length - 1)) * 100 + const widthPct = STEPS.length > 1 ? ((currentStep + 1) / STEPS.length) * 100 : 100 return ( <div role="group" aria-label={`Step ${currentStep + 1} of ${STEPS.length}: ${STEPS[currentStep]}`} - className="space-y-3" + className="[font-synthesis:none] flex flex-col pb-8 gap-2 antialiased text-xs/4" > - <div className="relative h-1 bg-border/50 rounded-full overflow-hidden"> + <div className="flex h-0.5 rounded-[1px] overflow-clip bg-foreground/[0.06] shrink-0"> <div - className="absolute inset-y-0 left-0 rounded-full transition-all duration-500 ease-out" - style={{ - width: `${progress}%`, - background: 'linear-gradient(90deg, rgb(180, 83, 9), rgb(245, 158, 11))' - }} + className="h-0.5 rounded-[1px] bg-[var(--tint)] transition-all duration-500 ease-out" + style={{ width: `${widthPct}%` }} /> </div> - <div className="flex justify-between"> + <div className="flex items-center justify-between"> {STEPS.map((label, i) => ( <span key={label} className={cn( - 'text-[10px] tracking-widest uppercase transition-colors duration-300', - i <= currentStep - ? 'text-amber-700 dark:text-amber-400 font-semibold' - : 'text-muted-foreground/50' + 'uppercase tracking-[0.05em] font-medium text-[10px]/3.5 transition-colors duration-300', + i <= currentStep ? 'text-[var(--tint)]' : 'text-muted-foreground/50' )} > {label} diff --git a/apps/desktop/src/renderer/src/pages/settings/shortcuts-section.tsx b/apps/desktop/src/renderer/src/pages/settings/shortcuts-section.tsx new file mode 100644 index 000000000..dcdce6033 --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/settings/shortcuts-section.tsx @@ -0,0 +1,467 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Kbd, KbdGroup } from '@/components/ui/kbd' +import { Badge } from '@/components/ui/badge' +import { Search, RotateCcw, X, AlertTriangle, Info } from '@/lib/icons' +import { useKeyboardSettings } from '@/hooks/use-keyboard-settings' +import { toast } from 'sonner' +import type { ShortcutBinding } from '@memry/contracts/settings-schemas' +import type { ShortcutBindingDTO } from '../../../../preload/index.d' +import { + SHORTCUT_REGISTRY, + CATEGORY_ORDER, + formatBinding, + resolveBinding, + findConflicts, + bindingsEqual, + getGroupedShortcuts, + type ShortcutEntry +} from '@/lib/shortcut-registry' +import { SettingsHeader, SettingsGroup } from '@/components/settings/settings-primitives' + +interface ShortcutRowProps { + entry: ShortcutEntry + effectiveBinding: ShortcutBinding + isDefault: boolean + overrides: Record<string, ShortcutBinding> + onRebind: (id: string, binding: ShortcutBinding) => Promise<void> + onClearOverride: (id: string) => Promise<void> +} + +function ShortcutRow({ + entry, + effectiveBinding, + isDefault, + overrides, + onRebind, + onClearOverride +}: ShortcutRowProps) { + const [isCapturing, setIsCapturing] = useState(false) + const [conflict, setConflict] = useState<string | null>(null) + const captureRef = useRef<HTMLDivElement>(null) + + const startCapture = useCallback(() => { + setIsCapturing(true) + setConflict(null) + }, []) + + const stopCapture = useCallback(() => { + setIsCapturing(false) + setConflict(null) + }, []) + + useEffect(() => { + if (!isCapturing) return + + const handleKeyDown = (e: KeyboardEvent): void => { + e.preventDefault() + e.stopPropagation() + + if (e.key === 'Escape') { + stopCapture() + return + } + + if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return + + const newBinding: ShortcutBinding = { + key: e.key, + modifiers: { + meta: e.metaKey || e.ctrlKey, + shift: e.shiftKey || undefined, + alt: e.altKey || undefined + } + } + + const conflicts = findConflicts(entry.id, newBinding, overrides) + if (conflicts.length > 0) { + setConflict(`Conflicts with: ${conflicts.map((c) => c.conflictingLabel).join(', ')}`) + return + } + + setIsCapturing(false) + setConflict(null) + void onRebind(entry.id, newBinding) + } + + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [isCapturing, entry.id, overrides, onRebind, stopCapture]) + + useEffect(() => { + if (!isCapturing) return + const handleClick = (e: MouseEvent): void => { + if (captureRef.current && !captureRef.current.contains(e.target as Node)) { + stopCapture() + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [isCapturing, stopCapture]) + + return ( + <> + <div className="flex items-center justify-between h-11 py-3 px-4 shrink-0 group"> + <div className="flex items-center gap-2 min-w-0"> + <span className="font-medium text-[13px]/4 text-foreground">{entry.label}</span> + {!isDefault && ( + <Badge + variant="secondary" + className="text-[10px]/3 px-1.5 py-0 h-4 bg-[var(--tint)]/15 text-[var(--tint)] border-0" + > + Custom + </Badge> + )} + </div> + + <div ref={captureRef} className="flex items-center gap-2 ml-4 shrink-0"> + {isCapturing ? ( + <div className="flex items-center gap-2"> + <div className="flex items-center gap-1 px-2 py-1 rounded border border-[var(--tint)] bg-[var(--tint)]/5 text-xs text-[var(--tint)] animate-pulse"> + Press shortcut… + </div> + <Button + variant="ghost" + size="sm" + onClick={stopCapture} + className="h-7 w-7 p-0" + title="Cancel" + > + <X className="w-3 h-3" /> + </Button> + </div> + ) : ( + <div className="flex items-center gap-1"> + <button + onClick={startCapture} + className="flex items-center gap-0.5 hover:opacity-70 transition-opacity" + title="Click to rebind" + > + <KbdGroup> + {formatBinding(effectiveBinding) + .split(' ') + .map((part, i) => ( + <Kbd key={i}>{part}</Kbd> + ))} + </KbdGroup> + </button> + {!isDefault && ( + <Button + variant="ghost" + size="sm" + onClick={() => onClearOverride(entry.id)} + className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity" + title="Reset to default" + > + <RotateCcw className="w-3 h-3" /> + </Button> + )} + </div> + )} + </div> + </div> + {conflict && <p className="text-[10px]/3 text-destructive px-4 pb-2">{conflict}</p>} + </> + ) +} + +const PLATFORM = window.navigator.platform.toLowerCase() +const IS_MACOS = PLATFORM.includes('mac') + +function getGlobalCaptureParts(binding: ShortcutBindingDTO): string[] { + const { key, modifiers } = binding + const parts: string[] = [] + if (modifiers.meta) parts.push(IS_MACOS ? '⌘' : 'Ctrl') + if (modifiers.ctrl && !modifiers.meta) parts.push('Ctrl') + if (modifiers.alt) parts.push(IS_MACOS ? '⌥' : 'Alt') + if (modifiers.shift) parts.push(IS_MACOS ? '⇧' : 'Shift') + parts.push(key.toUpperCase()) + return parts +} + +function GlobalCaptureRow({ + binding, + onSave +}: { + binding: ShortcutBindingDTO | null + onSave: (binding: ShortcutBindingDTO | null) => Promise<void> +}): React.JSX.Element { + const [isCapturing, setIsCapturing] = useState(false) + const [permissionStatus, setPermissionStatus] = useState<'unknown' | 'granted' | 'required'>( + 'unknown' + ) + const captureRef = useRef<HTMLDivElement>(null) + + const checkAndRegister = useCallback(async () => { + const result = await window.api.settings.registerGlobalCapture() + if (result.permissionRequired) { + setPermissionStatus('required') + } else if (result.registered) { + setPermissionStatus('granted') + } + }, []) + + useEffect(() => { + void checkAndRegister() + }, [checkAndRegister, binding]) + + const startCapture = useCallback(() => setIsCapturing(true), []) + const stopCapture = useCallback(() => setIsCapturing(false), []) + + useEffect(() => { + if (!isCapturing) return + const handleKeyDown = (e: KeyboardEvent): void => { + e.preventDefault() + e.stopPropagation() + if (e.key === 'Escape') { + stopCapture() + return + } + if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return + const newBinding: ShortcutBindingDTO = { + key: e.key, + modifiers: { + meta: e.metaKey || e.ctrlKey || undefined, + shift: e.shiftKey || undefined, + alt: e.altKey || undefined + } + } + setIsCapturing(false) + void onSave(newBinding) + } + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [isCapturing, onSave, stopCapture]) + + useEffect(() => { + if (!isCapturing) return + const handleClick = (e: MouseEvent): void => { + if (captureRef.current && !captureRef.current.contains(e.target as Node)) { + stopCapture() + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [isCapturing, stopCapture]) + + return ( + <> + <div className="flex items-center justify-between h-11 py-3 px-4 shrink-0 group"> + <div className="flex items-center gap-2 min-w-0"> + <span className="font-medium text-[13px]/4 text-foreground">Global Capture</span> + {permissionStatus === 'required' && ( + <Badge variant="destructive" className="text-[10px]/3 px-1.5 py-0 h-4 gap-1"> + <AlertTriangle className="w-3 h-3" /> + Permission needed + </Badge> + )} + {permissionStatus === 'granted' && binding && ( + <Badge + variant="secondary" + className="text-[10px]/3 px-1.5 py-0 h-4 bg-green-500/15 text-green-600 border-0" + > + Active + </Badge> + )} + <span className="text-xs/4 text-muted-foreground">Capture a note from anywhere</span> + </div> + + <div ref={captureRef} className="flex items-center gap-2 ml-4 shrink-0"> + {isCapturing ? ( + <div className="flex items-center gap-2"> + <div className="flex items-center gap-1 px-2 py-1 rounded border border-[var(--tint)] bg-[var(--tint)]/5 text-xs text-[var(--tint)] animate-pulse"> + Press shortcut… + </div> + <Button + variant="ghost" + size="sm" + onClick={stopCapture} + className="h-7 w-7 p-0" + title="Cancel" + > + <X className="w-3 h-3" /> + </Button> + </div> + ) : ( + <div className="flex items-center gap-1"> + {binding ? ( + <button + onClick={startCapture} + className="flex items-center gap-0.5 hover:opacity-70 transition-opacity" + title="Click to rebind" + > + <KbdGroup> + {getGlobalCaptureParts(binding).map((part, i) => ( + <Kbd key={i}>{part}</Kbd> + ))} + </KbdGroup> + </button> + ) : ( + <button + onClick={startCapture} + className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded border border-dashed border-border" + > + Click to set + </button> + )} + {binding && ( + <Button + variant="ghost" + size="sm" + onClick={() => void onSave(null)} + className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity" + title="Clear shortcut" + > + <X className="w-3 h-3" /> + </Button> + )} + </div> + )} + </div> + </div> + {permissionStatus === 'required' && IS_MACOS && ( + <div className="flex items-start gap-2 mx-4 mb-3 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-[10px]/3 text-amber-800 dark:text-amber-300"> + <Info className="w-3 h-3 mt-0.5 shrink-0" /> + <span> + Global shortcuts require Accessibility permission. Go to{' '} + <strong>System Settings → Privacy → Accessibility</strong> and enable memry. + </span> + </div> + )} + </> + ) +} + +export function ShortcutsSettings() { + const { settings, isLoading, updateSettings, resetToDefaults } = useKeyboardSettings() + const [query, setQuery] = useState('') + + const overrides = settings.overrides + const globalCapture = settings.globalCapture ?? null + + const handleGlobalCaptureSave = useCallback( + async (binding: ShortcutBindingDTO | null): Promise<void> => { + const success = await updateSettings({ globalCapture: binding }) + if (!success) toast.error('Failed to save global capture shortcut') + }, + [updateSettings] + ) + + const handleRebind = useCallback( + async (id: string, binding: ShortcutBinding): Promise<void> => { + const entry = SHORTCUT_REGISTRY.find((e) => e.id === id) + if (!entry) return + + if (bindingsEqual(binding, entry.defaultBinding)) { + const newOverrides = { ...overrides } + delete newOverrides[id] + const success = await updateSettings({ overrides: newOverrides }) + if (!success) toast.error('Failed to save shortcut') + return + } + + const success = await updateSettings({ overrides: { ...overrides, [id]: binding } }) + if (!success) toast.error('Failed to save shortcut') + }, + [overrides, updateSettings] + ) + + const handleClearOverride = useCallback( + async (id: string): Promise<void> => { + const newOverrides = { ...overrides } + delete newOverrides[id] + const success = await updateSettings({ overrides: newOverrides }) + if (!success) toast.error('Failed to reset shortcut') + }, + [overrides, updateSettings] + ) + + const handleResetAll = useCallback(async () => { + const success = await resetToDefaults() + if (success) toast.success('All shortcuts reset to defaults') + else toast.error('Failed to reset shortcuts') + }, [resetToDefaults]) + + const lowerQuery = query.toLowerCase() + const grouped = getGroupedShortcuts() + + const filteredGroups: [string, ShortcutEntry[]][] = CATEGORY_ORDER.flatMap((cat) => { + const entries = grouped.get(cat) ?? [] + const filtered = query + ? entries.filter( + (e) => + e.label.toLowerCase().includes(lowerQuery) || + e.description.toLowerCase().includes(lowerQuery) + ) + : entries + return filtered.length > 0 ? [[cat, filtered] as [string, ShortcutEntry[]]] : [] + }) + + const hasCustomBindings = Object.keys(overrides).length > 0 + + if (isLoading) { + return ( + <div className="flex flex-col antialiased"> + <SettingsHeader title="Keyboard Shortcuts" subtitle="Loading settings..." /> + </div> + ) + } + + return ( + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader + title="Keyboard Shortcuts" + subtitle="Click any shortcut to rebind it" + action={ + hasCustomBindings ? ( + <Button variant="outline" size="sm" onClick={handleResetAll} className="gap-1.5"> + <RotateCcw className="w-3.5 h-3.5" /> + Reset All + </Button> + ) : undefined + } + /> + + <div className="relative pb-6"> + <Search className="absolute left-3 top-2 w-3.5 h-3.5 text-muted-foreground" /> + <Input + placeholder="Search shortcuts..." + value={query} + onChange={(e) => setQuery(e.target.value)} + className="pl-8 h-8 text-xs/4 rounded-lg border-border bg-transparent" + /> + </div> + + <SettingsGroup label="Global Capture"> + <GlobalCaptureRow binding={globalCapture} onSave={handleGlobalCaptureSave} /> + </SettingsGroup> + + {filteredGroups.length === 0 && ( + <p className="text-xs/4 text-muted-foreground text-center py-4"> + No shortcuts match your search + </p> + )} + + {filteredGroups.map(([category, entries]) => ( + <SettingsGroup key={category} label={category}> + {entries.map((entry) => { + const effectiveBinding = resolveBinding(entry, overrides) + const isDefault = !overrides[entry.id] + return ( + <ShortcutRow + key={entry.id} + entry={entry} + effectiveBinding={effectiveBinding} + isDefault={isDefault} + overrides={overrides} + onRebind={handleRebind} + onClearOverride={handleClearOverride} + /> + ) + })} + </SettingsGroup> + ))} + </div> + ) +} diff --git a/apps/desktop/src/renderer/src/pages/settings/sync-section.tsx b/apps/desktop/src/renderer/src/pages/settings/sync-section.tsx deleted file mode 100644 index 21452689d..000000000 --- a/apps/desktop/src/renderer/src/pages/settings/sync-section.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { useState, useCallback, useEffect } from 'react' -import { Button } from '@/components/ui/button' -import { Separator } from '@/components/ui/separator' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle -} from '@/components/ui/alert-dialog' -import { CloudOff, RefreshCw, Pause, Play, LogOut, QrCode, RotateCw } from '@/lib/icons' -import { toast } from 'sonner' -import { extractErrorMessage } from '@/lib/ipc-error' -import { useAuth } from '@/contexts/auth-context' -import { useSync } from '@/contexts/sync-context' -import { useSyncStatus } from '@/hooks/use-sync-status' -import { SetupWizard } from './setup-wizard' -import { QrLinking } from '@/components/sync/qr-linking' -import { LinkingApprovalDialog } from '@/components/sync/linking-approval-dialog' -import { SyncHistoryPanel } from '@/components/sync/sync-history' -import { DeviceList } from '@/components/sync/device-list' -import { KeyRotationWizard } from '@/components/sync/key-rotation-wizard' - -export function SyncSettings() { - const { state, logout, setWizardStep } = useAuth() - const { linkingRequest, clearLinkingRequest } = useSync() - const syncStatus = useSyncStatus() - const [showSignOutDialog, setShowSignOutDialog] = useState(false) - const [signingOut, setSigningOut] = useState(false) - const [showLinkingQr, setShowLinkingQr] = useState(false) - const [showRotationWizard, setShowRotationWizard] = useState(false) - - useEffect(() => { - if (state.status === 'unauthenticated' && state.wizardStep === 'idle') { - setWizardStep('sign-in') - } - }, [state.status, state.wizardStep, setWizardStep]) - - const handleSignOut = useCallback(async () => { - setSigningOut(true) - try { - await logout() - toast.success('Signed out successfully') - } catch (error: unknown) { - toast.error(extractErrorMessage(error, 'Failed to sign out')) - } finally { - setSigningOut(false) - setShowSignOutDialog(false) - } - }, [logout]) - - const isSyncBusy = syncStatus.status === 'syncing' || syncStatus.status === 'offline' - - if (state.status === 'checking') { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Sync</h3> - <p className="text-sm text-muted-foreground">Loading...</p> - </div> - </div> - ) - } - - if (state.status === 'authenticated') { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Sync</h3> - <p className="text-sm text-muted-foreground">End-to-end encrypted</p> - </div> - <Separator /> - - <div className="space-y-4"> - <div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"> - <div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10"> - <syncStatus.IconComponent className="w-4 h-4 text-primary" /> - </div> - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - <span className={`w-2 h-2 rounded-full ${syncStatus.dotColor}`} /> - <p className="text-sm font-medium"> - {syncStatus.label} - <span className="text-muted-foreground font-normal"> - {' · '}Last synced {syncStatus.lastSyncLabel} - {syncStatus.pendingCount > 0 && ` · ${syncStatus.pendingCount} pending`} - </span> - </p> - </div> - <p className="text-xs text-muted-foreground"> - Signed in{state.email ? ` as ${state.email}` : ''} - </p> - </div> - </div> - - {showLinkingQr ? ( - <QrLinking onCancel={() => setShowLinkingQr(false)} /> - ) : ( - <div className="flex flex-wrap gap-2"> - <Button - variant="outline" - size="sm" - disabled={isSyncBusy} - onClick={() => void syncStatus.triggerSync()} - className="gap-2" - > - <RefreshCw className="w-4 h-4" /> - {syncStatus.status === 'syncing' - ? 'Syncing...' - : syncStatus.status === 'idle' && syncStatus.pendingCount > 0 - ? `Sync ${syncStatus.pendingCount} ${syncStatus.pendingCount === 1 ? 'change' : 'changes'}` - : 'Sync Now'} - </Button> - <Button - variant="outline" - size="sm" - onClick={() => - void (syncStatus.status === 'paused' ? syncStatus.resume() : syncStatus.pause()) - } - className="gap-2" - > - {syncStatus.status === 'paused' ? ( - <> - <Play className="w-4 h-4" /> - Resume - </> - ) : ( - <> - <Pause className="w-4 h-4" /> - Pause - </> - )} - </Button> - <Button - variant="outline" - size="sm" - onClick={() => setShowLinkingQr(true)} - className="gap-2" - > - <QrCode className="w-4 h-4" /> - Link Device - </Button> - </div> - )} - </div> - - <Separator /> - - <div className="space-y-3"> - <h4 className="text-sm font-medium">Devices</h4> - <DeviceList /> - </div> - - <Separator /> - - <div className="space-y-3"> - <h4 className="text-sm font-medium">Security</h4> - <p className="text-xs text-muted-foreground"> - Rotate encryption keys to generate a new recovery phrase. Your data stays intact. - </p> - <Button - variant="outline" - size="sm" - onClick={() => setShowRotationWizard(true)} - className="gap-2" - > - <RotateCw className="w-4 h-4" /> - Rotate Encryption Keys - </Button> - </div> - - <KeyRotationWizard open={showRotationWizard} onOpenChange={setShowRotationWizard} /> - - <Separator /> - - <SyncHistoryPanel /> - - <Separator /> - - <div> - <Button - variant="outline" - size="sm" - onClick={() => setShowSignOutDialog(true)} - className="text-destructive hover:text-destructive hover:bg-destructive/10" - > - <LogOut className="w-4 h-4 mr-2" /> - Sign out - </Button> - <p className="text-xs text-muted-foreground mt-2"> - Your notes stay on this device. Sync will stop until you sign in again. - </p> - </div> - - <AlertDialog open={showSignOutDialog} onOpenChange={setShowSignOutDialog}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Sign out of sync?</AlertDialogTitle> - <AlertDialogDescription> - Sync will stop and encryption keys will be removed from this device. Your notes will - remain on this device. You'll need your recovery phrase to set up sync again. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel disabled={signingOut}>Cancel</AlertDialogCancel> - <AlertDialogAction - onClick={handleSignOut} - disabled={signingOut} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {signingOut ? 'Signing out...' : 'Sign out'} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - - <LinkingApprovalDialog - open={!!linkingRequest} - event={linkingRequest} - onApprove={() => { - clearLinkingRequest() - toast.success('Device linked successfully') - }} - onReject={clearLinkingRequest} - /> - </div> - ) - } - - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Sync</h3> - <p className="text-sm text-muted-foreground"> - Sync your data across devices with end-to-end encryption - </p> - </div> - <Separator /> - <div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"> - <div className="flex items-center justify-center w-8 h-8 rounded-full bg-muted"> - <CloudOff className="w-4 h-4 text-muted-foreground" /> - </div> - <div className="flex-1 min-w-0"> - <p className="text-sm font-medium">Sync disabled</p> - <p className="text-xs text-muted-foreground">Your notes are only stored on this device</p> - </div> - </div> - <SetupWizard /> - </div> - ) -} diff --git a/apps/desktop/src/renderer/src/pages/settings/tags-section.tsx b/apps/desktop/src/renderer/src/pages/settings/tags-section.tsx index 929f4125e..46231598b 100644 --- a/apps/desktop/src/renderer/src/pages/settings/tags-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/tags-section.tsx @@ -1,18 +1,10 @@ -import { Separator } from '@/components/ui/separator' import { TagManager } from '@/components/settings/tag-manager' +import { SettingsHeader } from '@/components/settings/settings-primitives' export function TagsSettings() { return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Tags</h3> - <p className="text-sm text-muted-foreground"> - Manage tags across your notes, journals, and tasks - </p> - </div> - - <Separator /> - + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader title="Tags" subtitle="Manage tags across notes, journals, and tasks" /> <TagManager /> </div> ) diff --git a/apps/desktop/src/renderer/src/pages/settings/tasks-section.test.tsx b/apps/desktop/src/renderer/src/pages/settings/tasks-section.test.tsx index d6d364b5b..6d53f2333 100644 --- a/apps/desktop/src/renderer/src/pages/settings/tasks-section.test.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/tasks-section.test.tsx @@ -90,7 +90,7 @@ describe('TasksSettings', () => { expect(screen.getByText('Default Project')).toBeInTheDocument() expect(screen.getByText('Default Sort Order')).toBeInTheDocument() expect(screen.getByText('Week Starts On')).toBeInTheDocument() - expect(screen.getByText('Stale Inbox Threshold (days)')).toBeInTheDocument() + expect(screen.getByText('Stale Inbox Threshold')).toBeInTheDocument() }) it('shows active projects in dropdown (excludes archived)', async () => { diff --git a/apps/desktop/src/renderer/src/pages/settings/tasks-section.tsx b/apps/desktop/src/renderer/src/pages/settings/tasks-section.tsx index ded650146..8fa707288 100644 --- a/apps/desktop/src/renderer/src/pages/settings/tasks-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/tasks-section.tsx @@ -1,5 +1,4 @@ import { useCallback } from 'react' -import { Separator } from '@/components/ui/separator' import { Select, SelectContent, @@ -7,12 +6,17 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' -import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { useTaskPreferences } from '@/hooks/use-task-preferences' import { useTasksContext } from '@/contexts/tasks' import { toast } from 'sonner' +import { + SettingsHeader, + SettingsGroup, + SettingRow, + COMPACT_SELECT +} from '@/components/settings/settings-primitives' const SORT_OPTIONS = [ { value: 'manual', label: 'Manual (drag & drop)' }, @@ -67,39 +71,23 @@ export function TasksSettings() { if (isLoading) { return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Tasks</h3> - <p className="text-sm text-muted-foreground">Loading settings...</p> - </div> + <div className="flex flex-col antialiased"> + <SettingsHeader title="Tasks" subtitle="Loading settings..." /> </div> ) } return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Tasks</h3> - <p className="text-sm text-muted-foreground">Configure task defaults and behavior</p> - </div> - - <Separator /> + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader title="Tasks" subtitle="Configure task defaults and behavior" /> - <div className="space-y-6"> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Defaults - </h4> - - <div className="space-y-2"> - <Label>Default Project</Label> - <p className="text-sm text-muted-foreground"> - New tasks are assigned to this project when no project is explicitly selected - </p> + <SettingsGroup label="Defaults"> + <SettingRow label="Default Project" description="Assigned when no project is selected"> <Select value={settings.defaultProjectId ?? 'none'} onValueChange={handleDefaultProjectChange} > - <SelectTrigger className="w-full max-w-xs"> + <SelectTrigger className={COMPACT_SELECT}> <SelectValue placeholder="No default project" /> </SelectTrigger> <SelectContent> @@ -108,7 +96,7 @@ export function TasksSettings() { <SelectItem key={project.id} value={project.id}> <span className="flex items-center gap-2"> <span - className="w-2.5 h-2.5 rounded-full shrink-0" + className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: project.color }} /> {project.name} @@ -117,15 +105,11 @@ export function TasksSettings() { ))} </SelectContent> </Select> - </div> + </SettingRow> - <div className="space-y-2"> - <Label>Default Sort Order</Label> - <p className="text-sm text-muted-foreground"> - How tasks are ordered by default in list view - </p> + <SettingRow label="Default Sort Order" description="How tasks are ordered in list view"> <Select value={settings.defaultSortOrder} onValueChange={handleSortOrderChange}> - <SelectTrigger className="w-full max-w-xs"> + <SelectTrigger className={COMPACT_SELECT}> <SelectValue /> </SelectTrigger> <SelectContent> @@ -136,60 +120,53 @@ export function TasksSettings() { ))} </SelectContent> </Select> - </div> - </div> - - <Separator /> + </SettingRow> + </SettingsGroup> - <div className="space-y-6"> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Calendar - </h4> - - <div className="space-y-2"> - <Label>Week Starts On</Label> - <p className="text-sm text-muted-foreground"> - First day of the week in calendar and date views - </p> + <SettingsGroup label="Calendar"> + <SettingRow label="Week Starts On" description="First day of the week in calendar views"> <ToggleGroup type="single" value={settings.weekStartDay} onValueChange={handleWeekStartChange} - className="justify-start" + className="gap-0 rounded-md border border-border overflow-clip" > - <ToggleGroupItem value="sunday" aria-label="Sunday" className="px-4"> + <ToggleGroupItem + value="sunday" + aria-label="Sunday" + className="rounded-none border-none px-3 h-7 text-xs/4 font-medium data-[state=on]:bg-[var(--tint)] data-[state=on]:text-white" + > Sunday </ToggleGroupItem> - <ToggleGroupItem value="monday" aria-label="Monday" className="px-4"> + <ToggleGroupItem + value="monday" + aria-label="Monday" + className="rounded-none border-none border-l border-border px-3 h-7 text-xs/4 font-medium data-[state=on]:bg-[var(--tint)] data-[state=on]:text-white" + > Monday </ToggleGroupItem> </ToggleGroup> - </div> - </div> - - <Separator /> - - <div className="space-y-6"> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> - Inbox - </h4> - - <div className="space-y-2"> - <Label htmlFor="stale-inbox-days">Stale Inbox Threshold (days)</Label> - <p className="text-sm text-muted-foreground"> - Tasks in the inbox older than this are highlighted as stale - </p> - <Input - id="stale-inbox-days" - type="number" - min={1} - max={90} - value={settings.staleInboxDays} - onChange={(e) => void handleStaleInboxChange(e.target.value)} - className="w-24" - /> - </div> - </div> + </SettingRow> + </SettingsGroup> + + <SettingsGroup label="Inbox"> + <SettingRow + label="Stale Inbox Threshold" + description="Tasks older than this are highlighted as stale" + > + <div className="flex items-center gap-1.5"> + <Input + type="number" + min={1} + max={90} + value={settings.staleInboxDays} + onChange={(e) => void handleStaleInboxChange(e.target.value)} + className="w-14 h-7 text-center text-xs/4 px-2" + /> + <span className="text-xs/4 text-muted-foreground">days</span> + </div> + </SettingRow> + </SettingsGroup> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/settings/templates-section.tsx b/apps/desktop/src/renderer/src/pages/settings/templates-section.tsx index c7685d85f..69a56fbfb 100644 --- a/apps/desktop/src/renderer/src/pages/settings/templates-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/templates-section.tsx @@ -1,7 +1,6 @@ import { useState, useCallback } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Separator } from '@/components/ui/separator' import { AlertDialog, AlertDialogAction, @@ -22,6 +21,7 @@ import { FileText, Plus, MoreHorizontal, Pencil, Copy, Trash2, Lock } from '@/li import { useTemplates } from '@/hooks/use-templates' import { useTabs } from '@/contexts/tabs' import { toast } from 'sonner' +import { SettingsHeader, SettingsGroup } from '@/components/settings/settings-primitives' export function TemplatesSettings() { const { templates, isLoading, deleteTemplate, duplicateTemplate } = useTemplates() @@ -89,83 +89,71 @@ export function TemplatesSettings() { const customTemplates = templates.filter((t) => !t.isBuiltIn) return ( - <div className="space-y-6"> - <div className="flex items-center justify-between"> - <div> - <h3 className="text-lg font-semibold">Templates</h3> - <p className="text-sm text-muted-foreground"> - Manage note templates for quick note creation - </p> - </div> - <Button onClick={handleCreateTemplate}> - <Plus className="w-4 h-4 mr-2" /> - New Template - </Button> - </div> - - <Separator /> + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader + title="Templates" + subtitle="Manage note templates for quick creation" + action={ + <Button + onClick={handleCreateTemplate} + variant="outline" + size="sm" + className="gap-1.5 border-[var(--tint)] text-[var(--tint)] hover:bg-[var(--tint)]/10" + > + <Plus className="w-3.5 h-3.5" /> + New Template + </Button> + } + /> {isLoading ? ( - <div className="text-muted-foreground text-sm">Loading templates...</div> + <div className="text-muted-foreground text-xs/4 py-4">Loading templates...</div> ) : ( - <div className="space-y-6"> - {/* Built-in Templates */} + <> {builtInTemplates.length > 0 && ( - <div> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3"> - Built-in Templates - </h4> - <div className="space-y-2"> - {builtInTemplates.map((template) => ( - <TemplateListItem - key={template.id} - template={template} - onEdit={() => handleEditTemplate(template.id, template.name)} - onDuplicate={() => { - setDuplicateId(template.id) - setDuplicateName(`${template.name} (Copy)`) - }} - onDelete={null} // Can't delete built-in - /> - ))} - </div> - </div> + <SettingsGroup label="Built-in"> + {builtInTemplates.map((template) => ( + <TemplateRow + key={template.id} + template={template} + onEdit={() => handleEditTemplate(template.id, template.name)} + onDuplicate={() => { + setDuplicateId(template.id) + setDuplicateName(`${template.name} (Copy)`) + }} + onDelete={null} + /> + ))} + </SettingsGroup> )} - {/* Custom Templates */} {customTemplates.length > 0 && ( - <div> - <h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3"> - My Templates - </h4> - <div className="space-y-2"> - {customTemplates.map((template) => ( - <TemplateListItem - key={template.id} - template={template} - onEdit={() => handleEditTemplate(template.id, template.name)} - onDuplicate={() => { - setDuplicateId(template.id) - setDuplicateName(`${template.name} (Copy)`) - }} - onDelete={() => setDeleteConfirm(template.id)} - /> - ))} - </div> - </div> + <SettingsGroup label="My Templates"> + {customTemplates.map((template) => ( + <TemplateRow + key={template.id} + template={template} + onEdit={() => handleEditTemplate(template.id, template.name)} + onDuplicate={() => { + setDuplicateId(template.id) + setDuplicateName(`${template.name} (Copy)`) + }} + onDelete={() => setDeleteConfirm(template.id)} + /> + ))} + </SettingsGroup> )} {customTemplates.length === 0 && ( <div className="text-center py-8 text-muted-foreground"> - <FileText className="w-12 h-12 mx-auto mb-3 opacity-50" /> - <p>No custom templates yet</p> - <p className="text-sm">Create a template to get started</p> + <FileText className="w-10 h-10 mx-auto mb-2 opacity-40" /> + <p className="text-[13px]/4 font-medium">No custom templates yet</p> + <p className="text-xs/4">Create a template to get started</p> </div> )} - </div> + </> )} - {/* Delete Confirmation Dialog */} <AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}> <AlertDialogContent> <AlertDialogHeader> @@ -187,7 +175,6 @@ export function TemplatesSettings() { </AlertDialogContent> </AlertDialog> - {/* Duplicate Dialog */} <AlertDialog open={!!duplicateId} onOpenChange={() => setDuplicateId(null)}> <AlertDialogContent> <AlertDialogHeader> @@ -212,7 +199,7 @@ export function TemplatesSettings() { ) } -interface TemplateListItemProps { +interface TemplateRowProps { template: { id: string name: string @@ -225,58 +212,49 @@ interface TemplateListItemProps { onDelete: (() => void) | null } -function TemplateListItem({ template, onEdit, onDuplicate, onDelete }: TemplateListItemProps) { +function TemplateRow({ template, onEdit, onDuplicate, onDelete }: TemplateRowProps) { return ( - <div className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/30 transition-colors group"> - {/* Icon */} - <div className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-md bg-muted text-xl"> - {template.icon || <FileText className="w-5 h-5 text-muted-foreground" />} - </div> - - {/* Content */} - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - <span className="font-medium">{template.name}</span> - {template.isBuiltIn && ( - <span className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> - <Lock className="w-3 h-3" /> - Built-in - </span> + <div className="flex items-center justify-between h-11 py-3 px-4 shrink-0 group"> + <div className="flex items-center gap-2.5 min-w-0"> + <span className="text-muted-foreground shrink-0"> + {template.icon || <FileText className="w-3.5 h-3.5" />} + </span> + <div className="flex flex-col gap-px min-w-0"> + <span className="font-medium text-[13px]/4 text-foreground">{template.name}</span> + {template.description && ( + <span className="text-xs/4 text-muted-foreground truncate">{template.description}</span> )} </div> - {template.description && ( - <p className="text-sm text-muted-foreground truncate">{template.description}</p> + </div> + <div className="flex items-center gap-1 shrink-0 ml-4"> + {template.isBuiltIn ? ( + <Lock className="w-3.5 h-3.5 text-muted-foreground/50" /> + ) : ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button className="p-1 rounded text-muted-foreground/50 opacity-0 group-hover:opacity-100 hover:text-foreground transition-all"> + <MoreHorizontal className="w-3.5 h-3.5" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={onEdit}> + <Pencil className="w-4 h-4 mr-2" /> + Edit + </DropdownMenuItem> + <DropdownMenuItem onClick={onDuplicate}> + <Copy className="w-4 h-4 mr-2" /> + Duplicate + </DropdownMenuItem> + {onDelete && ( + <DropdownMenuItem onClick={onDelete} className="text-destructive"> + <Trash2 className="w-4 h-4 mr-2" /> + Delete + </DropdownMenuItem> + )} + </DropdownMenuContent> + </DropdownMenu> )} </div> - - {/* Actions */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="icon" - className="opacity-0 group-hover:opacity-100 transition-opacity" - > - <MoreHorizontal className="w-4 h-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={onEdit}> - <Pencil className="w-4 h-4 mr-2" /> - {template.isBuiltIn ? 'View' : 'Edit'} - </DropdownMenuItem> - <DropdownMenuItem onClick={onDuplicate}> - <Copy className="w-4 h-4 mr-2" /> - Duplicate - </DropdownMenuItem> - {onDelete && ( - <DropdownMenuItem onClick={onDelete} className="text-destructive"> - <Trash2 className="w-4 h-4 mr-2" /> - Delete - </DropdownMenuItem> - )} - </DropdownMenuContent> - </DropdownMenu> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/settings/vault-section.tsx b/apps/desktop/src/renderer/src/pages/settings/vault-section.tsx index abc140ab8..e171fd03e 100644 --- a/apps/desktop/src/renderer/src/pages/settings/vault-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/vault-section.tsx @@ -1,15 +1,133 @@ -import { Separator } from '@/components/ui/separator' -import { StorageUsageBar } from '@/components/settings/storage-usage-bar' +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { RefreshCw } from '@/lib/icons' +import { useStorageUsage } from '@/hooks/use-storage-usage' +import { formatBytes } from '@/lib/format' +import { + SettingsHeader, + SettingsGroup, + SettingRow +} from '@/components/settings/settings-primitives' + +const STORAGE_COLORS: Record<string, string> = { + notes: '#6366f1', + attachments: '#f97316', + crdt: '#22c55e', + other: '#8c8c8c' +} + +const STORAGE_LABELS: Record<string, string> = { + notes: 'Notes', + attachments: 'Attachments', + crdt: 'CRDT', + other: 'Other' +} export function VaultSettings() { + const { data, loading, refresh } = useStorageUsage() + const [vaultPath, setVaultPath] = useState<string | null>(null) + const [isRefreshing, setIsRefreshing] = useState(false) + + useEffect(() => { + window.api.vault + .getStatus() + .then((status) => { + if (status?.path) setVaultPath(status.path) + }) + .catch(() => null) + }, []) + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true) + await refresh() + setIsRefreshing(false) + }, [refresh]) + + const handleReveal = useCallback(async () => { + if (!vaultPath) return + await window.api.vault.reveal() + }, [vaultPath]) + return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-semibold">Vault</h3> - <p className="text-sm text-muted-foreground">Vault configuration and storage settings</p> - </div> - <Separator /> - <StorageUsageBar /> + <div className="flex flex-col antialiased text-xs/4"> + <SettingsHeader title="Vault" subtitle="Vault configuration and storage" /> + + <SettingsGroup label="Storage Usage"> + {loading ? ( + <div className="py-3 px-4"> + <p className="text-xs/4 text-muted-foreground">Loading storage info...</p> + </div> + ) : data ? ( + <div className="py-3 px-4 space-y-3"> + <div className="flex items-center justify-between"> + <span className="font-semibold text-[13px]/4 text-foreground"> + {formatBytes(data.used)} of {formatBytes(data.limit)} used + </span> + <Button + variant="ghost" + size="sm" + onClick={() => void handleRefresh()} + disabled={isRefreshing} + className="h-7 w-7 p-0" + > + <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} /> + </Button> + </div> + + <div className="h-2 rounded-full bg-muted overflow-hidden flex"> + {Object.entries(data.breakdown).map(([key, bytes]) => { + const pct = data.limit > 0 ? (bytes / data.limit) * 100 : 0 + if (pct < 0.5) return null + return ( + <div + key={key} + className="h-full first:rounded-l-full last:rounded-r-full" + style={{ + width: `${pct}%`, + backgroundColor: STORAGE_COLORS[key] ?? '#8c8c8c' + }} + /> + ) + })} + </div> + + {Object.entries(data.breakdown).map(([key, bytes]) => ( + <div key={key} className="flex items-center justify-between"> + <div className="flex items-center gap-1.5"> + <span + className="w-2 h-2 rounded-full shrink-0" + style={{ backgroundColor: STORAGE_COLORS[key] ?? '#8c8c8c' }} + /> + <span className="text-xs/4 text-muted-foreground"> + {STORAGE_LABELS[key] ?? key} + </span> + </div> + <span className="text-xs/4 text-muted-foreground tabular-nums"> + {formatBytes(bytes)} + </span> + </div> + ))} + </div> + ) : ( + <div className="py-3 px-4"> + <p className="text-xs/4 text-muted-foreground">Sign in to view storage usage</p> + </div> + )} + </SettingsGroup> + + <SettingsGroup label="Location"> + <SettingRow label="Vault Path" description={vaultPath ?? '~/Documents/memry'}> + <Button + variant="outline" + size="sm" + onClick={handleReveal} + disabled={!vaultPath} + className="h-7 px-3 text-xs/4" + > + Reveal + </Button> + </SettingRow> + </SettingsGroup> </div> ) } diff --git a/apps/desktop/src/renderer/src/pages/tasks.tsx b/apps/desktop/src/renderer/src/pages/tasks.tsx index 1188387ae..17676c2fd 100644 --- a/apps/desktop/src/renderer/src/pages/tasks.tsx +++ b/apps/desktop/src/renderer/src/pages/tasks.tsx @@ -7,7 +7,6 @@ import { ProjectsTabContent } from '@/components/tasks/projects/projects-tab-con import { ProjectSelector } from '@/components/tasks/projects/project-selector' import { AddTaskModal } from '@/components/tasks/add-task-modal' import { ProjectModal } from '@/components/tasks/project-modal' -import { CalendarView } from '@/components/tasks/calendar' import { KanbanBoard } from '@/components/tasks/kanban' import { QuickAddInput } from '@/components/tasks/quick-add-input' import { TaskDetailDrawer } from '@/components/tasks/task-detail-drawer' @@ -18,15 +17,12 @@ import { GroupByDropdown } from '@/components/tasks/filters' import { cn } from '@/lib/utils' -import { extractErrorMessage } from '@/lib/ipc-error' import { getFilteredTasks, getDefaultTodoStatus, - getDefaultDoneStatus, startOfDay, getCompletedTasks, getCompletedTodayTasks, - formatDateShort, getTodayTasks, countActiveFilters, scopeTasksByProject, @@ -40,9 +36,8 @@ import { type SavedFilter, type CompletionFilterType } from '@/data/tasks-data' -import { createDefaultTask, generateTaskId, type Task, type Priority } from '@/data/sample-tasks' +import { createDefaultTask, type Task, type Priority } from '@/data/sample-tasks' import { addDays } from '@/lib/task-utils' // used by handleBulkChangeDueDate -import { calculateNextOccurrence, shouldCreateNextOccurrence } from '@/lib/repeat-utils' import { useFilterState, useSavedFilters, @@ -52,6 +47,7 @@ import { useSubtaskManagement, useUndoTracker } from '@/hooks' +import { useUndoableTaskActions } from '@/hooks/use-undoable-task-actions' import { useTasksContext } from '@/contexts/tasks' import { useSaveFilterShortcut } from '@/hooks/use-save-filter-shortcut' import { useTaskPreferences } from '@/hooks/use-task-preferences' @@ -125,7 +121,17 @@ export const TasksPage = ({ } = useTasksContext() // T051-T054: Undo tracking for Cmd+Z support - const { registerUndo } = useUndoTracker() + const { registerUndo, removeUndoEntry } = useUndoTracker() + + const undoable = useUndoableTaskActions({ + tasks, + projects, + addTask: contextAddTask, + updateTask: contextUpdateTask, + deleteTask: contextDeleteTask, + registerUndo, + removeUndoEntry + }) const { settings: taskPrefs } = useTaskPreferences() const { openTab } = useTabActions() @@ -274,7 +280,7 @@ export const TasksPage = ({ if (activeInternalTab === 'today') { return ['list'] } - return ['list', 'kanban', 'calendar'] + return ['list', 'kanban'] }, [activeInternalTab]) // Reset to list view if current view becomes unavailable @@ -352,7 +358,9 @@ export const TasksPage = ({ projects, onUpdateTask: contextUpdateTask, onDeleteTask: contextDeleteTask, - onComplete: deselectAll + onComplete: deselectAll, + registerUndo, + onAddTask: contextAddTask }) // Toggle selection mode handler @@ -569,10 +577,9 @@ export const TasksPage = ({ const handleAddTaskFromModal = useCallback( (newTask: Task): void => { - // Use context addTask to persist to database - contextAddTask(newTask) + undoable.createTask(newTask) }, - [contextAddTask] + [undoable] ) // Get default project and due date for the modal based on current selection @@ -644,8 +651,7 @@ export const TasksPage = ({ const newTask = createDefaultTask(projectId, statusId, title, dueDate) newTask.priority = priority - // Use context addTask to persist to database - contextAddTask(newTask) + undoable.createTask(newTask) }, [ selectedId, @@ -653,7 +659,7 @@ export const TasksPage = ({ selectedProject, selectedProjectId, projects, - contextAddTask, + undoable, taskPrefs.defaultProjectId ] ) @@ -673,166 +679,43 @@ export const TasksPage = ({ newTask.completedAt = new Date() } - contextAddTask(newTask) + undoable.createTask(newTask) }, - [selectedProject, projects, contextAddTask] + [selectedProject, projects, undoable] ) const handleToggleComplete = useCallback( (taskId: string): void => { - const taskToComplete = tasks.find((t) => t.id === taskId) - if (!taskToComplete) return + const task = tasks.find((t) => t.id === taskId) + if (!task) return - const project = projects.find((p) => p.id === taskToComplete.projectId) + const project = projects.find((p) => p.id === task.projectId) if (!project) return - const currentStatus = project.statuses.find((s) => s.id === taskToComplete.statusId) + const currentStatus = project.statuses.find((s) => s.id === task.statusId) if (!currentStatus) return if (currentStatus.type === 'done') { - // Uncomplete: move back to todo status - const todoStatus = getDefaultTodoStatus(project) - contextUpdateTask(taskId, { - statusId: todoStatus?.id || taskToComplete.statusId, - completedAt: null - }) - return - } - - const doneStatus = getDefaultDoneStatus(project) - const completedAt = new Date() - - // Get subtasks to also complete them - const subtasks = getSubtasks(taskId, tasks) - const hasSubtasks = subtasks.length > 0 - - if (taskToComplete.isRepeating && taskToComplete.repeatConfig && taskToComplete.dueDate) { - const config = taskToComplete.repeatConfig - const newCompletedCount = config.completedCount + 1 - const nextDate = calculateNextOccurrence(taskToComplete.dueDate, config) - const shouldCreateNext = shouldCreateNextOccurrence({ - ...config, - completedCount: newCompletedCount - }) - - // Mark the completed task as done (no longer repeating) - contextUpdateTask(taskId, { - statusId: doneStatus?.id || taskToComplete.statusId, - completedAt, - isRepeating: false, - repeatConfig: null - }) - - // Also complete all subtasks - if (hasSubtasks) { - subtasks.forEach((subtask) => { - if (!subtask.completedAt) { - contextUpdateTask(subtask.id, { - statusId: doneStatus?.id || subtask.statusId, - completedAt - }) - } - }) - } - - // Create the next occurrence if needed - if (shouldCreateNext && nextDate) { - const newTask: Task = { - ...taskToComplete, - id: generateTaskId(), - dueDate: nextDate, - statusId: getDefaultTodoStatus(project)?.id || taskToComplete.statusId, - completedAt: null, - createdAt: new Date(), - repeatConfig: { - ...config, - completedCount: newCompletedCount - } - } - contextAddTask(newTask) - toast.success('Task completed!', { - description: `Next occurrence: ${formatDateShort(nextDate)}` - }) - } else { - toast.success('Series complete!', { - description: 'This was the final occurrence.' - }) - } + undoable.uncompleteTask(taskId) } else { - // Simple completion: mark as done - contextUpdateTask(taskId, { - statusId: doneStatus?.id || taskToComplete.statusId, - completedAt - }) - - // Also complete all subtasks - if (hasSubtasks) { - const incompleteSubtasks = subtasks.filter((s) => !s.completedAt) - incompleteSubtasks.forEach((subtask) => { - contextUpdateTask(subtask.id, { - statusId: doneStatus?.id || subtask.statusId, - completedAt - }) - }) - if (incompleteSubtasks.length > 0) { - toast.success('Task completed!', { - description: `Also marked ${incompleteSubtasks.length} subtask(s) as done.` - }) - } - } + undoable.completeTask(taskId) } }, - [tasks, projects, contextUpdateTask, contextAddTask] + [tasks, projects, undoable] ) const handleUpdateTask = useCallback( (taskId: string, updates: Partial<Task>): void => { - // Use context updateTask to persist to database - contextUpdateTask(taskId, updates) + undoable.updateTaskWithUndo(taskId, updates) }, - [contextUpdateTask] + [undoable] ) const handleDeleteTask = useCallback( (taskId: string): void => { - const task = tasks.find((t) => t.id === taskId) - if (!task) return - - const deletedTask = { ...task } - - contextDeleteTask(taskId) - - // T051-T054: Register undo for Cmd+Z support - const undoFn = () => { - contextAddTask(deletedTask) - } - registerUndo(`Delete "${task.title}"`, undoFn) - - toast.success('Task deleted', { - description: `"${task.title}" has been deleted.`, - duration: 10000, // T052: 10-second timeout for undo per spec - action: { - label: 'Undo', - onClick: undoFn - } - }) - }, - [tasks, contextDeleteTask, contextAddTask, registerUndo] - ) - - const handleAddTaskWithDate = useCallback( - (date: Date): void => { - const projectId = resolveModalDefaultProject( - { selectedType, selectedProject }, - taskPrefs.defaultProjectId, - selectedProjectId - ) - setAddTaskPrefillProjectId(projectId) - setAddTaskPrefillDueDate(date) - setAddTaskPrefillTitle('') - setIsAddTaskModalOpen(true) + undoable.deleteTask(taskId) }, - [selectedProject, selectedType, selectedProjectId, taskPrefs.defaultProjectId] + [undoable] ) // ========== BULK ACTION HANDLERS ========== @@ -965,7 +848,7 @@ export const TasksPage = ({ {/* Main Content Area */} <main className="flex-1 min-w-0 flex flex-col overflow-hidden py-2 px-2"> {/* Page Header — compact single-row toolbar */} - <div className="flex items-center gap-2.5 shrink-0 min-w-0 py-0.5 border-b border-border [font-synthesis:none] text-[12px] leading-4 antialiased"> + <div className="flex items-center gap-2.5 shrink-0 min-w-0 py-0.5 border-b border-border [font-synthesis:none] text-[13px] leading-4 antialiased"> <TasksTabBar activeTab={activeInternalTab} onTabChange={handleTabChange} @@ -1012,8 +895,8 @@ export const TasksPage = ({ className={cn( 'flex items-center shrink-0 rounded-[5px] py-1 px-2 gap-1 border transition-colors', isFilterDropdownOpen || filtersActive - ? 'border-foreground/20 bg-foreground/5 text-text-primary' - : 'border-border text-text-secondary hover:bg-surface-active/50' + ? 'border-foreground/20 bg-foreground/5 text-foreground/90' + : 'border-border text-muted-foreground hover:bg-surface-active/50' )} > <svg width="13" height="13" viewBox="0 0 13 13" fill="none"> @@ -1024,7 +907,7 @@ export const TasksPage = ({ strokeLinecap="round" /> </svg> - <span className="text-[11px] leading-3.5">Filter</span> + <span className="text-[13px] font-medium">Filter</span> {filtersActive && ( <span className="flex items-center justify-center size-[14px] rounded-full bg-foreground text-background text-[9px] font-bold"> {countActiveFilters(filters)} @@ -1093,34 +976,6 @@ export const TasksPage = ({ </svg> </button> )} - {availableViews.includes('calendar') && ( - <button - type="button" - role="radio" - aria-checked={activeView === 'calendar'} - aria-label="Calendar view" - onClick={() => setActiveView('calendar')} - className={cn( - 'flex items-center justify-center w-[26px] h-6 shrink-0 transition-colors', - activeView === 'calendar' - ? 'bg-foreground/10 text-foreground' - : 'text-text-tertiary hover:text-text-secondary' - )} - > - <svg width="13" height="13" viewBox="0 0 13 13" fill="none"> - <rect - x="1.5" - y="2.5" - width="10" - height="8.5" - rx="1.25" - stroke="currentColor" - /> - <path d="M1.5 5h10" stroke="currentColor" /> - <path d="M4 1.5v2M9 1.5v2" stroke="currentColor" strokeLinecap="round" /> - </svg> - </button> - )} </div> )} </div> @@ -1245,25 +1100,6 @@ export const TasksPage = ({ /> </div> )} - - {/* Calendar View - All Tab */} - {activeInternalTab === 'all' && activeView === 'calendar' && ( - <div className="flex flex-1 flex-col overflow-hidden"> - <CalendarView - tasks={filteredTasks} - projects={projects} - selectedId="all" - selectedType="view" - onUpdateTask={handleUpdateTask} - onAddTaskWithDate={handleAddTaskWithDate} - onToggleComplete={handleToggleComplete} - onTaskClick={handleTaskClick} - isSelectionMode={selection.isSelectionMode} - selectedIds={selection.selectedIds} - onToggleSelect={toggleTask} - /> - </div> - )} </main> {/* Task Detail Drawer */} diff --git a/apps/desktop/src/renderer/src/pages/template-editor.tsx b/apps/desktop/src/renderer/src/pages/template-editor.tsx index 2367b3791..9e6b581ff 100644 --- a/apps/desktop/src/renderer/src/pages/template-editor.tsx +++ b/apps/desktop/src/renderer/src/pages/template-editor.tsx @@ -434,7 +434,7 @@ export function TemplateEditorPage({ templateId }: TemplateEditorPageProps) { value={description} onChange={(e) => !isBuiltIn && setDescription(e.target.value)} placeholder="Brief description of this template..." - className="mt-1.5 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none" + className="mt-1.5 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none" rows={2} disabled={isBuiltIn} /> @@ -488,7 +488,7 @@ export function TemplateEditorPage({ templateId }: TemplateEditorPageProps) { <Separator className="my-6" /> {/* Content editor */} - <div className="min-h-[300px] border rounded-lg p-4 bg-card"> + <div className="min-h-[300px] border rounded-md p-4 bg-card"> <ContentArea key={templateId || 'new'} initialContent={content} diff --git a/apps/desktop/src/renderer/src/pages/templates.tsx b/apps/desktop/src/renderer/src/pages/templates.tsx index 46101b610..7dcf5131e 100644 --- a/apps/desktop/src/renderer/src/pages/templates.tsx +++ b/apps/desktop/src/renderer/src/pages/templates.tsx @@ -381,7 +381,7 @@ function TemplateListRow({ template, onEdit, onDuplicate, onDelete }: TemplateLi className={cn( 'group relative flex items-center gap-4', 'px-4 py-3 -mx-4', - 'rounded-lg', + 'rounded-md', 'transition-all duration-200 ease-out', 'hover:bg-muted/50', 'cursor-pointer' @@ -401,7 +401,7 @@ function TemplateListRow({ template, onEdit, onDuplicate, onDelete }: TemplateLi <div className={cn( 'flex-shrink-0', - 'w-10 h-10 rounded-lg', + 'w-10 h-10 rounded-md', 'bg-muted/60 dark:bg-muted/40', 'flex items-center justify-center', 'transition-all duration-200', diff --git a/apps/desktop/src/renderer/src/services/inbox-service.test.ts b/apps/desktop/src/renderer/src/services/inbox-service.test.ts index e0a4dd4e2..911deda13 100644 --- a/apps/desktop/src/renderer/src/services/inbox-service.test.ts +++ b/apps/desktop/src/renderer/src/services/inbox-service.test.ts @@ -13,7 +13,7 @@ import { onInboxProcessingError, getInboxItemIcon, getInboxItemColor, - formatRelativeTime, + formatCompactDate, isItemStale } from './inbox-service' @@ -136,13 +136,12 @@ describe('inbox-service', () => { expect(getInboxItemIcon('link')).toBe('Link') expect(getInboxItemColor('image')).toBe('text-purple-500') - vi.useFakeTimers() - vi.setSystemTime(new Date('2025-01-01T00:00:00Z')) + expect(formatCompactDate('2025-03-15T12:00:00Z')).toBe('15 Mar 25') + expect(formatCompactDate('2024-07-04T12:00:00Z')).toBe('04 Jul 24') - expect(formatRelativeTime('2025-01-01T00:00:00Z')).toBe('just now') - expect(formatRelativeTime('2024-12-31T22:00:00Z')).toBe('2h ago') - - expect(isItemStale('2024-12-20T00:00:00Z', 7)).toBe(true) - expect(isItemStale('2024-12-31T00:00:00Z', 7)).toBe(false) + const oldDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + const recentDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString() + expect(isItemStale(oldDate, 7)).toBe(true) + expect(isItemStale(recentDate, 7)).toBe(false) }) }) diff --git a/apps/desktop/src/renderer/src/services/inbox-service.ts b/apps/desktop/src/renderer/src/services/inbox-service.ts index 47a4f6761..db26188bb 100644 --- a/apps/desktop/src/renderer/src/services/inbox-service.ts +++ b/apps/desktop/src/renderer/src/services/inbox-service.ts @@ -415,10 +415,6 @@ export const inboxService = { return window.api.inbox.fileAllStale() }, - bulkArchiveOlderThan: (olderThanDays: number): Promise<BulkResponse> => { - return window.api.inbox.bulkArchiveOlderThan(olderThanDays) - }, - // ========================================================================= // Transcription // ========================================================================= @@ -664,25 +660,27 @@ export function getInboxItemColor(type: InboxItem['type']): string { return colors[type] || 'text-muted-foreground' } -/** - * Format relative time for inbox items. - * @param date - Date to format - * @returns Relative time string (e.g., "2 hours ago") - */ -export function formatRelativeTime(date: Date | string): string { +const SHORT_MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' +] as const + +export function formatCompactDate(date: Date | string): string { const d = typeof date === 'string' ? new Date(date) : date - const now = new Date() - const diffMs = now.getTime() - d.getTime() - const diffMins = Math.floor(diffMs / 60000) - const diffHours = Math.floor(diffMins / 60) - const diffDays = Math.floor(diffHours / 24) - - if (diffMins < 1) return 'just now' - if (diffMins < 60) return `${diffMins}m ago` - if (diffHours < 24) return `${diffHours}h ago` - if (diffDays < 7) return `${diffDays}d ago` - if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago` - return d.toLocaleDateString() + const day = String(d.getDate()).padStart(2, '0') + const month = SHORT_MONTHS[d.getMonth()] + const year = String(d.getFullYear()).slice(-2) + return `${day} ${month} ${year}` } /** @@ -691,6 +689,23 @@ export function formatRelativeTime(date: Date | string): string { * @param thresholdDays - Stale threshold in days * @returns Whether the item is stale */ +export function formatTimeAgo(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date + const ms = Date.now() - d.getTime() + const seconds = Math.floor(ms / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes === 1) return '1 minute ago' + if (minutes < 60) return `${minutes} minutes ago` + const hours = Math.floor(minutes / 60) + if (hours === 1) return '1 hour ago' + if (hours < 24) return `${hours} hours ago` + const days = Math.floor(hours / 24) + if (days === 1) return 'yesterday' + if (days < 7) return `${days} days ago` + return formatCompactDate(d) +} + export function isItemStale(createdAt: Date | string, thresholdDays: number = 7): boolean { const d = typeof createdAt === 'string' ? new Date(createdAt) : createdAt const now = new Date() diff --git a/apps/desktop/src/renderer/src/services/notes-service.ts b/apps/desktop/src/renderer/src/services/notes-service.ts index 701b06711..82b065887 100644 --- a/apps/desktop/src/renderer/src/services/notes-service.ts +++ b/apps/desktop/src/renderer/src/services/notes-service.ts @@ -20,6 +20,7 @@ import type { AttachmentInfo, DeleteAttachmentResponse, FolderConfig, + FolderInfo, ExportNoteInput, ExportNoteResponse, // Version history types (T114) @@ -142,7 +143,7 @@ export const notesService: NotesClientAPI = { /** * Get all folders containing notes. */ - getFolders: (): Promise<string[]> => { + getFolders: (): Promise<FolderInfo[]> => { return window.api.notes.getFolders() }, diff --git a/apps/desktop/src/renderer/src/services/vault-service.ts b/apps/desktop/src/renderer/src/services/vault-service.ts index 4869d7838..b5c5d1b92 100644 --- a/apps/desktop/src/renderer/src/services/vault-service.ts +++ b/apps/desktop/src/renderer/src/services/vault-service.ts @@ -81,6 +81,10 @@ export const vaultService: VaultClientAPI = { */ reindex: (): Promise<void> => { return window.api.vault.reindex() + }, + + reveal: (): Promise<void> => { + return window.api.vault.reveal() } } diff --git a/apps/desktop/src/renderer/src/types/index.ts b/apps/desktop/src/renderer/src/types/index.ts index 03466aabc..0d670b16b 100644 --- a/apps/desktop/src/renderer/src/types/index.ts +++ b/apps/desktop/src/renderer/src/types/index.ts @@ -78,7 +78,8 @@ export interface PdfMetadata { } export interface SocialMetadata { - platform: 'twitter' | 'linkedin' | 'mastodon' | 'bluesky' | 'threads' | 'other' + platform: 'twitter' | 'other' + tweetId?: string postUrl: string authorName: string authorHandle: string diff --git a/apps/sync-server/src/services/email.test.ts b/apps/sync-server/src/services/email.test.ts index 34ffa1094..994cc7a97 100644 --- a/apps/sync-server/src/services/email.test.ts +++ b/apps/sync-server/src/services/email.test.ts @@ -23,7 +23,7 @@ describe('email service', () => { 'Content-Type': 'application/json' }, body: JSON.stringify({ - from: 'Memry <noreply@memrynote.ai>', + from: 'Memry <noreply@memrynote.com>', to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' diff --git a/apps/sync-server/src/services/email.ts b/apps/sync-server/src/services/email.ts index 8510a9f0a..510410a8b 100644 --- a/apps/sync-server/src/services/email.ts +++ b/apps/sync-server/src/services/email.ts @@ -1,7 +1,7 @@ import { AppError, ErrorCodes } from '../lib/errors' const RESEND_API_URL = 'https://api.resend.com/emails' -const FROM_ADDRESS = 'Memry <noreply@memrynote.ai>' +const FROM_ADDRESS = 'Memry <noreply@memrynote.com>' const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ diff --git a/docs/DESIGN_TOKENS.md b/docs/DESIGN_TOKENS.md new file mode 100644 index 000000000..64c5965bf --- /dev/null +++ b/docs/DESIGN_TOKENS.md @@ -0,0 +1,472 @@ +# Memry Design Tokens + +Reference for all pages. Extracted from the **task page** and **inbox page** — the two polished surfaces that define the visual language. + +> **Source of truth:** `apps/desktop/src/renderer/src/assets/base.css` +> All tokens live as CSS custom properties in `@layer base` and are exposed to Tailwind via `@theme inline`. + +--- + +## Philosophy + +**"Warm Utility"** — editorial warmth meets productivity density. + +- Warm beige canvas, not clinical white +- Serif for content, sans for UI, mono for code +- Flat-first shadows — elevation is earned +- 8pt grid spacing rhythm +- One user-chosen accent color (tint) drives the entire accent palette via `color-mix()` +- Purposeful 100–400ms motion, never decorative + +--- + +## Color + +### Canvas + +| Token | Warm (default) | White | Dark | Tailwind | +|-------|---------------|-------|------|----------| +| `--background` | `#f6f5f0` | `#ffffff` | `#0e0e10` | `bg-background` | +| `--foreground` | `#1a1a1a` | `#37352f` | `#e8e6e1` | `text-foreground` | +| `--surface` | `#efefe9` | `#f7f6f3` | `#161618` | `bg-surface` | +| `--surface-active` | `#e4e4de` | `#efedea` | `#1e1e21` | `bg-surface-active` |<div class="scroll flex-grow padding-lr"><div style="width: 288px; height: 100%;"><div style="height: 100%;"><div data-id="frequent" class="category"><div class="sticky padding-small align-l">Frequently used</div><div class="relative" style="height: 36px;"><div data-index="0" class="flex row" style="top: 0px;"><button aria-label="👍" aria-posinset="1" aria-setsize="1878" title="Thumbs Up" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👍</span></span></button><button aria-label="😀" aria-posinset="2" aria-setsize="1878" title="Grinning Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😀</span></span></button><button aria-label="😘" aria-posinset="3" aria-setsize="1878" title="Face Blowing a Kiss" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😘</span></span></button><button aria-label="😍" aria-posinset="4" aria-setsize="1878" title="Smiling Face with Heart-Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😍</span></span></button><button aria-label="😆" aria-posinset="5" aria-setsize="1878" title="Grinning Squinting Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😆</span></span></button><button aria-label="😜" aria-posinset="6" aria-setsize="1878" title="Winking Face with Tongue" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😜</span></span></button><button aria-label="😅" aria-posinset="7" aria-setsize="1878" title="Grinning Face with Sweat" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😅</span></span></button><button aria-label="😂" aria-posinset="8" aria-setsize="1878" title="Face with Tears of Joy" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😂</span></span></button></div></div></div><div data-id="people" class="category"><div class="sticky padding-small align-l">Smileys & People</div><div class="relative" style="height: 2412px;"><div data-index="1" class="flex row" style="top: 0px;"><button aria-label="😀" aria-posinset="9" aria-setsize="1878" title="Grinning Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😀</span></span></button><button aria-label="😃" aria-posinset="10" aria-setsize="1878" title="Grinning Face with Big Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😃</span></span></button><button aria-label="😄" aria-posinset="11" aria-setsize="1878" title="Grinning Face with Smiling Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😄</span></span></button><button aria-label="😁" aria-posinset="12" aria-setsize="1878" title="Beaming Face with Smiling Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😁</span></span></button><button aria-label="😆" aria-posinset="13" aria-setsize="1878" title="Grinning Squinting Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😆</span></span></button><button aria-label="😅" aria-posinset="14" aria-setsize="1878" title="Grinning Face with Sweat" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😅</span></span></button><button aria-label="🤣" aria-posinset="15" aria-setsize="1878" title="Rolling on the Floor Laughing" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤣</span></span></button><button aria-label="😂" aria-posinset="16" aria-setsize="1878" title="Face with Tears of Joy" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😂</span></span></button></div><div data-index="2" class="flex row" style="top: 36px;"><button aria-label="🙂" aria-posinset="17" aria-setsize="1878" title="Slightly Smiling Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🙂</span></span></button><button aria-label="🙃" aria-posinset="18" aria-setsize="1878" title="Upside-Down Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🙃</span></span></button><button aria-label="🫠" aria-posinset="19" aria-setsize="1878" title="Melting Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫠</span></span></button><button aria-label="😉" aria-posinset="20" aria-setsize="1878" title="Winking Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😉</span></span></button><button aria-label="😊" aria-posinset="21" aria-setsize="1878" title="Smiling Face with Smiling Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😊</span></span></button><button aria-label="😇" aria-posinset="22" aria-setsize="1878" title="Smiling Face with Halo" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😇</span></span></button><button aria-label="🥰" aria-posinset="23" aria-setsize="1878" title="Smiling Face with Hearts" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥰</span></span></button><button aria-label="😍" aria-posinset="24" aria-setsize="1878" title="Smiling Face with Heart-Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😍</span></span></button></div><div data-index="3" class="flex row" style="top: 72px;"><button aria-label="🤩" aria-posinset="25" aria-setsize="1878" title="Star-Struck" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤩</span></span></button><button aria-label="😘" aria-posinset="26" aria-setsize="1878" title="Face Blowing a Kiss" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😘</span></span></button><button aria-label="😗" aria-posinset="27" aria-setsize="1878" title="Kissing Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😗</span></span></button><button aria-label="☺️" aria-posinset="28" aria-setsize="1878" title="Smiling Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">☺️</span></span></button><button aria-label="😚" aria-posinset="29" aria-setsize="1878" title="Kissing Face with Closed Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😚</span></span></button><button aria-label="😙" aria-posinset="30" aria-setsize="1878" title="Kissing Face with Smiling Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😙</span></span></button><button aria-label="🥲" aria-posinset="31" aria-setsize="1878" title="Smiling Face with Tear" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥲</span></span></button><button aria-label="😋" aria-posinset="32" aria-setsize="1878" title="Face Savoring Food" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😋</span></span></button></div><div data-index="4" class="flex row" style="top: 108px;"><button aria-label="😛" aria-posinset="33" aria-setsize="1878" title="Face with Tongue" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😛</span></span></button><button aria-label="😜" aria-posinset="34" aria-setsize="1878" title="Winking Face with Tongue" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😜</span></span></button><button aria-label="🤪" aria-posinset="35" aria-setsize="1878" title="Zany Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤪</span></span></button><button aria-label="😝" aria-posinset="36" aria-setsize="1878" title="Squinting Face with Tongue" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😝</span></span></button><button aria-label="🤑" aria-posinset="37" aria-setsize="1878" title="Money-Mouth Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤑</span></span></button><button aria-label="🤗" aria-posinset="38" aria-setsize="1878" title="Hugging Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤗</span></span></button><button aria-label="🤭" aria-posinset="39" aria-setsize="1878" title="Face with Hand over Mouth" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤭</span></span></button><button aria-label="🫢" aria-posinset="40" aria-setsize="1878" title="Face with Open Eyes and Hand over Mouth" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫢</span></span></button></div><div data-index="5" class="flex row" style="top: 144px;"><button aria-label="🫣" aria-posinset="41" aria-setsize="1878" title="Face with Peeking Eye" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫣</span></span></button><button aria-label="🤫" aria-posinset="42" aria-setsize="1878" title="Shushing Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤫</span></span></button><button aria-label="🤔" aria-posinset="43" aria-setsize="1878" title="Thinking Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤔</span></span></button><button aria-label="🫡" aria-posinset="44" aria-setsize="1878" title="Saluting Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫡</span></span></button><button aria-label="🤐" aria-posinset="45" aria-setsize="1878" title="Zipper-Mouth Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤐</span></span></button><button aria-label="🤨" aria-posinset="46" aria-setsize="1878" title="Face with Raised Eyebrow" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤨</span></span></button><button aria-label="😐" aria-posinset="47" aria-setsize="1878" title="Neutral Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😐</span></span></button><button aria-label="😑" aria-posinset="48" aria-setsize="1878" title="Expressionless Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😑</span></span></button></div><div data-index="6" class="flex row" style="top: 180px;"><button aria-label="😶" aria-posinset="49" aria-setsize="1878" title="Face Without Mouth" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😶</span></span></button><button aria-label="🫥" aria-posinset="50" aria-setsize="1878" title="Dotted Line Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫥</span></span></button><button aria-label="😶‍🌫️" aria-posinset="51" aria-setsize="1878" title="Face in Clouds" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😶‍🌫️</span></span></button><button aria-label="😏" aria-posinset="52" aria-setsize="1878" title="Smirking Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😏</span></span></button><button aria-label="😒" aria-posinset="53" aria-setsize="1878" title="Unamused Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😒</span></span></button><button aria-label="🙄" aria-posinset="54" aria-setsize="1878" title="Face with Rolling Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🙄</span></span></button><button aria-label="😬" aria-posinset="55" aria-setsize="1878" title="Grimacing Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😬</span></span></button><button aria-label="😮‍💨" aria-posinset="56" aria-setsize="1878" title="Face Exhaling" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😮‍💨</span></span></button></div><div data-index="7" class="flex row" style="top: 216px;"><button aria-label="🤥" aria-posinset="57" aria-setsize="1878" title="Lying Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤥</span></span></button><button aria-label="🫨" aria-posinset="58" aria-setsize="1878" title="Shaking Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫨</span></span></button><button aria-label="😌" aria-posinset="59" aria-setsize="1878" title="Relieved Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😌</span></span></button><button aria-label="😔" aria-posinset="60" aria-setsize="1878" title="Pensive Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😔</span></span></button><button aria-label="😪" aria-posinset="61" aria-setsize="1878" title="Sleepy Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😪</span></span></button><button aria-label="🤤" aria-posinset="62" aria-setsize="1878" title="Drooling Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤤</span></span></button><button aria-label="😴" aria-posinset="63" aria-setsize="1878" title="Sleeping Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😴</span></span></button><button aria-label="😷" aria-posinset="64" aria-setsize="1878" title="Face with Medical Mask" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😷</span></span></button></div><div data-index="8" class="flex row" style="top: 252px;"><button aria-label="🤒" aria-posinset="65" aria-setsize="1878" title="Face with Thermometer" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤒</span></span></button><button aria-label="🤕" aria-posinset="66" aria-setsize="1878" title="Face with Head-Bandage" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤕</span></span></button><button aria-label="🤢" aria-posinset="67" aria-setsize="1878" title="Nauseated Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤢</span></span></button><button aria-label="🤮" aria-posinset="68" aria-setsize="1878" title="Face Vomiting" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤮</span></span></button><button aria-label="🤧" aria-posinset="69" aria-setsize="1878" title="Sneezing Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤧</span></span></button><button aria-label="🥵" aria-posinset="70" aria-setsize="1878" title="Hot Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥵</span></span></button><button aria-label="🥶" aria-posinset="71" aria-setsize="1878" title="Cold Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥶</span></span></button><button aria-label="🥴" aria-posinset="72" aria-setsize="1878" title="Woozy Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥴</span></span></button></div><div data-index="9" class="flex row" style="top: 288px;"><button aria-label="😵" aria-posinset="73" aria-setsize="1878" title="Dizzy Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😵</span></span></button><button aria-label="😵‍💫" aria-posinset="74" aria-setsize="1878" title="Face with Spiral Eyes" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😵‍💫</span></span></button><button aria-label="🤯" aria-posinset="75" aria-setsize="1878" title="Exploding Head" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤯</span></span></button><button aria-label="🤠" aria-posinset="76" aria-setsize="1878" title="Cowboy Hat Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤠</span></span></button><button aria-label="🥳" aria-posinset="77" aria-setsize="1878" title="Partying Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥳</span></span></button><button aria-label="🥸" aria-posinset="78" aria-setsize="1878" title="Disguised Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥸</span></span></button><button aria-label="😎" aria-posinset="79" aria-setsize="1878" title="Smiling Face with Sunglasses" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😎</span></span></button><button aria-label="🤓" aria-posinset="80" aria-setsize="1878" title="Nerd Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤓</span></span></button></div><div data-index="10" class="flex row" style="top: 324px;"><button aria-label="🧐" aria-posinset="81" aria-setsize="1878" title="Face with Monocle" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🧐</span></span></button><button aria-label="😕" aria-posinset="82" aria-setsize="1878" title="Confused Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😕</span></span></button><button aria-label="🫤" aria-posinset="83" aria-setsize="1878" title="Face with Diagonal Mouth" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫤</span></span></button><button aria-label="😟" aria-posinset="84" aria-setsize="1878" title="Worried Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😟</span></span></button><button aria-label="🙁" aria-posinset="85" aria-setsize="1878" title="Slightly Frowning Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🙁</span></span></button><button aria-label="☹️" aria-posinset="86" aria-setsize="1878" title="Frowning Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">☹️</span></span></button><button aria-label="😮" aria-posinset="87" aria-setsize="1878" title="Face with Open Mouth" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😮</span></span></button><button aria-label="😯" aria-posinset="88" aria-setsize="1878" title="Hushed Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😯</span></span></button></div><div data-index="11" class="flex row" style="top: 360px;"><button aria-label="😲" aria-posinset="89" aria-setsize="1878" title="Astonished Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😲</span></span></button><button aria-label="😳" aria-posinset="90" aria-setsize="1878" title="Flushed Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😳</span></span></button><button aria-label="🥺" aria-posinset="91" aria-setsize="1878" title="Pleading Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥺</span></span></button><button aria-label="🥹" aria-posinset="92" aria-setsize="1878" title="Face Holding Back Tears" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥹</span></span></button><button aria-label="😦" aria-posinset="93" aria-setsize="1878" title="Frowning Face with Open Mouth" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😦</span></span></button><button aria-label="😧" aria-posinset="94" aria-setsize="1878" title="Anguished Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😧</span></span></button><button aria-label="😨" aria-posinset="95" aria-setsize="1878" title="Fearful Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😨</span></span></button><button aria-label="😰" aria-posinset="96" aria-setsize="1878" title="Anxious Face with Sweat" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😰</span></span></button></div><div data-index="12" class="flex row" style="top: 396px;"><button aria-label="😥" aria-posinset="97" aria-setsize="1878" title="Sad but Relieved Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😥</span></span></button><button aria-label="😢" aria-posinset="98" aria-setsize="1878" title="Crying Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😢</span></span></button><button aria-label="😭" aria-posinset="99" aria-setsize="1878" title="Loudly Crying Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😭</span></span></button><button aria-label="😱" aria-posinset="100" aria-setsize="1878" title="Face Screaming in Fear" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😱</span></span></button><button aria-label="😖" aria-posinset="101" aria-setsize="1878" title="Confounded Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😖</span></span></button><button aria-label="😣" aria-posinset="102" aria-setsize="1878" title="Persevering Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😣</span></span></button><button aria-label="😞" aria-posinset="103" aria-setsize="1878" title="Disappointed Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😞</span></span></button><button aria-label="😓" aria-posinset="104" aria-setsize="1878" title="Face with Cold Sweat" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😓</span></span></button></div><div data-index="13" class="flex row" style="top: 432px;"><button aria-label="😩" aria-posinset="105" aria-setsize="1878" title="Weary Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😩</span></span></button><button aria-label="😫" aria-posinset="106" aria-setsize="1878" title="Tired Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😫</span></span></button><button aria-label="🥱" aria-posinset="107" aria-setsize="1878" title="Yawning Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🥱</span></span></button><button aria-label="😤" aria-posinset="108" aria-setsize="1878" title="Face with Look of Triumph" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😤</span></span></button><button aria-label="😡" aria-posinset="109" aria-setsize="1878" title="Pouting Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😡</span></span></button><button aria-label="😠" aria-posinset="110" aria-setsize="1878" title="Angry Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😠</span></span></button><button aria-label="🤬" aria-posinset="111" aria-setsize="1878" title="Face with Symbols on Mouth" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤬</span></span></button><button aria-label="😈" aria-posinset="112" aria-setsize="1878" title="Smiling Face with Horns" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">😈</span></span></button></div><div data-index="14" class="flex row" style="top: 468px;"><button aria-label="👿" aria-posinset="113" aria-setsize="1878" title="Imp" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👿</span></span></button><button aria-label="💀" aria-posinset="114" aria-setsize="1878" title="Skull" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">💀</span></span></button><button aria-label="☠️" aria-posinset="115" aria-setsize="1878" title="Skull and Crossbones" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">☠️</span></span></button><button aria-label="💩" aria-posinset="116" aria-setsize="1878" title="Pile of Poo" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">💩</span></span></button><button aria-label="🤡" aria-posinset="117" aria-setsize="1878" title="Clown Face" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤡</span></span></button><button aria-label="👹" aria-posinset="118" aria-setsize="1878" title="Ogre" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👹</span></span></button><button aria-label="👺" aria-posinset="119" aria-setsize="1878" title="Goblin" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👺</span></span></button><button aria-label="👻" aria-posinset="120" aria-setsize="1878" title="Ghost" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👻</span></span></button></div><div data-index="15" class="flex row" style="top: 504px;"><button aria-label="👽" aria-posinset="121" aria-setsize="1878" title="Alien" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👽</span></span></button><button aria-label="👾" aria-posinset="122" aria-setsize="1878" title="Alien Monster" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👾</span></span></button><button aria-label="👋" aria-posinset="123" aria-setsize="1878" title="Waving Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👋</span></span></button><button aria-label="🤚" aria-posinset="124" aria-setsize="1878" title="Raised Back of Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤚</span></span></button><button aria-label="🖐️" aria-posinset="125" aria-setsize="1878" title="Hand with Fingers Splayed" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🖐️</span></span></button><button aria-label="✋" aria-posinset="126" aria-setsize="1878" title="Raised Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">✋</span></span></button><button aria-label="🖖" aria-posinset="127" aria-setsize="1878" title="Vulcan Salute" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🖖</span></span></button><button aria-label="🫱" aria-posinset="128" aria-setsize="1878" title="Rightwards Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫱</span></span></button></div><div data-index="16" class="flex row" style="top: 540px;"><button aria-label="🫲" aria-posinset="129" aria-setsize="1878" title="Leftwards Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫲</span></span></button><button aria-label="🫳" aria-posinset="130" aria-setsize="1878" title="Palm Down Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫳</span></span></button><button aria-label="🫴" aria-posinset="131" aria-setsize="1878" title="Palm Up Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫴</span></span></button><button aria-label="🫷" aria-posinset="132" aria-setsize="1878" title="Leftwards Pushing Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫷</span></span></button><button aria-label="🫸" aria-posinset="133" aria-setsize="1878" title="Rightwards Pushing Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫸</span></span></button><button aria-label="👌" aria-posinset="134" aria-setsize="1878" title="Ok Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👌</span></span></button><button aria-label="🤌" aria-posinset="135" aria-setsize="1878" title="Pinched Fingers" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤌</span></span></button><button aria-label="🤏" aria-posinset="136" aria-setsize="1878" title="Pinching Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤏</span></span></button></div><div data-index="17" class="flex row" style="top: 576px;"><button aria-label="✌️" aria-posinset="137" aria-setsize="1878" title="Victory Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">✌️</span></span></button><button aria-label="🤞" aria-posinset="138" aria-setsize="1878" title="Crossed Fingers" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤞</span></span></button><button aria-label="🫰" aria-posinset="139" aria-setsize="1878" title="Hand with Index Finger and Thumb Crossed" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫰</span></span></button><button aria-label="🤟" aria-posinset="140" aria-setsize="1878" title="Love-You Gesture" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤟</span></span></button><button aria-label="🤘" aria-posinset="141" aria-setsize="1878" title="Sign of the Horns" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤘</span></span></button><button aria-label="🤙" aria-posinset="142" aria-setsize="1878" title="Call Me Hand" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤙</span></span></button><button aria-label="👈" aria-posinset="143" aria-setsize="1878" title="Backhand Index Pointing Left" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👈</span></span></button><button aria-label="👉" aria-posinset="144" aria-setsize="1878" title="Backhand Index Pointing Right" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👉</span></span></button></div><div data-index="18" class="flex row" style="top: 612px;"><button aria-label="👆" aria-posinset="145" aria-setsize="1878" title="Backhand Index Pointing Up" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👆</span></span></button><button aria-label="🖕" aria-posinset="146" aria-setsize="1878" title="Middle Finger" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🖕</span></span></button><button aria-label="👇" aria-posinset="147" aria-setsize="1878" title="Backhand Index Pointing Down" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👇</span></span></button><button aria-label="☝️" aria-posinset="148" aria-setsize="1878" title="Index Pointing Up" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">☝️</span></span></button><button aria-label="🫵" aria-posinset="149" aria-setsize="1878" title="Index Pointing at the Viewer" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫵</span></span></button><button aria-label="👍" aria-posinset="150" aria-setsize="1878" title="Thumbs Up" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👍</span></span></button><button aria-label="👎" aria-posinset="151" aria-setsize="1878" title="Thumbs Down" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👎</span></span></button><button aria-label="✊" aria-posinset="152" aria-setsize="1878" title="Raised Fist" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">✊</span></span></button></div><div data-index="19" class="flex row" style="top: 648px;"><button aria-label="👊" aria-posinset="153" aria-setsize="1878" title="Oncoming Fist" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👊</span></span></button><button aria-label="🤛" aria-posinset="154" aria-setsize="1878" title="Left-Facing Fist" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤛</span></span></button><button aria-label="🤜" aria-posinset="155" aria-setsize="1878" title="Right-Facing Fist" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤜</span></span></button><button aria-label="👏" aria-posinset="156" aria-setsize="1878" title="Clapping Hands" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👏</span></span></button><button aria-label="🙌" aria-posinset="157" aria-setsize="1878" title="Raising Hands" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🙌</span></span></button><button aria-label="🫶" aria-posinset="158" aria-setsize="1878" title="Heart Hands" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🫶</span></span></button><button aria-label="👐" aria-posinset="159" aria-setsize="1878" title="Open Hands" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">👐</span></span></button><button aria-label="🤲" aria-posinset="160" aria-setsize="1878" title="Palms Up Together" type="button" class="flex flex-center flex-middle" tabindex="-1" style="width: 36px; height: 36px; font-size: 28px; line-height: 0;"><div aria-hidden="true" class="background" style="border-radius: 100%;"></div><span class="emoji-mart-emoji" data-emoji-set="native"><span style="font-size: 28px; font-family: EmojiMart, "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji";">🤲</span></span></button></div><div data-index="20" class="flex row" style="top: 684px;"></div><div data-index="30" class="flex row" style="top: 1044px;"></div><div data-index="40" class="flex row" style="top: 1404px;"></div><div data-index="50" class="flex row" style="top: 1764px;"></div><div data-index="60" class="flex row" style="top: 2124px;"></div></div></div><div data-id="nature" class="category"><div class="sticky padding-small align-l">Animals & Nature</div><div class="relative" style="height: 684px;"><div data-index="70" class="flex row" style="top: 72px;"></div><div data-index="80" class="flex row" style="top: 432px;"></div></div></div><div data-id="foods" class="category"><div class="sticky padding-small align-l">Food & Drink</div><div class="relative" style="height: 612px;"><div data-index="90" class="flex row" style="top: 108px;"></div><div data-index="100" class="flex row" style="top: 468px;"></div></div></div><div data-id="activity" class="category"><div class="sticky padding-small align-l">Activity</div><div class="relative" style="height: 396px;"><div data-index="110" class="flex row" style="top: 216px;"></div></div></div><div data-id="places" class="category"><div class="sticky padding-small align-l">Travel & Places</div><div class="relative" style="height: 1008px;"><div data-index="120" class="flex row" style="top: 180px;"></div><div data-index="130" class="flex row" style="top: 540px;"></div><div data-index="140" class="flex row" style="top: 900px;"></div></div></div><div data-id="objects" class="category"><div class="sticky padding-small align-l">Objects</div><div class="relative" style="height: 1188px;"><div data-index="150" class="flex row" style="top: 252px;"></div><div data-index="160" class="flex row" style="top: 612px;"></div><div data-index="170" class="flex row" style="top: 972px;"></div></div></div><div data-id="symbols" class="category"><div class="sticky padding-small align-l">Symbols</div><div class="relative" style="height: 1008px;"><div data-index="180" class="flex row" style="top: 144px;"></div><div data-index="190" class="flex row" style="top: 504px;"></div><div data-index="200" class="flex row" style="top: 864px;"></div></div></div><div data-id="flags" class="category"><div class="sticky padding-small align-l">Flags</div><div class="relative" style="height: 1224px;"><div data-index="210" class="flex row" style="top: 216px;"></div><div data-index="220" class="flex row" style="top: 576px;"></div><div data-index="230" class="flex row" style="top: 936px;"></div></div></div></div></div></div> + +### Typography + +| Token | Warm | White | Dark | Usage | +|-------|------|-------|------|-------| +| `--text-primary` | `#1a1a1a` | `#37352f` | `#e8e6e1` | Titles, headers | +| `--text-secondary` | `#4a4a4a` | `#6b6966` | `#fff` | Body, sidebar items | +| `--text-tertiary` | `#8c8c8c` | `#9b9a97` | `#6b6966` | Meta, dates, icons | + +### UI Semantic + +| Token | Warm | White | Dark | Tailwind | +|-------|------|-------|------|----------| +| `--muted` | `#efefe9` | `#f7f6f3` | `#161618` | `bg-muted` | +| `--muted-foreground` | `#4a4a4a` | `#6b6966` | `#a8a6a1` | `text-muted-foreground` | +| `--border` | `#e4e4de` | `#e3e2e0` | `#2a2a2e` | `border-border` | +| `--input` | `#e4e4de` | `#e3e2e0` | `#2a2a2e` | `border-input` | +| `--card` | `#ffffff` | `#ffffff` | `#161618` | `bg-card` | +| `--primary` | `#1a1a1a` | `#37352f` | `#e8e6e1` | `bg-primary` | +| `--secondary` | `#efefe9` | `#f7f6f3` | `#161618` | `bg-secondary` | +| `--destructive` | `#dc2626` | `#e03e3e` | `#dc2626` | `bg-destructive` | +| `--ring` | `#8c8c8c` | `#9b9a97` | `#6b6966` | `ring-ring` | +| `--popover` | `#f6f5f0` | `#ffffff` | `#131315` | `bg-popover` | + +### Accent (Category Dots) + +| Token | Warm | Dark | Tailwind | +|-------|------|------|----------| +| `--accent-cyan` | `#06b6d4` | `#22d3ee` | `text-accent-cyan` | +| `--accent-purple` | `#8b5cf6` | `#a78bfa` | `text-accent-purple` | +| `--accent-green` | `#22c55e` | `#4ade80` | `text-accent-green` | +| `--accent-orange` | `#f97316` | `#fb923c` | `text-accent-orange` | + +### Semantic Card Backgrounds + +| Token | Warm | Dark | Domain | +|-------|------|------|--------| +| `--card-sage` | `#e6efe6` | `#1a231a` | Biology / Nature | +| `--card-rose` | `#efe6e6` | `#231a1a` | Economics / History | +| `--card-sand` | `#efebdd` | `#23211a` | Cyber Security | +| `--card-lavender` | `#e6e0ef` | `#1d1a23` | Machine Learning | +| `--card-grey` | `#e0e0e0` | `#1a1a1c` | Unknown / Sort | + +### User Accent (Tint System) + +Single user-chosen color generates a full palette via CSS `color-mix()`: + +| Token | Formula | Usage | +|-------|---------|-------| +| `--tint` | `var(--user-accent-color, #6366f1)` | Primary accent | +| `--tint-foreground` | `#ffffff` | Text on tint | +| `--tint-hover` | `tint 85% + black` | Hover state | +| `--tint-light` | `tint 15% + transparent` | Subtle bg | +| `--tint-lighter` | `tint 10% + transparent` | Very subtle bg | +| `--tint-muted` | `tint 50% + transparent` | Muted accent | +| `--tint-ring` | `tint 30% + transparent` | Focus rings | +| `--tint-border` | `tint 50% + transparent` | Accent borders | + +--- + +## Task Colors + +### Priority + +| Level | Color | Background | Dark Color | +|-------|-------|-----------|------------| +| Urgent | `#ef4444` | `rgba(239,68,68,0.12)` | `#ef4444` | +| High | `#f97316` | `rgba(249,115,22,0.12)` | `#fb923c` | +| Medium | `#a0a0a8` | `rgba(160,160,168,0.12)` | `#a0a0a8` | +| Low | `#50505a` | `rgba(80,80,90,0.12)` | `#50505a` | +| None | `#50505a` | `rgba(80,80,90,0.12)` | `#50505a` | + +### Due Date Status + +| Status | Color | Background | Dark Color | +|--------|-------|-----------|------------| +| Overdue | `#dc2626` | `#fef2f2` | `#f87171` | +| Today | `#d97706` | `#fffbeb` | `#fbbf24` | +| Tomorrow | `#2563eb` | `#eff6ff` | `#60a5fa` | +| Upcoming | `#4f46e5` | — | `#818cf8` | + +### Completion & UI Accents + +| Token | Light | Dark | +|-------|-------|------| +| `--task-complete` | `#22c55e` | `#4ade80` | +| `--task-complete-bg` | `rgba(77,166,99,0.1)` | `rgba(77,166,99,0.15)` | +| `--task-progress` | `#3b82f6` | `#60a5fa` | +| `--task-star` | `#f59e0b` | `#fbbf24` | +| `--task-repeat` | `#3b82f6` | `#60a5fa` | +| `--task-checkbox-done` | `#7b9e87` | `#7b9e87` | + +--- + +## Inbox Type Colors + +Distinct color per capture type — used for type icons in inbox list: + +| Type | Light | Dark | +|------|-------|------| +| Link | `indigo-500` | `indigo-400` | +| Voice | `amber-500` | `amber-400` | +| Image | `emerald-500` | `emerald-400` | +| Clip | `purple-400` | `purple-300` | +| Note | `muted-foreground/60` | — | +| Social | `sky-400` | `sky-300` | +| PDF | `rose-500` | `rose-400` | +| Reminder | `amber-500` | `amber-400` | +| Video | `sky-500` | `sky-400` | + +### Inbox Selection States + +| State | Classes | +|-------|---------| +| Selected | `bg-amber-50 dark:bg-amber-950/30 ring-1 ring-amber-200 dark:ring-amber-800/50` | +| Focused | `bg-muted ring-2 ring-amber-400/50 dark:ring-amber-600/50` | +| Checkbox accent | `bg-amber-600 dark:bg-amber-500` | +| Stale items | `opacity-60` | + +### Heatmap + +| Token | Value | +|-------|-------| +| Base color | `#E8A44A` with dynamic alpha | +| Cell size | `12px` (`size-3`) | +| Cell radius | `rounded-xs` | +| Gap | `gap-0.75` | + +--- + +## Typography + +### Font Stacks + +| Token | Stack | Usage | +|-------|-------|-------| +| `--font-sans` | system UI (Segoe UI Variable, -apple-system, ...) | UI chrome, body | +| `--font-serif` | Crimson Pro Variable, Georgia | Content titles, cards | +| `--font-display` | Instrument Serif, Playfair Display Variable | Dramatic headers | +| `--font-heading` | Space Grotesk Variable, system-ui | Section headings | +| `--font-mono` | JetBrains Mono Variable, SF Mono, Fira Code | Code, technical | + +### Scale + +| Size | px | Usage | +|------|-----|-------| +| `text-[9px]` | 9 | Heatmap labels, tiny meta | +| `text-[10px]` | 10 | Dense metadata, icon labels | +| `text-[11px]` | 11 | Labels, section counts, captions | +| `text-[12px]` / `text-xs` | 12 | Secondary text, date badges, filter chips | +| `text-[13px]` | 13 | Compact body, dropdown items | +| `text-[14px]` / `text-sm` | 14 | Default body | +| `text-base` | 16 | Standard | +| `text-lg` | 18 | Card titles (serif) | +| `text-3xl` / `text-[28px]` | 28–30 | Stat card values | + +### Weight & Tracking + +| Style | Value | Usage | +|-------|-------|-------| +| Headings | `font-weight: 600`, `line-height: 1.2`, `letter-spacing: -0.01em` | h1–h6 | +| Section labels | `text-[12px] uppercase tracking-[0.04em]` | Section dividers | +| Stat values | `font-semibold tracking-tight tabular-nums` | Numeric displays | +| Meta serif | `tracking-wide` | Serif metadata | + +### Utility Classes + +| Class | Definition | +|-------|-----------| +| `.card-title` | `font-serif, 1.125rem, line-height 1.2` | +| `.text-meta` | `0.75rem, text-tertiary, line-height 1.5` | +| `.text-tag` | `0.625rem, weight 500, uppercase, tracking 0.05em` | + +--- + +## Spacing + +**Base grid: 8pt** + +### Common Patterns + +| Pattern | Value | Usage | +|---------|-------|-------| +| `gap-1` | 4px | Tight inline grouping | +| `gap-1.5` | 6px | Icon + label pairs | +| `gap-2` | 8px | Default element spacing | +| `gap-2.5` | 10px | Comfortable grouping | +| `gap-3` | 12px | Section-level spacing | +| `gap-4` | 16px | Major grouping | +| `gap-6` | 24px | Section spacing | +| `gap-8` | 32px | Large vertical spacing | + +### Vertical Rhythm + +| Pattern | Value | Usage | +|---------|-------|-------| +| `py-0.5` | 2px | Minimal row padding | +| `py-1` | 4px | Compact items | +| `py-1.5` | 6px | Standard row padding | +| `py-2` | 8px | Comfortable row padding | +| `py-4` | 16px | Section padding | +| `py-8` | 32px | Large vertical sections | +| `py-16` | 64px | Hero / empty states | + +### Fixed Dimensions + +| Token | Value | +|-------|-------| +| `--sidebar-width` | `240px` | +| `--card-width` | `280px` | +| `--card-height` | `200px` | +| Detail panel width | `600px` | + +--- + +## Border Radius + +| Token | Value | Usage | +|-------|-------|-------| +| `--radius-sm` | `6px` | Small interactive elements | +| `--radius-md` / `--radius` | `8px` | Default, list rows | +| `--radius-lg` | `12px` | Popovers, dropdowns | +| `--radius-xl` | `16px` | Cards | +| `--radius-2xl` | `20px` | Large panels | +| `--radius-full` | `9999px` | Tags, badges, pills | + +### Common Overrides + +| Pattern | Usage | +|---------|-------| +| `rounded-[5px]` | Badge pills, due date badges | +| `rounded-[10px]` | Stat cards, heatmap containers | +| `rounded-xs` (3px) | Heatmap cells | + +--- + +## Shadows + +| Token | Light | Dark | +|-------|-------|------| +| `--shadow-card` | `0 1px 2px rgb(0 0 0/0.03), 0 1px 3px rgb(0 0 0/0.05)` | `0 1px 2px rgb(0 0 0/0.3), 0 1px 3px rgb(0 0 0/0.4)` | +| `--shadow-card-hover` | `0 4px 6px -1px rgb(0 0 0/0.07), 0 2px 4px -2px rgb(0 0 0/0.05)` | `0 4px 6px -1px rgb(0 0 0/0.4), 0 2px 4px -2px rgb(0 0 0/0.3)` | +| `--shadow-dropdown` | `0 4px 24px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.04)` | `0 4px 24px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)` | + +--- + +## Borders + +| Pattern | Usage | +|---------|-------| +| `border border-border/50` | Standard subtle borders (50% opacity) | +| `border border-border` | Full-strength dividers | +| `ring-1 ring-border/50` | Image thumbnails | +| `ring-2 ring-primary/50 ring-inset` | Drag-over indicator | +| `2px solid var(--ring)` | Focus indicators | + +--- + +## Motion + +### Duration + +| Token | Value | Usage | +|-------|-------|-------| +| `--duration-instant` | `100ms` | Hover, micro-feedback | +| `--duration-fast` | `150ms` | Small transitions, appear/disappear | +| `--duration-normal` | `200ms` | Most interactions (default) | +| `--duration-slow` | `300ms` | Panel slides, larger movements | +| `--duration-deliberate` | `400ms` | Empty state entrance, significant changes | + +### Easing + +| Token | Curve | Usage | +|-------|-------|-------| +| `--ease-out` | `cubic-bezier(0, 0, 0.2, 1)` | Elements entering | +| `--ease-in` | `cubic-bezier(0.4, 0, 1, 1)` | Elements leaving | +| `--ease-in-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | Moving / transforming | + +### Animation Classes + +| Class | Effect | +|-------|--------| +| `.transition-card` | `transform + box-shadow` at `--duration-fast` | +| `.hover-lift:hover` | `translateY(-2px)` | +| `.press-effect:active` | `scale(0.98)` | +| `.quick-actions-reveal` | Slide from right on group hover | +| `.item-removing` | Height collapse + fade (list) | +| `.card-removing` | Scale down + fade (card) | +| `.tag-enter` / `.tag-exit` | Pop in/out scale animation | +| `.slide-up-enter` | Bulk action bar entrance | +| `.fade-in-up` | Staggered entrance (10px upward) | +| `.count-pulse` | Number emphasis pulse | +| `.animate-drop-flash` | Blue highlight on drag-drop (1s) | +| `.animate-row-drop-flash` | Blue left-border flash (300ms) | +| `.stagger-1` through `.stagger-4` | 0/100/200/300ms delays | + +### Reduced Motion + +`@media (prefers-reduced-motion: reduce)` kills all animation durations and removes transforms. Essential feedback (opacity) is preserved. + +--- + +## Component Recipes + +### Task Row (List Item) + +``` +gap-2 | py-1.5 px-2 +Checkbox: size-4 +Project dot: size-2 rounded-xs +Title: text-[13px] +Meta: text-[11px] text-text-tertiary +``` + +### Priority Badge + +``` +py-px px-[7px] gap-1 +text-[11px] font-medium rounded-sm +Hover: opacity-80 +Color: var(--task-priority-{level}) / var(--task-priority-{level}-bg) +``` + +### Due Date Badge + +``` +py-[3px] px-2 gap-1.5 +text-[12px] rounded-[5px] +Border: 1px solid with status color +``` + +### Section Divider + +``` +text-[12px] uppercase tracking-[0.04em] +gap-2 +Add button: size-5, icon size-3.5 +Visibility: opacity hidden -> visible on group hover +``` + +### Filter Chip + +``` +gap-1.5 | px-2 py-1.5 +text-[12px] rounded-[5px] +Dot indicator: size-1.5 rounded-full +``` + +### Stat Card (Inbox Health) + +``` +p-4 rounded-[10px] +border border-border/50 +Label: text-[11px]/3.5 tracking-[0.04em] text-text-tertiary +Value: text-[28px]/8 font-semibold tracking-tight tabular-nums +``` + +### Inbox List Item + +``` +Compact: px-2 py-1 rounded-md +Title: text-xs (compact) | text-sm (default) +Meta: text-[11px] +Thumbnail: w-9 h-9 rounded-md ring-1 ring-border/50 +Type icon: size-3.5, colored per TYPE_ICON_COLORS map +``` + +### Popover / Dropdown + +``` +p-0 or p-2 | w-[280px] +rounded-md or rounded-[10px] +shadow-dropdown +``` + +### Empty State + +``` +Icon: size-6 text-muted-foreground/30 +Text: font-serif text-sm italic +Padding: py-8 to py-16 +``` + +--- + +## Sidebar + +| Token | Warm | White | Dark | +|-------|------|-------|------| +| `--sidebar` | `#efefe9` | `#f9f8f7` | `#131315` | +| `--sidebar-foreground` | `#8a857a` | `#5f5e59` | `#b5b3ae` | +| `--sidebar-primary` | `#1a1917` | `#37352f` | `#e8e5df` | +| `--sidebar-border` | `#d9d5ce` | `#e9e9e7` | `#2a2a2e` | +| `--sidebar-accent` | `tint 10%` | `tint 8%` | `tint 15%` | +| `--sidebar-accent-foreground` | `var(--tint)` | `var(--tint)` | `var(--tint)` | +| `--sidebar-text-folder` | `#3d3a35` | `#37352f` | `#c5c0b8` | +| `--sidebar-text-child` | `#5c5850` | `#6b6966` | `#9a958d` | +| `--sidebar-dot-inactive` | `#d9d5ce` | `#e3e2e0` | `#3a3a3e` | + +--- + +## Graph View + +| Token | Warm | Dark | +|-------|------|------| +| `--graph-node-note` | `#4a9e8e` | `#5abfad` | +| `--graph-node-journal` | `#8b5cf6` | `#a78bfa` | +| `--graph-node-task` | `#d4944a` | `#e8a960` | +| `--graph-node-project` | `#5a8a5a` | `#6aaa6a` | +| `--graph-node-tag` | `#c2855a` | `#d4a070` | +| `--graph-edge-default` | `#8c8c8c` | `#6b6966` | + +--- + +## Scrollbar + +| State | Light | Dark | +|-------|-------|------| +| Track | `transparent` | `transparent` | +| Thumb | `rgba(0,0,0,0.25)` | `rgba(255,255,255,0.25)` | +| Thumb hover | `rgba(0,0,0,0.4)` | `rgba(255,255,255,0.4)` | +| Thumb active | `rgba(0,0,0,0.5)` | `rgba(255,255,255,0.5)` | +| Width | `8px` | `8px` | + +Use `.scrollbar-thin` for panels, `.scrollbar-none` for hidden-but-scrollable. + +--- + +## Checklist: Applying Tokens to a New Page + +1. **Canvas** — `bg-background text-foreground` on the container +2. **Sections** — `gap-6` between major sections, `gap-2` within groups +3. **Headers** — `text-[12px] uppercase tracking-[0.04em] text-text-tertiary` +4. **Cards / panels** — `bg-card rounded-xl shadow-card border border-border/50` +5. **Interactive rows** — `py-1.5 px-2 rounded-md hover:bg-surface-active transition-colors` +6. **Borders** — always `border-border/50` (half-opacity) unless emphasis needed +7. **Focus** — `ring-2 ring-tint-ring` for keyboard focus +8. **Selection** — amber palette (`amber-50/600/950`) for multi-select +9. **Transitions** — `transition-colors duration-150` for hovers; `transition-all duration-200` for transforms +10. **Empty states** — `font-serif text-sm italic text-muted-foreground/30` diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 000000000..ac76b307f --- /dev/null +++ b/favicon.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M20 70 L20 30 L35 45 L50 25 L65 45 L80 30 L80 70 L50 70" stroke="#222222" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/> + <path d="M50 70 L50 85 L80 70" stroke="#222222" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/> +</svg> \ No newline at end of file diff --git a/package.json b/package.json index 61206e8b6..d7d0d3f16 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,9 @@ "minimatch@<4": "3.1.4", "minimatch@>=5 <6": "5.1.8", "minimatch@>=9 <10": "9.0.7", - "minimatch@>=10": "10.2.3" + "minimatch@>=10": "10.2.3", + "undici": ">=7.24.0", + "flatted": ">=3.4.2" } } } diff --git a/packages/contracts/src/inbox-api.ts b/packages/contracts/src/inbox-api.ts index 9f7b71814..e1073309c 100644 --- a/packages/contracts/src/inbox-api.ts +++ b/packages/contracts/src/inbox-api.ts @@ -98,7 +98,8 @@ export interface PdfMetadata { } export interface SocialMetadata { - platform: 'twitter' | 'linkedin' | 'mastodon' | 'bluesky' | 'threads' | 'other' + platform: 'twitter' | 'other' + tweetId?: string postUrl: string authorName: string authorHandle: string @@ -434,10 +435,6 @@ export const BulkArchiveSchema = z.object({ itemIds: z.array(z.string()).min(1).max(100) }) -export const BulkArchiveOlderThanSchema = z.object({ - olderThanDays: z.number().int().min(1).max(365) -}) - export const BulkTagSchema = z.object({ itemIds: z.array(z.string()).min(1).max(100), tags: z.array(z.string().max(50)).min(1).max(20) @@ -604,9 +601,6 @@ export interface InboxHandlers { ) => Promise<BulkResponse> [InboxChannels.invoke.BULK_TAG]: (input: z.infer<typeof BulkTagSchema>) => Promise<BulkResponse> [InboxChannels.invoke.FILE_ALL_STALE]: () => Promise<BulkResponse> - [InboxChannels.invoke.BULK_ARCHIVE_OLDER_THAN]: ( - input: z.infer<typeof BulkArchiveOlderThanSchema> - ) => Promise<BulkResponse> // Transcription [InboxChannels.invoke.RETRY_TRANSCRIPTION]: ( @@ -748,7 +742,6 @@ export interface InboxClientAPI { bulkArchive(input: z.infer<typeof BulkArchiveSchema>): Promise<BulkResponse> bulkTag(input: z.infer<typeof BulkTagSchema>): Promise<BulkResponse> fileAllStale(): Promise<BulkResponse> - bulkArchiveOlderThan(input: z.infer<typeof BulkArchiveOlderThanSchema>): Promise<BulkResponse> // Transcription retryTranscription(itemId: string): Promise<{ success: boolean; error?: string }> diff --git a/packages/contracts/src/ipc-channels.ts b/packages/contracts/src/ipc-channels.ts index 024c5bbc9..2deb026c3 100644 --- a/packages/contracts/src/ipc-channels.ts +++ b/packages/contracts/src/ipc-channels.ts @@ -23,7 +23,9 @@ export const VaultChannels = { CLOSE: 'vault:close', SWITCH: 'vault:switch', REMOVE: 'vault:remove', - REINDEX: 'vault:reindex' + REINDEX: 'vault:reindex', + /** Reveal vault folder in OS file manager */ + REVEAL: 'vault:reveal' }, events: { STATUS_CHANGED: 'vault:status-changed', @@ -463,7 +465,9 @@ export const SettingsChannels = { /** Reset all settings to defaults */ RESET_ALL: 'settings:resetAll', /** Trigger manual sync */ - TRIGGER_SYNC: 'settings:triggerSync' + TRIGGER_SYNC: 'settings:triggerSync', + /** Register (or unregister) the OS-level global capture shortcut */ + REGISTER_GLOBAL_CAPTURE: 'settings:registerGlobalCapture' }, sync: { /** Get the saved startup theme synchronously for first-paint bootstrap */ @@ -592,6 +596,8 @@ export const InboxChannels = { CAPTURE_TEXT: 'inbox:capture-text', /** Capture a URL with metadata extraction */ CAPTURE_LINK: 'inbox:capture-link', + /** Preview link metadata without creating inbox item */ + PREVIEW_LINK: 'inbox:preview-link', /** Capture an image (from drag-drop or clipboard) */ CAPTURE_IMAGE: 'inbox:capture-image', /** Capture a voice recording */ @@ -656,9 +662,6 @@ export const InboxChannels = { BULK_TAG: 'inbox:bulk-tag', /** File all stale items to unsorted */ FILE_ALL_STALE: 'inbox:file-all-stale', - /** Archive all unfiled items older than N days (inbox bankruptcy) */ - BULK_ARCHIVE_OLDER_THAN: 'inbox:bulk-archive-older-than', - // Transcription /** Retry transcription for a voice item */ RETRY_TRANSCRIPTION: 'inbox:retry-transcription', diff --git a/packages/contracts/src/settings-schemas.ts b/packages/contracts/src/settings-schemas.ts index d2f92cb49..cb88881db 100644 --- a/packages/contracts/src/settings-schemas.ts +++ b/packages/contracts/src/settings-schemas.ts @@ -16,7 +16,7 @@ import { z } from 'zod' export const GeneralSettingsSchema = z.object({ theme: z.enum(['light', 'dark', 'white', 'system']), fontSize: z.enum(['small', 'medium', 'large']), - fontFamily: z.enum(['system', 'serif', 'sans-serif', 'monospace']), + fontFamily: z.enum(['system', 'serif', 'sans-serif', 'monospace', 'gelasio', 'geist', 'inter']), accentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/), startOnBoot: z.boolean(), language: z.string().min(2).max(5), diff --git a/packages/contracts/src/settings-sync.ts b/packages/contracts/src/settings-sync.ts index 2a21b5a6a..e74ac5008 100644 --- a/packages/contracts/src/settings-sync.ts +++ b/packages/contracts/src/settings-sync.ts @@ -6,7 +6,9 @@ export const SyncedSettingsSchema = z.object({ .object({ theme: z.enum(['light', 'dark', 'white', 'system']).optional(), fontSize: z.enum(['small', 'medium', 'large']).optional(), - fontFamily: z.enum(['system', 'serif', 'sans-serif', 'monospace']).optional(), + fontFamily: z + .enum(['system', 'serif', 'sans-serif', 'monospace', 'gelasio', 'geist', 'inter']) + .optional(), accentColor: z.string().optional(), startOnBoot: z.boolean().optional(), language: z.string().optional() diff --git a/packages/contracts/src/templates-api.ts b/packages/contracts/src/templates-api.ts index 748f3a7cb..c27a22c2d 100644 --- a/packages/contracts/src/templates-api.ts +++ b/packages/contracts/src/templates-api.ts @@ -67,6 +67,8 @@ export interface TemplateListItem { * Stored in .folder.md files in each folder. */ export interface FolderConfig { + /** Emoji or icon identifier (raw emoji "🎉" or prefixed "icon:StarIcon") */ + icon?: string | null /** Default template ID for new notes in this folder */ template?: string /** Whether to inherit template from parent folder (default: true) */ @@ -83,6 +85,15 @@ export interface FolderConfig { summaries?: Record<string, SummaryConfig> } +/** + * Lightweight folder info returned by getFolders(). + * Carries path + icon without the full FolderConfig payload. + */ +export interface FolderInfo { + path: string + icon?: string | null +} + // ============================================================================ // Zod Schemas // ============================================================================ @@ -119,6 +130,7 @@ export const TemplateDuplicateSchema = z.object({ }) export const FolderConfigSchema = z.object({ + icon: z.string().nullable().optional(), template: z.string().optional(), inherit: z.boolean().optional().default(true) }) diff --git a/packages/contracts/src/vault-api.ts b/packages/contracts/src/vault-api.ts index 91c27a64e..0983fd650 100644 --- a/packages/contracts/src/vault-api.ts +++ b/packages/contracts/src/vault-api.ts @@ -104,6 +104,8 @@ export interface VaultHandlers { [VaultChannels.invoke.REMOVE]: (vaultPath: string) => Promise<void> [VaultChannels.invoke.REINDEX]: () => Promise<void> + + [VaultChannels.invoke.REVEAL]: () => Promise<void> } // ============================================================================ @@ -140,4 +142,5 @@ export interface VaultClientAPI { switch(vaultPath: string): Promise<SelectVaultResponse> remove(vaultPath: string): Promise<void> reindex(): Promise<void> + reveal(): Promise<void> } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b3e0332f..dde53cae0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,8 @@ overrides: minimatch@>=5 <6: 5.1.8 minimatch@>=9 <10: 9.0.7 minimatch@>=10: 10.2.3 + undici: '>=7.24.0' + flatted: '>=3.4.2' importers: @@ -379,6 +381,9 @@ importers: react-player: specifier: ^3.4.0 version: 3.4.0(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-tweet: + specifier: ^3.3.0 + version: 3.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) sharp: specifier: 0.34.5 version: 0.34.5 @@ -443,6 +448,12 @@ importers: '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.2.8 + '@fontsource-variable/geist': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 '@fontsource-variable/jetbrains-mono': specifier: ^5.2.8 version: 5.2.8 @@ -452,6 +463,9 @@ importers: '@fontsource-variable/space-grotesk': specifier: ^5.2.10 version: 5.2.10 + '@fontsource/gelasio': + specifier: ^5.2.8 + version: 5.2.8 '@fontsource/instrument-serif': specifier: ^5.2.8 version: 5.2.8 @@ -1682,6 +1696,12 @@ packages: '@fontsource-variable/dm-sans@5.2.8': resolution: {integrity: sha512-AxkvMTvNWgfrmlyjiV05vlHYJa+nRQCf1EfvIrQAPBpFJW0O9VTz7oAFr9S3lvbWdmnFoBk7yFqQL86u64nl2g==} + '@fontsource-variable/geist@5.2.8': + resolution: {integrity: sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==} + + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@fontsource-variable/jetbrains-mono@5.2.8': resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} @@ -1691,6 +1711,9 @@ packages: '@fontsource-variable/space-grotesk@5.2.10': resolution: {integrity: sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==} + '@fontsource/gelasio@5.2.8': + resolution: {integrity: sha512-2IzFnu2+bGPDXtO8KOy9b7BGIBIO/2BD8j8LoopeXhO6jDYXTE8FVG8pGlW5k+gHrmczBV4wukH4R2HB+VLhog==} + '@fontsource/instrument-serif@5.2.8': resolution: {integrity: sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==} @@ -3306,6 +3329,9 @@ packages: peerDependencies: '@svta/cml-utils': 1.0.1 + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -5272,8 +5298,8 @@ packages: flatbuffers@25.9.23: resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -7436,6 +7462,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-tweet@3.3.0: + resolution: {integrity: sha512-gSIG2169ZK7UH6rBzuU+j1xnQbH3IlOTLEkuGrRiJJTMgETik+h+26yHyyVKrLkzwrOaYPk4K3OtEKycqKgNLw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -8227,12 +8259,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.18.2: - resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} - engines: {node: '>=20.18.1'} - - undici@7.21.0: - resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: @@ -9855,12 +9883,18 @@ snapshots: '@fontsource-variable/dm-sans@5.2.8': {} + '@fontsource-variable/geist@5.2.8': {} + + '@fontsource-variable/inter@5.2.8': {} + '@fontsource-variable/jetbrains-mono@5.2.8': {} '@fontsource-variable/playfair-display@5.2.8': {} '@fontsource-variable/space-grotesk@5.2.10': {} + '@fontsource/gelasio@5.2.8': {} + '@fontsource/instrument-serif@5.2.8': {} '@handlewithcare/prosemirror-inputrules@0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)': @@ -11377,6 +11411,10 @@ snapshots: dependencies: '@svta/cml-utils': 1.0.1 + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -12040,7 +12078,7 @@ snapshots: dependencies: '@vitest/utils': 4.0.18 fflate: 0.8.2 - flatted: 3.3.3 + flatted: 3.4.2 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 @@ -12578,7 +12616,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 7.21.0 + undici: 7.24.5 whatwg-mimetype: 4.0.0 chokidar@5.0.0: @@ -13694,12 +13732,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 flatbuffers@25.9.23: {} - flatted@3.3.3: {} + flatted@3.4.2: {} for-each@0.3.5: dependencies: @@ -15310,7 +15348,7 @@ snapshots: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 - undici: 7.18.2 + undici: 7.24.5 workerd: 1.20260301.1 ws: 8.18.0 youch: 4.1.0-beta.10 @@ -16245,6 +16283,14 @@ snapshots: transitivePeerDependencies: - '@types/react' + react-tweet@3.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@swc/helpers': 0.5.19 + clsx: 2.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + swr: 2.4.1(react@19.2.4) + react@19.2.4: {} read-binary-file-arch@1.0.6: @@ -17224,9 +17270,7 @@ snapshots: undici-types@7.16.0: {} - undici@7.18.2: {} - - undici@7.21.0: {} + undici@7.24.5: {} unenv@2.0.0-rc.24: dependencies: