From b645e72ff1a52e2c348db7aa5f8cff85327aacea Mon Sep 17 00:00:00 2001 From: RGB-loop <91311486+RGB-loop@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:04:13 +0800 Subject: [PATCH] feat(links): Copy Link always gives an http link; desktop runs a local link service - Desktop always listens on 127.0.0.1: (default 41721). With the Local API off it is links-only: /open and /media/thumbh only, no API, web UI or MCP. - /open requested from this machine as a top-level navigation focuses Vixel at the item and returns a script-free "Opened in Vixel" page; other devices get the web view. Background fetches (Sec-Fetch-Mode != navigate) get 403; Host guard still applies. - Copy Link / Copy Markdown / Copy Markdown with Thumbnail all use http links (LAN address when LAN access is on, else localhost); vixel:// stays only as an incoming protocol for old links. - Settings shows when the link service can't bind its port. - 0.5.3 --- CHANGELOG.md | 14 ++++++ README.en.md | 2 +- README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/main/index.ts | 12 ++++- src/main/localApi.ts | 39 +++++++++++---- src/main/menu.ts | 13 ++--- src/renderer/src/App.tsx | 35 ++++++-------- .../src/components/inspector/Inspector.tsx | 46 ++++++++---------- .../components/settings/LocalApiSettings.tsx | 25 +++++++--- src/renderer/src/lib/webLinks.ts | 14 +++--- src/server/app.test.ts | 29 ++++++++++- src/server/app.ts | 48 ++++++++++++++++++- src/server/webapp.ts | 34 ++++++++++++- src/shared/i18n/inspector.ts | 2 + src/shared/i18n/localapi.ts | 2 + src/shared/i18n/menu.ts | 2 - src/shared/i18n/web.ts | 9 ++++ src/shared/localapi.ts | 7 +++ src/shared/types.ts | 2 +- 21 files changed, 252 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35959b4..a68c3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.3] - 2026-10-18 + +### Changed +- **Copy Link now gives an http link that every app recognizes.** Right-click / inspector → + Copy Link, Copy Markdown Link and Copy Markdown with Thumbnail all produce + `http://localhost:41721/open?hash=…&t=…` instead of `vixel://…`: note, chat and document apps + highlight it as a link. Opening it on this machine jumps Vixel to that item (video at the + timestamp) and leaves a small "Opened in Vixel" page in the browser; on other devices (LAN + access on) it opens the web view. To make this work without turning on the Local API, the + desktop app now keeps a tiny link service on 127.0.0.1 while it runs: it answers only `/open` + and hash-addressed thumbnails, only to this machine, and only for top-level navigations — a + web page can't make Vixel jump by fetching the URL in the background. Links need Vixel to be + running. `vixel://` links already pasted keep working; the menu no longer copies them. + ## [0.5.2] - 2026-10-18 ### Added diff --git a/README.en.md b/README.en.md index 6dbc147..256e1c3 100644 --- a/README.en.md +++ b/README.en.md @@ -29,7 +29,7 @@ Local AI search for photos, videos and audio. Fully offline, no account, nothing - **Clean up similar photos.** Finds burst shots, re-exports and lightly edited copies, suggests which one to keep, and hides the rest in Vixel or moves them to the Trash after you confirm. - **Offline map, similar photos, de-duplication, HEIC / RAW, quick look, dark mode.** - **CLI for scripts and agents:** `vixel search "sunset at the beach" --json`. -- **Links for your notes.** Right-click → Copy Link / Copy Markdown Link gives a `vixel://` deep link to paste into Obsidian, Notes or any editor; clicking it opens Vixel at that item (video/audio jumps to the timestamp). Links are addressed by content hash, so they survive re-indexing. With the Local API on you can also Copy Web Link / Copy Markdown with Thumbnail: an `http://…/open?…` link that opens on phones and in web-based notes, with an inline preview in Markdown (the NAS web UI offers the same). +- **Links for your notes.** Right-click → Copy Link / Copy Markdown Link / Copy Markdown with Thumbnail gives an `http://localhost:…/open?…` link that every note, chat and document app recognizes as a link; opening it on this machine jumps Vixel to that item (video/audio at the timestamp), and the Markdown form can show a thumbnail. With the Local API's LAN access on, links use the LAN address and open a web view on phones; the NAS web UI offers the same. Links are addressed by content hash, so they survive re-indexing (Vixel needs to be running). - **Local API (off by default).** Settings → General → Local API serves a web UI and HTTP API for your desktop library — enable LAN access (password required) to browse it from your phone — plus a read-only MCP endpoint at `/mcp` for AI clients (see below). Video segment search diff --git a/README.md b/README.md index 4be1ff3..e09c991 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ SCRFD 人脸检测 + MobileFaceNet 512 维特征 + sqlite-vec 近邻索引。 - **HEIC / RAW** —— macOS 上通过系统 `sips` 解码 CR2 / CR3 / NEF / ARW / DNG / RAF / ORF / RW2,保留 EXIF。 - **快速查看** —— 空格预览,⌘1–5 切换 全部 / 图片 / 视频 / 音频 / 地图。 - **命令行** —— `vixel search "海边" --json`,方便脚本和 Agent 调用(见下文);结果里的 `matchedBy` 说明是画面、图中文字还是文件名命中。 -- **链接进笔记** —— 右键「拷贝链接 / 拷贝 Markdown 链接」得到 `vixel://` 深链接,贴进 Obsidian、备忘录等任意笔记,点击回到 Vixel 定位到那一项(视频 / 音频直接跳到时间点)。链接按内容哈希定位,清库重建、移动文件后仍然有效。打开了「本地接口」时还能「拷贝网页链接 / 拷贝 Markdown(带缩略图)」:`http://…/open?…` 在手机、网页笔记等装不了 Vixel 的地方也能点开,Markdown 里直接显示缩略图;NAS 版网页里同样能拷贝。 +- **链接进笔记** —— 右键「拷贝链接 / 拷贝 Markdown 链接 / 拷贝 Markdown(带缩略图)」得到 `http://localhost:…/open?…` 链接,任何笔记、聊天、文档软件都能识别成可点的链接;在本机点开回到 Vixel 定位到那一项(视频 / 音频直接跳到时间点),Markdown 还能直接显示缩略图。开了「本地接口」的局域网访问后,链接换成局域网地址,手机上点开是网页查看页;NAS 版网页里同样能拷贝。链接按内容哈希定位,清库重建、移动文件后仍然有效(需要 Vixel 在运行)。 - **本地接口(默认关闭)** —— 设置 → 通用 → 本地接口:在本机开出网页界面和 HTTP API,手机浏览器也能访问桌面的资料库(局域网访问必须设口令);同一服务的 `/mcp` 是给 AI 客户端的只读 MCP 接口(见下文)。 - **中英文界面** —— 跟随系统语言,也可在 设置 → 通用 里切换。 - **诊断** —— `vixel doctor` / `vixel bench` 体检和测速,`VIXEL_PROFILE=1` 打印慢操作,见 [docs/debugging.md](docs/debugging.md)。 diff --git a/package-lock.json b/package-lock.json index f4735b6..64be2a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vixel", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vixel", - "version": "0.5.2", + "version": "0.5.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a669da9..f66a319 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vixel", - "version": "0.5.2", + "version": "0.5.3", "description": "Private, offline AI search for your photos, videos and audio", "main": "./out/main/index.js", "author": "Vixel Team", diff --git a/src/main/index.ts b/src/main/index.ts index a1044a8..ecafc30 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -756,8 +756,18 @@ app.whenReady().then(async () => { dataDir: app.getPath('userData'), webDir: existsSync(prodWebDir) ? prodWebDir : join(process.cwd(), 'dist', 'web'), version: app.getVersion(), + // 本机点开的网页链接:和 vixel:// 深链接同一条路 —— 定位后交给主窗口打开(带时间点) + openInApp: async (target) => { + const result = await services.api.resolveLink(target) + if (!result) return false + quickPanel!.openResult(result) + // 从浏览器点进来:vixel:// 由系统激活应用,http 不会 —— 主动把 Vixel 拉到前台 + if (process.platform === 'darwin') app.focus({ steal: true }) + return true + }, }) - localApi.startIfEnabled() + // 常驻:没开本地接口时也起一个只响应 /open 的本机服务,"拷贝链接"出来的 http 链接才点得开 + localApi.startOnLaunch() // PDF / AI 的 pdf.js 栅格化服务(隐藏窗口按需创建;Windows/Linux 主路径,macOS 兜底) registerPdfRenderer() registerIpcHandlers() diff --git a/src/main/localApi.ts b/src/main/localApi.ts index 3f46232..11e4e06 100644 --- a/src/main/localApi.ts +++ b/src/main/localApi.ts @@ -21,6 +21,7 @@ import { createToolContext } from '../core/mcp/tools' import type { VixelServices } from '../services/api' import type { createServiceEvents } from '../services/events' import { DEFAULT_LOCALAPI_PORT, type LocalApiPatch, type LocalApiStatus } from '../shared/localapi' +import type { DeepLinkTarget } from '../shared/deeplink' const KEY = { enabled: 'localapi.enabled', @@ -31,6 +32,8 @@ const KEY = { } as const export interface LocalApiDeps { + /** 本机点开的 /open?… 链接:在 Vixel 窗口里定位(返回是否找到) */ + openInApp: (target: DeepLinkTarget) => Promise db: DatabaseInstance services: VixelServices events: ReturnType @@ -45,6 +48,8 @@ export interface LocalApiDeps { export class LocalApiServer { private server: Server | null = null private app: VixelServerApp | null = null + /** 当前跑的是完整本地接口,还是只响应链接的常驻服务 */ + private linksOnly = false private error: string | null = null constructor(private deps: LocalApiDeps) {} @@ -69,7 +74,7 @@ export class LocalApiServer { /** 正在监听的可访问地址:回环 + (LAN 时)各网卡的 IPv4 */ private urls(): string[] { - if (!this.server?.listening) return [] + if (!this.server?.listening || this.linksOnly) return [] const out = [`http://127.0.0.1:${this.port}`] if (this.lan) { for (const addrs of Object.values(networkInterfaces())) { @@ -81,16 +86,25 @@ export class LocalApiServer { return out } + /** "拷贝链接"的基址:局域网开着 → 第一个局域网地址;否则 localhost(读着比 127.0.0.1 友好,Host 校验同样放行) */ + private linkBase(): string | null { + if (!this.server?.listening) return null + const lan = this.urls().find((u) => !u.startsWith('http://127.0.0.1')) + return lan ?? `http://localhost:${this.port}` + } + status(): LocalApiStatus { return { enabled: this.enabled, - running: this.server?.listening ?? false, + running: (this.server?.listening ?? false) && !this.linksOnly, port: this.port, lan: this.lan, hasPassword: this.password.length > 0, mcpToken: this.mcpToken, urls: this.urls(), error: this.error, + linksRunning: this.server?.listening ?? false, + linkBase: this.linkBase(), } } @@ -118,7 +132,7 @@ export class LocalApiServer { } if (typeof p.enabled === 'boolean') db.setMetaState(KEY.enabled, p.enabled ? '1' : '0') await this.stop() - if (this.enabled) await this.start() + await this.start() return this.status() } @@ -132,14 +146,16 @@ export class LocalApiServer { return this.status() } - startIfEnabled(): void { - if (this.enabled) void this.start() + /** 应用启动时调用:开了本地接口就起完整栈,否则起只响应链接的常驻服务 */ + startOnLaunch(): void { + void this.start() } private start(): Promise { this.error = null - const { db, services, events, searchEngine, dataDir, webDir, version } = this.deps - const lan = this.lan + const { db, services, events, searchEngine, dataDir, webDir, version, openInApp } = this.deps + const linksOnly = !this.enabled + const lan = this.lan && !linksOnly const port = this.port // LAN 必须有口令(update 已拦;这里兜底防直接改库) if (lan && !this.password) { @@ -152,12 +168,14 @@ export class LocalApiServer { events, dataDir, webDir, + openInApp, + linksOnly, // 仅回环时不要求登录(与浏览器扩展收集服务一致);一开局域网就强制口令,连本机也要登录 password: lan ? this.password : null, cookieSecure: false, trustProxy: false, allowedRoots: () => [dataDir, ...db.getFolders().map((f) => f.path)], - mcp: { + mcp: linksOnly ? null : { server: createMcpServer(createToolContext(db, searchEngine), { name: 'vixel', version, @@ -182,7 +200,10 @@ export class LocalApiServer { server.listen(port, lan ? '0.0.0.0' : '127.0.0.1', () => { this.server = server this.app = app - console.log(`[localapi] serving library on ${lan ? '0.0.0.0' : '127.0.0.1'}:${port} (lan: ${lan}, auth: ${lan ? 'password' : 'none'}, /mcp: bearer token)`) + this.linksOnly = linksOnly + console.log(linksOnly + ? `[localapi] link service on 127.0.0.1:${port} (/open only)` + : `[localapi] serving library on ${lan ? '0.0.0.0' : '127.0.0.1'}:${port} (lan: ${lan}, auth: ${lan ? 'password' : 'none'}, /mcp: bearer token)`) resolve() }) }) diff --git a/src/main/menu.ts b/src/main/menu.ts index 40d143e..bb6e94c 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -142,15 +142,12 @@ export function popupItemMenu( ] as MenuItemConstructorOptions[]), { label: multi ? t.menu.revealMany(revealLabel(), count) : revealLabel(), accelerator: 'CmdOrCtrl+Shift+R', click: pick('reveal') }, { label: multi ? t.menu.copyPaths(count) : t.common.copyPath, click: pick('copy-path') }, - // 深链接(vixel://open?…,见 shared/deeplink.ts):贴进笔记,点击回到 Vixel 定位这一项 + // 拷贝链接(http://…/open?…,见 shared/deeplink.ts):贴进笔记,本机点开回到 Vixel 定位这一项; + // 链接服务没起来(端口被占)时置灰 ...(multi ? [] : [ - { label: t.menu.copyLink, click: pick('copy-link') }, - { label: t.menu.copyMarkdownLink, click: pick('copy-md-link') }, - // 网页链接(http://…/open?…):本地接口开着时才有 —— 手机、网页笔记等装不了 Vixel 的地方也能点开 - ...(webLinks ? [ - { label: t.menu.copyWebLink, click: pick('copy-web-link') }, - { label: t.menu.copyWebMarkdown, click: pick('copy-web-md') }, - ] : []), + { label: t.menu.copyLink, enabled: !!webLinks, click: pick('copy-link') }, + { label: t.menu.copyMarkdownLink, enabled: !!webLinks, click: pick('copy-md-link') }, + { label: t.menu.copyWebMarkdown, enabled: !!webLinks, click: pick('copy-web-md') }, ] as MenuItemConstructorOptions[]), { label: favorite ? t.organize.unfavorite : t.organize.favorite, accelerator: 'F', registerAccelerator: false, click: pick('toggle-favorite') }, ...(multi ? [] : [ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1f4f859..26667ec 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -20,7 +20,7 @@ import { thumbUrl } from './lib/mediaUrl' import { sourcePathOf } from './lib/resultActions' import { useSearchHistory, normalizeQuery } from './lib/searchHistory' import { t } from '../../shared/i18n' -import { deepLinkForItem, markdownLinkForItem, parseLinkParams, httpLinkForItem, markdownThumbLinkForItem, thumbUrlForHash } from '../../shared/deeplink' +import { markdownLinkForItem, parseLinkParams, httpLinkForItem, markdownThumbLinkForItem, thumbUrlForHash } from '../../shared/deeplink' import { copyText, useWebLinkBase } from './lib/webLinks' import { FilterBar, hasExtraFilters, countExtraFilters, describeExtraFilters, useTags, type ExtraFilters } from './components/shell/FilterBar' import { useSmartAlbums, sameAlbumState, splitAlbumFilters } from './lib/smartAlbums' @@ -870,25 +870,19 @@ function App(): JSX.Element { try { await navigator.clipboard.writeText(paths.join('\n')) } catch (e) { console.error('Copy failed:', e) } }, []) - // 深链接(vixel://open?…,见 shared/deeplink.ts):贴进笔记后点击回到 Vixel 定位; - // 命中片段的起点作为时间点,Markdown 形态是 [文件名 @ 0:42](vixel://…) - const copyDeepLinks = useCallback(async (items: SearchResult[], markdown: boolean) => { - const lines = items.map((r) => { - const url = deepLinkForItem(r.photo, r.segment?.startMs) - return markdown ? markdownLinkForItem(r.photo.fileName, url, r.segment?.startMs) : url - }) - try { await navigator.clipboard.writeText(lines.join('\n')) } catch (e) { console.error('Copy failed:', e) } - }, []) - - // 网页链接(http://…/open?…):本地接口 / 网页版才有;Markdown 形态带按 hash 取的缩略图,笔记里直接显示预览 + // 拷贝链接(http://…/open?…,见 shared/deeplink.ts):贴进笔记 / 聊天能被识别成链接,本机点开回到 Vixel 定位, + // 手机等其他设备打开网页查看页。命中片段的起点作为时间点;Markdown 形态 [文件名 @ 0:42](链接), + // 带缩略图的形态内嵌按 hash 取的预览图 const webBase = useWebLinkBase() - const copyWebLinks = useCallback(async (items: SearchResult[], markdown: boolean) => { + const copyLinks = useCallback(async (items: SearchResult[], form: 'url' | 'markdown' | 'thumb') => { if (!webBase) return const lines = items.map((r) => { const url = httpLinkForItem(webBase, r.photo, r.segment?.startMs) - return markdown && r.photo.fileHash - ? markdownThumbLinkForItem(r.photo.fileName, url, thumbUrlForHash(webBase, r.photo.fileHash), r.segment?.startMs) - : url + if (form === 'url') return url + if (form === 'thumb' && r.photo.fileHash) { + return markdownThumbLinkForItem(r.photo.fileName, url, thumbUrlForHash(webBase, r.photo.fileHash), r.segment?.startMs) + } + return markdownLinkForItem(r.photo.fileName, url, r.segment?.startMs) }) try { await copyText(lines.join('\n')) } catch (e) { console.error('Copy failed:', e) } }, [webBase]) @@ -924,15 +918,14 @@ function App(): JSX.Element { case 'quick-look': setQuickLookId(first.photo.id); break case 'reveal': revealItems(items); break case 'copy-path': copyPaths(items); break - case 'copy-link': copyDeepLinks(items, false); break - case 'copy-md-link': copyDeepLinks(items, true); break - case 'copy-web-link': copyWebLinks(items, false); break - case 'copy-web-md': copyWebLinks(items, true); break + case 'copy-link': copyLinks(items, 'url'); break + case 'copy-md-link': copyLinks(items, 'markdown'); break + case 'copy-web-md': copyLinks(items, 'thumb'); break case 'search-by-image': searchWithItem(first); break case 'open-external': if (first.photo.videoId != null) window.api.openSourceVideo(first.photo.videoId); break case 'toggle-favorite': toggleFavorite(items); break } - }, [revealItems, copyPaths, copyDeepLinks, copyWebLinks, searchWithItem, toggleFavorite]) + }, [revealItems, copyPaths, copyLinks, searchWithItem, toggleFavorite]) const handleItemContextMenu = useCallback(async (index: number, e: React.MouseEvent) => { e.preventDefault() diff --git a/src/renderer/src/components/inspector/Inspector.tsx b/src/renderer/src/components/inspector/Inspector.tsx index 20978c3..faffa4d 100644 --- a/src/renderer/src/components/inspector/Inspector.tsx +++ b/src/renderer/src/components/inspector/Inspector.tsx @@ -4,7 +4,7 @@ import type { ItemSource } from '../../../../shared/collect' import { formatDuration, formatFileSize, mediaKindOf } from '../../lib/format' import { downloadOriginal, imageUrl, mediaSrcUrl, thumbUrl } from '../../lib/mediaUrl' import { IS_WEB, revealLabel } from '../../lib/platform' -import { deepLinkForItem, markdownLinkForItem, httpLinkForItem, markdownThumbLinkForItem, thumbUrlForHash } from '../../../../shared/deeplink' +import { markdownLinkForItem, httpLinkForItem, markdownThumbLinkForItem, thumbUrlForHash } from '../../../../shared/deeplink' import { copyText, useWebLinkBase } from '../../lib/webLinks' import { Icon } from '../shell/icons' import { TagEditor } from './TagEditor' @@ -86,20 +86,16 @@ export function Inspector({ result, onSelect, showPreview, onClose, resolution: const sourcePath = isMedia ? media?.filePath : photo.filePath const dup = photo.duplicateCount && photo.duplicateCount > 1 ? photo.duplicateCount : 0 - // 深链接:hash 优先(重建索引后仍有效);媒体带上命中片段的起点 - const copyDeepLink = (markdown: boolean): void => { - const url = deepLinkForItem(photo, segment?.startMs) - const text = markdown ? markdownLinkForItem(media?.fileName ?? photo.fileName, url, segment?.startMs) : url - void copyText(text).catch((e) => console.error('Copy failed:', e)) - } - // 网页链接(http://…/open?…,同样按 hash 定位):网页版总有;桌面版本地接口开着时才有 + // 拷贝链接(http://…/open?…,hash 定位、重建索引后仍有效;媒体带上命中片段的起点): + // 本机点开回到 Vixel,别的设备打开网页查看页。桌面版链接服务没起来(端口被占)时按钮置灰 const webBase = useWebLinkBase() - const copyWebLink = (markdown: boolean): void => { + const copyLink = (form: 'url' | 'markdown' | 'thumb'): void => { if (!webBase) return + const name = media?.fileName ?? photo.fileName const url = httpLinkForItem(webBase, photo, segment?.startMs) - const text = markdown && photo.fileHash - ? markdownThumbLinkForItem(media?.fileName ?? photo.fileName, url, thumbUrlForHash(webBase, photo.fileHash), segment?.startMs) - : url + const text = form === 'url' ? url + : form === 'thumb' && photo.fileHash ? markdownThumbLinkForItem(name, url, thumbUrlForHash(webBase, photo.fileHash), segment?.startMs) + : markdownLinkForItem(name, url, segment?.startMs) void copyText(text).catch((e) => console.error('Copy failed:', e)) } // 音频代表图没有图片向量,embed_status 永远 pending,不能算"索引中" @@ -281,8 +277,6 @@ export function Inspector({ result, onSelect, showPreview, onClose, resolution: > {t.web.downloadOriginal} - copyWebLink(false)}>{t.menu.copyLink} - copyWebLink(true)}>{t.menu.copyWebMarkdown} ) : ( <> @@ -292,17 +286,17 @@ export function Inspector({ result, onSelect, showPreview, onClose, resolution: {t.inspector.systemPlayer} )} - {/* 深链接(vixel://open?…):贴进笔记后点击回到 Vixel 定位;网页版隐藏(等价物是 http 链接) */} - copyDeepLink(false)}>{t.menu.copyLink} - copyDeepLink(true)}>{t.menu.copyMarkdownLink} - {webBase && ( - <> - copyWebLink(false)}>{t.menu.copyWebLink} - copyWebLink(true)}>{t.menu.copyWebMarkdown} - - )} )} + {[ + { form: 'url' as const, label: t.menu.copyLink }, + { form: 'markdown' as const, label: t.menu.copyMarkdownLink }, + { form: 'thumb' as const, label: t.menu.copyWebMarkdown }, + ].map(({ form, label }) => ( + copyLink(form)}> + {label} + + ))} @@ -440,11 +434,13 @@ function Row({ label, children }: { label: string; children: React.ReactNode }): ) } -function ActionButton({ icon, onClick, children }: { icon: Parameters[0]['name']; onClick: () => void; children: React.ReactNode }): JSX.Element { +function ActionButton({ icon, onClick, children, disabled, title }: { icon: Parameters[0]['name']; onClick: () => void; children: React.ReactNode; disabled?: boolean; title?: string }): JSX.Element { return (