mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-20 21:39:25 +01:00
Compare commits
53 Commits
v0.9.1
...
add-search
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab93407ed | ||
|
|
a488b0b465 | ||
|
|
5fdbc7cd31 | ||
|
|
6d00efc7e8 | ||
|
|
798bd923c6 | ||
|
|
56234526a8 | ||
|
|
5fe4032209 | ||
|
|
8d0d719964 | ||
|
|
d056271848 | ||
|
|
b11fec29dc | ||
|
|
97369963a6 | ||
|
|
f3fa91a03c | ||
|
|
0e6d7fa52f | ||
|
|
209438a638 | ||
|
|
4ada34ad75 | ||
|
|
52f6f73d53 | ||
|
|
aa779ec156 | ||
|
|
93da46662c | ||
|
|
fbbc5f1d6a | ||
|
|
e89cca1c2f | ||
|
|
5e103bfc3d | ||
|
|
78ec0836a1 | ||
|
|
c698b4094b | ||
|
|
2235bd1da1 | ||
|
|
8a2898b0b9 | ||
|
|
53f6b33279 | ||
|
|
b20cc771ea | ||
|
|
a339dbab9c | ||
|
|
1bb2d8b3b3 | ||
|
|
1ceb385505 | ||
|
|
371f8d0daa | ||
|
|
28cf5ca326 | ||
|
|
ed10be5c6f | ||
|
|
0bcb974837 | ||
|
|
4c4c08a598 | ||
|
|
724db0f66d | ||
|
|
a8a14cae18 | ||
|
|
eea6a7a5ae | ||
|
|
fdc8967b76 | ||
|
|
5df1f9668d | ||
|
|
23607e6b4c | ||
|
|
494be3db62 | ||
|
|
530502ecff | ||
|
|
8d6204a9d8 | ||
|
|
ca350dfeed | ||
|
|
3a9c9ec9eb | ||
|
|
f7c0600480 | ||
|
|
ed8a1cb477 | ||
|
|
abf20f81ff | ||
|
|
65a48efdde | ||
|
|
0613238876 | ||
|
|
501f6df473 | ||
|
|
67c8c4bdfa |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -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",
|
||||
|
||||
@@ -7,11 +7,24 @@ title: "Release Notes"
|
||||
|
||||
Information about release notes of Coco App is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
### ❌ Breaking changes
|
||||
### 🚀 Features
|
||||
### 🐛 Bug fix
|
||||
### ✈️ Improvements
|
||||
## Latest (In development)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- feat: add open button to launch installed extension #1013
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
- fix: fix the abnormal input height issue #1006
|
||||
- fix: implement custom serialization for Extension.minimum_coco_version #1010
|
||||
|
||||
### ✈️ 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)
|
||||
|
||||
|
||||
27
package.json
27
package.json
@@ -8,9 +8,10 @@
|
||||
"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",
|
||||
@@ -42,17 +51,19 @@
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
"filesize": "^10.1.6",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.461.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
"react-i18next": "^15.5.1",
|
||||
@@ -77,6 +88,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 +106,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"
|
||||
}
|
||||
}
|
||||
|
||||
1602
pnpm-lock.yaml
generated
1602
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
39
scripts/buildWebAfter.ts
Normal 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();
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::common::document::Document;
|
||||
use crate::common::http::get_response_body_text;
|
||||
use reqwest::Response;
|
||||
use serde::de::Deserializer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
@@ -11,6 +12,7 @@ pub struct SearchResponse<T> {
|
||||
pub timed_out: Option<bool>,
|
||||
pub _shards: Option<Shards>,
|
||||
pub hits: Hits<T>,
|
||||
pub aggregations: Option<Aggregations>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -122,11 +124,162 @@ pub struct FailedRequest {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Aggregation {
|
||||
// Frontend code needs to this to not be NULL, so we `clean_aggregations()`
|
||||
// in query_coco_fusion() to ensure this.
|
||||
pub buckets: Option<Vec<AggBucket>>,
|
||||
}
|
||||
|
||||
/// A bucket's fields contain more than just "doc_count" and "key", but we only
|
||||
/// need them. Serde can deserialize this as we don't `deny_unknown_fields`.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct AggBucket {
|
||||
doc_count: usize,
|
||||
key: String,
|
||||
/// Optional human label extracted from `top.hits.hits[0]._source.source.name`.
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AggBucket {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct Wrapper {
|
||||
doc_count: usize,
|
||||
key: String,
|
||||
#[serde(default)]
|
||||
top: Option<Top>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Top {
|
||||
hits: TopHits,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TopHits {
|
||||
hits: Vec<TopHit>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TopHit {
|
||||
#[serde(default)]
|
||||
_source: Option<TopSource>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TopSource {
|
||||
#[serde(default)]
|
||||
source: Option<SourceLabel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SourceLabel {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
let wrapper = Wrapper::deserialize(deserializer)?;
|
||||
|
||||
let label = wrapper
|
||||
.top
|
||||
.and_then(|top| top.hits.hits.into_iter().next())
|
||||
.and_then(|hit| hit._source)
|
||||
.and_then(|src| src.source)
|
||||
.and_then(|lbl| lbl.name);
|
||||
|
||||
Ok(AggBucket {
|
||||
doc_count: wrapper.doc_count,
|
||||
key: wrapper.key,
|
||||
label,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Coco server aggregation result.
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "type": {
|
||||
/// "buckets": [
|
||||
/// {
|
||||
/// "doc_count": 26,
|
||||
/// "key": "web_page"
|
||||
/// },
|
||||
/// {
|
||||
/// "doc_count": 1,
|
||||
/// "key": "pdf"
|
||||
/// }
|
||||
/// ]
|
||||
/// },
|
||||
/// "lang": {
|
||||
/// "buckets": [
|
||||
/// {
|
||||
/// "doc_count": 30,
|
||||
/// "key": "en"
|
||||
/// }
|
||||
/// ]
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub type Aggregations = HashMap<String, Aggregation>;
|
||||
|
||||
/// Merge the buckets in `from` to `to`.
|
||||
pub(crate) fn merge_aggregations(to: &mut Option<Aggregations>, from: Aggregations) {
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
if from.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match to {
|
||||
None => {
|
||||
*to = Some(from);
|
||||
}
|
||||
Some(to_map) => {
|
||||
for (agg_name, agg) in from {
|
||||
match to_map.entry(agg_name) {
|
||||
Entry::Occupied(mut occ) => {
|
||||
let to_agg = occ.get_mut();
|
||||
|
||||
if let Some(from_buckets) = agg.buckets {
|
||||
match &mut to_agg.buckets {
|
||||
Some(to_buckets) => {
|
||||
for bucket in from_buckets {
|
||||
if let Some(existing) = to_buckets
|
||||
.iter_mut()
|
||||
.find(|existing| existing.key == bucket.key)
|
||||
{
|
||||
existing.doc_count += bucket.doc_count;
|
||||
} else {
|
||||
to_buckets.push(bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
to_agg.buckets = Some(from_buckets);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(vacant) => {
|
||||
vacant.insert(agg);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct QueryResponse {
|
||||
pub source: QuerySource,
|
||||
pub hits: Vec<(Document, f64)>,
|
||||
pub total_hits: usize,
|
||||
pub aggregations: Option<Aggregations>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -134,4 +287,121 @@ pub struct MultiSourceQueryResponse {
|
||||
pub failed: Vec<FailedRequest>,
|
||||
pub hits: Vec<QueryHits>,
|
||||
pub total_hits: usize,
|
||||
pub aggregations: Option<Aggregations>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json;
|
||||
|
||||
/// Helper function to create an `AggBucket`, used in tests.
|
||||
fn bucket(key: &str, doc_count: usize) -> AggBucket {
|
||||
AggBucket {
|
||||
key: key.to_string(),
|
||||
doc_count,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to create an `Aggregation`, used in tests.
|
||||
fn agg_with_buckets(buckets: Vec<AggBucket>) -> Aggregation {
|
||||
Aggregation {
|
||||
buckets: Some(buckets),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to get `doc_count` from the bucket specified by `key`.
|
||||
/// Returns `None` when buckets are absent or the key is missing.
|
||||
fn get_doc_count(agg: &Aggregation, key: &str) -> Option<usize> {
|
||||
agg.buckets
|
||||
.as_ref()
|
||||
.and_then(|buckets| buckets.iter().find(|b| b.key == key))
|
||||
.map(|b| b.doc_count)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_into_none_initializes() {
|
||||
let mut to: Option<Aggregations> = None;
|
||||
let mut from = Aggregations::new();
|
||||
from.insert("terms".to_string(), agg_with_buckets(vec![bucket("a", 2)]));
|
||||
|
||||
merge_aggregations(&mut to, from);
|
||||
|
||||
let terms = to.unwrap().get("terms").cloned().unwrap();
|
||||
assert_eq!(get_doc_count(&terms, "a"), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_sums_and_appends_buckets() {
|
||||
let mut to_inner = Aggregations::new();
|
||||
to_inner.insert(
|
||||
"terms".to_string(),
|
||||
agg_with_buckets(vec![bucket("a", 1), bucket("b", 2)]),
|
||||
);
|
||||
let mut to = Some(to_inner);
|
||||
|
||||
let mut from = Aggregations::new();
|
||||
from.insert(
|
||||
"terms".to_string(),
|
||||
agg_with_buckets(vec![bucket("a", 3), bucket("c", 5)]),
|
||||
);
|
||||
from.insert(
|
||||
"lang".to_string(),
|
||||
agg_with_buckets(vec![bucket("zh", 3), bucket("en", 5)]),
|
||||
);
|
||||
|
||||
merge_aggregations(&mut to, from);
|
||||
|
||||
let terms = to.as_ref().unwrap().get("terms").unwrap();
|
||||
assert_eq!(get_doc_count(terms, "a"), Some(4));
|
||||
assert_eq!(get_doc_count(terms, "b"), Some(2));
|
||||
assert_eq!(get_doc_count(terms, "c"), Some(5));
|
||||
let lang = to.as_ref().unwrap().get("lang").unwrap();
|
||||
assert_eq!(get_doc_count(lang, "zh"), Some(3));
|
||||
assert_eq!(get_doc_count(lang, "en"), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_bucket_with_label() {
|
||||
let json = r#"
|
||||
{
|
||||
"doc_count": 251,
|
||||
"key": "d23ek9gqlqbcd9e3uiig",
|
||||
"top": {
|
||||
"hits": {
|
||||
"hits": [
|
||||
{
|
||||
"_source": {
|
||||
"source": {
|
||||
"name": "INFINI Easysearch"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let bucket: AggBucket = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(bucket.doc_count, 251);
|
||||
assert_eq!(bucket.key, "d23ek9gqlqbcd9e3uiig");
|
||||
assert_eq!(bucket.label.as_deref(), Some("INFINI Easysearch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_bucket_without_top_sets_label_none() {
|
||||
let json = r#"
|
||||
{
|
||||
"doc_count": 10,
|
||||
"key": "no-top"
|
||||
}
|
||||
"#;
|
||||
|
||||
let bucket: AggBucket = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(bucket.doc_count, 10);
|
||||
assert_eq!(bucket.key, "no-top");
|
||||
assert_eq!(bucket.label, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,6 +654,8 @@ impl SearchSource for ApplicationSearchSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -689,6 +691,8 @@ impl SearchSource for ApplicationSearchSource {
|
||||
source,
|
||||
hits,
|
||||
total_hits,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ impl SearchSource for ApplicationSearchSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,8 @@ impl SearchSource for CalculatorSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -143,6 +145,8 @@ impl SearchSource for CalculatorSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +160,8 @@ impl SearchSource for CalculatorSource {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
};
|
||||
};
|
||||
// If it is only a number, no need to evaluate it as the result is
|
||||
@@ -167,6 +173,8 @@ impl SearchSource for CalculatorSource {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,12 +207,16 @@ impl SearchSource for CalculatorSource {
|
||||
source: query_source,
|
||||
hits: vec![(doc, base_score)],
|
||||
total_hits: 1,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
}
|
||||
}
|
||||
Err(_) => QueryResponse {
|
||||
source: query_source,
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,6 +51,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
};
|
||||
let from = usize::try_from(query.from).expect("from too big");
|
||||
@@ -62,6 +64,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +81,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,6 +98,8 @@ impl SearchSource for FileSearchExtensionSearchSource {
|
||||
source: query_source,
|
||||
hits,
|
||||
total_hits,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ impl SearchSource for WindowManagementSearchSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregation
|
||||
aggregations: None,
|
||||
});
|
||||
};
|
||||
let from = usize::try_from(query.from).expect("from too big");
|
||||
@@ -49,6 +51,8 @@ impl SearchSource for WindowManagementSearchSource {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregation
|
||||
aggregations: None,
|
||||
});
|
||||
}
|
||||
let query_string_lowercase = query_string.to_lowercase();
|
||||
@@ -133,6 +137,8 @@ impl SearchSource for WindowManagementSearchSource {
|
||||
source: self.get_type(),
|
||||
hits: from_size_applied,
|
||||
total_hits,
|
||||
// Local search source does not support aggregation
|
||||
aggregations: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ pub struct Extension {
|
||||
/// It is only for third-party extensions. Built-in extensions should always
|
||||
/// set this field to `None`.
|
||||
#[serde(deserialize_with = "deserialize_coco_semver")]
|
||||
#[serde(default)] // None if this field is missing
|
||||
#[serde(serialize_with = "serialize_coco_semver")]
|
||||
// None if this field is missing, required as we use custom deserilize method.
|
||||
#[serde(default)]
|
||||
minimum_coco_version: Option<SemVer>,
|
||||
|
||||
/*
|
||||
@@ -216,112 +218,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> {
|
||||
@@ -419,6 +426,37 @@ where
|
||||
Ok(Some(semver))
|
||||
}
|
||||
|
||||
/// Serialize Coco SemVer to a string.
|
||||
///
|
||||
/// For a `SemVer`, there are 2 possible input cases, guarded by `to_semver()`:
|
||||
///
|
||||
/// 1. "x.y.z" => "x.y.z"
|
||||
/// 2. "x.y.z-SNAPSHOT.2560" => "x.y.z-SNAPSHOT-2560"
|
||||
fn serialize_coco_semver<S>(version: &Option<SemVer>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match version {
|
||||
Some(v) => {
|
||||
assert!(v.build.is_empty());
|
||||
|
||||
let s = if v.pre.is_empty() {
|
||||
format!("{}.{}.{}", v.major, v.minor, v.patch)
|
||||
} else {
|
||||
format!(
|
||||
"{}.{}.{}-{}",
|
||||
v.major,
|
||||
v.minor,
|
||||
v.patch,
|
||||
v.pre.as_str().replace('.', "-")
|
||||
)
|
||||
};
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub(crate) struct CommandAction {
|
||||
pub(crate) exec: String,
|
||||
@@ -673,7 +711,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
|
||||
@@ -693,19 +754,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
|
||||
@@ -721,8 +769,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)
|
||||
@@ -779,7 +842,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| {
|
||||
@@ -787,11 +851,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
|
||||
@@ -2150,4 +2223,31 @@ mod tests {
|
||||
let deserialized: FileSystemAccess = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_coco_semver_none() {
|
||||
let version: Option<SemVer> = None;
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_coco_semver(&version, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(serialized, "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_coco_semver_simple() {
|
||||
let version: Option<SemVer> = Some(SemVer::parse("1.2.3").unwrap());
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_coco_semver(&version, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(serialized, "\"1.2.3\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_coco_semver_with_pre() {
|
||||
let version: Option<SemVer> = Some(SemVer::parse("1.2.3-SNAPSHOT.1234").unwrap());
|
||||
let mut serializer = serde_json::Serializer::new(Vec::new());
|
||||
serialize_coco_semver(&version, &mut serializer).unwrap();
|
||||
let serialized = String::from_utf8(serializer.into_inner()).unwrap();
|
||||
assert_eq!(serialized, "\"1.2.3-SNAPSHOT-1234\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ impl SearchSource for ExtensionStore {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -94,12 +96,16 @@ impl SearchSource for ExtensionStore {
|
||||
source: self.get_type(),
|
||||
hits: vec![(doc, SCORE)],
|
||||
total_hits: 1,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
})
|
||||
} else {
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
257
src-tauri/src/extension/third_party/mod.rs
vendored
257
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1005,6 +1110,8 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
// Local search source does not support aggregations
|
||||
aggregations: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1029,9 +1136,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 +1157,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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::common::error::{ReportErrorStyle, SearchError, report_error};
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
Aggregations, FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
merge_aggregations,
|
||||
};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
@@ -17,6 +18,34 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
/// Helper function to drop empty aggregations and normalize `Option` state.
|
||||
fn clean_aggregations(aggs: &mut Option<Aggregations>) {
|
||||
if let Some(map) = aggs {
|
||||
map.retain(|_, agg| match &agg.buckets {
|
||||
Some(buckets) => !buckets.is_empty(),
|
||||
None => false,
|
||||
});
|
||||
|
||||
if map.is_empty() {
|
||||
*aggs = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +55,12 @@ pub async fn query_coco_fusion(
|
||||
query_strings: HashMap<String, String>,
|
||||
query_timeout: u64,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
println!("DBG: querystrings {:?}", query_strings);
|
||||
|
||||
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;
|
||||
@@ -42,7 +77,7 @@ pub async fn query_coco_fusion(
|
||||
);
|
||||
|
||||
// Dispatch to different `query_coco_fusion_xxx()` functions.
|
||||
if let Some(query_source_id) = opt_query_source_id {
|
||||
let mut res_response = if let Some(query_source_id) = opt_query_source_id {
|
||||
query_coco_fusion_single_query_source(
|
||||
tauri_app_handle,
|
||||
query_source_list,
|
||||
@@ -59,7 +94,13 @@ pub async fn query_coco_fusion(
|
||||
search_query,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
if let Ok(ref mut response) = res_response {
|
||||
clean_aggregations(&mut response.aggregations);
|
||||
}
|
||||
|
||||
res_response
|
||||
}
|
||||
|
||||
/// Query only 1 query source.
|
||||
@@ -103,6 +144,7 @@ async fn query_coco_fusion_single_query_source(
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
aggregations: None,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -114,6 +156,7 @@ async fn query_coco_fusion_single_query_source(
|
||||
let mut failed_requests: Vec<FailedRequest> = Vec::new();
|
||||
let mut hits = Vec::new();
|
||||
let mut total_hits = 0;
|
||||
let mut aggregations = None;
|
||||
|
||||
match timeout_result {
|
||||
// Ignore the `_timeout` variable as it won't provide any useful debugging information.
|
||||
@@ -126,6 +169,7 @@ async fn query_coco_fusion_single_query_source(
|
||||
Ok(query_result) => match query_result {
|
||||
Ok(response) => {
|
||||
total_hits = response.total_hits;
|
||||
aggregations = response.aggregations;
|
||||
|
||||
for (document, score) in response.hits {
|
||||
log::debug!(
|
||||
@@ -161,6 +205,7 @@ async fn query_coco_fusion_single_query_source(
|
||||
failed: failed_requests,
|
||||
hits,
|
||||
total_hits,
|
||||
aggregations,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,6 +254,7 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
let mut total_hits = 0;
|
||||
let mut failed_requests = Vec::new();
|
||||
let mut all_hits_grouped_by_query_source: HashMap<QuerySource, Vec<QueryHits>> = HashMap::new();
|
||||
let mut aggregations = None;
|
||||
|
||||
while let Some((query_source, timeout_result)) = futures.next().await {
|
||||
match timeout_result {
|
||||
@@ -222,6 +268,9 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
Ok(query_result) => match query_result {
|
||||
Ok(response) => {
|
||||
total_hits += response.total_hits;
|
||||
if let Some(from) = response.aggregations {
|
||||
merge_aggregations(&mut aggregations, from);
|
||||
}
|
||||
|
||||
for (document, score) in response.hits {
|
||||
log::debug!(
|
||||
@@ -264,6 +313,7 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
failed: Vec::new(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
aggregations: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -405,6 +455,7 @@ async fn query_coco_fusion_multi_query_sources(
|
||||
failed: failed_requests,
|
||||
hits: final_hits,
|
||||
total_hits,
|
||||
aggregations,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,31 @@ pub struct CocoSearchSource {
|
||||
server: Server,
|
||||
}
|
||||
|
||||
/// Convert frontend query string key/value into coco server query param.
|
||||
/// Returns `None` when the key is not recognized.
|
||||
fn convert_query_string(key: &str, value: &str) -> Option<String> {
|
||||
match key {
|
||||
// existing single-value params
|
||||
"querysource" | "datasource" | "query" | "fuzziness" => Some(format!("{}={}", key, value)),
|
||||
|
||||
// time range filters (single value)
|
||||
"update_time_start" => Some(format!("filter=updated>={}", value)),
|
||||
"update_time_end" => Some(format!("filter=updated<={}", value)),
|
||||
"create_time_start" => Some(format!("filter=created>={}", value)),
|
||||
"create_time_end" => Some(format!("filter=created<={}", value)),
|
||||
|
||||
// multi-value filters (value string may already contain any(...))
|
||||
"type" => Some(format!("filter=type:{}", value)),
|
||||
"source.id" => Some(format!("filter=source.id:{}", value)),
|
||||
"category" => Some(format!("filter=category:{}", value)),
|
||||
"subcategory" => Some(format!("filter=subcategory:{}", value)),
|
||||
"lang" => Some(format!("filter=lang:{}", value)),
|
||||
"tag" => Some(format!("filter=tag:{}", value)),
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl CocoSearchSource {
|
||||
pub fn new(server: Server) -> Self {
|
||||
CocoSearchSource { server }
|
||||
@@ -99,6 +124,7 @@ impl SearchSource for CocoSearchSource {
|
||||
let url = "/query/_search";
|
||||
let mut total_hits = 0;
|
||||
let mut hits: Vec<(Document, f64)> = Vec::new();
|
||||
let mut aggregations = None;
|
||||
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
@@ -108,12 +134,55 @@ impl SearchSource for CocoSearchSource {
|
||||
|
||||
// Add query strings
|
||||
for (key, value) in query.query_strings {
|
||||
query_params.push(format!("{}={}", key, value));
|
||||
if let Some(param) = convert_query_string(&key, &value) {
|
||||
query_params.push(param);
|
||||
}
|
||||
}
|
||||
println!("DBG: query params\n{:?}", query_params);
|
||||
|
||||
let response = HttpClient::get(&self.server.id, &url, Some(query_params))
|
||||
.await
|
||||
.context(HttpSnafu)?;
|
||||
let request_body = r#"
|
||||
{
|
||||
"aggs": {
|
||||
"category": {
|
||||
"terms": {
|
||||
"field": "category"
|
||||
}
|
||||
},
|
||||
"lang": {
|
||||
"terms": {
|
||||
"field": "lang"
|
||||
}
|
||||
},
|
||||
"source.id": {
|
||||
"terms": {
|
||||
"field": "source.id"
|
||||
},
|
||||
"aggs": {
|
||||
"top": {
|
||||
"top_hits": {
|
||||
"size": 1,
|
||||
"_source": [
|
||||
"source.name"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"terms": {
|
||||
"field": "type"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let response = HttpClient::post(
|
||||
&self.server.id,
|
||||
url,
|
||||
Some(query_params),
|
||||
Some(request_body.into()),
|
||||
)
|
||||
.await
|
||||
.context(HttpSnafu)?;
|
||||
let status_code = response.status();
|
||||
|
||||
if ![StatusCode::OK, StatusCode::CREATED].contains(&status_code) {
|
||||
@@ -156,6 +225,8 @@ impl SearchSource for CocoSearchSource {
|
||||
hits.push((document, score));
|
||||
}
|
||||
}
|
||||
|
||||
aggregations = parsed.aggregations;
|
||||
}
|
||||
|
||||
// Return the final result
|
||||
@@ -163,6 +234,7 @@ impl SearchSource for CocoSearchSource {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
aggregations,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { User, LogOut } from "lucide-react";
|
||||
|
||||
import { UserProfile as UserInfo } from "@/types/server";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface UserProfileProps {
|
||||
server: string; //server's id
|
||||
@@ -38,12 +39,14 @@ export function UserProfile({ server, userInfo, onLogout }: UserProfileProps) {
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{userInfo?.name || "-"}
|
||||
</span>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
className="flex items-center p-1 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
className="size-7 text-red-500!"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
<LogOut className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{userInfo?.email || "-"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import clsx from "clsx";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { ChatMessage } from "../ChatMessage";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface AiSummaryProps {
|
||||
message: string;
|
||||
@@ -33,20 +34,22 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
|
||||
<div className={clsx({ "p-2": visible })}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-2 relative max-h-[210px] px-4 py-3 rounded-[4px] text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-[#141414] shadow-[0_4px_8px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_20px_rgba(255,255,255,0.2)]",
|
||||
"flex flex-col gap-2 relative max-h-[210px] px-4 py-3 rounded text-[#333] dark:text-[#D8D8D8] bg-white dark:bg-[#141414] shadow-[0_4px_8px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_20px_rgba(255,255,255,0.2)]",
|
||||
{
|
||||
hidden: !visible,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 right-2 flex items-center justify-center size-[20px] border rounded-[6px] cursor-pointer dark:border-[#282828]"
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="absolute top-2 right-2 size-5"
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
|
||||
<div className="flex item-center gap-1">
|
||||
<Sparkles className="size-4 text-[#881c94]" />
|
||||
@@ -77,7 +80,7 @@ const AiOverview: FC<AiSummaryProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx("min-h-[20px]", {
|
||||
className={clsx("min-h-5", {
|
||||
hidden: isTyping,
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
@@ -244,7 +250,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
||||
"info"
|
||||
);
|
||||
} catch (error) {
|
||||
installExtensionError(String(error));
|
||||
installExtensionError(error);
|
||||
} finally {
|
||||
const { installingExtensions } = useSearchStore.getState();
|
||||
|
||||
@@ -295,6 +301,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
||||
<ExtensionDetail
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUnInstall}
|
||||
changeInput={changeInput}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -306,7 +313,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
||||
<div
|
||||
key={id}
|
||||
className={clsx(
|
||||
"flex justify-between gap-4 h-[40px] px-2 rounded-lg cursor-pointer text-[#333] dark:text-[#d8d8d8] transition",
|
||||
"flex justify-between gap-4 h-10 px-2 rounded-lg cursor-pointer text-[#333] dark:text-[#d8d8d8] transition",
|
||||
{
|
||||
"bg-black/10 dark:bg-white/15":
|
||||
selectedExtension?.id === id,
|
||||
@@ -325,7 +332,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<img src={icon} className="size-[20px]" />
|
||||
<img src={icon} className="size-5" />
|
||||
<span className="whitespace-nowrap">{name}</span>
|
||||
<span className="truncate text-[#999]">{description}</span>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Brain, Sparkles } from "lucide-react";
|
||||
import { Brain, RotateCcw, ScanSearch, Sparkles } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -12,11 +12,13 @@ import { useConnectStore } from "@/stores/connectStore";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { DEFAULT_FUZZINESS, useSearchStore } from "@/stores/searchStore";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import { parseSearchQuery, SearchQuery } from "@/utils";
|
||||
import InputUpload from "./InputUpload";
|
||||
import Copyright from "../Common/Copyright";
|
||||
import TimeFilter from "./TimeFilter";
|
||||
import { Slider } from "../ui/slider";
|
||||
|
||||
interface InputControlsProps {
|
||||
isChatMode: boolean;
|
||||
@@ -161,7 +163,13 @@ const InputControls = ({
|
||||
return state.aiOverviewAssistant;
|
||||
});
|
||||
const aiOverviewShortcut = useShortcutsStore((state) => state.aiOverview);
|
||||
const { visibleExtensionStore } = useSearchStore();
|
||||
const {
|
||||
visibleExtensionStore,
|
||||
enabledFuzzyMatch,
|
||||
setEnabledFuzzyMatch,
|
||||
fuzziness,
|
||||
setFuzziness,
|
||||
} = useSearchStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -187,9 +195,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}
|
||||
@@ -242,7 +250,10 @@ const InputControls = ({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div data-tauri-drag-region className="w-28 flex gap-2 relative">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="w-28 flex gap-2 items-center relative"
|
||||
>
|
||||
{!disabledExtensions.includes("AIOverview") &&
|
||||
isTauri &&
|
||||
aiOverviewServer &&
|
||||
@@ -250,7 +261,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]"
|
||||
@@ -274,12 +285,74 @@ const InputControls = ({
|
||||
</VisibleKey>
|
||||
|
||||
<span
|
||||
className={clsx("text-xs", { hidden: !enabledAiOverview })}
|
||||
className={clsx("text-xs truncate", {
|
||||
hidden: !enabledAiOverview,
|
||||
})}
|
||||
>
|
||||
AI Overview
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* app search filter */}
|
||||
{isTauri && (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 h-5 px-1 rounded-full hover:text-[#881c94]! cursor-pointer transition",
|
||||
[
|
||||
enabledFuzzyMatch
|
||||
? "text-[#881c94]"
|
||||
: "text-[#333] dark:text-[#d8d8d8]",
|
||||
],
|
||||
{
|
||||
"bg-[#881C94]/20 dark:bg-[#202126]": enabledFuzzyMatch,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
setEnabledFuzzyMatch(!enabledFuzzyMatch);
|
||||
}}
|
||||
>
|
||||
<ScanSearch className="size-3" />
|
||||
|
||||
{enabledFuzzyMatch && (
|
||||
<>
|
||||
<span className={clsx("text-xs truncate")}>
|
||||
{t("search.fuzziness.fuzzyMatch")}
|
||||
</span>
|
||||
|
||||
<Slider
|
||||
value={[fuzziness]}
|
||||
max={5}
|
||||
className="w-20"
|
||||
classNames={{
|
||||
range: "bg-[#881C94]",
|
||||
thumb:
|
||||
"border-[#881C94] focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
setFuzziness(value[0]);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
|
||||
<RotateCcw
|
||||
className="size-3"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
setFuzziness(DEFAULT_FUZZINESS);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TimeFilter />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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-[40px] 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-[40px] 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
207
src/components/Search/TimeFilter.tsx
Normal file
207
src/components/Search/TimeFilter.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, Fragment, useMemo } from "react";
|
||||
import { ListFilter, ChevronRight, BrushCleaning } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import MultiSelect from "../ui/multi-select";
|
||||
import DatePickerRange from "../ui/date-picker-range";
|
||||
import { camelCase, upperFirst } from "lodash-es";
|
||||
import dayjs from "dayjs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const TimeFilter = () => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const {
|
||||
filterDateRange,
|
||||
setFilterDateRange,
|
||||
aggregateFilter,
|
||||
setAggregateFilter,
|
||||
aggregations,
|
||||
} = useSearchStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dropdownMenuItems = [
|
||||
{
|
||||
key: "all-time",
|
||||
label: t("search.filers.allTime"),
|
||||
value: void 0,
|
||||
},
|
||||
{
|
||||
key: "7-day",
|
||||
label: t("search.filers.past7Days"),
|
||||
value: {
|
||||
from: dayjs().subtract(7, "day").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "90-day",
|
||||
label: t("search.filers.past90Days"),
|
||||
value: {
|
||||
from: dayjs().subtract(90, "day").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "1-year",
|
||||
label: t("search.filers.past1year"),
|
||||
value: {
|
||||
from: dayjs().subtract(1, "year").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "more",
|
||||
label: t("search.filers.more"),
|
||||
onClick: () => {
|
||||
setPopoverOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
|
||||
if (filterDateRange) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if (aggregateFilter) {
|
||||
for (const item of Object.values(aggregateFilter)) {
|
||||
if (item.length === 0) continue;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}, [filterDateRange, aggregateFilter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-5 px-1 text-xs rounded-full hover:text-[#881c94]! cursor-pointer transition",
|
||||
{
|
||||
"bg-[#881C94]/20 dark:bg-[#202126] text-[#881c94]":
|
||||
filterCount > 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ListFilter className="size-3" />
|
||||
|
||||
{filterCount > 0 && (
|
||||
<>
|
||||
<div className="whitespace-nowrap">
|
||||
{t("search.filers.filters")}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center justify-center size-4 rounded-full text-white bg-[#881c94]">
|
||||
{filterCount}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
{dropdownMenuItems.map((item) => {
|
||||
const { key, label, value, onClick } = item;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
className="flex justify-between"
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
setFilterDateRange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
|
||||
{key === "more" && (
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div />
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-100 p-4 text-sm">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-bold">{t("search.filers.filters")}</span>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-6"
|
||||
onClick={() => {
|
||||
setFilterDateRange(void 0);
|
||||
|
||||
setAggregateFilter(void 0);
|
||||
}}
|
||||
>
|
||||
<BrushCleaning className="size-3 text-[#6000FF]" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 pb-2 text-[#999]">
|
||||
{t("search.filers.dateRange")}
|
||||
</div>
|
||||
<DatePickerRange
|
||||
selected={filterDateRange}
|
||||
onSelect={setFilterDateRange}
|
||||
/>
|
||||
|
||||
{aggregations &&
|
||||
Object.entries(aggregations).map(([key, value]) => {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<div className="pt-4 pb-2 text-[#999]">
|
||||
{upperFirst(camelCase(key))}
|
||||
</div>
|
||||
|
||||
<MultiSelect
|
||||
value={aggregateFilter?.[key] ?? []}
|
||||
placeholder={`Please select ${key}`}
|
||||
options={value.buckets.map((bucket) => ({
|
||||
label: bucket.label ?? bucket.key,
|
||||
value: bucket.key,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
setAggregateFilter({
|
||||
...aggregateFilter,
|
||||
[key]: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeFilter;
|
||||
@@ -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";
|
||||
|
||||
@@ -383,11 +386,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",
|
||||
[
|
||||
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,
|
||||
@@ -438,7 +441,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"
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,8 +21,15 @@ import SettingsInput from "@/components//Settings/SettingsInput";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import UpdateSettings from "./components/UpdateSettings";
|
||||
import SettingsToggle from "../SettingsToggle";
|
||||
// import SelectionSettings from "./components/Selection";
|
||||
// import { isMac } from "@/utils/platform";
|
||||
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,29 +176,27 @@ 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* {isMac && <SelectionSettings />} */}
|
||||
{isMac && <SelectionSettings />}
|
||||
|
||||
<Shortcuts />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -184,95 +190,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
212
src/components/ui/calendar.tsx
Normal file
212
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
style={{
|
||||
// @ts-ignore
|
||||
"--cell-size": "2rem",
|
||||
}}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("relative flex gap-4", defaultClassNames.months),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[var(--cell-size)] w-[var(--cell-size)] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[var(--cell-size)] w-[var(--cell-size)] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-[var(--cell-size)] w-full items-center justify-center px-[var(--cell-size)]",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-[var(--cell-size)] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-[var(--cell-size)] select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"bg-accent rounded-l-md",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-[var(--cell-size)] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[var(--cell-size)] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
25
src/components/ui/checkbox.tsx
Normal file
25
src/components/ui/checkbox.tsx
Normal 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 };
|
||||
43
src/components/ui/date-picker-range.tsx
Normal file
43
src/components/ui/date-picker-range.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { FC, memo } from "react";
|
||||
import { PropsRange } from "react-day-picker";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
||||
import { Calendar } from "./calendar";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import dayjs from "dayjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const DatePickerRange: FC<Partial<PropsRange>> = (props) => {
|
||||
const { selected } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="h-8 flex items-center justify-between px-2 border border-border rounded-lg">
|
||||
{selected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{dayjs(selected.from).format("YYYY-MM-DD")}</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<span>{dayjs(selected.to).format("YYYY-MM-DD")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{t("search.filers.selectDateRange")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent>
|
||||
<div>
|
||||
<Calendar mode="range" numberOfMonths={2} {...props} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DatePickerRange);
|
||||
108
src/components/ui/dialog.tsx
Normal file
108
src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
78
src/components/ui/dropdown-menu.tsx
Normal file
78
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user