9 Commits

Author SHA1 Message Date
BiggerRain
7a4364665e chore: bump version to 0.10.0 (#1022) 2025-12-19 17:56:32 +08:00
BiggerRain
444ba968c4 chore: hide selection settings (#1020) 2025-12-19 16:15:03 +08:00
ayangweb
206d8db4f4 refactor: disable input auto-correction (#1019) 2025-12-19 15:46:53 +08:00
BiggerRain
7e40632c29 style: adjust delete button color (#1018) 2025-12-19 12:59:58 +08:00
SteveLauC
f483ce4887 feat: resizable extension UI (#1009)
* wip

* define config entries: width/height/resizable/detachable

* chore: window size

* fix: add default values for ViewExtensionUiSettings fields

* chore: open

* chore: add window size set

* wip

* chore: window size

* define config entries: width/height/resizable/detachable

* chore: open

* fix: add default values for ViewExtensionUiSettings fields

* chore: add window size set

* chore: up

* fix: consle error

* chore: up

* chore: up

* chore: up

* chore: up

* refactor: update

* fix: page error about install

* chore: up

* chore: ci error

* docs: update release notes

* style: adjust styles

---------

Co-authored-by: rain9 <15911122312@163.com>
Co-authored-by: ayang <473033518@qq.com>
2025-12-19 09:01:51 +08:00
BiggerRain
3a9c9ec9eb style: img styles (#1015) 2025-12-18 15:51:00 +08:00
BiggerRain
f7c0600480 feat: add open button to launch installed extension (#1013)
* chore: up

* support query string main_extension_id

* chore: up

* fix tests

* open non-group/extension extensions

* dbg

* chore: upadate

* extension SearchSource now accepts empty querystring

* update

* chore: open

* chore: input

* remove DBG statements

* chore: icon

* style: adjust styles

* docs: update release notes

---------

Co-authored-by: Steve Lau <stevelauc@outlook.com>
2025-12-18 15:50:26 +08:00
BiggerRain
ed8a1cb477 refactor: replace legacy components with shadcn/ui components (#1002)
* chore: shadcn config

* feat: add shadcn ui config

* style: adjust styles

* style: adjust styles

* refactor: update style

* style: adjust styles

* style: adjust styles

* style: adjust styles

* style: adjust styles

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* style: adjust styles

* style: adjust styles

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* style: web styles

* refactor: update

* style: web styles

* style: web styles

* refactor: update

* refactor: update

* refactor: update

* chhore: add

* chore: add

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* chore: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* chore: rename

* refactor: update

* refactor: update

* chore: add

* refactor: update

* chore: update

* chroe: up

* refactor: update

* refactor: update

* chore: up

* refactor: update

* chore: up

* feat: support for extracting css variables

* chore: update

* fix: fixed dark mode

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* docs: update release notes

* style: adjust styles

* style: adjust styles

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

* refactor: update

---------

Co-authored-by: ayang <473033518@qq.com>
2025-12-18 10:26:13 +08:00
SteveLauC
abf20f81ff refactor: treat Applications and File Search as normal extensions (#1012)
I was digging into an issue that the "File Search" extension does
not appear in the extension list under some cases, then I realized
that, in list_extensions(), "File Search" is another extension that
needs to be processed separately, just like the "Applications"
extension.

Both extensions are of type group/extension, which semantically should
behave like a folder, i.e., they contain sub-extensions, but they
do not. In our implementation, they are more like command extensions.

So we treat them as normal extensions in list_extensioins().
2025-12-17 15:08:01 +08:00
131 changed files with 4976 additions and 2822 deletions

4
.gitignore vendored
View File

@@ -13,6 +13,8 @@ dist-ssr
*.local
out
src/components/web
SearchChatDemo/
web.md
# Editor directories and files
# .vscode/*
@@ -26,3 +28,5 @@ src/components/web
*.sln
*.sw?
.env
.trae

View File

@@ -37,15 +37,18 @@
"meval",
"Minimizable",
"msvc",
"njsproj",
"nord",
"nowrap",
"nspanel",
"nsstring",
"ntvs",
"objc",
"overscan",
"partialize",
"patchelf",
"Quicklink",
"Quicklinks",
"Raycast",
"rehype",
"reqwest",
@@ -54,6 +57,7 @@
"rustup",
"screenshotable",
"serde",
"Shadcn",
"swatinem",
"tailwindcss",
"tauri",
@@ -61,6 +65,7 @@
"timedout",
"titlebar",
"tpddns",
"trae",
"traptitech",
"unlisten",
"unlistener",

View File

@@ -13,6 +13,9 @@ Information about release notes of Coco App is provided here.
### 🚀 Features
- feat: resizable extension UI #1009
- feat: add open button to launch installed extension #1013
### 🐛 Bug fix
- fix: fix the abnormal input height issue #1006
@@ -20,7 +23,9 @@ Information about release notes of Coco App is provided here.
### ✈️ Improvements
- refactor: replace legacy components with shadcn/ui components #1002
- chore: show error msg (not err code) when installing exts via deeplink fails #1007
- refactor: treat Applications and File Search as normal extensions #1012
## 0.9.1 (2025-12-05)

0
foo Normal file
View File

View File

@@ -1,16 +1,17 @@
{
"name": "coco",
"private": true,
"version": "0.9.1",
"version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:web": "cross-env BUILD_TARGET=web tsc && cross-env BUILD_TARGET=web tsup --format esm",
"publish:web": "cd out/search-chat && npm publish",
"publish:web:beta": "cd dist/search-chat && npm publish --tag beta",
"publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha",
"publish:web:rc": "cd dist/search-chat && npm publish --tag rc",
"publish:web:beta": "cd out/search-chat && npm publish --tag beta",
"publish:web:alpha": "cd out/search-chat && npm publish --tag alpha",
"publish:web:rc": "cd out/search-chat && npm publish --tag rc",
"publish:web:otp": "cd out/search-chat && npm publish --access public --otp $NPM_OTP",
"preview": "vite preview",
"tauri": "tauri",
"release": "release-it",
@@ -18,10 +19,18 @@
"release-beta": "release-it --preRelease=beta --preReleaseBase=1"
},
"dependencies": {
"@headlessui/react": "^2.2.2",
"@infinilabs/custom-icons": "0.0.4",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "~2.2.0",
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",
@@ -77,6 +86,8 @@
"zustand": "^5.0.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.5.0",
"@types/dom-speech-recognition": "^0.0.4",
"@types/lodash-es": "^4.17.12",
@@ -93,11 +104,11 @@
"postcss": "^8.5.3",
"release-it": "^18.1.2",
"sass": "^1.87.0",
"tailwindcss": "^3.4.17",
"tailwindcss": "^4.0.0",
"tsup": "^8.4.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vite": "^5.4.19"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}
}

1550
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
export default {
plugins: {
tailwindcss: {},
// Tailwind v4 PostCSS plugin has moved to @tailwindcss/postcss
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

39
scripts/buildWebAfter.ts Normal file
View File

@@ -0,0 +1,39 @@
import { readFileSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const extractCssVars = () => {
const filePath = join(__dirname, "../out/search-chat/index.css");
const cssContent = readFileSync(filePath, "utf-8");
const vars: Record<string, string> = {};
const propertyBlockRegex = /@property\s+(--[\w-]+)\s*\{([\s\S]*?)\}/g;
let match: RegExpExecArray | null;
while ((match = propertyBlockRegex.exec(cssContent))) {
const [, varName, body] = match;
const initialValueMatch = /initial-value\s*:\s*([^;]+);/.exec(body);
if (initialValueMatch) {
vars[varName] = initialValueMatch[1].trim();
}
}
const cssVarsBlock =
`.coco-container {\n` +
Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`)
.join("\n") +
`\n}\n`;
writeFileSync(filePath, `${cssContent}\n${cssVarsBlock}`, "utf-8");
};
extractCssVars();

14
src-tauri/Cargo.lock generated
View File

@@ -1132,7 +1132,7 @@ dependencies = [
[[package]]
name = "coco"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"actix-files",
"actix-web",
@@ -1184,6 +1184,7 @@ dependencies = [
"scraper",
"semver",
"serde",
"serde-inline-default",
"serde_json",
"serde_plain",
"snafu",
@@ -6308,6 +6309,17 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-inline-default"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d48532bc0781ac622a5fea0f16502d3b4f1af0fcebe56d618120969f35d315"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "serde-untagged"
version = "0.1.9"

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.9.1"
version = "0.10.0"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2024"
@@ -122,6 +122,7 @@ actix-web = "4.11.0"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-zustand = "1"
snafu = "0.8.9"
serde-inline-default = "1.0.0"
[dev-dependencies]
tempfile = "3.23.0"

View File

@@ -31,6 +31,11 @@
"core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:window:allow-set-position",
"core:window:allow-set-theme",
"core:window:allow-unminimize",
"core:window:allow-set-fullscreen",
"core:window:allow-set-resizable",
"core:window:allow-maximize",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",
@@ -65,12 +70,10 @@
"fs-pro:default",
"macos-permissions:default",
"screenshots:default",
"core:window:allow-set-theme",
"process:default",
"updater:default",
"windows-version:default",
"log:default",
"opener:default",
"core:window:allow-unminimize"
"opener:default"
]
}

View File

@@ -152,14 +152,31 @@ pub struct Extension {
}
/// Settings that control the built-in UI Components
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct ViewExtensionUISettings {
/// Show the search bar
#[serde_inline_default(true)]
search_bar: bool,
/// Show the filter bar
#[serde_inline_default(true)]
filter_bar: bool,
/// Show the footer
#[serde_inline_default(true)]
footer: bool,
/// The recommended width of the window for this extension
width: Option<u32>,
/// The recommended heigh of the window for this extension
height: Option<u32>,
/// Is the extension window's size adjustable?
#[serde_inline_default(false)]
resizable: bool,
/// Detch the extension window from Coco's main window.
///
/// If true, user can click the detach button to open this
/// extension in a seprate window.
#[serde_inline_default(false)]
detachable: bool,
}
/// Bundle ID uniquely identifies an extension.
@@ -218,112 +235,117 @@ impl<'ext> PartialEq<ExtensionBundleId> for ExtensionBundleIdBorrowed<'ext> {
}
}
#[tauri::command]
pub(crate) fn extension_on_opened(extension: Extension) -> Option<OnOpened> {
_extension_on_opened(&extension)
}
/// Return what will happen when we open this extension.
///
/// `None` if it cannot be opened.
pub(crate) fn _extension_on_opened(extension: &Extension) -> Option<OnOpened> {
let settings = extension.settings.clone();
let permission = extension.permission.clone();
match extension.r#type {
// This function, at the time of writing this comment, is primarily
// used by third-party extensions.
//
// Built-in extensions don't use this as they are technically not
// "struct Extension"s. Typically, they directly construct a
// "struct Document" from their own type.
ExtensionType::Calculator => unreachable!("this is handled by frontend"),
ExtensionType::AiExtension => unreachable!(
"currently, all AI extensions we have are non-searchable, so we won't open them"
),
ExtensionType::Application => {
// We can have a impl like:
//
// Some(OnOpened::Application { app_path: self.id.clone() })
//
// but it won't be used.
unreachable!(
"Applications are not \"struct Extension\" under the hood, they won't call this method"
)
}
// These 2 types of extensions cannot be opened
ExtensionType::Group => return None,
ExtensionType::Extension => return None,
ExtensionType::Command => {
let ty = ExtensionOnOpenedType::Command {
action: extension.action.clone().unwrap_or_else(|| {
panic!(
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", extension.id
)
}),
};
let extension_on_opened = ExtensionOnOpened {
ty,
settings,
permission,
};
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Quicklink => {
let quicklink = extension.quicklink.clone().unwrap_or_else(|| {
panic!(
"Quicklink extension [{}]'s [quicklink] field is not set, something wrong with your extension validity check", extension.id
)
});
let ty = ExtensionOnOpenedType::Quicklink {
link: quicklink.link,
open_with: quicklink.open_with,
};
let extension_on_opened = ExtensionOnOpened {
ty,
settings,
permission,
};
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::View => {
let name = extension.name.clone();
let icon = extension.icon.clone();
let page = extension.page.as_ref().unwrap_or_else(|| {
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", extension.id);
}).clone();
let ui = extension.ui.clone();
let extension_on_opened_type = ExtensionOnOpenedType::View {
name,
icon,
page,
ui,
};
let extension_on_opened = ExtensionOnOpened {
ty: extension_on_opened_type,
settings,
permission,
};
let on_opened = OnOpened::Extension(extension_on_opened);
Some(on_opened)
}
ExtensionType::Unknown => {
unreachable!("Extensions of type [Unknown] should never be opened")
}
}
}
impl Extension {
/// Whether this extension could be searched.
pub(crate) fn searchable(&self) -> bool {
self.on_opened().is_some()
}
/// Return what will happen when we open this extension.
///
/// `None` if it cannot be opened.
pub(crate) fn on_opened(&self) -> Option<OnOpened> {
let settings = self.settings.clone();
let permission = self.permission.clone();
match self.r#type {
// This function, at the time of writing this comment, is primarily
// used by third-party extensions.
//
// Built-in extensions don't use this as they are technically not
// "struct Extension"s. Typically, they directly construct a
// "struct Document" from their own type.
ExtensionType::Calculator => unreachable!("this is handled by frontend"),
ExtensionType::AiExtension => unreachable!(
"currently, all AI extensions we have are non-searchable, so we won't open them"
),
ExtensionType::Application => {
// We can have a impl like:
//
// Some(OnOpened::Application { app_path: self.id.clone() })
//
// but it won't be used.
unreachable!(
"Applications are not \"struct Extension\" under the hood, they won't call this method"
)
}
// These 2 types of extensions cannot be opened
ExtensionType::Group => return None,
ExtensionType::Extension => return None,
ExtensionType::Command => {
let ty = ExtensionOnOpenedType::Command {
action: self.action.clone().unwrap_or_else(|| {
panic!(
"Command extension [{}]'s [action] field is not set, something wrong with your extension validity check", self.id
)
}),
};
let extension_on_opened = ExtensionOnOpened {
ty,
settings,
permission,
};
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Quicklink => {
let quicklink = self.quicklink.clone().unwrap_or_else(|| {
panic!(
"Quicklink extension [{}]'s [quicklink] field is not set, something wrong with your extension validity check", self.id
)
});
let ty = ExtensionOnOpenedType::Quicklink {
link: quicklink.link,
open_with: quicklink.open_with,
};
let extension_on_opened = ExtensionOnOpened {
ty,
settings,
permission,
};
Some(OnOpened::Extension(extension_on_opened))
}
ExtensionType::Script => todo!("not supported yet"),
ExtensionType::Setting => todo!("not supported yet"),
ExtensionType::View => {
let name = self.name.clone();
let icon = self.icon.clone();
let page = self.page.as_ref().unwrap_or_else(|| {
panic!("View extension [{}]'s [page] field is not set, something wrong with your extension validity check", self.id);
}).clone();
let ui = self.ui.clone();
let extension_on_opened_type = ExtensionOnOpenedType::View {
name,
icon,
page,
ui,
};
let extension_on_opened = ExtensionOnOpened {
ty: extension_on_opened_type,
settings,
permission,
};
let on_opened = OnOpened::Extension(extension_on_opened);
Some(on_opened)
}
ExtensionType::Unknown => {
unreachable!("Extensions of type [Unknown] should never be opened")
}
}
_extension_on_opened(self).is_some()
}
pub(crate) fn get_sub_extension(&self, sub_extension_id: &str) -> Option<&Self> {
@@ -706,7 +728,30 @@ fn filter_out_extensions(
extensions.retain(|ext| {
let ty = ext.r#type;
ty == ExtensionType::Group || ty == ExtensionType::Extension || ty == extension_type
if ty.contains_sub_items() {
/*
* We should not filter out group/extension extensions, with 2
* exceptions: "Applications" and "File Search". They contains
* no sub-extensions, so we treat them as normal extensions.
*
* When `extenison_type` is "Application", we return the "Applications"
* extension as well because it is the entry to access the application
* list.
*/
if ext.developer.is_none()
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
{
ty == extension_type || extension_type == ExtensionType::Application
} else if ext.developer.is_none() && ext.id == built_in::file_search::EXTENSION_ID {
ty == extension_type
} else {
// We should not filter out group/extension extensions
true
}
} else {
ty == extension_type
}
});
// Filter sub-extensions to only include the requested type
@@ -726,19 +771,6 @@ fn filter_out_extensions(
}
}
}
// Application is special, technically, it should never be filtered out by
// this condition. But if our users will be surprising if they choose a
// non-Application type and see it in the results. So we do this to remedy the
// issue
if let Some(idx) = extensions.iter().position(|ext| {
ext.developer.is_none()
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
}) {
if extension_type != ExtensionType::Application {
extensions.remove(idx);
}
}
}
// apply query filter
@@ -754,8 +786,23 @@ fn filter_out_extensions(
extensions.retain(|ext| {
if ext.r#type.contains_sub_items() {
// Keep all group/extension types
true
/*
* We should keep all the group/extension extensions. But we
* have 2 exceptions: "Applications" and "File Search". Even
* though they are of type group/extension, they do not contain
* sub-extensions, so they are more like commands, apply the
* `match_closure` here
*/
if ext.developer.is_none()
&& (ext.id
== built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|| ext.id == built_in::file_search::EXTENSION_ID)
{
match_closure(ext)
} else {
// Keep all group/extension types
true
}
} else {
// Apply filter to non-group/extension types
match_closure(ext)
@@ -812,7 +859,8 @@ pub(crate) async fn list_extensions(
// Cleanup after filtering extensions, don't do it if filter is not performed.
//
// Remove parent extensions (Group/Extension types) that have no sub-items after filtering
// Remove parent extensions (Group/Extension types) that have no sub-items
// after filtering
let filter_performed = query.is_some() || extension_type.is_some() || list_enabled;
if filter_performed {
extensions.retain(|ext| {
@@ -820,11 +868,20 @@ pub(crate) async fn list_extensions(
return true;
}
// We don't do this filter to applications since it is always empty, load at runtime.
if ext.developer.is_none()
&& ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
{
return true;
/*
* Two exceptions: "Applications" and "File Search"
*
* They are of type group/extension, but they contain no sub
* extensions, which means technically, we should filter them
* out. However, we sould not do this because they are not real
* group/extension extensions.
*/
if ext.developer.is_none() {
if ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME
|| ext.id == built_in::file_search::EXTENSION_ID
{
return true;
}
}
let has_commands = ext

View File

@@ -16,6 +16,8 @@ use crate::common::search::QueryResponse;
use crate::common::search::QuerySource;
use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource;
use crate::extension::_extension_on_opened;
use crate::extension::ExtensionBundleId;
use crate::extension::ExtensionBundleIdBorrowed;
use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
@@ -26,11 +28,13 @@ use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
use crate::util::version::parse_coco_semver;
use async_trait::async_trait;
use borrowme::Borrow;
use borrowme::ToOwned;
use check::general_check;
use function_name::named;
use semver::Version as SemVer;
use serde_json::Value as Json;
use snafu::prelude::*;
use std::io::ErrorKind;
use std::ops::Deref;
use std::path::Path;
@@ -44,6 +48,7 @@ use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::ShortcutState;
use tokio::fs::read_dir;
use tokio::sync::RwLock;
use tokio::sync::RwLockReadGuard;
use tokio::sync::RwLockWriteGuard;
pub(crate) fn get_third_party_extension_directory(tauri_app_handle: &AppHandle) -> PathBuf {
@@ -393,6 +398,26 @@ impl ThirdPartyExtensionsSearchSource {
extension.get_sub_extension_mut(sub_extension_id)
}
/// Return an immutable reference to the extension specified by `bundle_id` if it exists.
fn get_extension<'lock, 'extensions>(
extensions_read_lock: &'lock RwLockReadGuard<'extensions, Vec<Extension>>,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Option<&'lock Extension> {
let index = extensions_read_lock.iter().position(|ext| {
ext.id == bundle_id.extension_id && ext.developer.as_deref() == bundle_id.developer
})?;
let extension = extensions_read_lock
.get(index)
.expect("just checked this extension exists");
let Some(sub_extension_id) = bundle_id.sub_extension_id else {
return Some(extension);
};
extension.get_sub_extension(sub_extension_id)
}
/// Difference between this function and `enable_extension()`
///
/// This function does the actual job, i.e., to enable/activate the extension.
@@ -407,7 +432,7 @@ impl ThirdPartyExtensionsSearchSource {
) -> Result<(), String> {
if extension.supports_alias_hotkey() {
if let Some(ref hotkey) = extension.hotkey {
let on_opened = extension.on_opened().unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
let on_opened = _extension_on_opened(extension).unwrap_or_else(|| panic!( "extension has hotkey, but on_open() returns None, extension ID [{}], extension type [{:?}]", extension.id, extension.r#type));
let extension_id_clone = extension.id.clone();
tauri_app_handle
@@ -681,7 +706,7 @@ impl ThirdPartyExtensionsSearchSource {
)?;
// Set hotkey
let on_opened = extension.on_opened().unwrap_or_else(|| panic!(
let on_opened = _extension_on_opened(extension).unwrap_or_else(|| panic!(
"setting hotkey for an extension that cannot be opened, extension ID [{:?}], extension type [{:?}]", bundle_id, extension.r#type,
));
@@ -867,6 +892,59 @@ impl ThirdPartyExtensionsSearchSource {
pub(crate) async fn extensions_snapshot(&self) -> Vec<Extension> {
self.inner.extensions.read().await.clone()
}
/// Open the specified third-party extension.
async fn open(
&self,
tauri_app_handle: AppHandle,
bundle_id: &ExtensionBundleIdBorrowed<'_>,
) -> Result<(), OpenThirdPartyExtensionError> {
let extensions_read_lock = self.inner.extensions.read().await;
let Some(ext) = Self::get_extension(&extensions_read_lock, bundle_id) else {
log::warn!(
"trying to open() a third-party extension [{:?}] that does not exist",
bundle_id
);
return Err(OpenThirdPartyExtensionError::ExtensionNotFound {
bundle_id: bundle_id.to_owned(),
});
};
let Some(on_opened) = _extension_on_opened(ext) else {
log::warn!("third-party extension [{:?}] cannot be opened", bundle_id);
return Err(OpenThirdPartyExtensionError::ExtensionCannotBeOpened {
bundle_id: bundle_id.to_owned(),
});
};
crate::common::document::open(tauri_app_handle, on_opened, None)
.await
.map_err(|err_msg| OpenThirdPartyExtensionError::OnOpenedOpenError { msg: err_msg })?;
Ok(())
}
}
#[derive(Debug, Snafu, serde::Serialize)]
pub(crate) enum OpenThirdPartyExtensionError {
#[snafu(display("extension '{:?}' does not exist", bundle_id))]
ExtensionNotFound { bundle_id: ExtensionBundleId },
#[snafu(display("extension '{:?}' cannot be opened", bundle_id))]
ExtensionCannotBeOpened { bundle_id: ExtensionBundleId },
#[snafu(display("executing open(OnOpened) failed: '{}'", msg))]
OnOpenedOpenError { msg: String },
}
#[tauri::command]
pub(crate) async fn open_third_party_extension(
tauri_app_handle: AppHandle,
bundle_id: ExtensionBundleId,
) -> Result<(), OpenThirdPartyExtensionError> {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap_or_else(|| panic!("THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE is not set"))
.open(tauri_app_handle, &bundle_id.borrow())
.await
}
pub(crate) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock<ThirdPartyExtensionsSearchSource> =
@@ -890,25 +968,37 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
}
}
// query main_extension_id querysource
// main_extension_id querysource
// query querysource datasource
async fn search(
&self,
_tauri_app_handle: AppHandle,
query: SearchQuery,
) -> Result<QueryResponse, SearchError> {
let Some(query_string) = query.query_strings.get("query") else {
return Ok(QueryResponse {
source: self.get_type(),
hits: Vec::new(),
total_hits: 0,
});
let opt_lowercase_query_string: Option<String> = {
match query.query_strings.get("query") {
Some(query_string) => {
if query_string.is_empty() {
None
} else {
Some(query_string.to_lowercase())
}
}
None => None,
}
};
let opt_data_source = query
.query_strings
.get("datasource")
.map(|owned_str| owned_str.to_string());
.map(|str| str.to_string());
let opt_main_extension_id = query
.query_strings
.get("main_extension_id")
.map(|str| str.to_string());
let query_lower = query_string.to_lowercase();
let inner_clone = Arc::clone(&self.inner);
let closure = move || {
@@ -916,10 +1006,22 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
let extensions_read_lock =
futures::executor::block_on(async { inner_clone.extensions.read().await });
let main_extension_filter_closure = |ext: &&Extension| -> bool {
// field minimum_coco_extension is only set for main
// extensions, so we only check main extensions.
let condition1 = ext.enabled && is_extension_compatible(Extension::clone(ext));
let condition2 = if let Some(ref main_extension_id) = opt_main_extension_id {
&ext.id == main_extension_id
} else {
true
};
condition1 && condition2
};
for extension in extensions_read_lock
.iter()
// field minimum_coco_extension is only set for main extensions.
.filter(|ext| ext.enabled && is_extension_compatible(Extension::clone(ext)))
.filter(main_extension_filter_closure)
{
if extension.r#type.contains_sub_items() {
let opt_main_extension_lowercase_name =
@@ -934,7 +1036,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for command in commands.iter().filter(|cmd| cmd.enabled) {
if let Some(hit) = extension_to_hit(
command,
&query_lower,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
@@ -947,7 +1049,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for script in scripts.iter().filter(|script| script.enabled) {
if let Some(hit) = extension_to_hit(
script,
&query_lower,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
@@ -960,7 +1062,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for quicklink in quicklinks.iter().filter(|link| link.enabled) {
if let Some(hit) = extension_to_hit(
quicklink,
&query_lower,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
@@ -973,7 +1075,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
for view in views.iter().filter(|view| view.enabled) {
if let Some(hit) = extension_to_hit(
view,
&query_lower,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
opt_main_extension_lowercase_name.as_deref(),
) {
@@ -982,9 +1084,12 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
}
}
} else {
if let Some(hit) =
extension_to_hit(extension, &query_lower, opt_data_source.as_deref(), None)
{
if let Some(hit) = extension_to_hit(
extension,
opt_lowercase_query_string.as_deref(),
opt_data_source.as_deref(),
None,
) {
hits.push(hit);
}
}
@@ -1029,9 +1134,9 @@ pub(crate) async fn uninstall_extension(
/// This argument is needed as an "extension" type extension should return all its
/// sub-extensions when the query string matches its name. To do this, we pass the
/// extension name, score it and take that into account.
pub(crate) fn extension_to_hit(
fn extension_to_hit(
extension: &Extension,
query_lower: &str,
opt_lowercase_query_string: Option<&str>,
opt_data_source: Option<&str>,
opt_main_extension_lowercase_name: Option<&str>,
) -> Option<(Document, f64)> {
@@ -1050,64 +1155,66 @@ pub(crate) fn extension_to_hit(
}
let mut total_score = 0.0;
// Score based on title match
// Title is considered more important, so it gets a higher weight.
if let Some(title_score) =
calculate_text_similarity(&query_lower, &extension.name.to_lowercase())
{
total_score += title_score;
}
// Score based on alias match if available
// Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias {
if let Some(alias_score) = calculate_text_similarity(&query_lower, &alias.to_lowercase()) {
total_score += alias_score;
}
}
// An "extension" type extension should return all its
// sub-extensions when the query string matches its ID.
// To do this, we score the extension ID and take that
// into account.
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
if let Some(main_extension_score) =
calculate_text_similarity(&query_lower, main_extension_lowercase_id)
if let Some(query_lower) = opt_lowercase_query_string {
// Score based on title match
// Title is considered more important, so it gets a higher weight.
if let Some(title_score) =
calculate_text_similarity(query_lower, &extension.name.to_lowercase())
{
total_score += main_extension_score;
total_score += title_score;
}
// Score based on alias match if available
// Alias is considered less important than title, so it gets a lower weight.
if let Some(alias) = &extension.alias {
if let Some(alias_score) = calculate_text_similarity(query_lower, &alias.to_lowercase())
{
total_score += alias_score;
}
}
// An "extension" type extension should return all its
// sub-extensions when the query string matches its ID.
// To do this, we score the extension ID and take that
// into account.
if let Some(main_extension_lowercase_id) = opt_main_extension_lowercase_name {
if let Some(main_extension_score) =
calculate_text_similarity(query_lower, main_extension_lowercase_id)
{
total_score += main_extension_score;
}
}
// Only filter by score if query string is set
if total_score == 0.0 {
return None;
}
}
// Only include if there's some relevance (score is meaningfully positive)
if total_score > 0.01 {
let on_opened = extension.on_opened().unwrap_or_else(|| {
panic!(
"extension (id [{}], type [{:?}]) is searchable, and should have a valid on_opened",
extension.id, extension.r#type
)
});
let url = on_opened.url();
let on_opened = _extension_on_opened(extension).unwrap_or_else(|| {
panic!(
"extension (id [{}], type [{:?}]) is searchable, and should have a valid on_opened",
extension.id, extension.r#type
)
});
let url = on_opened.url();
let document = Document {
id: extension.id.clone(),
title: Some(extension.name.clone()),
icon: Some(extension.icon.clone()),
on_opened: Some(on_opened),
url: Some(url),
category: Some(extension_type_string.clone()),
source: Some(DataSourceReference {
id: Some(extension_type_string.clone()),
name: Some(extension_type_string.clone()),
icon: None,
r#type: Some(extension_type_string),
}),
let document = Document {
id: extension.id.clone(),
title: Some(extension.name.clone()),
icon: Some(extension.icon.clone()),
on_opened: Some(on_opened),
url: Some(url),
category: Some(extension_type_string.clone()),
source: Some(DataSourceReference {
id: Some(extension_type_string.clone()),
name: Some(extension_type_string.clone()),
icon: None,
r#type: Some(extension_type_string),
}),
..Default::default()
};
..Default::default()
};
Some((document, total_score))
} else {
None
}
Some((document, total_score))
}

View File

@@ -177,6 +177,7 @@ pub fn run() {
extension::enable_extension,
extension::disable_extension,
extension::set_extension_alias,
extension::extension_on_opened,
extension::register_extension_hotkey,
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
@@ -185,6 +186,7 @@ pub fn run() {
extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension,
extension::third_party::open_third_party_extension,
extension::is_extension_compatible,
extension::api::apis,
extension::api::fs::read_dir,

View File

@@ -17,6 +17,20 @@ use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::time::{Duration, timeout};
/// Available `query_strings`:
///
/// * "querysource": the query/search source to search
/// * "datasource": the data source to search. If this is provided, then
/// "querysource" has to be specified as well.
/// * "main_extension_id": Currently, only the "extensions" query source
/// supports this. If you set
///
/// ```text
/// {"querysource": "extension", "main_extension_id"}
/// ```
///
/// then only the extension with this ID will be returned, if exists.
#[named]
#[tauri::command]
pub async fn query_coco_fusion(
@@ -26,6 +40,10 @@ pub async fn query_coco_fusion(
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
if query_strings.contains_key("datasource") && !query_strings.contains_key("querysource") {
panic!("[querysource] has to be provided if [datasource] is set")
}
let opt_query_source_id = query_strings.get("querysource");
let search_sources = tauri_app_handle.state::<SearchSourceRegistry>();
let query_source_list = search_sources.get_sources().await;

View File

@@ -32,7 +32,7 @@ pub fn platform(
let panel = main_window.to_panel::<NsPanel>().unwrap();
// set level
panel.set_level(PanelLevel::Utility.value());
panel.set_level(PanelLevel::Dock.value());
// Do not steal focus from other windows
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());

View File

@@ -20,7 +20,7 @@
"width": 680,
"decorations": false,
"minimizable": false,
"maximizable": false,
"maximizable": true,
"skipTaskbar": true,
"resizable": false,
"acceptFirstMouse": true,

View File

@@ -12,7 +12,7 @@ import {
handleNetworkError,
} from "./tools";
type Fn = (data: FcResponse<any>) => unknown;
type Fn = (data: FcResponse<unknown>) => unknown;
interface IAnyObj {
[index: string]: unknown;
@@ -85,8 +85,26 @@ export const Get = <T>(
new Promise((resolve) => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http;
if (!baseURL || baseURL === "undefined") {
// In Vite dev, prefer using the proxy by keeping requests relative
const isDev = (import.meta as any).env?.DEV === true;
const PROXY_PREFIXES: readonly string[] = [
"account",
"chat",
"query",
"connector",
"integration",
"assistant",
"datasource",
"settings",
"mcp_server",
];
const shouldProxy =
isDev &&
url.startsWith("/") &&
PROXY_PREFIXES.some((p) => url.startsWith(`/${p}`));
let baseURL: string = appStore.state?.endpoint_http as string;
if (!baseURL || baseURL === "undefined" || shouldProxy) {
baseURL = "";
}
@@ -117,8 +135,25 @@ export const Post = <T>(
return new Promise((resolve) => {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http;
if (!baseURL || baseURL === "undefined") {
const isDev = (import.meta as any).env?.DEV === true;
const PROXY_PREFIXES: readonly string[] = [
"account",
"chat",
"query",
"connector",
"integration",
"assistant",
"datasource",
"settings",
"mcp_server",
];
const shouldProxy =
isDev &&
url.startsWith("/") &&
PROXY_PREFIXES.some((p) => url.startsWith(`/${p}`));
let baseURL: string = appStore.state?.endpoint_http as string;
if (!baseURL || baseURL === "undefined" || shouldProxy) {
baseURL = "";
}

View File

@@ -81,14 +81,24 @@ async function invokeWithErrorHandler<T>(
return result;
} catch (error: any) {
const errorMessage = error || "Command execution failed";
console.log(error);
const errorMessage =
error instanceof Error
? error.message
: typeof error === "object"
? JSON.stringify(error)
: String(error || "Command execution failed");
// 401 Unauthorized
if (errorMessage.includes("Unauthorized")) {
handleLogout();
} else {
addError(command + ":" + errorMessage, "error");
}
throw error;
if (error instanceof Error) {
throw error;
}
throw new Error(errorMessage);
}
}

View File

@@ -41,15 +41,20 @@ const AssistantItem = memo(
)}
onClick={onClick}
>
<div className="flex items-center justify-center size-6 bg-white border border-[#E6E6E6] rounded-full overflow-hidden">
{_source?.icon?.startsWith("font_") ? (
<FontIcon name={_source?.icon} className="size-4" />
{_source?.icon?.startsWith("font_") ? (
<FontIcon
name={_source?.icon}
className="w-4 h-4 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
/>
) : (
<img src={logoImg} className="size-4" alt={name} />
<img
src={logoImg}
className="w-4 h-4 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
alt={name}
/>
)}
</div>
<div className="text-left flex-1 min-w-0">
<div className="font-medium text-gray-900 dark:text-white truncate">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{_source?.name || "-"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
@@ -67,4 +72,4 @@ const AssistantItem = memo(
)
);
export default AssistantItem;
export default AssistantItem;

View File

@@ -1,8 +1,11 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { ChevronDownIcon, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { isNil } from "lodash-es";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { useDebounce, useKeyPress, usePagination } from "ahooks";
import clsx from "clsx";
@@ -17,6 +20,7 @@ import { AssistantFetcher } from "./AssistantFetcher";
import AssistantItem from "./AssistantItem";
import Pagination from "@/components/Common/Pagination";
import { useSearchStore } from "@/stores/searchStore";
import { Button } from "../ui/button";
interface AssistantListProps {
assistantIDs?: string[];
@@ -83,6 +87,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const targetId = askAiAssistantId ?? targetAssistantId;
@@ -105,7 +110,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
useKeyPress(
["uparrow", "downarrow", "enter"],
(event, key) => {
const isClose = isNil(popoverButtonRef.current?.dataset["open"]);
const isClose = !open;
if (isClose) return;
@@ -161,26 +166,29 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
}, []);
return (
<div className="relative">
<Popover ref={popoverRef}>
<PopoverButton
<div ref={popoverRef} className="relative">
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v);
}}
>
<PopoverTrigger
ref={popoverButtonRef}
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] text-sm/6 font-semibold text-gray-800 dark:text-[#d8d8d8] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full border border-input bg-background text-sm/6 font-semibold text-foreground hover:bg-accent hover:text-accent-foreground focus:outline-none"
>
<div className="w-4 h-4 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
{currentAssistant?._source?.icon?.startsWith("font_") ? (
<FontIcon
name={currentAssistant._source.icon}
className="w-3 h-3"
/>
) : (
<img
src={logoImg}
className="w-3 h-3"
alt={t("assistant.message.logo")}
/>
)}
</div>
{currentAssistant?._source?.icon?.startsWith("font_") ? (
<FontIcon
name={currentAssistant._source.icon}
className="w-4 h-4"
/>
) : (
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
)}
<div className="max-w-[100px] truncate">
{currentAssistant?._source?.name || "Coco AI"}
</div>
@@ -190,12 +198,14 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
popoverButtonRef.current?.click();
}}
>
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400 transition-transform" />
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform" />
</VisibleKey>
</PopoverButton>
</PopoverTrigger>
<PopoverPanel
className="absolute z-50 top-full mt-1 left-0 w-60 rounded-xl bg-white dark:bg-[#202126] p-3 text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 focus:outline-none max-h-[calc(100vh-150px)] overflow-y-auto"
<PopoverContent
side="bottom"
align="start"
className="z-50 w-60 rounded-xl p-3 shadow-lg focus:outline-none max-h-[calc(100vh-150px)] overflow-y-auto"
onMouseMove={handleMouseMove}
>
<div className="flex items-center justify-between text-sm font-bold">
@@ -203,9 +213,11 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
{t("assistant.popover.title")}{pagination.total}
</div>
<button
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
className="flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-lg border dark:border-white/10"
className="size-6"
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
@@ -218,7 +230,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
)}
/>
</VisibleKey>
</button>
</Button>
</div>
<VisibleKey
@@ -234,8 +246,8 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
autoFocus
value={keyword}
placeholder={t("assistant.popover.search")}
className="w-full h-8 px-2 bg-transparent border rounded-[6px] dark:border-white/10"
onChange={(event) => {
className="w-full h-8"
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(event.target.value);
}}
/>
@@ -272,7 +284,7 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
<NoDataImage />
</div>
)}
</PopoverPanel>
</PopoverContent>
</Popover>
</div>
);

View File

@@ -388,7 +388,7 @@ const ChatAI = memo(
<div
data-tauri-drag-region
data-chat-instance={instanceId}
className={`flex flex-col rounded-[6px] h-full overflow-hidden relative`}
className={`flex flex-col rounded-md h-full overflow-hidden relative`}
>
<ChatHeader
clearChat={clearChat}

View File

@@ -95,13 +95,13 @@ export function ChatHeader({
{isChatPage ? null : (
<button className="inline-flex" onClick={onOpenChatAI}>
<VisibleKey shortcut={external} onKeyPress={onOpenChatAI}>
<WindowsFullIcon className="rotate-30 scale-x-[-1]" />
<WindowsFullIcon className="scale-x-[-1]" />
</VisibleKey>
</button>
)}
</div>
) : (
<WebLogin panelClassName="top-8 right-0" />
<WebLogin side="bottom" align="end" />
)}
</header>
);

View File

@@ -28,7 +28,7 @@ const ConnectPrompt = () => {
<p className="mb-4 w-[388px]">{t("assistant.chat.connect_tip")}</p>
<button
className="flex items-center gap-2 px-6 py-2 rounded-[6px] text-[#0072ff] transition-colors"
className="flex items-center gap-2 px-6 py-2 rounded-md text-[#0072ff] transition-colors"
onClick={handleConnect}
>
<span>{t("assistant.chat.connect")}</span>

View File

@@ -1,9 +1,13 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Settings, RefreshCw, Check, Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { useKeyPress } from "ahooks";
import { isNil } from "lodash-es";
import logoImg from "@/assets/icon.svg";
import ServerIcon from "@/icons/Server";
@@ -61,6 +65,7 @@ export function ServerList({ clearChat }: ServerListProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const serverListButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const { refreshServerList } = useServers();
@@ -143,7 +148,7 @@ export function ServerList({ clearChat }: ServerListProps) {
["uparrow", "downarrow", "enter"],
async (event, key) => {
const service = await getCurrentWindowService();
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
const isClose = !open;
const length = serverList.length;
if (isClose || length <= 1) return;
@@ -182,122 +187,130 @@ export function ServerList({ clearChat }: ServerListProps) {
}, []);
return (
<Popover ref={popoverRef} className="relative">
<PopoverButton ref={serverListButtonRef} className="flex items-center">
<VisibleKey
shortcut={serviceListShortcut}
onKeyPress={() => {
serverListButtonRef.current?.click();
}}
>
<ServerIcon />
</VisibleKey>
</PopoverButton>
<div ref={popoverRef} className="relative">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger ref={serverListButtonRef} className="flex items-center">
<VisibleKey
shortcut={serviceListShortcut}
onKeyPress={() => {
serverListButtonRef.current?.click();
}}
>
<ServerIcon />
</VisibleKey>
</PopoverTrigger>
<PopoverPanel
onMouseMove={handleMouseMove}
className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
>
<div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("assistant.chat.servers")}
</h3>
<div className="flex items-center gap-2">
<button
onClick={openSettings}
className="p-1 rounded-[6px] hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
>
<VisibleKey shortcut=",">
<Settings className="h-4 w-4 text-[#0287FF]" />
</VisibleKey>
</button>
<button
onClick={handleRefresh}
className="p-1 rounded-[6px] hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`}
/>
</VisibleKey>
</button>
<PopoverContent
side="bottom"
align="end"
onMouseMove={handleMouseMove}
className="z-10 min-w-60 rounded-lg shadow-lg"
>
<div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-foreground">
{t("assistant.chat.servers")}
</h3>
<div className="flex items-center gap-2">
<Button
onClick={openSettings}
variant="ghost"
size="icon"
className="rounded-md focus-visible:ring-0 focus-visible:ring-offset-0"
>
<VisibleKey shortcut=",">
<Settings className="h-4 w-4 text-primary" />
</VisibleKey>
</Button>
<Button
onClick={handleRefresh}
variant="ghost"
size="icon"
className="rounded-md focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
className={`h-4 w-4 text-primary transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`}
/>
</VisibleKey>
</Button>
</div>
</div>
</div>
<div className="space-y-1">
{list.length > 0 ? (
list.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap
<div className="space-y-1">
{list.length > 0 ? (
list.map((server) => (
<div
key={server.id}
onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap
${
currentService?.id === server.id ||
highlightId === server.id
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
? "bg-muted"
: "hover:bg-muted"
}`}
>
<div className="flex items-center gap-2 min-w-0">
<img
src={server?.provider?.icon || logoImg}
alt={server.name}
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = logoImg;
}}
/>
<div className="text-left flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{server.name}
>
<div className="flex items-center gap-2 min-w-0">
<img
src={server?.provider?.icon || logoImg}
alt={server.name}
className="w-6 h-6 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = logoImg;
}}
/>
<div className="text-left flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate max-w-[200px]">
{server.name}
</div>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">
{t("assistant.chat.aiAssistant")}:{" "}
{server.stats?.assistant_count || 1}
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
{t("assistant.chat.aiAssistant")}:{" "}
{server.stats?.assistant_count || 1}
</div>
<div className="flex flex-col items-center gap-2">
<StatusIndicator
enabled={server.enabled}
public={server.public}
hasProfile={!!server?.profile}
status={server.health?.status}
/>
<div className="size-4 flex justify-end">
{currentService?.id === server.id && (
<VisibleKey
shortcut="↓↑"
shortcutClassName="w-6 -translate-x-4"
>
<Check className="w-full h-full text-muted-foreground" />
</VisibleKey>
)}
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<StatusIndicator
enabled={server.enabled}
public={server.public}
hasProfile={!!server?.profile}
status={server.health?.status}
/>
<div className="size-4 flex justify-end">
{currentService?.id === server.id && (
<VisibleKey
shortcut="↓↑"
shortcutClassName="w-6 -translate-x-4"
>
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
</VisibleKey>
)}
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server className="w-8 h-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{t("assistant.chat.noServers")}
</p>
<button
onClick={openSettings}
className="mt-2 text-xs text-[#0287FF] hover:underline"
>
{t("assistant.chat.addServer")}
</button>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("assistant.chat.noServers")}
</p>
<button
onClick={openSettings}
className="mt-2 text-xs text-[#0287FF] hover:underline"
>
{t("assistant.chat.addServer")}
</button>
</div>
)}
)}
</div>
</div>
</div>
</PopoverPanel>
</Popover>
</PopoverContent>
</Popover>
</div>
);
}
}

View File

@@ -122,7 +122,7 @@ const Splash = ({ assistantIDs = [], startPage }: SplashProps) => {
return (
<li key={id} className="mobile:w-full w-1/2 p-1">
<div
className="group h-[74px] px-3 py-2 text-sm rounded-xl border dark:border-[#262626] bg-white dark:bg-black cursor-pointer transition hover:!border-[#0087FF]"
className="group h-[74px] px-3 py-2 text-sm rounded-xl border border-input bg-white dark:bg-black cursor-pointer transition hover:border-[#0087FF]!"
onClick={() => {
setCurrentAssistant(item);

View File

@@ -34,7 +34,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
const state = useReactive({ ...INITIAL_STATE });
const containerRef = useRef<HTMLDivElement>(null);
const recordRef = useRef<RecordPlugin>();
const { withVisibility, addError } = useAppStore();
const { addError } = useAppStore();
const { currentService } = useConnectStore();
const { wavesurfer } = useWavesurfer({
@@ -146,7 +146,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
};
const startRecording = async () => {
await withVisibility(checkPermission);
await checkPermission();
state.isRecording = true;
recordRef.current?.startRecording();
};
@@ -173,9 +173,9 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
<div
className={clsx(
"absolute -inset-2 flex items-center gap-1 px-1 rounded translate-x-full transition-all bg-[#ededed] dark:bg-[#202126]",
"absolute inset-0 flex items-center gap-1 px-1 rounded translate-x-full transition-all bg-[#ededed] dark:bg-[#202126]",
{
"!translate-x-0": state.isRecording || state.converting,
"translate-x-0!": state.isRecording || state.converting,
}
)}
>
@@ -184,7 +184,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
className={clsx(
"flex items-center justify-center size-6 bg-white dark:bg-black rounded-full transition cursor-pointer",
{
"!cursor-not-allowed opacity-50": state.converting,
"cursor-not-allowed! opacity-50": state.converting,
}
)}
onClick={() => resetState()}

View File

@@ -107,7 +107,7 @@ export const MessageActions = ({
<button
id={copyButtonId}
onClick={handleCopy}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
className="p-1 rounded-lg hover:bg-muted transition-colors"
>
{copied ? (
<Check
@@ -131,7 +131,7 @@ export const MessageActions = ({
{!isRefreshOnly && (
<button
onClick={handleLike}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
className={`p-1 rounded-lg hover:bg-muted transition-colors ${
liked ? "animate-shake" : ""
}`}
>
@@ -151,7 +151,7 @@ export const MessageActions = ({
{!isRefreshOnly && (
<button
onClick={handleDislike}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
className={`p-1 rounded-lg hover:bg-muted transition-colors ${
disliked ? "animate-shake" : ""
}`}
>
@@ -172,7 +172,7 @@ export const MessageActions = ({
<>
<button
onClick={handleSpeak}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
className="p-1 rounded-lg hover:bg-muted transition-colors"
>
<Volume2
className={`w-4 h-4 ${
@@ -191,7 +191,7 @@ export const MessageActions = ({
{question && (
<button
onClick={handleResend}
className={`p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors ${
className={`p-1 rounded-lg hover:bg-muted transition-colors ${
isResending ? "animate-spin" : ""
}`}
>

View File

@@ -63,6 +63,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
type="text"
id="endpoint"
value={endpointLink}
autoCorrect="off"
placeholder={t("cloud.connect.serverPlaceholder")}
onChange={onChangeEndpoint}
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"

View File

@@ -59,7 +59,7 @@ export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
{icon?.startsWith("font_") ? (
<FontIcon name={icon} className="size-6" />
) : (
<img src={getTypeIcon()} alt={name} className="size-6 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
<img src={getTypeIcon()} alt={name} className="size-6 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]" />
)}
<span className="font-medium text-gray-900 dark:text-white">

View File

@@ -44,7 +44,7 @@ export function DataSourcesList({ server }: { server: string }) {
<h2 className="flex justify-between text-xl font-medium text-gray-900 dark:text-white">
{t("cloud.dataSource.title")}
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => initServerAppData()}
>
<RefreshCcw

View File

@@ -165,7 +165,7 @@ const LoginButton: FC<LoginButtonProps> = memo((props) => {
return (
<button
className="px-6 py-2 bg-blue-500 text-white rounded-[6px] hover:bg-blue-600 transition-colors mb-3"
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
onClick={LoginClick}
aria-label={t("cloud.login")}
>
@@ -186,7 +186,7 @@ const LoadingState: FC<LoadingStateProps> = memo((props) => {
return (
<div className="flex items-center space-x-2 mb-3">
<button
className="px-6 py-2 text-white bg-red-500 rounded-[6px] hover:bg-red-600 transition-colors"
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors"
onClick={onCancel}
>
{t("cloud.cancel")}

View File

@@ -18,7 +18,9 @@ const ServiceHeader = memo(
({ refreshLoading, refreshClick }: ServiceHeaderProps) => {
const { t } = useTranslation();
const cloudSelectService = useConnectStore((state) => state.cloudSelectService);
const cloudSelectService = useConnectStore(
(state) => state.cloudSelectService
);
const { enableServer, removeServer } = useServers();
@@ -46,7 +48,7 @@ const ServiceHeader = memo(
/>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() =>
OpenURLWithBrowser(cloudSelectService?.provider?.website)
}
@@ -54,7 +56,7 @@ const ServiceHeader = memo(
<Globe className="w-3.5 h-3.5" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => refreshClick(cloudSelectService?.id)}
>
<RefreshCcw
@@ -63,7 +65,7 @@ const ServiceHeader = memo(
</button>
{!cloudSelectService?.builtin && (
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-md bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
onClick={() => removeServer(cloudSelectService?.id)}
>
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />

View File

@@ -53,7 +53,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
<img
src={item?.provider?.icon || cocoLogoImg}
alt={`${item.name} logo`}
className="w-5 h-5 flex-shrink-0 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
className="w-5 h-5 shrink-0 rounded-full dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = cocoLogoImg;

View File

@@ -62,13 +62,13 @@ const ApiDetails: React.FC = () => {
{logs.map((log, index) => (
<div
key={index}
className="p-4 border rounded-[6px] shadow-sm bg-gray-50"
className="p-4 border rounded-md shadow-sm bg-gray-50"
>
<h4 className="font-semibold text-gray-800">
Latest Request {index + 1}:
</h4>
<div className="text-sm text-gray-700 mt-1">
<pre className="bg-gray-100 p-2 rounded-[6px] whitespace-pre-wrap">
<pre className="bg-gray-100 p-2 rounded-md whitespace-pre-wrap">
{JSON.stringify(log.request, null, 2)}
</pre>
</div>
@@ -87,7 +87,7 @@ const ApiDetails: React.FC = () => {
</h4>
{showIndex === index ? (
<div className="text-sm text-gray-700 mt-1">
<pre className="bg-green-100 p-2 rounded-[6px] text-green-700 whitespace-pre-wrap">
<pre className="bg-green-100 p-2 rounded-md text-green-700 whitespace-pre-wrap">
{JSON.stringify(log.response, null, 2)}
</pre>
</div>
@@ -98,7 +98,7 @@ const ApiDetails: React.FC = () => {
<>
<h4 className="font-semibold text-red-800 mt-4">Error:</h4>
<div className="text-sm text-gray-700 mt-1">
<pre className="bg-red-100 p-2 rounded-[6px] text-red-700 whitespace-pre-wrap">
<pre className="bg-red-100 p-2 rounded-md text-red-700 whitespace-pre-wrap">
{JSON.stringify(log.error, null, 2)}
</pre>
</div>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from "react";
import { Bot, Search } from "lucide-react";
import platformAdapter from "@/utils/platformAdapter";
import clsx from "clsx";
interface ChatSwitchProps {
isChatMode: boolean;
@@ -29,19 +30,31 @@ const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
<div
role="switch"
aria-checked={isChatMode}
className={`relative flex items-center justify-between w-10 h-[20px] rounded-full cursor-pointer transition-colors duration-300 ${
isChatMode ? "bg-[#0072ff]" : "bg-[var(--coco-primary-color)]"
className={`relative flex items-center justify-between w-10 h-5 rounded-full cursor-pointer transition-colors duration-300 ${
isChatMode ? "bg-[#0072ff]" : "bg-(--coco-primary-color)"
}`}
onClick={handleToggle}
>
<div className="absolute top-0 left-0 w-full h-full pointer-events-none flex items-center justify-between px-1">
{isChatMode ? <Bot className="w-4 h-4 text-white" /> : <div></div>}
{!isChatMode ? <Search className="w-4 h-4 text-white" /> : <div></div>}
</div>
<div
className={`absolute top-[1px] left-[1px] h-[18px] w-[18px] bg-white rounded-full shadow-md transform transition-transform duration-300 ${
isChatMode ? "translate-x-5" : "translate-x-0"
}`}
className={clsx(
"absolute inset-0 pointer-events-none flex items-center px-1 text-white",
{
"justify-end": !isChatMode,
}
)}
>
{isChatMode ? (
<Bot className="size-4" />
) : (
<Search className="size-4" />
)}
</div>
<div
className={clsx(
"absolute top-px h-4.5 w-4.5 bg-white rounded-full shadow-md",
[isChatMode ? "right-px" : "left-px"]
)}
></div>
</div>
);

View File

@@ -1,33 +1,35 @@
import {
CheckboxProps as HeadlessCheckboxProps,
Checkbox as HeadlessCheckbox,
} from "@headlessui/react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import type { ComponentProps } from "react";
import clsx from "clsx";
import { CheckIcon } from "lucide-react";
interface CheckboxProps extends HeadlessCheckboxProps {
interface CheckboxProps
extends Omit<ComponentProps<typeof CheckboxPrimitive.Root>, "onCheckedChange" | "onChange"> {
indeterminate?: boolean;
onChange?: (checked: boolean) => void;
}
const Checkbox = (props: CheckboxProps) => {
const { indeterminate, className, ...rest } = props;
const { indeterminate, className, onChange, checked, ...rest } = props;
return (
<HeadlessCheckbox
<CheckboxPrimitive.Root
{...rest}
checked={checked}
onCheckedChange={(v) => onChange?.(v === true)}
className={clsx(
"group size-4 rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition cursor-pointer",
"group h-4 w-4 rounded-sm border border-black/30 dark:border-white/30 data-[state=checked]:bg-[#2F54EB] data-[state=checked]:border-[#2F54EB] transition cursor-pointer inline-flex items-center justify-center",
className
)}
>
{indeterminate && (
<div className="size-full flex items-center justify-center group-data-[checked]:hidden">
<div className="size-2 bg-[#2F54EB]"></div>
<div className="h-full w-full flex items-center justify-center group-data-[state=checked]:hidden">
<div className="h-2 w-2 bg-[#2F54EB]"></div>
</div>
)}
<CheckIcon className="hidden size-[14px] text-white group-data-[checked]:block" />
</HeadlessCheckbox>
<CheckIcon className="hidden h-[14px] w-[14px] text-white group-data-[state=checked]:block" />
</CheckboxPrimitive.Root>
);
};

View File

@@ -8,7 +8,7 @@ const Copyright = () => {
const renderLogo = () => {
return (
<a href="https://coco.rs/" target="_blank">
<img src={isDark ? logoDark : logoLight} alt="Logo" className="h-4" />
<img src={isDark ? logoDark : logoLight} alt="Logo" className="h-4!" />
</a>
);
};

View File

@@ -1,24 +1,20 @@
import {
Button,
ButtonProps,
Description,
Dialog,
DialogPanel,
DialogTitle,
} from "@headlessui/react";
import { FC, KeyboardEvent } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FC, KeyboardEvent, ComponentProps } from "react";
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
import VisibleKey from "./VisibleKey";
import { useTranslation } from "react-i18next";
type ShadButtonProps = ComponentProps<typeof Button>;
interface DeleteDialogProps {
isOpen: boolean;
title: string;
description: string;
deleteButtonProps?: ButtonProps;
cancelButtonProps?: ButtonProps;
deleteButtonProps?: ShadButtonProps;
cancelButtonProps?: ShadButtonProps;
reverseButtonPosition?: boolean;
setIsOpen: (isOpen: boolean) => void;
onCancel: () => void;
@@ -49,69 +45,60 @@ const DeleteDialog: FC<DeleteDialogProps> = (props) => {
};
return (
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-1000"
>
<div
id="headlessui-popover-panel:delete-history"
className="fixed inset-0 flex items-center justify-center w-screen"
>
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-[0_4px_10px_rgba(0,0,0,0.2)] rounded-lg dark:shadow-[0_8px_20px_rgba(0,0,0,0.4)]">
<div className="flex flex-col gap-3">
<DialogTitle className="text-base font-bold">{title}</DialogTitle>
<Description className="text-sm">{description}</Description>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-[0_4px_10px_rgba(0,0,0,0.2)] rounded-lg dark:shadow-[0_8px_20px_rgba(0,0,0,0.4)]">
<DialogHeader className="mb-2">
<DialogTitle className="text-base font-bold">{title}</DialogTitle>
<DialogDescription className="text-sm">{description}</DialogDescription>
</DialogHeader>
<div
className={clsx("flex gap-4 self-end", {
"flex-row-reverse": reverseButtonPosition,
})}
<div
className={clsx("flex gap-4 self-end", {
"flex-row-reverse": reverseButtonPosition,
})}
>
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={onCancel}
>
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={onCancel}
<Button
{...cancelButtonProps}
autoFocus
className={twMerge(
"h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg focus:border-black/30 dark:focus:border-white/50 transition",
cancelButtonProps?.className as string
)}
onClick={onCancel}
onKeyDown={(event) => {
handleEnter(event, onCancel);
}}
>
<Button
{...cancelButtonProps}
autoFocus
className={twMerge(
"h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg focus:border-black/30 dark:focus:border-white/50 transition",
cancelButtonProps?.className as string
)}
onClick={onCancel}
onKeyDown={(event) => {
handleEnter(event, onCancel);
}}
>
{t("deleteDialog.button.cancel")}
</Button>
</VisibleKey>
{t("deleteDialog.button.cancel")}
</Button>
</VisibleKey>
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={onDelete}
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={onDelete}
>
<Button
{...deleteButtonProps}
className={twMerge(
"h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg border border-[#EF4444] focus:border-black/30 dark:focus:border-white/50 transition",
deleteButtonProps?.className as string
)}
onClick={onDelete}
onKeyDown={(event) => {
handleEnter(event, onDelete);
}}
>
<Button
{...deleteButtonProps}
className={twMerge(
"h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg border border-[#EF4444] focus:border-black/30 dark:focus:border-white/50 transition",
deleteButtonProps?.className as string
)}
onClick={onDelete}
onKeyDown={(event) => {
handleEnter(event, onDelete);
}}
>
{t("deleteDialog.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogPanel>
</div>
{t("deleteDialog.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -57,13 +57,13 @@ const ErrorNotification = ({
>
<div className="flex items-center">
{visibleError.type === "error" && (
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
<AlertCircle className="size-5 shrink-0 text-red-500 mr-2" />
)}
{visibleError.type === "warning" && (
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-2" />
<AlertTriangle className="size-5 shrink-0 text-yellow-500 mr-2" />
)}
{visibleError.type === "info" && (
<Info className="w-5 h-5 text-blue-500 mr-2" />
<Info className="size-5 shrink-0 text-blue-500 mr-2" />
)}
<span className="text-sm text-gray-700 dark:text-gray-200">
@@ -78,7 +78,7 @@ const ErrorNotification = ({
</div>
<X
className="w-5 h-5 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
className="size-5 shrink-0 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
onClick={() => removeError(visibleError.id)}
/>
</div>

View File

@@ -1,10 +1,11 @@
import {
Button,
Description,
Dialog,
DialogPanel,
DialogContent,
DialogHeader,
DialogTitle,
} from "@headlessui/react";
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import VisibleKey from "@/components/Common/VisibleKey";
@@ -36,69 +37,63 @@ const DeleteDialog = ({
};
return (
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-1000"
>
<div
id="headlessui-popover-panel:delete-history"
className="fixed inset-0 flex items-center justify-center w-screen"
>
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-xl rounded-lg">
<div className="flex flex-col gap-3">
<DialogTitle className="text-base font-bold">
{t("history_list.delete_modal.title")}
</DialogTitle>
<Description className="text-sm">
{t("history_list.delete_modal.description", {
replace: [
active?._source?.title ||
active?._source?.message ||
active?._id,
],
})}
</Description>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="flex flex-col justify-between w-[360px] h-40 p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-xl rounded-lg">
<DialogHeader className="mb-2">
<DialogTitle className="text-base font-bold">
{t("history_list.delete_modal.title")}
</DialogTitle>
<DialogDescription className="text-sm">
{t("history_list.delete_modal.description", {
replace: [
active?._source?.title ||
active?._source?.message ||
active?._id,
],
})}
</DialogDescription>
</DialogHeader>
<div className="flex gap-4 self-end">
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={() => setIsOpen(false)}
<div className="flex gap-4 self-end">
<VisibleKey
shortcut="N"
shortcutClassName="left-[unset] right-0"
onKeyPress={() => {
setIsOpen(false);
}}
>
<Button
variant="outline"
autoFocus
onClick={() => setIsOpen(false)}
onKeyDown={(event) => {
handleEnter(event, () => {
setIsOpen(false);
});
}}
>
<Button
autoFocus
className="h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg focus:border-black/30 dark:focus:border-white/50 transition"
onClick={() => setIsOpen(false)}
onKeyDown={(event) => {
handleEnter(event, () => {
setIsOpen(false);
});
}}
>
{t("history_list.delete_modal.button.cancel")}
</Button>
</VisibleKey>
{t("history_list.delete_modal.button.cancel")}
</Button>
</VisibleKey>
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={handleRemove}
<VisibleKey
shortcut="Y"
shortcutClassName="left-[unset] right-0"
onKeyPress={handleRemove}
>
<Button
variant="destructive"
className="text-white"
onClick={handleRemove}
onKeyDown={(event) => {
handleEnter(event, handleRemove);
}}
>
<Button
className="h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg border border-[#EF4444] focus:border-black/30 dark:focus:border-white/50 transition"
onClick={handleRemove}
onKeyDown={(event) => {
handleEnter(event, handleRemove);
}}
>
{t("history_list.delete_modal.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogPanel>
</div>
{t("history_list.delete_modal.button.delete")}
</Button>
</VisibleKey>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -113,7 +113,8 @@ const HistoryListContent: FC<HistoryListContentProps> = ({
const scrollToElement = useCallback(
(elementId: string, isKeyboardNav: boolean) => {
if (!listRef.current) return;
if (typeof window === 'undefined' || typeof document === 'undefined') return;
if (typeof window === "undefined" || typeof document === "undefined")
return;
const element = listRef.current.querySelector(`#${elementId}`);
if (!element) return;
@@ -123,7 +124,7 @@ const HistoryListContent: FC<HistoryListContentProps> = ({
const isVisible =
rect.top >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight);
(window.innerHeight || document.documentElement.clientHeight);
// Only scroll if element is not visible
if (!isVisible) {

View File

@@ -1,10 +1,16 @@
import { FC, useRef, useCallback, useState } from "react";
import { Input, Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { FC, useRef, useCallback, useState, useEffect } from "react";
import { Ellipsis } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Pencil, Trash2 } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverPortal,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import type { Chat } from "@/types/chat";
import VisibleKey from "../VisibleKey";
@@ -31,9 +37,11 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
const moreButtonRef = useRef<HTMLButtonElement>(null);
const { _id, _source } = item;
const title = _source?.title ?? _id;
const isActive = item._id === active?._id || item._id === highlightId;
const isSelected = item._id === active?._id;
const isHovered = item._id === highlightId;
const [isEdit, setIsEdit] = useState(false);
const [open, setOpen] = useState(false);
const onContextMenu = useCallback(
(e: React.MouseEvent) => {
@@ -72,24 +80,34 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
},
];
useEffect(() => {
if (!(isSelected || isHovered) || isEdit) {
setOpen(false);
}
}, [isSelected, isHovered, isEdit]);
return (
<li
key={_id}
id={_id}
className={clsx(
"flex items-center mt-1 h-10 rounded-lg cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#353F4D] transition",
"group flex w-full items-center mt-1 h-10 rounded-lg cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#353F4D] transition-colors",
{
"!bg-[#E5E7EB] dark:!bg-[#2B3444]": isActive,
"bg-[#E5E7EB] dark:bg-[#2B3444]": isSelected,
"bg-[#EDEDED] dark:bg-[#353F4D]": isHovered && !isSelected,
}
)}
onClick={() => {
if (!isActive) {
if (!isSelected) {
setIsEdit(false);
}
onSelect(item);
}}
onMouseEnter={onMouseEnter}
onMouseLeave={() => {
setOpen(false);
}}
onContextMenu={onContextMenu}
>
<div
@@ -99,11 +117,11 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
/>
<div className="flex-1 flex items-center justify-between gap-2 px-2 overflow-hidden">
{isEdit && isActive ? (
{isEdit && isSelected ? (
<Input
autoFocus
defaultValue={title}
className="flex-1 -mx-px outline-none bg-transparent border border-[#0061FF] rounded-[4px]"
className="flex-1 -mx-px outline-none bg-transparent border border-[#0061FF] rounded-sm"
onKeyDown={(event) => {
if (event.key !== "Enter") return;
@@ -128,7 +146,7 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
)}
<div className="flex items-center gap-2">
{isActive && !isEdit && (
{!isEdit && isSelected && (
<VisibleKey
shortcut="↑↓"
rootClassName="w-6"
@@ -136,56 +154,73 @@ const HistoryListItem: FC<HistoryListItemProps> = ({
/>
)}
<Popover>
{isActive && !isEdit && (
<PopoverButton ref={moreButtonRef} className="flex gap-2">
<VisibleKey
shortcut="O"
onKeyPress={() => {
moreButtonRef.current?.click();
}}
>
<Ellipsis className="size-4 text-[#979797]" />
</VisibleKey>
</PopoverButton>
)}
<PopoverPanel
anchor="bottom"
className="flex flex-col rounded-lg shadow-md z-100 bg-white dark:bg-[#202126] p-1 border border-black/2 dark:border-white/10"
onClick={(event) => {
event.stopPropagation();
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
ref={moreButtonRef}
className={clsx("flex gap-2", {
"opacity-100 pointer-events-auto":
!isEdit && (isSelected || isHovered),
"opacity-0 pointer-events-none": !(
!isEdit &&
(isSelected || isHovered)
),
})}
onClick={(e) => {
e.stopPropagation();
setOpen((prev) => !prev);
}}
>
{menuItems.map((menuItem) => {
const {
label,
icon: Icon,
shortcut,
iconColor,
onClick,
} = menuItem;
<VisibleKey
shortcut="O"
onKeyPress={() => {
moreButtonRef.current?.click();
}}
>
<Ellipsis className="size-4 text-[#979797]" />
</VisibleKey>
</PopoverTrigger>
return (
<button
key={label}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-[6px] hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
onClick={onClick}
>
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>
<Icon
className="size-4"
style={{
color: iconColor,
}}
/>
</VisibleKey>
<PopoverPortal>
<PopoverContent
side="bottom"
className="flex flex-col rounded-lg shadow-md z-100 bg-white dark:bg-[#202126] p-1 border border-black/2 dark:border-white/10"
onClick={(event) => {
event.stopPropagation();
}}
onMouseLeave={() => {
setOpen(false);
}}
>
{menuItems.map((menuItem) => {
const {
label,
icon: Icon,
shortcut,
iconColor,
onClick,
} = menuItem;
<span>{t(label)}</span>
</button>
);
})}
</PopoverPanel>
return (
<button
key={label}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
onClick={onClick}
>
<VisibleKey shortcut={shortcut} onKeyPress={onClick}>
<Icon
className="size-4"
style={{
color: iconColor,
}}
/>
</VisibleKey>
<span>{t(label)}</span>
</button>
);
})}
</PopoverContent>
</PopoverPortal>
</Popover>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Input } from "@headlessui/react";
import { Input } from "@/components/ui/input";
import { debounce } from "lodash-es";
import { FC, useMemo, useRef, useState } from "react";
import clsx from "clsx";
@@ -9,6 +9,7 @@ import VisibleKey from "../VisibleKey";
import { Chat } from "@/types/chat";
import { closeHistoryPanel } from "@/utils";
import HistoryListContent from "./HistoryListContent";
import { Button } from "@/components/ui/button";
interface HistoryListProps {
historyPanelId?: string;
@@ -57,21 +58,21 @@ const HistoryList: FC<HistoryListProps> = (props) => {
"flex flex-col h-screen text-sm bg-[#F3F4F6] dark:bg-[#1F2937]"
)}
>
<div className="flex gap-1 p-2 border-b dark:border-[#343D4D]">
<div className="flex-1 flex items-center gap-2 px-2 rounded-lg border transition border-[#E6E6E6] bg-[#F8F9FA] dark:bg-[#2B3444] dark:border-[#343D4D] focus-within:border-[#0061FF]">
<div className="flex gap-1 p-2 border-b border-input">
<div className="flex-1 h-8 flex items-center px-2 rounded-lg border border-input bg-background transition focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
<VisibleKey
shortcut="F"
onKeyPress={() => {
searchInputRef.current?.focus();
}}
>
<Search className="size-4 text-[#6B7280]" />
<Search className="size-4 text-muted-foreground" />
</VisibleKey>
<Input
autoFocus
ref={searchInputRef}
className="w-full bg-transparent outline-none"
className="w-full h-8 bg-transparent border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={t("history_list.search.placeholder")}
onChange={(event) => {
debouncedSearch(event.target.value);
@@ -79,18 +80,20 @@ const HistoryList: FC<HistoryListProps> = (props) => {
/>
</div>
<div
className="size-8 flex items-center justify-center rounded-lg border text-[#0072FF] border-[#E6E6E6] bg-[#F3F4F6] dark:border-[#343D4D] dark:bg-[#1F2937] hover:bg-[#F8F9FA] dark:hover:bg-[#353F4D] cursor-pointer transition"
<Button
variant="outline"
size="icon"
className="size-8"
onClick={handleRefresh}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCcw
className={clsx("size-4", {
className={clsx("size-4 text-[#0287FF]", {
"animate-spin": isRefresh,
})}
/>
</VisibleKey>
</div>
</Button>
</div>
<div className="flex-1 px-2 overflow-auto custom-scrollbar">
@@ -104,10 +107,10 @@ const HistoryList: FC<HistoryListProps> = (props) => {
</div>
{historyPanelId && (
<div className="flex justify-end p-2 border-t dark:border-[#343D4D]">
<div className="flex justify-end p-2 border-t border-input">
<VisibleKey shortcut="Esc" shortcutClassName="w-7">
<PanelLeftClose
className="size-4 text-black/80 dark:text-white/80 cursor-pointer"
className="size-4 text-muted-foreground cursor-pointer"
onClick={closeHistoryPanel}
/>
</VisibleKey>

View File

@@ -41,7 +41,7 @@ function UniversalIcon({
icon,
defaultIcon = File,
appIcon = false,
className = "w-5 h-5 flex-shrink-0",
className = "w-5 h-5 shrink-0",
onClick = () => {},
wrapWithIconWrapper = true,
}: UniversalIconProps) {

View File

@@ -1,6 +1,7 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import VisibleKey from "./VisibleKey";
import { cn } from "@/lib/utils";
interface PaginationProps {
current: number;
@@ -19,10 +20,15 @@ function Pagination({
}: PaginationProps) {
return (
<div
className={`flex items-center justify-between h-8 px-3 text-[#999] border-t dark:border-t-white/10 ${className}`}
className={`flex items-center justify-between h-8 px-2 text-muted-foreground border-t border-input ${className}`}
>
<VisibleKey shortcut="leftarrow" onKeyPress={onPrev}>
<ChevronLeft className="size-4 cursor-pointer" onClick={onPrev} />
<ChevronLeft
className={cn("size-4 cursor-pointer", {
"cursor-not-allowed opacity-50": current === 1,
})}
onClick={onPrev}
/>
</VisibleKey>
<div className="text-xs">
@@ -30,7 +36,12 @@ function Pagination({
</div>
<VisibleKey shortcut="rightarrow" onKeyPress={onNext}>
<ChevronRight className="size-4 cursor-pointer" onClick={onNext} />
<ChevronRight
className={cn("size-4 cursor-pointer", {
"cursor-not-allowed opacity-50": current === totalPage,
})}
onClick={onNext}
/>
</VisibleKey>
</div>
);

View File

@@ -1,4 +1,5 @@
import { Input, InputProps } from "@headlessui/react";
import type { InputProps } from "@/components/ui/input";
import { Input } from "@/components/ui/input";
import { useKeyPress } from "ahooks";
import { forwardRef, useImperativeHandle, useRef } from "react";
@@ -29,7 +30,7 @@ const PopoverInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
}
);
return <Input autoCorrect="off" ref={inputRef} {...props} />;
return <Input autoCorrect="off" ref={inputRef} {...(props as any)} />;
});
export default PopoverInput;

View File

@@ -1,20 +1,20 @@
import { RefObject } from "react";
import clsx from "clsx";
import { ArrowDown } from "lucide-react";
import { Button } from "../ui/button";
interface ScrollToBottomProps {
scrollRef: RefObject<HTMLDivElement>;
isAtBottom: boolean;
}
const ScrollToBottom = ({
scrollRef,
isAtBottom,
}: ScrollToBottomProps) => {
const ScrollToBottom = ({ scrollRef, isAtBottom }: ScrollToBottomProps) => {
return (
<button
<Button
size="icon"
variant="outline"
className={clsx(
"absolute right-4 bottom-4 flex items-center justify-center size-8 border bg-white rounded-full shadow dark:border-[#272828] dark:bg-black dark:shadow-white/15",
"absolute right-4 bottom-4 border border-border rounded-full shadow dark:shadow-white/15",
{
hidden: isAtBottom,
}
@@ -27,7 +27,7 @@ const ScrollToBottom = ({
}}
>
<ArrowDown className="size-5" />
</button>
</Button>
);
};

View File

@@ -1,40 +1,38 @@
import {
Popover,
PopoverButton,
PopoverPanel,
PopoverPanelProps,
} from "@headlessui/react";
import { FC, ReactNode } from "react";
import { useBoolean } from "ahooks";
import clsx from "clsx";
import { FC, ReactNode } from "react";
interface Tooltip2Props extends PopoverPanelProps {
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
interface Tooltip2Props {
content: string;
children: ReactNode;
className?: string;
}
const Tooltip2: FC<Tooltip2Props> = (props) => {
const { content, children, anchor = "top", ...rest } = props;
const { content, children, className } = props;
const [visible, { setTrue, setFalse }] = useBoolean(false);
return (
<Popover>
<PopoverButton onMouseOver={setTrue} onMouseOut={setFalse}>
<PopoverTrigger onMouseOver={setTrue} onMouseOut={setFalse}>
{children}
</PopoverButton>
<PopoverPanel
{...rest}
static
anchor={anchor}
</PopoverTrigger>
<PopoverContent
side="top"
className={clsx(
"fixed z-1000 p-2 rounded-[6px] text-xs text-white bg-black/75 hidden",
"z-1000 p-2 rounded-md text-xs text-white bg-black/75 hidden",
{
"!block": visible,
}
block: visible,
},
className
)}
>
{content}
</PopoverPanel>
</PopoverContent>
</Popover>
);
};

View File

@@ -71,7 +71,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
<div className="flex items-center gap-2">
<img
src={selectedExtension.icon}
className="size-5 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
className="h-5 w-5 dark:drop-shadow-[0_0_6px_rgb(255,255,255)]"
/>
<span className="text-sm">{selectedExtension.name}</span>
</div>
@@ -81,7 +81,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
if (visibleExtensionStore) {
return (
<div className="flex items-center gap-2">
<FontIcon name="font_Store" className="size-5" />
<FontIcon name="font_Store" className="h-5 w-5" />
<span className="text-sm">Extension Store</span>
</div>
);
@@ -100,7 +100,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
{hasUpdate ? (
<div className="cursor-pointer" onClick={() => setVisible(true)}>
<span>{t("search.footer.updateAvailable")}</span>
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
<span className="absolute top-0 -right-2 h-1.5 w-1.5 bg-[#FF3434] rounded-full"></span>
</div>
) : (
sourceData?.source?.name ||
@@ -117,7 +117,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
<div
data-tauri-drag-region={isTauri}
className={clsx(
"px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-[6px] rounded-t-none",
"px-4 z-999 mx-px h-8 absolute bottom-0 left-0 right-0 border-t! border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-md rounded-t-none",
{
"overflow-hidden": isTauri,
}
@@ -137,7 +137,7 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) {
</div>
</div>
) : (
<WebLogin panelClassName="bottom-5 left-0" />
<WebLogin side="top" align="start" />
)}
<div className={`flex mobile:hidden items-center gap-3`}>

View File

@@ -37,7 +37,7 @@ export const NoResults = () => {
<div className="flex gap-2">
<WebLoginButton />
<WebRefreshButton className="size-8" />
<WebRefreshButton />
</div>
</div>
);
@@ -54,7 +54,7 @@ export const NoResults = () => {
<span
className={clsx(
"ml-3 h-5 min-w-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center",
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
{
"px-1": !isMac,
}
@@ -63,7 +63,7 @@ export const NoResults = () => {
{formatKey(modifierKey)}
</span>
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
<span className="ml-1 w-5 h-5 rounded-md border border-[#D8D8D8] flex justify-center items-center">
{modeSwitch}
</span>
</div>

View File

@@ -1,13 +1,16 @@
import { Menu, MenuButton } from "@headlessui/react";
import {
DropdownMenu,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import logoImg from "@/assets/icon.svg";
const Footer = () => {
return (
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-white/10">
<div className="max-w-6xl mx-auto px-4 h-8 flex items-center justify-between">
<Menu as="div" className="relative">
<MenuButton className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<DropdownMenu>
<DropdownMenuTrigger className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<img
src={logoImg}
className="w-5 h-5 text-gray-600 dark:text-gray-400"
@@ -16,7 +19,7 @@ const Footer = () => {
Coco
</span>
{/* <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" /> */}
</MenuButton>
</DropdownMenuTrigger>
{/* <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="p-1">
@@ -27,7 +30,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
>
<Home className="w-4 h-4 mr-2" />
<Link to={`/`}>Home</Link>
@@ -41,7 +44,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
>
<User className="w-4 h-4 mr-2" />
Profile
@@ -55,7 +58,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
>
<Settings className="w-4 h-4 mr-2" />
<Link to={`settings`}>Settings</Link>
@@ -70,7 +73,7 @@ const Footer = () => {
active
? "bg-gray-100 dark:bg-gray-700"
: "text-gray-900 dark:text-gray-100"
} group flex w-full items-center rounded-[6px] px-3 py-2 text-sm`}
} group flex w-full items-center rounded-md px-3 py-2 text-sm`}
>
<LogOut className="w-4 h-4 mr-2" />
Sign Out
@@ -79,7 +82,7 @@ const Footer = () => {
</MenuItem>
</div>
</MenuItems> */}
</Menu>
</DropdownMenu>
<div className="flex items-center space-x-4">
<span className="text-xs text-gray-500 dark:text-gray-400">

View File

@@ -111,7 +111,7 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
{showTooltip && visibleShortcut ? (
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-[6px] shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
shortcutClassName
)}
>

View File

@@ -40,7 +40,7 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
)}
>
<div
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-[6px] cursor-pointer dark:border-[#282828]"
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-md cursor-pointer dark:border-[#282828]"
onClick={() => {
setVisible(false);
}}

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef, useMemo, useState, useEffect } from "react";
import { cloneDeep, isEmpty } from "lodash-es";
import { useKeyPress } from "ahooks";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
@@ -8,7 +9,6 @@ import { Get } from "@/api/axiosRequest";
import type { Assistant } from "@/types/chat";
import { useAppStore } from "@/stores/appStore";
import { canNavigateBack, navigateBack } from "@/utils";
import { useKeyPress } from "ahooks";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AssistantManagerProps {
@@ -167,7 +167,7 @@ export function useAssistantManager({
const { selectedSearchContent, visibleExtensionStore } =
useSearchStore.getState();
console.log("selectedSearchContent", selectedSearchContent);
// console.log("selectedSearchContent", selectedSearchContent);
const { id, type, category } = selectedSearchContent ?? {};

View File

@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { useAppStore } from "@/stores/appStore";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { useBoolean } from "ahooks";
@@ -37,6 +38,7 @@ const AutoResizeTextarea = forwardRef<
setInput,
handleKeyDown,
chatPlaceholder,
lineCount,
onLineCountChange,
firstLineMaxWidth,
},
@@ -79,8 +81,10 @@ const AutoResizeTextarea = forwardRef<
let height = lineHeight;
let minHeight = lineHeight;
const hasNewline = /[\r\n]/.test(input);
const hasContent = input.length > 0;
const firstLineExceeds =
calcRef.current?.offsetWidth >= firstLineMaxWidth - 32;
hasContent &&
(calcRef.current?.offsetWidth ?? 0) >= Math.max(firstLineMaxWidth - 32, 0);
if (hasNewline || firstLineExceeds) {
minHeight = lineHeight * 2;
@@ -115,7 +119,12 @@ const AutoResizeTextarea = forwardRef<
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="auto-resize-textarea text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar resize-none overflow-y-auto"
className={cn(
"auto-resize-textarea text-base flex-1 outline-none w-full min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent custom-scrollbar resize-none overflow-y-auto",
{
"overflow-y-hidden": lineCount === 1,
}
)}
placeholder={chatPlaceholder || t("search.textarea.placeholder")}
aria-label={t("search.textarea.ariaLabel")}
value={input}

View File

@@ -12,7 +12,6 @@ import {
} from "lucide-react";
import { cloneElement, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@headlessui/react";
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
import { useSearchStore } from "@/stores/searchStore";
@@ -292,9 +291,9 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
ref={containerRef}
id={visibleContextMenu ? CONTEXT_MENU_PANEL_ID : ""}
className={clsx(
"absolute bottom-[50px] right-[18px] w-[300px] flex flex-col gap-2 scale-0 transition origin-bottom-right text-sm p-3 pb-0 bg-white dark:bg-black rounded-lg shadow-xs border border-[#EDEDED] dark:border-[#272828] shadow-lg dark:shadow-white/15",
"absolute bottom-[50px] right-[18px] w-[300px] flex flex-col gap-2 scale-0 transition origin-bottom-right text-sm p-3 pb-0 bg-white dark:bg-black rounded-lg border border-[#EDEDED] dark:border-[#272828] shadow-lg dark:shadow-white/15",
{
"!scale-100": visibleContextMenu,
"scale-100": visibleContextMenu,
}
)}
>
@@ -329,12 +328,12 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
<span style={{ color }}>{name}</span>
</div>
<div className="flex gap-[4px] text-black/60 dark:text-white/60">
<div className="flex gap-1 text-black/60 dark:text-white/60">
{keys.map((key) => (
<kbd
key={key}
className={clsx(
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-[6px] border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
"flex justify-center items-center font-sans h-5 min-w-5 text-[10px] rounded-md border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
{
"px-1": key.length > 1,
}
@@ -363,7 +362,7 @@ const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
searchInputRef.current?.focus();
}}
>
<Input
<input
ref={searchInputRef}
autoFocus
autoCorrect="off"

View File

@@ -77,7 +77,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
{/* Document Summary */}
{document?.summary && (
<div className="mb-4 text-xs text-[rgba(153,153,153,1)] dark:text-[#D8D8D8] whitespace-pre-wrap break-words">
<div className="mb-4 text-xs text-[rgba(153,153,153,1)] dark:text-[#D8D8D8] whitespace-pre-wrap wrap-break-word">
{document.summary}
</div>
)}

View File

@@ -88,6 +88,9 @@ export const DocumentList: React.FC<DocumentListProps> = ({
rich_category: sourceData?.rich_categories[0]?.key,
};
}
if (sourceData?.main_extension_id) {
queryStrings.main_extension_id = sourceData?.main_extension_id
}
let response: any;
if (isTauri) {
@@ -178,7 +181,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
{
target: containerRef,
isNoMore: (d) => !d?.hasMore,
reloadDeps: [input?.trim(), JSON.stringify(sourceData)],
reloadDeps: [input, JSON.stringify(sourceData)],
onFinally: (data) => {
if (data?.page === 1) return;
if (selectedItem === null) return;
@@ -211,7 +214,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
list: [],
}));
loadingFromRef.current = -1;
}, [input]);
}, [input, JSON.stringify(sourceData)]);
const { visibleContextMenu } = useSearchStore();
@@ -292,10 +295,10 @@ export const DocumentList: React.FC<DocumentListProps> = ({
return (
<div
className={`border-r border-gray-200 dark:border-gray-700 flex flex-col h-full overflow-x-hidden ${
viewMode === "list" ? "w-[100%]" : "w-[50%]"
viewMode === "list" ? "w-full" : "w-[50%]"
}`}
>
<div className="px-2 flex-shrink-0">
<div className="px-2 shrink-0">
<SearchHeader
total={total}
viewMode={viewMode}
@@ -306,6 +309,10 @@ export const DocumentList: React.FC<DocumentListProps> = ({
<Scrollbar className="flex-1 overflow-auto pr-0.5" ref={containerRef}>
{data?.list && data.list.length > 0 && (
<div>
{(() => {
console.log("Rendering list with items:", data.list.length);
return null;
})()}
{data.list.map((hit, index) => (
<SearchListItem
key={hit.document.id + index}

View File

@@ -46,8 +46,8 @@ const DropdownListItem = memo(
aria-selected={isSelected}
id={`search-item-${currentIndex}`}
className={clsx("p-2 transition rounded-lg", {
"bg-[#EDEDED] dark:bg-[#202126]": isSelected,
"!p-0": isAiOverview,
"bg-muted": isSelected,
"p-0!": isAiOverview,
})}
>
{isCalculator && <Calculator item={item} isSelected={isSelected} />}

View File

@@ -1,4 +1,4 @@
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import dayjs from "dayjs";
import {
CircleCheck,
@@ -8,6 +8,7 @@ import {
Loader,
Trash2,
User,
SquareArrowOutUpRight,
} from "lucide-react";
import { FC, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -15,15 +16,24 @@ import { useTranslation } from "react-i18next";
import { useSearchStore } from "@/stores/searchStore";
import DeleteDialog from "../Common/DeleteDialog";
import PreviewImage from "../Common/PreviewImage";
import platformAdapter from "@/utils/platformAdapter";
interface ExtensionDetailProps {
onInstall: () => void;
onUninstall: () => void;
changeInput: (value: string) => void;
}
const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
const { onInstall, onUninstall } = props;
const { selectedExtension, installingExtensions } = useSearchStore();
const { onInstall, onUninstall, changeInput } = props;
const {
selectedExtension,
installingExtensions,
setVisibleExtensionStore,
setVisibleExtensionDetail,
setSourceData,
setSearchValue,
} = useSearchStore();
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
@@ -37,6 +47,53 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
setIsOpen(false);
};
const extensionOpen = (item: any) => {
setSourceData({
source: {
name: item.name,
icon: item.icon,
},
querySource: {
id: "extensions",
},
main_extension_id: item.id,
});
setSearchValue(item.name || "");
};
const otherExtensionOpen = (item: any) => {
changeInput("");
//
const extension = { ...item };
let developerId = extension.developer.id;
let extensionId = extension.id;
const bundleId = {
developer: developerId,
extension_id: extensionId,
sub_extension_id: null,
};
platformAdapter.invokeBackend("open_third_party_extension", {
bundleId,
});
};
const handleOpen = async (item: any) => {
if (!item) return;
// close extension store
setVisibleExtensionStore(false);
setVisibleExtensionDetail(false);
//
if (item.type === "group" || item.type === "extension") {
extensionOpen(item);
} else {
otherExtensionOpen(item);
}
};
const renderDivider = () => {
return <div className="my-4 h-px bg-[#E6E6E6] dark:bg-[#262626]"></div>;
};
@@ -61,7 +118,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
</div>
<div className="flex items-center gap-1">
<FolderDown className="size-4" />
<span>{selectedExtension.stats.installs}</span>
<span>{selectedExtension.stats?.installs ?? 0}</span>
</div>
</div>
</div>
@@ -69,13 +126,19 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
<div className="pt-2">
{selectedExtension.installed ? (
<div className="flex items-center gap-2">
<Trash2
className="size-4 text-red-500 cursor-pointer"
onClick={() => {
setIsOpen(true);
}}
/>
<div className="flex items-center gap-1 h-6 px-2 rounded-full text-[#22C461] bg-[#22C461]/20">
<Button
className="flex justify-center items-center h-6 px-3 rounded-full bg-[#007BFF] hover:bg-[#007BFF] text-white ring-0 ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 outline-none"
onClick={() => handleOpen(selectedExtension)}
>
<SquareArrowOutUpRight className="size-4" />
</Button>
<Button
className="flex justify-center items-center h-6 px-3 rounded-full bg-[#FFE2E2] hover:bg-[#FFE2E2] text-red-500 ring-0 ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 outline-none"
onClick={() => setIsOpen(true)}
>
<Trash2 className="size-4" />
</Button>
<div className="flex items-center gap-1 h-6 px-2 rounded-full text-[#999999] bg-[#E6E6E6]">
<CircleCheck className="size-4" />
<span>{t("extensionDetail.hints.installed")}</span>
</div>
@@ -170,8 +233,9 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
<DeleteDialog
reverseButtonPosition
isOpen={isOpen}
title={`${t("extensionDetail.deleteDialog.title")} ${selectedExtension.name
}`}
title={`${t("extensionDetail.deleteDialog.title")} ${
selectedExtension.name
}`}
description={t("extensionDetail.deleteDialog.description")}
cancelButtonProps={{
className:
@@ -179,7 +243,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
}}
deleteButtonProps={{
className:
"!text-[#FF4949] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border-[#E6E6E6] dark:border-white/10",
"text-white bg-[#FF4949] hover:bg-[#FF4949] border-[#E6E6E6] dark:border-white/10",
}}
setIsOpen={setIsOpen}
onCancel={handleCancel}

View File

@@ -73,7 +73,13 @@ export interface SearchExtensionItem {
}>;
}
const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
const ExtensionStore = ({
extensionId,
changeInput,
}: {
extensionId?: string;
changeInput: (value: string) => void;
}) => {
const {
searchValue,
selectedExtension,
@@ -295,6 +301,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
<ExtensionDetail
onInstall={handleInstall}
onUninstall={handleUnInstall}
changeInput={changeInput}
/>
) : (
<>
@@ -341,7 +348,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
<div className="flex items-center gap-1 text-[#999]">
<FolderDown className="size-4" />
<span>{stats.installs}</span>
<span>{stats?.installs ?? 0}</span>
</div>
</div>
</div>

View File

@@ -252,7 +252,7 @@ export default function ChatInput({
replace: [akiAiTooltipPrefix, askAI.name],
})}
</span>
<div className="flex items-center justify-center px-1 h-[20px] text-xs rounded-[6px] border border-black/10 dark:border-[#545454]">
<div className="flex items-center justify-center px-1 h-5 text-xs rounded-md border border-black/10 dark:border-[#545454]">
{formatKey(modifierKey)} + {formatKey("Enter")}
</div>
</div>
@@ -276,8 +276,8 @@ export default function ChatInput({
return (
<VisibleKey
shortcut={returnToInput}
rootClassName="flex-1 flex items-center justify-center"
shortcutClassName="!left-0 !translate-x-0"
rootClassName="flex-1 flex items-center justify-center w-full"
shortcutClassName="!left-auto !right-2 !translate-x-0"
>
<AutoResizeTextarea
ref={textareaRef}
@@ -308,14 +308,13 @@ export default function ChatInput({
<div className={`w-full relative`}>
<div
ref={containerRef}
className={`flex items-center dark:text-[#D8D8D8] rounded-[6px] transition-all relative overflow-hidden`}
className={`flex items-center dark:text-[#D8D8D8] rounded-md transition-all relative overflow-hidden`}
>
{lineCount === 1 && renderSearchIcon()}
{visibleSearchBar() && (
<div
className={clsx(
"relative w-full p-2 bg-[#ededed] dark:bg-[#202126]",
"min-h-10 w-full p-[7px] bg-[#ededed] dark:bg-[#202126]",
{
"flex items-center gap-2": lineCount === 1,
}

View File

@@ -14,7 +14,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
import { parseSearchQuery, SearchQuery, canNavigateBack } from "@/utils";
import InputUpload from "./InputUpload";
import Copyright from "../Common/Copyright";
@@ -187,9 +187,9 @@ const InputControls = ({
{source?.type === "deep_think" && source?.config?.visible && (
<button
className={clsx(
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
"flex items-center justify-center gap-1 h-5 px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
"bg-[rgba(0,114,255,0.3)]!": isDeepThinkActive,
}
)}
onClick={setIsDeepThinkActive}
@@ -250,7 +250,7 @@ const InputControls = ({
!visibleExtensionStore && (
<div
className={clsx(
"inline-flex items-center gap-1 h-[20px] px-1 rounded-full hover:!text-[#881c94] cursor-pointer transition",
"inline-flex items-center gap-1 h-5 px-1 rounded-full hover:text-[#881c94]! cursor-pointer transition",
[
enabledAiOverview
? "text-[#881c94]"
@@ -283,7 +283,7 @@ const InputControls = ({
</div>
)}
{isChatPage || hasModules?.length !== 2 ? null : (
{isChatPage || hasModules?.length !== 2 || canNavigateBack() ? null : (
<div className="relative w-16 flex justify-end items-center">
<div className="absolute right-[52px] -top-2 z-10">
<VisibleKey

View File

@@ -2,14 +2,16 @@ import { FC, Fragment, MouseEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Plus } from "lucide-react";
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverButton,
PopoverPanel,
} from "@headlessui/react";
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { castArray, find, isNil } from "lodash-es";
import { nanoid } from "nanoid";
import { useCreation, useMount, useReactive } from "ahooks";
@@ -198,8 +200,8 @@ const InputUpload: FC<InputUploadProps> = (props) => {
]);
return (
<Menu>
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center justify-center h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip
content={t("search.input.uploadFileHints.tooltip", {
replace: [
@@ -212,32 +214,41 @@ const InputUpload: FC<InputUploadProps> = (props) => {
<Plus className="size-3 scale-[1.3]" />
</VisibleKey>
</Tooltip>
</MenuButton>
</DropdownMenuTrigger>
<MenuItems
anchor="bottom start"
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
<DropdownMenuContent
side="bottom"
align="start"
className="p-1 text-sm rounded-lg"
>
{menuItems.map((item) => {
const { label, children, clickEvent } = item;
return (
<MenuItem key={label}>
<DropdownMenuItem
key={label}
onSelect={(e: Event) => {
if (children) e.preventDefault();
}}
className="px-0 py-0"
>
{children ? (
<Popover>
<PopoverButton
className="flex items-center justify-between gap-2 px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={clickEvent}
>
<span>{label}</span>
<PopoverTrigger asChild>
<div
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg hover:bg-muted cursor-pointer"
onClick={clickEvent}
>
<span>{label}</span>
<ChevronRight className="size-4" />
</PopoverButton>
<ChevronRight className="size-4" />
</div>
</PopoverTrigger>
<PopoverPanel
transition
anchor="right"
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
<PopoverContent
side="right"
align="start"
className="p-1 text-sm rounded-lg"
>
{children.map((childItem) => {
const { groupName, groupItems } = childItem;
@@ -259,7 +270,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
return (
<div
key={id}
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
className="px-3 py-2 rounded-lg hover:bg-muted cursor-pointer"
onClick={clickEvent}
>
{label}
@@ -269,21 +280,21 @@ const InputUpload: FC<InputUploadProps> = (props) => {
</Fragment>
);
})}
</PopoverPanel>
</PopoverContent>
</Popover>
) : (
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
className="px-3 py-2 rounded-lg hover:bg-muted cursor-pointer"
onClick={clickEvent}
>
{label}
</div>
)}
</MenuItem>
</DropdownMenuItem>
);
})}
</MenuItems>
</Menu>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,10 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { ChevronDownIcon, RefreshCw, Layers, Hammer } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useDebounce } from "ahooks";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import CommonIcon from "@/components/Common/Icons/CommonIcon";
import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore";
@@ -16,6 +20,7 @@ import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import Pagination from "@/components/Common/Pagination";
import { SearchQuery } from "@/utils";
import { Button } from "../ui/button";
interface MCPPopoverProps {
mcp_servers: any;
@@ -79,6 +84,7 @@ export default function MCPPopover({
}, [currentService?.id, debouncedKeyword, getMCPByServer]);
const popoverButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const mcpSearch = useShortcutsStore((state) => state.mcpSearch);
const mcpSearchScope = useShortcutsStore((state) => {
return state.mcpSearchScope;
@@ -166,9 +172,9 @@ export default function MCPPopover({
return (
<div
className={clsx(
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-5 px-1 rounded-md transition cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
{
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
"bg-[rgba(0,114,255,0.3)]!": isMCPActive,
}
)}
onClick={setIsMCPActive}
@@ -191,8 +197,14 @@ export default function MCPPopover({
{t("search.input.MCP")}
</span>
<Popover className="relative">
<PopoverButton ref={popoverButtonRef} className="flex items-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
ref={popoverButtonRef}
className="flex items-center"
onClick={(e) => {
e.stopPropagation();
}}
>
<VisibleKey
shortcut={mcpSearchScope}
onKeyPress={() => {
@@ -200,29 +212,35 @@ export default function MCPPopover({
}}
>
<ChevronDownIcon
className={clsx("size-3", [
className={clsx("size-3 cursor-pointer", [
isMCPActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white",
])}
/>
</VisibleKey>
</PopoverButton>
</PopoverTrigger>
<PopoverPanel className="absolute z-50 left-0 bottom-6 w-[240px] overflow-y-auto bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<PopoverContent
side="top"
align="start"
className="z-50 w-60 overflow-y-auto rounded-lg shadow-lg p-0"
>
<div
className="text-sm"
onClick={(e) => {
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
}}
>
<div className="p-3">
<div className="p-2">
<div className="flex justify-between">
<span>{t("search.input.searchPopover.title")}</span>
<div
<Button
variant="outline"
size="icon"
className="size-6"
onClick={handleRefresh}
className="size-[24px] flex justify-center items-center rounded-lg border border-black/10 dark:border-white/10 cursor-pointer"
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
@@ -231,7 +249,7 @@ export default function MCPPopover({
}`}
/>
</VisibleKey>
</div>
</Button>
</div>
<div className="relative h-8 my-2">
@@ -250,7 +268,7 @@ export default function MCPPopover({
value={keyword}
ref={searchInputRef}
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"
onChange={(e) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value);
}}
/>
@@ -280,7 +298,7 @@ export default function MCPPopover({
>
<div className="flex items-center gap-2 overflow-hidden">
{isAll ? (
<Layers className="size-[16px] text-[#0287FF]" />
<Layers className="min-w-4 min-h-4 size-4 text-[#0287FF]" />
) : (
<CommonIcon
item={item}
@@ -290,7 +308,7 @@ export default function MCPPopover({
"default_icon",
]}
itemIcon={item.icon}
className="size-4"
className="min-w-4 min-h-4 size-4"
/>
)}
@@ -308,7 +326,7 @@ export default function MCPPopover({
}}
/>
<div className="flex justify-center items-center size-[24px]">
<div className="flex justify-center items-center size-6">
<Checkbox
checked={isChecked()}
indeterminate={isAll}
@@ -339,7 +357,7 @@ export default function MCPPopover({
/>
)}
</div>
</PopoverPanel>
</PopoverContent>
</Popover>
</>
)}

View File

@@ -1,4 +1,5 @@
import { useEffect, memo, useRef, useCallback, useState } from "react";
import clsx from "clsx";
import DropdownList from "./DropdownList";
import { SearchResults } from "@/components/Search/SearchResults";
@@ -12,7 +13,6 @@ import ExtensionStore from "./ExtensionStore";
import platformAdapter from "@/utils/platformAdapter";
import ViewExtension from "./ViewExtension";
import { visibleFooterBar } from "@/utils";
import clsx from "clsx";
const SearchResultsPanel = memo<{
input: string;
@@ -124,7 +124,9 @@ const SearchResultsPanel = memo<{
// If state gets updated, render the UI
if (visibleExtensionStore) {
return <ExtensionStore extensionId={extensionId} />;
return (
<ExtensionStore extensionId={extensionId} changeInput={changeInput} />
);
}
// Render the view extension
@@ -133,11 +135,14 @@ const SearchResultsPanel = memo<{
}
if (goAskAi) return <AskAi isChatMode={isChatMode} />;
if (sourceData) {
return <SearchResults input={input} isChatMode={isChatMode} />;
}
if (suggests.length === 0) return <NoResults />;
return sourceData ? (
<SearchResults input={input} isChatMode={isChatMode} />
) : (
return (
<DropdownList
suggests={suggests}
searchData={searchData}

View File

@@ -1,15 +1,16 @@
import { useSearchStore } from "@/stores/searchStore";
import { ChevronLeft, Search } from "lucide-react";
import { FC } from "react";
import clsx from "clsx";
import FontIcon from "@/components/Common/Icons/FontIcon";
import { FC } from "react";
import lightDefaultIcon from "@/assets/images/source_default.png";
import darkDefaultIcon from "@/assets/images/source_default_dark.png";
import { useThemeStore } from "@/stores/themeStore";
import platformAdapter from "@/utils/platformAdapter";
import { navigateBack, visibleSearchBar } from "@/utils";
import VisibleKey from "../Common/VisibleKey";
import clsx from "clsx";
import { cn } from "@/lib/utils";
interface MultilevelWrapperProps {
title?: string;
@@ -36,7 +37,7 @@ const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
<div
data-tauri-drag-region
className={clsx(
"flex items-center h-10 gap-1 px-2 border border-[#EDEDED] dark:border-[#202126] rounded-l-lg",
"flex items-center h-10 gap-1 px-2 border border-(--border) rounded-l-lg",
{
"justify-center": visibleSearchBar(),
"w-[calc(100vw-16px)] rounded-r-lg": !visibleSearchBar(),
@@ -50,7 +51,7 @@ const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
/>
</VisibleKey>
<div className="size-5 [&>*]:size-full">{renderIcon()}</div>
<div className="size-5 *:size-full">{renderIcon()}</div>
<span className="text-sm whitespace-nowrap">{title}</span>
</div>
@@ -115,7 +116,14 @@ export default function SearchIcons({
}
return (
<div className="flex items-center justify-center pl-2 h-10 bg-[#ededed] dark:bg-[#202126]">
<div
className={cn(
"flex items-center justify-center bg-[#ededed] dark:bg-[#202126]",
{
"pl-2 h-10": lineCount === 1,
}
)}
>
<Search className="w-4 h-4 text-[#ccc] dark:text-[#d8d8d8]" />
</div>
);

View File

@@ -1,10 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { ChevronDownIcon, RefreshCw, Layers, Globe } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useDebounce } from "ahooks";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import CommonIcon from "@/components/Common/Icons/CommonIcon";
import { useConnectStore } from "@/stores/connectStore";
import { useSearchStore } from "@/stores/searchStore";
@@ -15,6 +19,7 @@ import VisibleKey from "@/components/Common/VisibleKey";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import Pagination from "@/components/Common/Pagination";
import { Button } from "../ui/button";
interface SearchPopoverProps {
datasource: any;
@@ -85,6 +90,7 @@ export default function SearchPopover({
}, [currentService?.id, debouncedKeyword, getDataSourcesByServer]);
const popoverButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const internetSearch = useShortcutsStore((state) => state.internetSearch);
const internetSearchScope = useShortcutsStore((state) => {
return state.internetSearchScope;
@@ -172,9 +178,9 @@ export default function SearchPopover({
return (
<div
className={clsx(
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-[6px] transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-5 px-1 rounded-md transition cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
{
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
"bg-[rgba(0,114,255,0.3)]!": isSearchActive,
}
)}
onClick={setIsSearchActive}
@@ -199,8 +205,14 @@ export default function SearchPopover({
{t("search.input.search")}
</span>
<Popover className="relative">
<PopoverButton ref={popoverButtonRef} className="flex items-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
ref={popoverButtonRef}
className="flex items-center"
onClick={(e) => {
e.stopPropagation();
}}
>
<VisibleKey
shortcut={internetSearchScope}
onKeyPress={() => {
@@ -208,29 +220,35 @@ export default function SearchPopover({
}}
>
<ChevronDownIcon
className={clsx("size-3", [
className={clsx("size-3 cursor-pointer", [
isSearchActive
? "text-[#0072FF] dark:text-[#0072FF]"
: "text-[#333] dark:text-white",
])}
/>
</VisibleKey>
</PopoverButton>
</PopoverTrigger>
<PopoverPanel className="absolute z-50 left-0 bottom-6 w-[240px] overflow-y-auto bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<PopoverContent
side="top"
align="start"
className="z-50 w-60 overflow-y-auto rounded-lg shadow-lg p-0"
>
<div
className="text-sm"
onClick={(e) => {
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
}}
>
<div className="p-3">
<div className="p-2">
<div className="flex justify-between">
<span>{t("search.input.searchPopover.title")}</span>
<div
<Button
variant="outline"
size="icon"
className="size-6"
onClick={handleRefresh}
className="size-[24px] flex justify-center items-center rounded-lg border border-black/10 dark:border-white/10 cursor-pointer"
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
<RefreshCw
@@ -239,7 +257,7 @@ export default function SearchPopover({
}`}
/>
</VisibleKey>
</div>
</Button>
</div>
<div className="relative h-8 my-2">
@@ -258,7 +276,7 @@ export default function SearchPopover({
value={keyword}
ref={searchInputRef}
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"
onChange={(e) => {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value);
}}
/>
@@ -288,7 +306,7 @@ export default function SearchPopover({
>
<div className="flex items-center gap-2 overflow-hidden">
{isAll ? (
<Layers className="size-[16px] text-[#0287FF]" />
<Layers className="size-4 text-[#0287FF]" />
) : (
<CommonIcon
item={item}
@@ -316,7 +334,7 @@ export default function SearchPopover({
}}
/>
<div className="flex justify-center items-center size-[24px]">
<div className="flex justify-center items-center size-6">
<Checkbox
checked={isChecked()}
indeterminate={isAll}
@@ -347,7 +365,7 @@ export default function SearchPopover({
/>
)}
</div>
</PopoverPanel>
</PopoverContent>
</Popover>
</>
)}

View File

@@ -1,19 +1,47 @@
import React from "react";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Maximize2, Minimize2, Focus } from "lucide-react";
import { useSearchStore } from "@/stores/searchStore";
import {
ExtensionFileSystemPermission,
FileSystemAccess,
ViewExtensionUISettings,
} from "../Settings/Extensions";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { isMac } from "@/utils/platform";
import { useAppStore } from "@/stores/appStore";
const ViewExtension: React.FC = () => {
const { viewExtensionOpened } = useSearchStore();
const isTauri = useAppStore((state) => state.isTauri);
// Complete list of the backend APIs, grouped by their category.
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
const { setModifierKeyPressed } = useShortcutsStore();
const { t } = useTranslation();
const [isFullscreen, setIsFullscreen] = useState(false);
const prevWindowRef = useRef<{
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
} | null>(null);
const fullscreenPrevRef = useRef<{
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
} | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const DEFAULT_VIEW_WIDTH = 1200;
const DEFAULT_VIEW_HEIGHT = 900;
const [scale, setScale] = useState(1);
if (viewExtensionOpened == null) {
// When this view gets loaded, this state should not be NULL.
@@ -156,7 +184,6 @@ const ViewExtension: React.FC = () => {
}
};
window.addEventListener("message", messageHandler);
console.info("Coco extension API listener is up");
return () => {
window.removeEventListener("message", messageHandler);
@@ -164,15 +191,233 @@ const ViewExtension: React.FC = () => {
}, [reversedApis, permission]); // Add apiPermissions as dependency
const fileUrl = viewExtensionOpened[2];
const ui: ViewExtensionUISettings | undefined = useMemo(() => {
return viewExtensionOpened[4] as ViewExtensionUISettings | undefined;
}, [viewExtensionOpened]);
const resizable = ui?.resizable;
const baseWidth = useMemo(() => {
return ui && typeof ui?.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
}, [ui]);
const baseHeight = useMemo(() => {
return ui && typeof ui?.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
}, [ui]);
const recomputeScale = useCallback(async () => {
const size = await platformAdapter.getWindowSize();
const nextScale = Math.min(size.width / baseWidth, size.height / baseHeight);
setScale(Math.max(nextScale, 0.1));
}, [baseWidth, baseHeight]);
const applyFullscreen = useCallback(
async (next: boolean) => {
if (next) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
fullscreenPrevRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
if (isMac && isTauri) {
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await platformAdapter.getCurrentWebviewWindow();
const factor = await window.scaleFactor();
const { size, position } = monitor;
const { width, height } = size.toLogical(factor);
const { x, y } = position.toLogical(factor);
await platformAdapter.setWindowSize(width, height);
await platformAdapter.setWindowPosition(x, y);
await platformAdapter.setWindowResizable(true);
await recomputeScale();
} else {
await platformAdapter.setWindowFullscreen(true);
await recomputeScale();
}
} else {
if (!isMac) {
await platformAdapter.setWindowFullscreen(false);
}
const nextWidth =
ui && typeof ui.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
const nextHeight =
ui && typeof ui.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(nextWidth, nextHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
}
},
[ui, recomputeScale]
);
useEffect(() => {
const applyWindowSettings = async () => {
if (viewExtensionOpened != null) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
prevWindowRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
const nextWidth =
ui && typeof ui.width === "number" ? ui.width : DEFAULT_VIEW_WIDTH;
const nextHeight =
ui && typeof ui.height === "number" ? ui.height : DEFAULT_VIEW_HEIGHT;
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(nextWidth, nextHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
} else {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
await platformAdapter.setWindowSize(prev.width, prev.height);
await platformAdapter.setWindowResizable(prev.resizable);
await platformAdapter.centerOnCurrentMonitor();
prevWindowRef.current = null;
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
}, 0);
}
}
};
applyWindowSettings();
return () => {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
platformAdapter.setWindowSize(prev.width, prev.height);
platformAdapter.setWindowResizable(prev.resizable);
platformAdapter.centerOnCurrentMonitor();
prevWindowRef.current = null;
}
};
}, [viewExtensionOpened]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
applyFullscreen(false);
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
} as any);
};
}, [isFullscreen, applyFullscreen]);
return (
<iframe
src={fileUrl}
className="w-full h-full border-0"
onLoad={(event) => {
event.currentTarget.focus();
}}
/>
<div className="relative w-full h-full">
{isFullscreen && <div className="absolute inset-0 pointer-events-none" />}
{resizable && (
<button
aria-label={
isFullscreen
? t("viewExtension.fullscreen.exit")
: t("viewExtension.fullscreen.enter")
}
className="absolute top-2 right-2 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={async () => {
const next = !isFullscreen;
await applyFullscreen(next);
setIsFullscreen(next);
if (next) {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}
}}
>
{isFullscreen ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</button>
)}
{/* Focus helper button */}
<button
aria-label={t("viewExtension.focus")}
className="absolute top-2 right-12 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
>
<Focus className="size-4"/>
</button>
<div
className="w-full h-full flex items-center justify-center"
onMouseDownCapture={() => {
iframeRef.current?.focus();
}}
onPointerDown={() => {
iframeRef.current?.focus();
}}
onClickCapture={() => {
iframeRef.current?.focus();
}}
>
<iframe
ref={iframeRef}
src={fileUrl}
className="border-0"
style={{
width: `${baseWidth}px`,
height: `${baseHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "center center",
outline: "none",
}}
allow="fullscreen; pointer-lock; gamepad"
allowFullScreen
tabIndex={-1}
onLoad={(event) => {
event.currentTarget.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
/>
</div>
</div>
);
};

View File

@@ -35,7 +35,10 @@ import {
visibleSearchBar,
} from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { POPOVER_PANEL_SELECTOR, WINDOW_CENTER_BASELINE_HEIGHT } from "@/constants";
import {
POPOVER_PANEL_SELECTOR,
WINDOW_CENTER_BASELINE_HEIGHT,
} from "@/constants";
import { useChatStore } from "@/stores/chatStore";
import { useSearchStore } from "@/stores/searchStore";
@@ -107,9 +110,13 @@ function SearchChat({
let collapseWindowTimer = useRef<ReturnType<typeof setTimeout>>();
const setWindowSize = useCallback(() => {
const { viewExtensionOpened } = useSearchStore.getState();
if (collapseWindowTimer.current) {
clearTimeout(collapseWindowTimer.current);
}
if (viewExtensionOpened != null) {
return;
}
const width = 680;
let height = WINDOW_CENTER_BASELINE_HEIGHT;
@@ -174,6 +181,28 @@ function SearchChat({
onFocus: debouncedSetWindowSize,
});
useEffect(() => {
const unlisten = platformAdapter.listenEvent(
"refresh-window-size",
() => {
debouncedSetWindowSize();
}
);
return () => {
unlisten
.then((fn) => {
try {
typeof fn === "function" && fn();
} catch {
// ignore
}
})
.catch(() => {
// ignore
});
};
}, [debouncedSetWindowSize]);
useEffect(() => {
dispatch({
type: "SET_SEARCH_ACTIVE",
@@ -383,11 +412,11 @@ function SearchChat({
<div
data-tauri-drag-region={isTauri}
className={clsx(
"m-auto overflow-hidden relative bg-no-repeat bg-white dark:bg-black flex flex-col",
"m-auto overflow-hidden relative bg-no-repeat flex flex-col bg-cover",
[
isTransitioned
? "bg-bottom bg-chat_bg_light dark:bg-chat_bg_dark"
: "bg-top bg-search_bg_light dark:bg-search_bg_dark",
? "bg-bottom bg-[url('/assets/chat_bg_light.png')] dark:bg-[url('/assets/chat_bg_dark.png')]"
: "bg-top bg-[url('/assets/search_bg_light.png')] dark:bg-[url('/assets/search_bg_dark.png')]",
],
{
"size-full": !isTauri,
@@ -398,7 +427,6 @@ function SearchChat({
}
)}
style={{
backgroundSize: "auto 590px",
opacity: blurred ? blurOpacity / 100 : normalOpacity / 100,
}}
>
@@ -438,7 +466,7 @@ function SearchChat({
{!hideMiddleBorder && (
<div
className={clsx(
"pointer-events-none absolute left-0 right-0 h-[1px] bg-[#E6E6E6] dark:bg-[#272626]",
"pointer-events-none absolute left-0 right-0 h-px bg-[#E6E6E6] dark:bg-[#272626]",
isTransitioned ? "top-0" : "bottom-0"
)}
/>

View File

@@ -6,6 +6,13 @@ import { nanoid } from "nanoid";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import { Button } from "@/components/ui/button";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { ButtonConfig } from "./config";
import { useThemeStore } from "@/stores/themeStore";
import { useAppStore } from "@/stores/appStore";
@@ -169,43 +176,58 @@ export default function AddChatDialog({
<label className="text-sm font-medium text-muted-foreground">
{t("selection.bind.service")}
</label>
<select
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
<Select
value={serverId}
onChange={(e) => setServerId(e.target.value)}
onValueChange={(v) => setServerId(v === "__default__" ? "" : v)}
>
<option value="" disabled>
{t("selection.bind.defaultService")}
</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</option>
))}
</select>
<SelectTrigger className="h-8 w-full">
<SelectValue className="truncate" placeholder={t("selection.bind.defaultService") as string} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__" disabled>
{t("selection.bind.defaultService")}
</SelectItem>
{serverList.map((s: any) => (
<SelectItem key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">
{t("selection.bind.assistant")}
</label>
<select
className="h-8 rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-full"
<Select
value={assistantId}
onChange={(e) => setAssistantId(e.target.value)}
onValueChange={(v) => setAssistantId(v === "__default__" ? "" : v)}
disabled={loading || !serverId}
>
<option value="" disabled>
{loading
? t("common.loading")
: t("selection.bind.defaultAssistant")}
</option>
{!loading &&
assistantList.map((a: any) => (
<option key={a._id} value={a._id}>
{a._source?.name || a._id}
</option>
))}
</select>
<SelectTrigger className="h-8 w-full">
<SelectValue
className="truncate"
placeholder={
(loading
? t("common.loading")
: t("selection.bind.defaultAssistant")) as string
}
/>
</SelectTrigger>
<SelectContent>
{!loading && (
<SelectItem value="__default__">
{t("selection.bind.defaultAssistant")}
</SelectItem>
)}
{!loading &&
assistantList.map((a: any) => (
<SelectItem key={a._id} value={a._id}>
{a._source?.name || a._id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>

View File

@@ -7,6 +7,13 @@ import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import { setCurrentWindowService } from "@/commands/windowService";
import { AddChatButton } from "./AddChatButton";
import { ButtonConfig, resolveLucideIcon } from "./config";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
const ASSISTANT_CACHE_KEY = "assistant_list_cache";
@@ -250,50 +257,58 @@ const ButtonsList = ({ buttons, setButtons, serverList }: ButtonsListProps) => {
<div className="ml-auto flex items-center gap-2">
{isChat && (
<>
<select
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
<Select
value={btn.action.assistantServerId || ""}
onChange={(e) => handleServerSelect(btn, e.target.value)}
title={t("selection.bind.service")}
onValueChange={(v) =>
handleServerSelect(btn, v === "__default__" ? "" : v)
}
>
<option value="">
{t("selection.bind.defaultService")}
</option>
{serverList.map((s: any) => (
<option key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</option>
))}
</select>
<SelectTrigger className="h-8 w-60">
<SelectValue className="truncate" placeholder={t("selection.bind.defaultService") as string} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t("selection.bind.defaultService")}
</SelectItem>
{serverList.map((s: any) => (
<SelectItem key={s.id} value={s.id}>
{s.name || s.endpoint || s.id}
</SelectItem>
))}
</SelectContent>
</Select>
{(() => {
const sid = btn.action.assistantServerId;
const list = (sid && assistantByServer[sid]) || [];
const loading = !!(sid && assistantLoadingByServer[sid]);
return (
<select
className="rounded-md px-2 py-1 text-sm bg-white dark:bg-[#0B1220] w-44"
<Select
value={btn.action.assistantId || ""}
onChange={(e) =>
handleAssistantSelect(btn, e.target.value)
onValueChange={(v) =>
handleAssistantSelect(
btn,
v === "__default__" ? "" : v
)
}
title={t("selection.bind.assistant")}
disabled={loading}
>
<option value="">
{t("selection.bind.defaultAssistant")}
</option>
{loading && (
<option value="" disabled>
{t("common.loading")}
</option>
)}
{list.map((a: any) => (
<option key={a._id} value={a._id}>
{a._source?.name || a._id}
</option>
))}
</select>
<SelectTrigger className="h-8 w-60">
<SelectValue className="truncate" placeholder={t("selection.bind.defaultAssistant") as string} />
</SelectTrigger>
<SelectContent>
{!loading && (
<SelectItem value="__default__">
{t("selection.bind.defaultAssistant")}
</SelectItem>
)}
{list.map((a: any) => (
<SelectItem key={a._id} value={a._id}>
{a._source?.name || a._id}
</SelectItem>
))}
</SelectContent>
</Select>
);
})()}
</>

View File

@@ -117,7 +117,7 @@ const SelectionSettings = () => {
<h2 className="text-lg font-semibold">{t("selection.title")}</h2>
</div>
<div className="relative rounded-xl p-4 bg-gradient-to-r from-[#E6F0FA] to-[#FFF1F1]">
<div className="relative rounded-xl p-4 bg-linear-to-r from-[#E6F0FA] to-[#FFF1F1] dark:from-[#0B1220] dark:to-[#1A2234] dark:border dark:border-gray-800 dark:shadow-sm transition-colors">
<div className="flex items-center flex-col" aria-hidden="true">
<div className="rounded-xl border border-gray-200 bg-white/70 shadow-sm dark:border-gray-700 dark:bg-gray-900/40">
<HeaderToolbar
@@ -148,7 +148,7 @@ const SelectionSettings = () => {
</SettingsItem>
{selectionEnabled && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4">
<SettingsItem
icon={Sparkles}
title={t("selection.display.title")}

View File

@@ -1,7 +1,14 @@
import { useTranslation } from "react-i18next";
import { Command, RotateCcw } from "lucide-react";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { useEffect } from "react";
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import clsx from "clsx";
import { formatKey } from "@/utils/keyboardUtils";
@@ -246,21 +253,21 @@ const Shortcuts = () => {
title={t("settings.advanced.shortcuts.modifierKey.title")}
description={t("settings.advanced.shortcuts.modifierKey.description")}
>
<select
<Select
value={modifierKey}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(event) => {
setModifierKey(event.target.value as ModifierKey);
}}
onValueChange={(v) => setModifierKey(v as ModifierKey)}
>
{modifierKeys.map((item) => {
return (
<option key={item} value={item}>
<SelectTrigger className="h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modifierKeys.map((item) => (
<SelectItem key={item} value={item}>
{formatKey(item)}
</option>
);
})}
</select>
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsItem>
{list.map((item) => {
@@ -279,6 +286,7 @@ const Shortcuts = () => {
<span>{formatKey(modifierKey)}</span>
<span>+</span>
<SettingsInput
className="w-20"
value={value}
max={1}
onChange={(value) => {
@@ -287,23 +295,14 @@ const Shortcuts = () => {
/>
<Button
variant="outline"
disabled={disabled}
className={clsx(
"flex items-center justify-center size-8 rounded-[6px] border border-black/5 dark:border-white/10 transition",
{
"hover:border-[#0072FF]": !disabled,
"opacity-70 cursor-not-allowed": disabled,
}
)}
size="icon"
onClick={() => {
handleChange(initialValue, setValue);
}}
>
<RotateCcw
className={clsx("size-4 text-[#999]", {
"!text-[#0072FF]": !disabled,
})}
/>
<RotateCcw className={clsx("size-4 opacity-80")} />
</Button>
</div>
</SettingsItem>

View File

@@ -23,6 +23,13 @@ import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
// import SelectionSettings from "./components/Selection";
// import { isMac } from "@/utils/platform";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
const Advanced = () => {
const { t } = useTranslation();
@@ -169,23 +176,21 @@ const Advanced = () => {
title={t(title)}
description={t(description)}
>
<select
value={value}
onChange={(event) => {
onChange(event.target.value as never);
}}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{items.map((item) => {
const { label, value } = item;
return (
<option key={value} value={value}>
{t(label)}
</option>
);
})}
</select>
<Select value={value as string} onValueChange={(v) => onChange(v as never)}>
<SelectTrigger className="h-8 w-44">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectContent>
{items.map((item) => {
const { label, value } = item;
return (
<SelectItem key={value} value={value as string}>
{t(label)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</SettingsItem>
);
})}
@@ -278,33 +283,35 @@ const Advanced = () => {
"settings.advanced.other.localSearchResultWeight.description"
)}
>
<select
value={localSearchResultWeight}
onChange={(event) => {
const weight = Number(event.target.value);
<Select
value={String(localSearchResultWeight)}
onValueChange={(v) => {
const weight = Number(v);
setLocalSearchResultWeight(weight);
platformAdapter.invokeBackend("set_local_query_source_weight", {
value: weight,
});
}}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="0.5">
{t("settings.advanced.other.localSearchResultWeight.options.low")}
</option>
<option value="1">
{t(
"settings.advanced.other.localSearchResultWeight.options.medium"
)}
</option>
<option value="2">
{t(
"settings.advanced.other.localSearchResultWeight.options.high"
)}
</option>
</select>
<SelectTrigger className="h-8 w-44">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">
{t("settings.advanced.other.localSearchResultWeight.options.low")}
</SelectItem>
<SelectItem value="1">
{t(
"settings.advanced.other.localSearchResultWeight.options.medium"
)}
</SelectItem>
<SelectItem value="2">
{t(
"settings.advanced.other.localSearchResultWeight.options.high"
)}
</SelectItem>
</SelectContent>
</Select>
</SettingsItem>
<SettingsItem

View File

@@ -13,6 +13,7 @@ import Shortcut from "../Shortcut";
import SettingsToggle from "@/components/Settings/SettingsToggle";
import { platform } from "@/utils/platform";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { cn } from "@/lib/utils";
const Content = () => {
const { rootState } = useContext(ExtensionsContext);
@@ -165,7 +166,9 @@ const Item: FC<ItemProps> = (props) => {
<SettingsInput
defaultValue={alias}
placeholder={t("settings.extensions.hints.addAlias")}
className="!w-[90%] !h-6 !border-transparent rounded-[4px]"
className={cn(
"w-[90%] h-6 px-1 py-0 border-none rounded-sm shadow-none bg-transparent placeholder:text-[#999]"
)}
onChange={(value) => {
handleChange(String(value));
}}
@@ -292,7 +295,7 @@ const Item: FC<ItemProps> = (props) => {
return (
<>
<div
className={clsx("-mx-2 px-2 text-sm rounded-[6px]", {
className={clsx("-mx-2 px-2 text-sm rounded-md", {
"bg-[#f0f6fe] dark:bg-gray-700":
id === rootState.activeExtension?.id,
})}

View File

@@ -72,7 +72,7 @@ const AiOverview = () => {
/>
<>
<div className="mt-6 text-[#333] dark:text-white/90">
<div className="mt-6">
{t("settings.extensions.aiOverview.details.aiOverviewTrigger.title")}
</div>
@@ -88,9 +88,7 @@ const AiOverview = () => {
return (
<div>
<div className="mb-2 text-[#666] dark:text-white/70">
{label}
</div>
<div className="mb-2">{label}</div>
<SettingsInput
type="number"

View File

@@ -1,4 +1,4 @@
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import { useMount } from "ahooks";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -35,15 +35,13 @@ const Applications = () => {
return (
<>
<div className="text-[#999]">
<p className="font-bold mb-2">
{t("settings.extensions.application.details.searchScope")}
</p>
<p className="mb-2">
{t("settings.extensions.application.details.searchScope")}
</p>
<p>
{t("settings.extensions.application.details.searchScopeDescription")}
</p>
</div>
<p className="text-[#999]">
{t("settings.extensions.application.details.searchScopeDescription")}
</p>
<DirectoryScope
paths={paths}
@@ -72,18 +70,18 @@ const Applications = () => {
}}
/>
<div className="text-[#999] mt-4">
<p className="font-bold mb-2">
{t("settings.extensions.application.details.rebuildIndex")}
</p>
<p className="mt-4 mb-2">
{t("settings.extensions.application.details.rebuildIndex")}
</p>
<p>
{t("settings.extensions.application.details.rebuildIndexDescription")}
</p>
</div>
<p className="text-[#999]">
{t("settings.extensions.application.details.rebuildIndexDescription")}
</p>
<Button
className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-[6px] transition"
variant="outline"
className="w-full my-4"
// className="w-full h-8 my-4 text-[#0087FF] border border-[#EEF0F3] hover:border-[#0087FF] dark:border-gray-700 rounded-md transition"
onClick={handleReindex}
>
{t("settings.extensions.application.details.reindex")}

View File

@@ -1,6 +1,6 @@
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import clsx from "clsx";
import { castArray } from "lodash-es";
import { Folder, SquareArrowOutUpRight, X } from "lucide-react";
@@ -82,7 +82,7 @@ const DirectoryScope: FC<DirectoryScopeProps> = (props) => {
return (
<div
key={item}
className="flex items-center justify-between gap-2"
className="flex items-center justify-between gap-2 text-[#666] dark:text-white/70"
>
<div className="flex items-center gap-1 flex-1 overflow-hidden">
<Folder className="size-4" />
@@ -112,7 +112,9 @@ const DirectoryScope: FC<DirectoryScopeProps> = (props) => {
)}
<Button
className="w-full h-8 text-[#0087FF] border border-[#EEF0F3] hover:!border-[#0087FF] dark:border-gray-700 rounded-[6px] transition"
variant="outline"
className="w-full"
size="sm"
onClick={handleAdd}
>
{t("settings.extensions.directoryScope.button.addDirectories")}

View File

@@ -82,7 +82,7 @@ const FileSearch = () => {
{t("settings.extensions.fileSearch.description")}
</div>
<div className="mt-4 mb-2 text-[#666] dark:text-white/70">
<div className="mt-4 mb-2">
{t("settings.extensions.fileSearch.label.searchBy")}
</div>
@@ -99,10 +99,7 @@ const FileSearch = () => {
return (
<>
<div
key={label}
className="mt-4 mb-2 text-[#666] dark:text-white/70"
>
<div key={label} className="mt-4 mb-2">
{label}
</div>
@@ -111,16 +108,16 @@ const FileSearch = () => {
);
})}
<div className="mt-4 mb-2 text-[#666] dark:text-white/70">
<div className="mt-4 mb-2">
{t("settings.extensions.fileSearch.label.searchFileTypes")}
</div>
<div className="flex flex-wrap gap-2 p-2 border rounded-[6px] dark:border-gray-700">
<div className="flex flex-wrap items-center gap-2 p-2 rounded-lg border border-input bg-background hover:border-[#0072FF] focus-within:border-[#0072FF] transition">
{config.file_types.map((item) => {
return (
<div
key={item}
className="flex items-center gap-1 h-6 px-1 rounded bg-[#f0f0f0] dark:bg-[#444]"
className="flex items-center gap-1 h-6 px-2 rounded-full text-xs border border-black/5 dark:border-white/10 bg-black/5 dark:bg-white/10"
>
<span>{item}</span>
@@ -140,7 +137,7 @@ const FileSearch = () => {
<SettingsInput
placeholder=".*"
className="h-6 border-0 -ml-2"
className="h-6 w-24 px-2 border-0 outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
onKeyDown={(event) => {
if (event.code !== "Enter") return;

View File

@@ -4,7 +4,13 @@ import { isArray } from "lodash-es";
import { useAsyncEffect, useMount } from "ahooks";
import { AssistantFetcher } from "@/components/Assistant/AssistantFetcher";
import SettingsSelectPro from "@/components/Settings/SettingsSelectPro";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { useAppStore } from "@/stores/appStore";
import { ExtensionId } from "@/components/Settings/Extensions/index";
import { useConnectStore } from "@/stores/connectStore";
@@ -175,27 +181,40 @@ const SharedAi: FC<SharedAiProps> = (props) => {
<>
<div className="text-[#999]">{renderDescription()}</div>
<div className="mt-6 text-[#333] dark:text-white/90">
<div className="mt-6">
{t("settings.extensions.shardAi.details.linkedAssistant.title")}
</div>
{selectList.map((item) => {
const { label, value, data, searchable, onChange, onSearch } = item;
const { label, value, data, searchable, onChange } = item;
return (
<div key={label} className="mt-4">
<div className="mb-2 text-[#666] dark:text-white/70">{label}</div>
<SettingsSelectPro
<Select
value={value}
options={data}
searchable={searchable}
onChange={onChange}
onSearch={onSearch}
placeholder={
isLoadingAssistants && searchable ? "Loading..." : undefined
}
/>
onValueChange={(v) => onChange?.(v)}
disabled={searchable && isLoadingAssistants}
>
<SelectTrigger className="ml-1 h-9 w-full max-w-[480px]">
<SelectValue
className="truncate"
placeholder={
(searchable && isLoadingAssistants
? (t("common.loading") as string)
: undefined) as string | undefined
}
/>
</SelectTrigger>
<SelectContent>
{data?.map((opt: any) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.name || opt.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}

View File

@@ -9,7 +9,13 @@ import AiOverview from "./AiOverview";
import Calculator from "./Calculator";
import FileSearch from "./FileSearch";
import { Ellipsis, Info } from "lucide-react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuContent,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore";
import { useTranslation } from "react-i18next";
@@ -93,58 +99,60 @@ const Details = () => {
};
return (
<div className="flex-1 h-full pr-4 pb-4 overflow-auto">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex-1 h-full p-4 overflow-auto">
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="m-0 text-lg font-semibold text-gray-900 dark:text-white">
{rootState.activeExtension?.name}
</h2>
{rootState.activeExtension?.developer && (
<Menu>
<MenuButton className="h-7">
<Ellipsis className="size-5 text-[#999]" />
</MenuButton>
<MenuItems
anchor="bottom end"
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<Ellipsis className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
className="p-1 text-sm rounded-lg"
>
<MenuItem>
<div
className="px-3 py-2 text-nowrap text-red-500 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={async () => {
try {
const { id, developer } = rootState.activeExtension!;
<DropdownMenuItem
className="px-3 py-2 text-nowrap text-red-500 rounded-lg hover:bg-muted"
onSelect={async (e: Event) => {
e.preventDefault();
try {
const { id, developer } = rootState.activeExtension!;
await platformAdapter.invokeBackend(
"uninstall_extension",
{
extensionId: id,
developer: developer,
}
);
await platformAdapter.invokeBackend("uninstall_extension", {
extensionId: id,
developer: developer,
});
Object.assign(rootState, {
activeExtension: void 0,
extensions: rootState.extensions.filter((item) => {
return item.id !== id;
}),
});
Object.assign(rootState, {
activeExtension: void 0,
extensions: rootState.extensions.filter((item) => {
return item.id !== id;
}),
});
addError(
t("settings.extensions.hints.uninstallSuccess"),
"info"
);
} catch (error) {
addError(String(error));
}
}}
>
{t("settings.extensions.hints.uninstall")}
</div>
</MenuItem>
</MenuItems>
</Menu>
addError(
t("settings.extensions.hints.uninstallSuccess"),
"info"
);
} catch (error) {
addError(String(error));
}
}}
>
{t("settings.extensions.hints.uninstall")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>

View File

@@ -3,15 +3,21 @@ import { useReactive } from "ahooks";
import { useTranslation } from "react-i18next";
import type { LiteralUnion } from "type-fest";
import { cloneDeep, sortBy } from "lodash-es";
import clsx from "clsx";
import { Plus } from "lucide-react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import platformAdapter from "@/utils/platformAdapter";
import Content from "./components/Content";
import Details from "./components/Details";
import { useExtensionsStore } from "@/stores/extensionsStore";
import SettingsInput from "../SettingsInput";
import { useAppStore } from "@/stores/appStore";
import { installExtensionError } from "@/utils";
@@ -69,6 +75,10 @@ export interface ViewExtensionUISettings {
search_bar: boolean;
filter_bar: boolean;
footer: boolean;
width: number | null;
height: number | null;
resizable: boolean;
detachable: boolean;
}
export interface Extension {
@@ -184,95 +194,88 @@ export const Extensions = () => {
rootState: state,
}}
>
<div className="flex h-[calc(100vh-128px)] -mx-6 gap-4 text-sm">
<div className="w-2/3 h-full px-4 border-r dark:border-gray-700 overflow-auto">
<div className="flex h-[calc(100vh-128px)] -mx-6 text-sm">
<div className="w-2/3 h-full px-4 border-r border-border overflow-auto">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t("settings.extensions.title")}
</h2>
<Menu>
<MenuButton className="flex items-center justify-center size-6 border rounded-[6px] dark:border-gray-700 hover:!border-[#0096FB] transition">
<Plus className="size-4 text-[#0096FB]" />
</MenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="size-6">
<Plus className="h-4 w-4 text-primary" />
</Button>
</DropdownMenuTrigger>
<MenuItems
anchor={{ gap: 4 }}
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
<DropdownMenuContent
sideOffset={4}
className="p-1 text-sm rounded-lg"
>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
{t("settings.extensions.menuItem.extensionStore")}
</div>
</MenuItem>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={async () => {
try {
const path = await platformAdapter.openFileDialog({
directory: true,
});
<DropdownMenuItem
className="px-3 py-2 rounded-lg hover:bg-muted"
onSelect={(e: Event) => {
e.preventDefault();
platformAdapter.emitEvent("open-extension-store");
}}
>
{t("settings.extensions.menuItem.extensionStore")}
</DropdownMenuItem>
<DropdownMenuItem
className="px-3 py-2 rounded-lg hover:bg-muted"
onSelect={async (e: Event) => {
e.preventDefault();
try {
const path = await platformAdapter.openFileDialog({
directory: true,
});
if (!path) return;
if (!path) return;
await platformAdapter.invokeBackend(
"install_local_extension",
{ path }
);
await platformAdapter.invokeBackend(
"install_local_extension",
{ path }
);
await getExtensions();
await getExtensions();
addError(
t("settings.extensions.hints.importSuccess"),
"info"
);
} catch (error) {
installExtensionError(error);
}
}}
>
{t("settings.extensions.menuItem.localExtensionImport")}
</div>
</MenuItem>
</MenuItems>
</Menu>
addError(
t("settings.extensions.hints.importSuccess"),
"info"
);
} catch (error) {
installExtensionError(error);
}
}}
>
{t("settings.extensions.menuItem.localExtensionImport")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex justify-between gap-6 my-4">
<div className="flex h-8 border dark:border-gray-700 rounded-[6px] overflow-hidden">
{state.categories.map((item) => {
return (
<div
key={item}
className={clsx(
"flex items-center h-full px-4 cursor-pointer",
{
"bg-[#F0F6FE] dark:bg-gray-700":
item === state.currentCategory,
}
)}
onClick={() => {
state.currentCategory = item;
}}
>
<div className="flex items-center justify-between gap-6 my-4">
<Tabs
value={state.currentCategory}
onValueChange={(v) => {
state.currentCategory = v as Category;
}}
>
<TabsList>
{state.categories.map((item) => (
<TabsTrigger key={item} value={item}>
{item}
</div>
);
})}
</div>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<SettingsInput
className="flex-1"
<Input
className="flex-1 h-8"
placeholder="Search"
value={state.searchValue}
onChange={(value) => {
state.searchValue = String(value);
value={state.searchValue ?? ""}
onChange={(e) => {
state.searchValue = e.target.value;
}}
/>
</div>

View File

@@ -36,6 +36,13 @@ import {
} from "@/commands";
import platformAdapter from "@/utils/platformAdapter";
import { useAppearanceStore, WindowMode } from "@/stores/appearanceStore";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
export function ThemeOption({
icon: Icon,
@@ -83,8 +90,6 @@ export default function GeneralSettings() {
const { showTooltip, setShowTooltip, language, setLanguage } = useAppStore();
const { windowMode, setWindowMode } = useAppearanceStore();
const fetchAutoStartStatus = async () => {
if (isTauri()) {
try {
@@ -283,7 +288,7 @@ export default function GeneralSettings() {
className={clsx(
"p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 flex flex-col items-center justify-center space-y-2 transition-all",
{
"!border-blue-500 bg-blue-50 dark:bg-blue-900/20":
"border-blue-500! bg-blue-50! dark:bg-blue-900/20!":
isSelected,
}
)}
@@ -307,28 +312,31 @@ export default function GeneralSettings() {
})}
</div>
<SettingsItem
icon={Globe}
title={t("settings.language.title")}
description={t("settings.language.description")}
>
<div className="flex items-center gap-2">
<select
<Select
value={currentLanguage}
onChange={(event) => {
const lang = event.currentTarget.value;
onValueChange={(lang) => {
setLanguage(lang);
platformAdapter.invokeBackend("update_app_lang", { lang });
}}
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="en">{t("settings.language.english")}</option>
<option value="zh">{t("settings.language.chinese")}</option>
</select>
<SelectTrigger className="h-8 w-44">
<SelectValue className="truncate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">
{t("settings.language.english")}
</SelectItem>
<SelectItem value="zh">
{t("settings.language.chinese")}
</SelectItem>
</SelectContent>
</Select>
</div>
</SettingsItem>

View File

@@ -1,10 +1,13 @@
import { Input, InputProps } from "@headlessui/react";
import { Input } from "@/components/ui/input";
import { isNumber } from "lodash-es";
import { FC, FocusEvent } from "react";
import { FC, FocusEvent, InputHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
interface SettingsInputProps
extends Omit<InputProps, "onChange" | "className"> {
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"onChange" | "className"
> {
className?: string;
onChange?: (value?: string | number) => void;
}
@@ -35,10 +38,7 @@ const SettingsInput: FC<SettingsInputProps> = (props) => {
<Input
{...rest}
autoCorrect="off"
className={twMerge(
"w-20 h-8 px-2 rounded-[6px] border bg-transparent border-black/5 dark:border-white/10 hover:!border-[#0072FF] focus:!border-[#0072FF] transition",
className
)}
className={twMerge("w-44 h-8", className)}
onBlur={handleBlur}
onChange={(event) => {
onChange?.(event.target.value);

View File

@@ -7,7 +7,7 @@ interface SettingsPanelProps {
const SettingsPanel: React.FC<SettingsPanelProps> = ({ children }) => {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-6">
<div className="bg-background text-foreground rounded-xl p-6">
{/* <h2 className="text-xl font-semibold mb-6">{title}</h2> */}
{children}
</div>

View File

@@ -1,7 +1,8 @@
import { useBoolean, useClickAway, useDebounce } from "ahooks";
import clsx from "clsx";
import { FC, useEffect, useMemo, useRef, useState } from "react";
import SettingsInput from "./SettingsInput";
import { Input } from "@/components/ui/input";
import NoDataImage from "../Common/NoDataImage";
interface SettingsSelectProProps {
@@ -47,7 +48,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
return (
<div ref={containerRef} className="relative">
<div
className="flex items-center h-8 px-3 truncate rounded-[6px] border dark:bg-[#1F2937] bg-white dark:border-[#374151]"
className="flex items-center h-9 px-3 truncate rounded-md border border-input bg-background text-foreground shadow-sm"
onClick={toggle}
>
{option?.[labelField] ?? (
@@ -57,7 +58,7 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
<div
className={clsx(
"absolute z-100 top-10 left-0 right-0 rounded-[6px] py-2 border dark:border-[#374151] bg-white dark:bg-[#1F2937] shadow-[0_5px_15px_rgba(0,0,0,0.2)] dark:shadow-[0_5px_10px_rgba(0,0,0,0.3)]",
"absolute z-50 top-11 left-0 right-0 rounded-md p-2 border border-input bg-popover text-popover-foreground shadow-md",
{
hidden: !open,
}
@@ -65,12 +66,12 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
>
{searchable && (
<div className="px-2 mb-2">
<SettingsInput
<Input
autoFocus
value={searchValue}
className="w-full"
onChange={(value) => {
setSearchValue(String(value));
className="w-full h-8 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => {
setSearchValue(String(e.target.value));
}}
/>
</div>
@@ -83,9 +84,9 @@ const SettingsSelectPro: FC<SettingsSelectProProps> = (props) => {
<div
key={item?.[valueField] ?? index}
className={clsx(
"h-8 leading-8 px-2 rounded-[6px] hover:bg-[#EDEDED] hover:dark:bg-[#374151] transition cursor-pointer",
"h-8 leading-8 px-2 rounded-md hover:bg-accent hover:text-accent-foreground transition cursor-pointer",
{
"bg-[#EDEDED] dark:bg-[#374151]":
"bg-accent text-accent-foreground":
value === item?.[valueField],
}
)}

View File

@@ -1,28 +1,26 @@
import { Switch, SwitchProps } from "@headlessui/react";
import { Switch } from "@/components/ui/switch";
import clsx from "clsx";
interface SettingsToggleProps extends SwitchProps {
type BaseSwitchProps = React.ComponentProps<typeof Switch>;
interface SettingsToggleProps
extends Omit<BaseSwitchProps, "onChange" | "onCheckedChange"> {
label: string;
className?: string;
onChange?: (checked: boolean) => void;
}
export default function SettingsToggle(props: SettingsToggleProps) {
const { label, className, ...rest } = props;
const { label, className, onChange, ...rest } = props;
return (
<Switch
{...rest}
aria-label={label}
onCheckedChange={(v) => onChange?.(v)}
className={clsx(
`group relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 bg-gray-200 data-[checked]:bg-blue-600`,
"h-5 w-9",
className
)}
>
<span className="sr-only">{label}</span>
<span
className="pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow
ring-0 transition duration-200 ease-in-out translate-x-0 group-data-[checked]:translate-x-5"
/>
</Switch>
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useEffect } from "react";
import { Button, Dialog, DialogPanel } from "@headlessui/react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { noop } from "lodash-es";
import { LoaderCircle, X } from "lucide-react";
import { useInterval, useReactive } from "ahooks";
import clsx from "clsx";
@@ -141,117 +141,107 @@ const UpdateApp = ({ isCheckPage }: UpdateAppProps) => {
return (
<Dialog
open={isCheckPage ? true : visible}
as="div"
id="update-app-dialog"
className="relative z-10 focus:outline-none"
onClose={noop}
onOpenChange={(v) => {
if (!isCheckPage) setVisible(v);
}}
>
<div
className={`fixed inset-0 z-10 w-screen overflow-y-auto ${
<DialogContent
id="update-app-dialog"
overlayClassName={clsx("bg-transparent backdrop-blur-0 rounded-xl")}
className={clsx(
isCheckPage
? "rounded-lg bg-white dark:bg-[#333] border border-[#EDEDED] dark:border-black/20 shadow-md"
: ""
}`}
? "inset-0 left-0 top-0 translate-x-0 translate-y-0 w-full h-screen max-w-none rounded-lg border-none bg-background text-foreground p-0"
: "w-[340px] py-8 flex flex-col items-center rounded-lg border border-input bg-background text-foreground shadow-md"
)}
>
<div
data-tauri-drag-region
className={clsx(
"flex min-h-full items-center justify-center",
!isCheckPage && "p-4"
"w-full flex flex-col items-center justify-center px-6",
isCheckPage && "h-full"
)}
>
<DialogPanel
transition
className={clsx(
"relative w-[340px] py-8 flex flex-col items-center",
{
"rounded-lg bg-white dark:bg-[#333] border border-[#EDEDED] dark:border-black/20 shadow-md":
!isCheckPage,
}
)}
>
{!isCheckPage && isOptional && (
<X
className={clsx(
"absolute size-5 top-3 right-3 text-[#999] dark:text-[#D8D8D8]",
cursorClassName
)}
onClick={handleCancel}
role="button"
aria-label="Close dialog"
tabIndex={0}
/>
)}
<img src={isDark ? darkIcon : lightIcon} className="h-6" />
<div className="text-[#333] text-sm leading-5 py-2 dark:text-[#D8D8D8] text-center">
{updateInfo ? (
isOptional ? (
t("update.optional_description")
) : (
<>
<p>{t("update.force_description1")}</p>
<p>{t("update.force_description2")}</p>
</>
)
) : (
t("update.date")
)}
</div>
{updateInfo ? (
<div
className="text-xs text-[#0072FF] cursor-pointer"
onClick={() =>
OpenURLWithBrowser(
"https://docs.infinilabs.com/coco-app/main/docs/release-notes"
)
}
>
v{updateInfo.version} {t("update.releaseNotes")}
</div>
) : (
<div className={clsx("text-xs text-[#999]", cursorClassName)}>
{t("update.latest", {
replace: [
updateInfo?.version || process.env.VERSION || "N/A",
],
})}
</div>
)}
<Button
{!isCheckPage && isOptional && (
<X
className={clsx(
"mb-3 mt-6 bg-[#0072FF] text-white text-sm px-[14px] py-[8px] rounded-lg",
cursorClassName,
state.loading && "opacity-50"
"absolute h-5 w-5 top-3 right-3 text-muted-foreground",
cursorClassName
)}
onClick={updateInfo ? handleDownload : handleSkip}
>
{state.loading ? (
<div className="flex justify-center items-center gap-2">
<LoaderCircle className="animate-spin size-5" />
{percent}%
</div>
) : updateInfo ? (
t("update.button.install")
) : (
t("update.button.ok")
)}
</Button>
onClick={handleCancel}
role="button"
aria-label="Close dialog"
tabIndex={0}
/>
)}
{updateInfo && isOptional && (
<div
className={clsx("text-xs text-[#999]", cursorClassName)}
onClick={handleSkip}
>
{t("update.skip_version")}
</div>
<img src={isDark ? darkIcon : lightIcon} className="h-6" />
<div className="text-sm leading-5 py-2 text-foreground text-center">
{updateInfo ? (
isOptional ? (
t("update.optional_description")
) : (
<>
<p>{t("update.force_description1")}</p>
<p>{t("update.force_description2")}</p>
</>
)
) : (
t("update.date")
)}
</DialogPanel>
</div>
{updateInfo ? (
<div
className="text-xs text-primary cursor-pointer"
onClick={() =>
OpenURLWithBrowser(
"https://docs.infinilabs.com/coco-app/main/docs/release-notes"
)
}
>
v{updateInfo.version} {t("update.releaseNotes")}
</div>
) : (
<div
className={clsx("text-xs text-muted-foreground", cursorClassName)}
>
{t("update.latest", {
replace: [updateInfo?.version || process.env.VERSION || "N/A"],
})}
</div>
)}
<Button
className={clsx(
"mb-3 mt-6 bg-primary text-primary-foreground text-sm px-[14px] py-[8px] rounded-lg",
cursorClassName,
state.loading && "opacity-50"
)}
onClick={updateInfo ? handleDownload : handleSkip}
>
{state.loading ? (
<div className="flex justify-center items-center gap-2">
<LoaderCircle className="animate-spin h-5 w-5" />
{percent}%
</div>
) : updateInfo ? (
t("update.button.install")
) : (
t("update.button.ok")
)}
</Button>
{updateInfo && isOptional && (
<div
className={clsx("text-xs text-muted-foreground", cursorClassName)}
onClick={handleSkip}
>
{t("update.skip_version")}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,5 +1,5 @@
import { useAppStore } from "@/stores/appStore";
import { Button } from "@headlessui/react";
import { Button } from "@/components/ui/button";
import { SquareArrowOutUpRight } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -12,10 +12,7 @@ const LoginButton = () => {
};
return (
<Button
className="px-6 h-8 text-white bg-[#0287FF] flex rounded-[8px] items-center justify-center gap-1"
onClick={handleClick}
>
<Button className="h-8" onClick={handleClick}>
<span>{t("webLogin.buttons.login")}</span>
<SquareArrowOutUpRight className="size-4" />

View File

@@ -1,6 +1,6 @@
import { RefreshCw } from "lucide-react";
import { FC, useState } from "react";
import { Button, ButtonProps } from "@headlessui/react";
import { Button, ButtonProps } from "@/components/ui/button";
import clsx from "clsx";
import { useWebConfigStore } from "@/stores/webConfigStore";
@@ -25,10 +25,9 @@ const RefreshButton: FC<ButtonProps> = (props) => {
<Button
{...rest}
onClick={handleRefresh}
className={clsx(
"flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-[8px] border dark:border-white/10",
className
)}
variant="outline"
size="icon"
className={clsx("size-8", className)}
disabled={isRefreshing}
>
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>

View File

@@ -12,7 +12,7 @@ const UserAvatar: FC<UserAvatarProps> = (props) => {
return (
<div
className={clsx(
"flex items-center justify-center size-5 rounded-full border dark:border-white/10 overflow-hidden",
"flex items-center justify-center size-5 rounded-full border border-border overflow-hidden",
className
)}
>

View File

@@ -1,4 +1,8 @@
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { useWebConfigStore } from "@/stores/webConfigStore";
import { LogOut } from "lucide-react";
import clsx from "clsx";
@@ -10,21 +14,18 @@ import RefreshButton from "./RefreshButton";
import LoginButton from "./LoginButton";
import { FC } from "react";
import Copyright from "../Common/Copyright";
import { PopoverContentProps } from "@radix-ui/react-popover";
import { Button } from "../ui/button";
interface WebLoginProps {
panelClassName: string;
}
const WebLogin: FC<WebLoginProps> = (props) => {
const { panelClassName } = props;
const WebLogin: FC<PopoverContentProps> = (props) => {
const { integration, loginInfo, setIntegration, setLoginInfo } =
useWebConfigStore();
const { t } = useTranslation();
return (
<div className="relative">
<div className="flex items-center relative text-sm">
<Popover>
<PopoverButton>
<PopoverTrigger className="cursor-pointer">
{loginInfo ? (
<UserAvatar />
) : (
@@ -33,38 +34,35 @@ const WebLogin: FC<WebLoginProps> = (props) => {
className="size-5 text-[#999]"
/>
)}
</PopoverButton>
</PopoverTrigger>
<PopoverPanel
className={clsx(
"absolute z-50 w-[300px] rounded-xl bg-white dark:bg-[#202126] text-sm/6 text-[#333] dark:text-[#D8D8D8] shadow-lg border dark:border-white/10 -translate-y-2",
panelClassName
)}
>
<PopoverContent {...props} className="p-0">
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<span>{t("webLogin.title")}</span>
<RefreshButton />
<RefreshButton className="size-6" />
</div>
<div className="py-2">
{loginInfo ? (
<div className="flex justify-between items-center">
<div className="flex justify-between items-center gap-3">
<div className="flex items-center gap-3">
<UserAvatar
className="!size-12"
icon={{ className: "!size-6" }}
className="h-12 w-12"
icon={{ className: "h-6 w-6" }}
/>
<div className="flex flex-col">
<span>{loginInfo.name}</span>
<span className="text-[#999]">{loginInfo.email}</span>
<span>{loginInfo?.name}</span>
<span className="text-[#999]">{loginInfo?.email}</span>
</div>
</div>
<button
className="flex items-center justify-center size-6 bg-white dark:bg-[#202126] rounded-[8px] border dark:border-white/10"
<Button
variant="outline"
size="icon"
className="size-6"
onClick={async () => {
await Post("/account/logout", void 0);
@@ -77,7 +75,7 @@ const WebLogin: FC<WebLoginProps> = (props) => {
"size-3 text-[#0287FF] transition-transform duration-1000"
)}
/>
</button>
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-3">
@@ -93,10 +91,10 @@ const WebLogin: FC<WebLoginProps> = (props) => {
</div>
</div>
<div className="p-3 border-t dark:border-t-white/10">
<div className="p-3 border-t border-border">
<Copyright />
</div>
</PopoverPanel>
</PopoverContent>
</Popover>
</div>
);

View File

@@ -1,52 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
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-[6px] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-[6px] px-3 text-xs",
lg: "h-10 rounded-[6px] px-8",
sm: "h-8 px-3",
md: "h-9 px-4",
lg: "h-10 px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
size: "md",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
({ className, variant, size, ...props }, ref) => {
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-input bg-background shadow ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-primary-foreground">
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,108 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-3000 bg-black/60 backdrop-blur-sm",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
overlayClassName?: string;
};
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, overlayClassName, ...props }, ref) => (
<DialogPortal>
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-3001 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-input bg-background p-6 text-foreground shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background",
className
)}
{...props}
/>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
};

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, side = "bottom", align = "start", sideOffset = 8, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
side={side}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-40 rounded-lg border border-input bg-background p-1 text-foreground shadow-lg",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm outline-none focus:bg-muted",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-3 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
};

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = "text", ...props }, ref) => {
return (
<input
type={type}
ref={ref}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

Some files were not shown because too many files have changed in this diff Show More