🎉 创世提交
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Tauri + Solid + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, Solid and Typescript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
17
index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
||||
<title>Tauri + Solid + Typescript App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "spark-store",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.7",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.2.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-autoplay": "^8.5.2",
|
||||
"embla-carousel-solid": "^8.5.2",
|
||||
"lucide-solid": "^0.473.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^22.10.7",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-solid": "^2.11.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
6
public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5650
src-tauri/Cargo.lock
generated
Normal file
31
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "spark-store"
|
||||
version = "4.9.9"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "spark_store_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12.12", features = ["json"] }
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
||||
tokio-macros = { version = "2.5.0" }
|
||||
lazy_static = "1.5.0"
|
||||
futures = "0.3.31"
|
||||
pinyin = "0.10.0"
|
||||
tauri-plugin-clipboard-manager = "2.2.0"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
66
src-tauri/src/handlers/app.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::{handlers::server::get_json_server_url, models::app::{AppDetail, AppItem}, utils::{format_icon_url, UA}};
|
||||
|
||||
use super::category::get_all_apps;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_info(category: String, pkgname: String) -> Result<AppDetail, String> {
|
||||
use crate::models::app::AppDetail;
|
||||
use crate::utils::UA;
|
||||
|
||||
// 获取服务器URL
|
||||
let json_server_url = get_json_server_url();
|
||||
let url = format!("{}{}/{}/app.json", json_server_url, category, pkgname);
|
||||
|
||||
// 创建HTTP客户端并发送请求
|
||||
let response = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("User-Agent", UA)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("获取应用信息失败: {}", e))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("读取响应内容失败: {}", e))?;
|
||||
|
||||
// 直接解析JSON响应为AppDetail结构体
|
||||
let mut app_info: AppDetail = serde_json::from_str(&response)
|
||||
.map_err(|e| format!("解析应用信息JSON失败: {}", e))?;
|
||||
|
||||
// 设置图标URL和下载次数
|
||||
app_info.category = Some(category.clone());
|
||||
app_info.icon = Some(format_icon_url(&category.clone(), &app_info.pkgname));
|
||||
app_info.download_times = Some(get_download_times(category, pkgname)
|
||||
.await
|
||||
.map_err(|e| format!("获取下载次数失败: {}", e))?);
|
||||
|
||||
Ok(app_info)
|
||||
}
|
||||
|
||||
pub async fn get_download_times(category: String, pkgname: String) -> Result<i32, String> {
|
||||
let json_server_url = get_json_server_url();
|
||||
let url = format!("{}{}/{}/download-times.txt", json_server_url, category, pkgname);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", UA)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let times = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(times)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_all_apps(query: String) -> Result<Vec<AppItem>, String> {
|
||||
let all_apps = get_all_apps().await?;
|
||||
Ok(crate::utils::search::search_apps(&all_apps, &query))
|
||||
}
|
||||
100
src-tauri/src/handlers/category.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use crate::{models::category::Category, utils::format_icon_url};
|
||||
use crate::models::app::AppItem;
|
||||
use crate::utils::UA;
|
||||
use super::server::get_json_server_url;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use lazy_static::lazy_static;
|
||||
use futures::future::join_all;
|
||||
|
||||
lazy_static! {
|
||||
static ref APPS_CACHE: Mutex<HashMap<String, Vec<AppItem>>> = Mutex::new(HashMap::new());
|
||||
static ref CATEGORIES_CACHE: Mutex<Option<Vec<Category>>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_category_apps(category_id: String) -> Result<Vec<AppItem>, String> {
|
||||
// 尝试从缓存中获取数据
|
||||
if let Some(cached_apps) = APPS_CACHE.lock().unwrap().get(&category_id) {
|
||||
return Ok(cached_apps.clone());
|
||||
}
|
||||
|
||||
// 如果缓存中没有,从服务器获取数据
|
||||
let json_server_url = get_json_server_url();
|
||||
let url = format!("{}{}/applist.json", json_server_url, category_id);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", UA)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut apps: Vec<AppItem> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 为每个应用设置图标URL
|
||||
for app in &mut apps {
|
||||
app.icon = Some(format_icon_url(&category_id, &app.pkgname));
|
||||
app.category = Some(category_id.clone());
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
APPS_CACHE.lock().unwrap().insert(category_id.clone(), apps.clone());
|
||||
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_all_apps() -> Result<Vec<AppItem>, String> {
|
||||
let categories = get_all_categories().await?;
|
||||
let futures: Vec<_> = categories
|
||||
.iter()
|
||||
.map(|category| get_category_apps(category.id.clone()))
|
||||
.collect();
|
||||
|
||||
let results = join_all(futures).await;
|
||||
let mut all_apps = Vec::new();
|
||||
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(apps) => all_apps.extend(apps),
|
||||
Err(e) => eprintln!("Error fetching apps for category: {}", e)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_apps)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_all_categories() -> Result<Vec<Category>, String> {
|
||||
// 尝试从缓存中获取数据
|
||||
if let Some(cached_categories) = CATEGORIES_CACHE.lock().unwrap().as_ref() {
|
||||
return Ok(cached_categories.clone());
|
||||
}
|
||||
|
||||
// 如果缓存中没有,从服务器获取数据
|
||||
let json_server_url = get_json_server_url();
|
||||
let url = format!("{}category.json", json_server_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", UA)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let categories: Vec<Category> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 更新缓存
|
||||
*CATEGORIES_CACHE.lock().unwrap() = Some(categories.clone());
|
||||
|
||||
Ok(categories)
|
||||
}
|
||||
36
src-tauri/src/handlers/home.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::handlers::server::get_img_server_url;
|
||||
use crate::models::home::HomeLink;
|
||||
use crate::utils::UA;
|
||||
use super::server::get_json_server_url;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_home_links() -> Result<Vec<HomeLink>, String> {
|
||||
let json_server_url = get_json_server_url();
|
||||
let img_server_url = get_img_server_url();
|
||||
let url = format!("{}home/homelinks.json", json_server_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", UA)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 获取响应内容的文本形式
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 将文本解析为 HomeLink 向量
|
||||
let mut links: Vec<HomeLink> = serde_json::from_str(&response_text)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 为每个 HomeLink 的 img_url 添加图片服务器前缀
|
||||
for link in &mut links {
|
||||
link.img_url = format!("{}{}", img_server_url, link.img_url);
|
||||
}
|
||||
|
||||
Ok(links)
|
||||
}
|
||||
4
src-tauri/src/handlers/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod category;
|
||||
pub mod server;
|
||||
pub mod app;
|
||||
pub mod home;
|
||||
29
src-tauri/src/handlers/server.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
#[tauri::command]
|
||||
pub fn get_target_arch_to_store() -> String {
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
return "amd64-store".to_string();
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
{
|
||||
return "arm64-store".to_string();
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "loongarch64")]
|
||||
{
|
||||
return "loong64-store".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_json_server_url() -> String {
|
||||
let arch = get_target_arch_to_store();
|
||||
return format!("https://cdn.d.store.deepinos.org.cn/{}/", arch);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_img_server_url() -> String {
|
||||
let arch = get_target_arch_to_store();
|
||||
return format!("https://spk-json.spark-app.store/{}/", arch);
|
||||
}
|
||||
24
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
mod models;
|
||||
mod handlers;
|
||||
mod utils;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
handlers::category::get_all_categories,
|
||||
handlers::category::get_category_apps,
|
||||
handlers::category::get_all_apps,
|
||||
handlers::server::get_target_arch_to_store,
|
||||
handlers::server::get_json_server_url,
|
||||
handlers::server::get_img_server_url,
|
||||
handlers::app::get_app_info,
|
||||
handlers::app::search_all_apps,
|
||||
handlers::home::get_home_links,
|
||||
utils::get_user_agent,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
spark_store_lib::run()
|
||||
}
|
||||
51
src-tauri/src/models/app.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct AppItem {
|
||||
#[serde(rename = "More")]
|
||||
pub more: String,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "Pkgname")]
|
||||
pub pkgname: String,
|
||||
#[serde(rename = "Tags")]
|
||||
pub tags: Option<String>,
|
||||
#[serde(rename = "Update")]
|
||||
pub update: String,
|
||||
pub icon: Option<String>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct AppDetail {
|
||||
#[serde(rename = "More")]
|
||||
pub more: String,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "Pkgname")]
|
||||
pub pkgname: String,
|
||||
#[serde(rename = "Tags")]
|
||||
pub tags: String,
|
||||
#[serde(rename = "Update")]
|
||||
pub update: String,
|
||||
#[serde(rename = "Icon")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(rename = "Category")]
|
||||
pub category: Option<String>,
|
||||
#[serde(rename = "Version")]
|
||||
pub version: String,
|
||||
#[serde(rename = "Filename")]
|
||||
pub filename: String,
|
||||
#[serde(rename = "Torrent_address")]
|
||||
pub torrent_address: String,
|
||||
#[serde(rename = "Author")]
|
||||
pub author: String,
|
||||
#[serde(rename = "Contributor")]
|
||||
pub contributor: String,
|
||||
#[serde(rename = "Website")]
|
||||
pub website: String,
|
||||
#[serde(rename = "Size")]
|
||||
pub size: String,
|
||||
#[serde(rename = "DownloadTimes")]
|
||||
pub download_times: Option<i32>,
|
||||
}
|
||||
8
src-tauri/src/models/category.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize,Clone,Debug)]
|
||||
pub struct Category {
|
||||
pub id: String,
|
||||
pub icon: String,
|
||||
pub name_zh_cn: String,
|
||||
}
|
||||
12
src-tauri/src/models/home.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct HomeLink {
|
||||
pub name: String,
|
||||
pub more: String,
|
||||
#[serde(rename = "imgUrl")]
|
||||
pub img_url: String,
|
||||
#[serde(rename = "type")]
|
||||
pub link_type: String,
|
||||
pub url: String,
|
||||
}
|
||||
3
src-tauri/src/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod category;
|
||||
pub mod app;
|
||||
pub mod home;
|
||||
14
src-tauri/src/utils/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::handlers::server::get_img_server_url;
|
||||
|
||||
pub mod search;
|
||||
|
||||
pub const UA: &str = concat!("Spark-Store/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_user_agent() -> String {
|
||||
UA.into()
|
||||
}
|
||||
|
||||
pub fn format_icon_url(category: &str, pkgname: &str) -> String {
|
||||
format!("{}{}/{}/icon.png", get_img_server_url(), category, pkgname)
|
||||
}
|
||||
60
src-tauri/src/utils/search.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use pinyin::ToPinyin;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub fn match_text(text: &str, query: &str) -> bool {
|
||||
let text_lower = text.to_lowercase();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
// 直接匹配原文
|
||||
if text_lower.contains(&query_lower) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取文本的拼音并转换为小写
|
||||
let text_pinyin: String = text
|
||||
.to_pinyin()
|
||||
.filter_map(|p| p)
|
||||
.map(|p| p.plain().to_lowercase())
|
||||
.collect();
|
||||
|
||||
// 获取查询的拼音并转换为小写
|
||||
// 判断查询是否为纯英文
|
||||
let query_pinyin = if query.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
query.to_lowercase()
|
||||
} else {
|
||||
query
|
||||
.to_pinyin()
|
||||
.filter_map(|p| p)
|
||||
.map(|p| p.plain().to_lowercase())
|
||||
.collect()
|
||||
};
|
||||
|
||||
// 匹配拼音
|
||||
text_pinyin.contains(&query_pinyin)
|
||||
}
|
||||
|
||||
pub fn search_apps(apps: &[crate::models::app::AppItem], query: &str) -> Vec<crate::models::app::AppItem> {
|
||||
if query.is_empty() {
|
||||
return apps.to_vec();
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for app in apps {
|
||||
// 避免重复
|
||||
if seen.contains(&app.pkgname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 匹配名称、包名和描述
|
||||
if match_text(&app.name, query) ||
|
||||
match_text(&app.pkgname, query) ||
|
||||
match_text(&app.more, query) {
|
||||
results.push(app.clone());
|
||||
seen.insert(app.pkgname.clone());
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
35
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "spark-store",
|
||||
"version": "0.1.0",
|
||||
"identifier": "store.spark-app.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "yarn dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "yarn build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "spark-store",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
141
src/App.css
Normal file
@@ -0,0 +1,141 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--info: 204 94% 94%;
|
||||
--info-foreground: 199 89% 48%;
|
||||
|
||||
--success: 149 80% 90%;
|
||||
--success-foreground: 160 84% 39%;
|
||||
|
||||
--warning: 48 96% 89%;
|
||||
--warning-foreground: 25 95% 53%;
|
||||
|
||||
--error: 0 93% 94%;
|
||||
--error-foreground: 0 84% 60%;
|
||||
|
||||
--ring: 240 5.9% 10%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark,
|
||||
[data-kb-theme="dark"] {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--info: 204 94% 94%;
|
||||
--info-foreground: 199 89% 48%;
|
||||
|
||||
--success: 149 80% 90%;
|
||||
--success-foreground: 160 84% 39%;
|
||||
|
||||
--warning: 48 96% 89%;
|
||||
--warning-foreground: 25 95% 53%;
|
||||
|
||||
--error: 0 93% 94%;
|
||||
--error-foreground: 0 84% 60%;
|
||||
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.step {
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.step:before {
|
||||
@apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background;
|
||||
@apply ml-[-50px] mt-[-4px];
|
||||
content: counter(step);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
@apply px-4;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
border: 4px solid transparent;
|
||||
background-clip: content-box;
|
||||
@apply bg-accent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
43
src/App.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Component, ParentProps, createSignal } from "solid-js";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import "./App.css";
|
||||
import { SidebarProvider, useIsMobile, useSidebar } from "./components/ui/sidebar";
|
||||
import TitleBar from "./components/TitleBar";
|
||||
import { Toaster } from "./components/ui/toast";
|
||||
|
||||
const App: Component = (props: ParentProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [shouldRefresh, setShouldRefresh] = createSignal(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setShouldRefresh(prev => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="app-layout">
|
||||
<SidebarProvider>
|
||||
{/* 根据刷新状态重新渲染 Sidebar */}
|
||||
{shouldRefresh() ? (
|
||||
<Sidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
<div class="flex flex-col w-full">
|
||||
<Toaster />
|
||||
<TitleBar onRefresh={handleRefresh} isSidebarOpen={!isMobile() && useSidebar().open()} />
|
||||
{shouldRefresh() ? (
|
||||
<main class="main-content w-full h-[calc(100vh-48px)] mt-12 overflow-y-auto" id="main-content">
|
||||
{props.children}
|
||||
</main>
|
||||
) : (
|
||||
<main class="main-content w-full h-[calc(100vh-48px)] mt-12 overflow-y-auto" id="main-content">
|
||||
{props.children}
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
1
src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
41
src/components/AppCard/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
export interface AppCardProps {
|
||||
category: string;
|
||||
pkgname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const AppCard: Component<AppCardProps> = (props) => {
|
||||
return (
|
||||
<A
|
||||
href={`/app/${props.category}/${props.pkgname}`}
|
||||
class="block p-4 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-12 h-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
|
||||
{props.icon ? (
|
||||
<img src={props.icon} alt={props.name} class="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div class="text-2xl font-bold text-muted-foreground">
|
||||
{props.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-medium text-base truncate">{props.name}</h3>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</A>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppCard;
|
||||
52
src/components/AppList/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Component, For } from 'solid-js';
|
||||
import { AppItem } from '@/types/app';
|
||||
import AppCard from '../AppCard';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface AppListProps {
|
||||
apps: AppItem[];
|
||||
loading: boolean;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const AppList: Component<AppListProps> = (props) => {
|
||||
return (
|
||||
<div class="grid auto-rows-auto grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 pb-6">
|
||||
{props.loading ? (
|
||||
<For each={Array(8).fill(0)}>
|
||||
{() => (
|
||||
<div class="p-4 rounded-lg border border-border/40 bg-card">
|
||||
<div class="flex items-start gap-3">
|
||||
<Skeleton width={48} height={48} radius={8} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Skeleton height={20} width={96} />
|
||||
<Skeleton height={16} width={48} />
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<Skeleton height={16} width={100} class="mt-2" />
|
||||
<Skeleton height={16} width={100} class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
) : (
|
||||
<For each={props.apps}>
|
||||
{(app) => (
|
||||
<AppCard
|
||||
category={props.category || app.category || ''}
|
||||
pkgname={app.pkgname}
|
||||
name={app.name}
|
||||
description={app.more}
|
||||
icon={app.icon}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppList;
|
||||
53
src/components/HomeCarousel/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import BaseCarousel from "@/components/ui/base-carousel";
|
||||
import { HomeLink } from '@/types/home';
|
||||
|
||||
interface HomeCarouselProps {
|
||||
slides?: HomeLink[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const HomeCarousel: Component<HomeCarouselProps> = (props) => {
|
||||
const renderItem = (slide: NonNullable<HomeCarouselProps['slides']>[number]) => (
|
||||
<a
|
||||
href={slide.linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-center w-full h-[120px] relative bg-cover bg-center bg-no-repeat block"
|
||||
style={{"background-image": `url(${slide.imgUrl})`}}
|
||||
>
|
||||
{/* 添加一个半透明的遮罩层使文字更易读 */}
|
||||
<div class="absolute inset-0 bg-black/50" />
|
||||
<div class="relative z-10 p-4 flex flex-col justify-center h-full">
|
||||
<h3 class="text-xl font-semibold mb-2 break-words text-white">{slide.name}</h3>
|
||||
<p class="text-sm text-gray-200 break-words">{slide.more}</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
const renderSkeleton = () => (
|
||||
<div class="text-center w-full h-full relative">
|
||||
<div class="absolute inset-0">
|
||||
<Skeleton class="w-full h-full" />
|
||||
</div>
|
||||
<div class="relative z-10 p-4 flex flex-col justify-center h-full">
|
||||
<div class="mb-2">
|
||||
<Skeleton height={24} width={60} class="mx-auto" />
|
||||
</div>
|
||||
<Skeleton height={16} width={80} class="mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCarousel
|
||||
items={props.slides}
|
||||
loading={props.loading}
|
||||
renderItem={renderItem}
|
||||
renderSkeleton={renderSkeleton}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeCarousel;
|
||||
40
src/components/ScreenshotCarousel/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import BaseCarousel from "@/components/ui/base-carousel";
|
||||
|
||||
interface ScreenshotCarouselProps {
|
||||
screenshots?: {
|
||||
url: string;
|
||||
title: string;
|
||||
}[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const ScreenshotCarousel: Component<ScreenshotCarouselProps> = (props) => {
|
||||
const renderItem = (screenshot: NonNullable<ScreenshotCarouselProps['screenshots']>[0]) => (
|
||||
<div class="w-full h-full aspect-[16/9]">
|
||||
<img
|
||||
src={screenshot.url}
|
||||
alt={screenshot.title}
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSkeleton = () => (
|
||||
<div class="w-full h-full aspect-[16/9]">
|
||||
<Skeleton class="w-full h-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCarousel
|
||||
items={props.screenshots}
|
||||
loading={props.loading}
|
||||
renderItem={renderItem}
|
||||
renderSkeleton={renderSkeleton}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenshotCarousel;
|
||||
114
src/components/Sidebar/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Component, For } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail
|
||||
} from '../ui/sidebar';
|
||||
import "./style.css"
|
||||
import { Compass, Heart, RefreshCw, Settings, Waves } from 'lucide-solid';
|
||||
import { useCategoriesStore } from '@/features/categories/store';
|
||||
import { getIconComponent } from '@/lib/icon';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: '探索发现',
|
||||
url: '/',
|
||||
icon: () => <Compass size={20} />
|
||||
},
|
||||
{
|
||||
title: '我的收藏',
|
||||
url: '/',
|
||||
icon: () => <Heart size={20} />
|
||||
},
|
||||
{
|
||||
title: '下载列表',
|
||||
url: '/',
|
||||
icon: () => <Waves size={20} />
|
||||
}
|
||||
];
|
||||
|
||||
const footerItems = [
|
||||
{
|
||||
title: '检查更新',
|
||||
url: '/update',
|
||||
icon: () => <RefreshCw size={20} />
|
||||
},
|
||||
{
|
||||
title: '应用设置',
|
||||
url: '/settings',
|
||||
icon: () => <Settings size={20} />
|
||||
}
|
||||
];
|
||||
|
||||
const AppSidebar: Component = () => {
|
||||
const { categories } = useCategoriesStore();
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Spark Store</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<For each={menuItems}>
|
||||
{(item) => (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as={A} href={item.url}>
|
||||
{item.icon ? item.icon() : null}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</For>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<SidebarGroupLabel>应用分类</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<For each={categories()}>
|
||||
{(category) => {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as={A} href={`/categories/${category.id}`}>
|
||||
{getIconComponent(category.icon)({
|
||||
size: 20,
|
||||
iconNode: []
|
||||
})}
|
||||
<span>{category.name_zh_cn}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<SidebarGroupLabel>高级选项</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<For each={footerItems}>
|
||||
{(item) => (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as={A} href={item.url}>
|
||||
{item.icon ? item.icon() : null}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</For>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
28
src/components/Sidebar/style.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 85%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark,
|
||||
[data-kb-theme="dark"] {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
85
src/components/TitleBar/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Component, Show } from "solid-js";
|
||||
import { Maximize2, Minus, X, ArrowLeft, RotateCw, Search } from "lucide-solid";
|
||||
import { useTitleBarStore } from "./store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SidebarTrigger } from "../ui/sidebar";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { TextField, TextFieldInput } from "@/components/ui/text-field";
|
||||
|
||||
interface TitleBarProps {
|
||||
onRefresh?: () => void;
|
||||
isSidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
const TitleBar: Component<TitleBarProps> = (props) => {
|
||||
const { goBack, canGoBack, refresh } = useTitleBarStore();
|
||||
const navigate = useNavigate();
|
||||
let searchInput: HTMLInputElement | undefined;
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchInput?.value) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchInput.value)}`);
|
||||
searchInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`h-12 border-b flex items-center justify-between px-2 bg-background fixed top-0 right-0 z-50 ${
|
||||
props.isSidebarOpen ? "left-[var(--sidebar-width)]" : "left-0"
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarTrigger class="h-8 px-2" />
|
||||
<Show when={canGoBack()}>
|
||||
<Button variant="ghost" size="icon" class="h-8 px-2" onClick={goBack}>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 px-2"
|
||||
onClick={() => refresh(props.onRefresh)}
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
</Button>
|
||||
<TextField>
|
||||
<TextFieldInput
|
||||
ref={searchInput}
|
||||
placeholder="搜索应用..."
|
||||
class="h-8"
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
</TextField>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 px-2"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" class="h-8 px-2">
|
||||
<Minus size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" class="h-8 px-2">
|
||||
<Maximize2 size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" class="h-8 px-2">
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleBar;
|
||||
54
src/components/TitleBar/store.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useNavigate, useLocation, useBeforeLeave } from '@solidjs/router';
|
||||
|
||||
export const useTitleBarStore = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
let lastScrollPosition = 0;
|
||||
|
||||
const canGoBack = () => {
|
||||
return location.pathname.startsWith('/app');
|
||||
};
|
||||
|
||||
const saveScrollPosition = () => {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
lastScrollPosition = mainContent.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreScrollPosition = () => {
|
||||
if (lastScrollPosition) {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
mainContent.scrollTop = lastScrollPosition;
|
||||
lastScrollPosition = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
navigate(-1);
|
||||
// 增加延迟时间以确保页面完全加载
|
||||
setTimeout(restoreScrollPosition, 50);
|
||||
};
|
||||
|
||||
const refresh = (onRefresh?: () => void) => {
|
||||
if (onRefresh) {
|
||||
onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
// 使用useBeforeLeave在路由离开前保存滚动位置
|
||||
useBeforeLeave((_e) => {
|
||||
if(!location.pathname.startsWith('/app'))
|
||||
{
|
||||
saveScrollPosition();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
canGoBack,
|
||||
goBack,
|
||||
refresh
|
||||
};
|
||||
};
|
||||
95
src/components/ui/base-carousel.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Component, JSX, Show, createSignal } from 'solid-js';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious
|
||||
} from '@/components/ui/carousel';
|
||||
import Autoplay from "embla-carousel-autoplay"
|
||||
|
||||
interface BaseCarouselProps {
|
||||
loading?: boolean;
|
||||
renderItem: (item: any) => JSX.Element;
|
||||
renderSkeleton: () => JSX.Element;
|
||||
items?: any[];
|
||||
}
|
||||
|
||||
const BaseCarousel: Component<BaseCarouselProps> = (props) => {
|
||||
return (
|
||||
<div class="relative w-full group">
|
||||
<Carousel
|
||||
opts={{
|
||||
align: 'start',
|
||||
loop: true
|
||||
}}
|
||||
plugins={[
|
||||
Autoplay({
|
||||
delay: 2000
|
||||
})
|
||||
]}
|
||||
class="w-full"
|
||||
>
|
||||
<CarouselContent class="-ml-1">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
<>
|
||||
{Array(3).fill(0).map(() => (
|
||||
<CarouselItem class="pl-1 basis-full sm:basis-1/2 lg:basis-1/3">
|
||||
<Card>
|
||||
<CardContent class="flex items-center justify-center p-2 sm:p-4 lg:p-6">
|
||||
{props.renderSkeleton()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{props.items?.map((item) => {
|
||||
// 如果item包含url属性,说明是图片类型的轮播项
|
||||
if (item.url) {
|
||||
const [isLoaded, setIsLoaded] = createSignal(false);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (img.naturalWidth > 0) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
img.src = item.url;
|
||||
|
||||
return (
|
||||
<Show when={isLoaded() || (img.complete && img.naturalWidth > 0)}>
|
||||
<CarouselItem class="pl-1 basis-full sm:basis-1/2 lg:basis-1/3">
|
||||
<Card class='overflow-hidden'>
|
||||
<CardContent class="flex items-center justify-center p-0">
|
||||
{props.renderItem(item)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CarouselItem class="pl-1 basis-full sm:basis-1/2 lg:basis-1/3">
|
||||
<Card class='overflow-hidden'>
|
||||
<CardContent class="flex items-center justify-center p-0">
|
||||
{props.renderItem(item)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</Show>
|
||||
</CarouselContent>
|
||||
<CarouselPrevious class="left-3 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background" />
|
||||
<CarouselNext class="right-3 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background" />
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseCarousel;
|
||||
53
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import * as ButtonPrimitive from "@kobalte/core/button"
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3 text-xs",
|
||||
lg: "h-11 px-8",
|
||||
icon: "size-10"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type ButtonProps<T extends ValidComponent = "button"> = ButtonPrimitive.ButtonRootProps<T> &
|
||||
VariantProps<typeof buttonVariants> & { class?: string | undefined; children?: JSX.Element }
|
||||
|
||||
const Button = <T extends ValidComponent = "button">(
|
||||
props: PolymorphicProps<T, ButtonProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"])
|
||||
return (
|
||||
<ButtonPrimitive.Root
|
||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { ButtonProps }
|
||||
export { Button, buttonVariants }
|
||||
43
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Component, ComponentProps } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
class={cn("rounded-lg border bg-card text-card-foreground shadow-sm", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CardHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("flex flex-col space-y-1.5 p-6", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardTitle: Component<ComponentProps<"h3">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<h3 class={cn("text-lg font-semibold leading-none tracking-tight", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const CardDescription: Component<ComponentProps<"p">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <p class={cn("text-sm text-muted-foreground", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("p-6 pt-0", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("flex items-center p-6 pt-0", local.class)} {...others} />
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
263
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import type { Accessor, Component, ComponentProps, VoidProps } from "solid-js"
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
mergeProps,
|
||||
splitProps,
|
||||
useContext
|
||||
} from "solid-js"
|
||||
|
||||
import type { CreateEmblaCarouselType } from "embla-carousel-solid"
|
||||
import createEmblaCarousel from "embla-carousel-solid"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ButtonProps } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export type CarouselApi = CreateEmblaCarouselType[1]
|
||||
|
||||
type UseCarouselParameters = Parameters<typeof createEmblaCarousel>
|
||||
type CarouselOptions = NonNullable<UseCarouselParameters[0]>
|
||||
type CarouselPlugin = NonNullable<UseCarouselParameters[1]>
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: ReturnType<CarouselOptions>
|
||||
plugins?: ReturnType<CarouselPlugin>
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof createEmblaCarousel>[0]
|
||||
api: ReturnType<typeof createEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: Accessor<boolean>
|
||||
canScrollNext: Accessor<boolean>
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = createContext<Accessor<CarouselContextProps> | null>(null)
|
||||
|
||||
const useCarousel = () => {
|
||||
const context = useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context()
|
||||
}
|
||||
|
||||
const Carousel: Component<CarouselProps & ComponentProps<"div">> = (rawProps) => {
|
||||
const props = mergeProps<(CarouselProps & ComponentProps<"div">)[]>(
|
||||
{ orientation: "horizontal" },
|
||||
rawProps
|
||||
)
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"orientation",
|
||||
"opts",
|
||||
"setApi",
|
||||
"plugins",
|
||||
"class",
|
||||
"children"
|
||||
])
|
||||
|
||||
const [carouselRef, api] = createEmblaCarousel(
|
||||
() => ({
|
||||
...local.opts,
|
||||
axis: local.orientation === "horizontal" ? "x" : "y"
|
||||
}),
|
||||
() => (local.plugins === undefined ? [] : local.plugins)
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = createSignal(false)
|
||||
const [canScrollNext, setCanScrollNext] = createSignal(false)
|
||||
|
||||
const onSelect = (api: NonNullable<ReturnType<CarouselApi>>) => {
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}
|
||||
|
||||
const scrollPrev = () => {
|
||||
api()?.scrollPrev()
|
||||
}
|
||||
|
||||
const scrollNext = () => {
|
||||
api()?.scrollNext()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!api() || !local.setApi) {
|
||||
return
|
||||
}
|
||||
local.setApi(api)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!api()) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api()!)
|
||||
api()!.on("reInit", onSelect)
|
||||
api()!.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api()?.off("select", onSelect)
|
||||
}
|
||||
})
|
||||
|
||||
const value = createMemo(
|
||||
() =>
|
||||
({
|
||||
carouselRef,
|
||||
api,
|
||||
opts: local.opts,
|
||||
orientation: local.orientation || (local.opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext
|
||||
}) satisfies CarouselContextProps
|
||||
)
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider value={value}>
|
||||
<div
|
||||
onKeyDown={handleKeyDown}
|
||||
class={cn("relative", local.class)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const CarouselContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} class="overflow-hidden">
|
||||
<div
|
||||
class={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CarouselItem: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
class={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type CarouselButtonProps = VoidProps<ButtonProps>
|
||||
|
||||
const CarouselPrevious: Component<CarouselButtonProps> = (rawProps) => {
|
||||
const props = mergeProps<CarouselButtonProps[]>({ variant: "outline", size: "icon" }, rawProps)
|
||||
const [local, others] = splitProps(props, ["class", "variant", "size"])
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={local.variant}
|
||||
size={local.size}
|
||||
class={cn(
|
||||
"absolute size-8 touch-manipulation rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
local.class
|
||||
)}
|
||||
disabled={!canScrollPrev()}
|
||||
onClick={scrollPrev}
|
||||
{...others}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M5 12l14 0" />
|
||||
<path d="M5 12l6 6" />
|
||||
<path d="M5 12l6 -6" />
|
||||
</svg>
|
||||
<span class="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const CarouselNext: Component<CarouselButtonProps> = (rawProps) => {
|
||||
const props = mergeProps<CarouselButtonProps[]>({ variant: "outline", size: "icon" }, rawProps)
|
||||
const [local, others] = splitProps(props, ["class", "variant", "size"])
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={local.variant}
|
||||
size={local.size}
|
||||
class={cn(
|
||||
"absolute size-8 touch-manipulation rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
local.class
|
||||
)}
|
||||
disabled={!canScrollNext()}
|
||||
onClick={scrollNext}
|
||||
{...others}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M5 12l14 0" />
|
||||
<path d="M13 18l6 -6" />
|
||||
<path d="M13 6l6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }
|
||||
9
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@kobalte/core/collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.Trigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.Content
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
260
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import * as DropdownMenuPrimitive from "@kobalte/core/dropdown-menu"
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenu: Component<DropdownMenuPrimitive.DropdownMenuRootProps> = (props) => {
|
||||
return <DropdownMenuPrimitive.Root gutter={4} {...props} />
|
||||
}
|
||||
|
||||
type DropdownMenuContentProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuContentProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuContentProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuContentProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
class={cn(
|
||||
"z-50 min-w-32 origin-[var(--kb-menu-content-transform-origin)] animate-content-hide overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[expanded]:animate-content-show",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuItemProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuItemProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuItem = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuItemProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuItemProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
class={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuShortcut: Component<ComponentProps<"span">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <span class={cn("ml-auto text-xs tracking-widest opacity-60", props.class)} {...rest} />
|
||||
}
|
||||
|
||||
const DropdownMenuLabel: Component<ComponentProps<"div"> & { inset?: boolean }> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class", "inset"])
|
||||
return (
|
||||
<div
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", props.inset && "pl-8", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuSeparatorProps<T extends ValidComponent = "hr"> =
|
||||
DropdownMenuPrimitive.DropdownMenuSeparatorProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuSeparator = <T extends ValidComponent = "hr">(
|
||||
props: PolymorphicProps<T, DropdownMenuSeparatorProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuSeparatorProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
class={cn("-mx-1 my-1 h-px bg-muted", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuSubTriggerProps<T> & {
|
||||
class?: string | undefined
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DropdownMenuSubTrigger = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuSubTriggerProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuSubTriggerProps, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
class={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{props.children}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="ml-auto size-4"
|
||||
>
|
||||
<path d="M9 6l6 6l-6 6" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuSubContentProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuSubContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuSubContentProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuSubContentProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
class={cn(
|
||||
"z-50 min-w-32 origin-[var(--kb-menu-content-transform-origin)] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuCheckboxItemProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuCheckboxItemProps<T> & {
|
||||
class?: string | undefined
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DropdownMenuCheckboxItem = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuCheckboxItemProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuCheckboxItemProps, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
class={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span class="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M5 12l5 5l10 -10" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuGroupLabelProps<T extends ValidComponent = "span"> =
|
||||
DropdownMenuPrimitive.DropdownMenuGroupLabelProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuGroupLabel = <T extends ValidComponent = "span">(
|
||||
props: PolymorphicProps<T, DropdownMenuGroupLabelProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuGroupLabelProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.GroupLabel
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuRadioItemProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuRadioItemProps<T> & {
|
||||
class?: string | undefined
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DropdownMenuRadioItem = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuRadioItemProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuRadioItemProps, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
class={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span class="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-2 fill-current"
|
||||
>
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuGroupLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem
|
||||
}
|
||||
29
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as SeparatorPrimitive from "@kobalte/core/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SeparatorRootProps<T extends ValidComponent = "hr"> =
|
||||
SeparatorPrimitive.SeparatorRootProps<T> & { class?: string | undefined }
|
||||
|
||||
const Separator = <T extends ValidComponent = "hr">(
|
||||
props: PolymorphicProps<T, SeparatorRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SeparatorRootProps, ["class", "orientation"])
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
orientation={local.orientation ?? "horizontal"}
|
||||
class={cn(
|
||||
"shrink-0 bg-border",
|
||||
local.orientation === "vertical" ? "h-full w-px" : "h-px w-full",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
172
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import * as SheetPrimitive from "@kobalte/core/dialog"
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
const SheetClose = SheetPrimitive.CloseButton
|
||||
|
||||
const portalVariants = cva("fixed inset-0 z-50 flex", {
|
||||
variants: {
|
||||
position: {
|
||||
top: "items-start",
|
||||
bottom: "items-end",
|
||||
left: "justify-start",
|
||||
right: "justify-end"
|
||||
}
|
||||
},
|
||||
defaultVariants: { position: "right" }
|
||||
})
|
||||
|
||||
type PortalProps = SheetPrimitive.DialogPortalProps & VariantProps<typeof portalVariants>
|
||||
|
||||
const SheetPortal: Component<PortalProps> = (props) => {
|
||||
const [local, others] = splitProps(props, ["position", "children"])
|
||||
return (
|
||||
<SheetPrimitive.Portal {...others}>
|
||||
<div class={portalVariants({ position: local.position })}>{local.children}</div>
|
||||
</SheetPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogOverlayProps<T extends ValidComponent = "div"> = SheetPrimitive.DialogOverlayProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const SheetOverlay = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DialogOverlayProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as DialogOverlayProps, ["class"])
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
class={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[expanded=]:animate-in data-[closed=]:animate-out data-[closed=]:fade-out-0 data-[expanded=]:fade-in-0",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[closed=]:duration-300 data-[expanded=]:duration-500 data-[expanded=]:animate-in data-[closed=]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
position: {
|
||||
top: "inset-x-0 top-0 border-b data-[closed=]:slide-out-to-top data-[expanded=]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[closed=]:slide-out-to-bottom data-[expanded=]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[closed=]:slide-out-to-left data-[expanded]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[closed=]:slide-out-to-right data-[expanded=]:slide-in-from-right sm:max-w-sm"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
position: "right"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type DialogContentProps<T extends ValidComponent = "div"> = SheetPrimitive.DialogContentProps<T> &
|
||||
VariantProps<typeof sheetVariants> & { class?: string | undefined; children?: JSX.Element }
|
||||
|
||||
const SheetContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DialogContentProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as DialogContentProps, ["position", "class", "children"])
|
||||
return (
|
||||
<SheetPortal position={local.position}>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
class={cn(
|
||||
sheetVariants({ position: local.position }),
|
||||
local.class,
|
||||
"max-h-screen overflow-y-auto"
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
<SheetPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M18 6l-12 12" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="sr-only">Close</span>
|
||||
</SheetPrimitive.CloseButton>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const SheetHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div class={cn("flex flex-col space-y-2 text-center sm:text-left", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const SheetFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogTitleProps<T extends ValidComponent = "h2"> = SheetPrimitive.DialogTitleProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const SheetTitle = <T extends ValidComponent = "h2">(
|
||||
props: PolymorphicProps<T, DialogTitleProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as DialogTitleProps, ["class"])
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
class={cn("text-lg font-semibold text-foreground", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogDescriptionProps<T extends ValidComponent = "p"> =
|
||||
SheetPrimitive.DialogDescriptionProps<T> & { class?: string | undefined }
|
||||
|
||||
const SheetDescription = <T extends ValidComponent = "p">(
|
||||
props: PolymorphicProps<T, DialogDescriptionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as DialogDescriptionProps, ["class"])
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
class={cn("text-sm text-muted-foreground", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
}
|
||||
691
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
import type { Accessor, Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
Match,
|
||||
mergeProps,
|
||||
onCleanup,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
useContext
|
||||
} from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core"
|
||||
import { Polymorphic } from "@kobalte/core"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ButtonProps } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { TextField, TextFieldInput } from "@/components/ui/text-field"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "10rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "10rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContext = {
|
||||
state: Accessor<"expanded" | "collapsed">
|
||||
open: Accessor<boolean>
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: Accessor<boolean>
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: Accessor<boolean>
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContext | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a Sidebar.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export function useIsMobile(fallback = false) {
|
||||
const [isMobile, setIsMobile] = createSignal(fallback)
|
||||
|
||||
createEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
onCleanup(() => mql.removeEventListener("change", onChange))
|
||||
})
|
||||
|
||||
return isMobile
|
||||
}
|
||||
|
||||
type SidebarProviderProps = Omit<ComponentProps<"div">, "style"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
style?: JSX.CSSProperties
|
||||
}
|
||||
|
||||
const SidebarProvider: Component<SidebarProviderProps> = (rawProps) => {
|
||||
const props = mergeProps({ defaultOpen: true }, rawProps)
|
||||
const [local, others] = splitProps(props, [
|
||||
"defaultOpen",
|
||||
"open",
|
||||
"onOpenChange",
|
||||
"class",
|
||||
"style",
|
||||
"children"
|
||||
])
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = createSignal(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use open and onOpenChange for control from outside the component.
|
||||
const [_open, _setOpen] = createSignal(local.defaultOpen)
|
||||
const open = () => local.open ?? _open()
|
||||
const setOpen = (value: boolean | ((value: boolean) => boolean)) => {
|
||||
if (local.onOpenChange) {
|
||||
return local.onOpenChange?.(typeof value === "function" ? value(open()) : value)
|
||||
}
|
||||
_setOpen(value)
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open()}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
}
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = () => {
|
||||
return isMobile() ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
createEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => window.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = () => (open() ? "expanded" : "collapsed")
|
||||
|
||||
const contextValue = {
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
style={{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...local.style
|
||||
}}
|
||||
class={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full text-sidebar-foreground has-[[data-variant=inset]]:bg-sidebar",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarProps = ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}
|
||||
|
||||
const Sidebar: Component<SidebarProps> = (rawProps) => {
|
||||
const props = mergeProps<SidebarProps[]>(
|
||||
{
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
collapsible: "offcanvas"
|
||||
},
|
||||
rawProps
|
||||
)
|
||||
const [local, others] = splitProps(props, ["side", "variant", "collapsible", "class", "children"])
|
||||
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={local.collapsible === "none"}>
|
||||
<div
|
||||
class={cn(
|
||||
"test flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={isMobile()}>
|
||||
<Sheet open={openMobile()} onOpenChange={setOpenMobile} {...others}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
class="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
|
||||
}}
|
||||
position={local.side}
|
||||
>
|
||||
<div class="flex size-full flex-col">{local.children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Match>
|
||||
<Match when={!isMobile()}>
|
||||
<div
|
||||
class="group peer hidden md:block"
|
||||
data-state={state()}
|
||||
data-collapsible={state() === "collapsed" ? local.collapsible : ""}
|
||||
data-variant={local.variant}
|
||||
data-side={local.side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
class={cn(
|
||||
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
local.variant === "floating" || local.variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
class={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
local.side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
local.variant === "floating" || local.variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarTriggerProps<T extends ValidComponent = "button"> = ButtonProps<T> & {
|
||||
onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
const SidebarTrigger = <T extends ValidComponent = "button">(props: SidebarTriggerProps<T>) => {
|
||||
const [local, others] = splitProps(props as SidebarTriggerProps, ["class", "onClick"])
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn("size-7", local.class)}
|
||||
onClick={(event: MouseEvent) => {
|
||||
local.onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
<span class="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarRail: Component<ComponentProps<"button">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
class={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarInset: Component<ComponentProps<"main">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<main
|
||||
class={cn(
|
||||
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarInputProps<T extends ValidComponent = "input"> = ComponentProps<
|
||||
typeof TextFieldInput<T>
|
||||
>
|
||||
|
||||
const SidebarInput = <T extends ValidComponent = "input">(props: SidebarInputProps<T>) => {
|
||||
const [local, others] = splitProps(props as SidebarInputProps, ["class"])
|
||||
return (
|
||||
<TextField>
|
||||
<TextFieldInput
|
||||
data-sidebar="input"
|
||||
class={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div data-sidebar="header" class={cn("flex flex-col gap-2 p-2", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div data-sidebar="footer" class={cn("flex flex-col gap-2 p-2", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarSeparatorProps<T extends ValidComponent = "hr"> = ComponentProps<typeof Separator<T>>
|
||||
|
||||
const SidebarSeparator = <T extends ValidComponent = "hr">(props: SidebarSeparatorProps<T>) => {
|
||||
const [local, others] = splitProps(props as SidebarSeparatorProps, ["class"])
|
||||
return (
|
||||
<Separator
|
||||
data-sidebar="separator"
|
||||
class={cn("mx-2 w-auto bg-sidebar-border", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
data-sidebar="content"
|
||||
class={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarGroup: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
data-sidebar="group"
|
||||
class={cn("relative flex w-full min-w-0 flex-col p-2", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarGroupLabelProps<T extends ValidComponent = "div"> = ComponentProps<T>
|
||||
|
||||
const SidebarGroupLabel = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, SidebarGroupLabelProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SidebarGroupLabelProps, ["class"])
|
||||
|
||||
return (
|
||||
<Polymorphic<SidebarGroupLabelProps>
|
||||
as="div"
|
||||
data-sidebar="group-label"
|
||||
class={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarGroupActionProps<T extends ValidComponent = "button"> = ComponentProps<T>
|
||||
|
||||
const SidebarGroupAction = <T extends ValidComponent = "button">(
|
||||
props: PolymorphicProps<T, SidebarGroupActionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SidebarGroupActionProps, ["class"])
|
||||
return (
|
||||
<Polymorphic<SidebarGroupActionProps>
|
||||
as="button"
|
||||
data-sidebar="group-action"
|
||||
class={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarGroupContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div data-sidebar="group-content" class={cn("w-full text-sm", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const SidebarMenu: Component<ComponentProps<"ul">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<ul
|
||||
data-sidebar="menu"
|
||||
class={cn("flex w-full min-w-0 flex-col gap-1", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuItem: Component<ComponentProps<"li">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<li data-sidebar="menu-item" class={cn("group/menu-item relative", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]"
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type SidebarMenuButtonProps<T extends ValidComponent = "button"> = ComponentProps<T> &
|
||||
VariantProps<typeof sidebarMenuButtonVariants> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const SidebarMenuButton = <T extends ValidComponent = "button">(
|
||||
rawProps: PolymorphicProps<T, SidebarMenuButtonProps<T>>
|
||||
) => {
|
||||
const props = mergeProps({ isActive: false, variant: "default", size: "default" }, rawProps)
|
||||
const [local, others] = splitProps(props as SidebarMenuButtonProps, [
|
||||
"isActive",
|
||||
"tooltip",
|
||||
"variant",
|
||||
"size",
|
||||
"class"
|
||||
])
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Polymorphic<SidebarMenuButtonProps>
|
||||
as="button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={local.size}
|
||||
data-active={local.isActive}
|
||||
class={cn(
|
||||
sidebarMenuButtonVariants({ variant: local.variant, size: local.size }),
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={local.tooltip} fallback={button}>
|
||||
<Tooltip placement="right">
|
||||
<TooltipTrigger class="w-full">{button}</TooltipTrigger>
|
||||
<TooltipContent hidden={state() !== "collapsed" || isMobile()}>
|
||||
{local.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarMenuActionProps<T extends ValidComponent = "button"> = ComponentProps<T> & {
|
||||
showOnHover?: boolean
|
||||
}
|
||||
|
||||
const SidebarMenuAction = <T extends ValidComponent = "button">(
|
||||
rawProps: PolymorphicProps<T, SidebarMenuActionProps<T>>
|
||||
) => {
|
||||
const props = mergeProps({ showOnHover: false }, rawProps)
|
||||
const [local, others] = splitProps(props as SidebarMenuActionProps, ["class", "showOnHover"])
|
||||
|
||||
return (
|
||||
<Polymorphic<SidebarMenuActionProps>
|
||||
as="button"
|
||||
data-sidebar="menu-action"
|
||||
class={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuBadge: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
data-sidebar="menu-badge"
|
||||
class={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarMenuSkeletonProps = ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const SidebarMenuSkeleton: Component<SidebarMenuSkeletonProps> = (rawProps) => {
|
||||
const props = mergeProps({ showIcon: false }, rawProps)
|
||||
const [local, others] = splitProps(props, ["class", "showIcon"])
|
||||
|
||||
// Random width between 50 to 90%.
|
||||
const width = createMemo(() => `${Math.floor(Math.random() * 40) + 50}%`)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sidebar="menu-skeleton"
|
||||
class={cn("flex h-8 items-center gap-2 rounded-md px-2", local.class)}
|
||||
{...others}
|
||||
>
|
||||
{local.showIcon && <Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
<Skeleton
|
||||
class="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={{
|
||||
"--skeleton-width": width()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuSub: Component<ComponentProps<"ul">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<ul
|
||||
data-sidebar="menu-sub"
|
||||
class={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuSubItem: Component<ComponentProps<"li">> = (props) => <li {...props} />
|
||||
|
||||
type SidebarMenuSubButtonProps<T extends ValidComponent = "a"> = ComponentProps<T> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const SidebarMenuSubButton = <T extends ValidComponent = "a">(
|
||||
rawProps: PolymorphicProps<T, SidebarMenuSubButtonProps<T>>
|
||||
) => {
|
||||
const props = mergeProps({ size: "md" }, rawProps)
|
||||
const [local, others] = splitProps(props as SidebarMenuSubButtonProps, [
|
||||
"size",
|
||||
"isActive",
|
||||
"class"
|
||||
])
|
||||
|
||||
return (
|
||||
<Polymorphic<SidebarMenuSubButtonProps>
|
||||
as="a"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={local.size}
|
||||
data-active={local.isActive}
|
||||
class={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
local.size === "sm" && "text-xs",
|
||||
local.size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar
|
||||
}
|
||||
24
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as SkeletonPrimitive from "@kobalte/core/skeleton"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SkeletonRootProps<T extends ValidComponent = "div"> =
|
||||
SkeletonPrimitive.SkeletonRootProps<T> & { class?: string | undefined }
|
||||
|
||||
const Skeleton = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, SkeletonRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SkeletonRootProps, ["class"])
|
||||
return (
|
||||
<SkeletonPrimitive.Root
|
||||
class={cn("bg-primary/10 data-[animate='true']:animate-pulse", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
152
src/components/ui/text-field.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { mergeProps, splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core"
|
||||
import * as TextFieldPrimitive from "@kobalte/core/text-field"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type TextFieldRootProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldRootProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextField = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldRootProps, ["class"])
|
||||
return <TextFieldPrimitive.Root class={cn("flex flex-col gap-1", local.class)} {...others} />
|
||||
}
|
||||
|
||||
type TextFieldInputProps<T extends ValidComponent = "input"> =
|
||||
TextFieldPrimitive.TextFieldInputProps<T> & {
|
||||
class?: string | undefined
|
||||
type?:
|
||||
| "button"
|
||||
| "checkbox"
|
||||
| "color"
|
||||
| "date"
|
||||
| "datetime-local"
|
||||
| "email"
|
||||
| "file"
|
||||
| "hidden"
|
||||
| "image"
|
||||
| "month"
|
||||
| "number"
|
||||
| "password"
|
||||
| "radio"
|
||||
| "range"
|
||||
| "reset"
|
||||
| "search"
|
||||
| "submit"
|
||||
| "tel"
|
||||
| "text"
|
||||
| "time"
|
||||
| "url"
|
||||
| "week"
|
||||
}
|
||||
|
||||
const TextFieldInput = <T extends ValidComponent = "input">(
|
||||
rawProps: PolymorphicProps<T, TextFieldInputProps<T>>
|
||||
) => {
|
||||
const props = mergeProps<TextFieldInputProps<T>[]>({ type: "text" }, rawProps)
|
||||
const [local, others] = splitProps(props as TextFieldInputProps, ["type", "class"])
|
||||
return (
|
||||
<TextFieldPrimitive.Input
|
||||
type={local.type}
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[invalid]:border-error-foreground data-[invalid]:text-error-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TextFieldTextAreaProps<T extends ValidComponent = "textarea"> =
|
||||
TextFieldPrimitive.TextFieldTextAreaProps<T> & { class?: string | undefined }
|
||||
|
||||
const TextFieldTextArea = <T extends ValidComponent = "textarea">(
|
||||
props: PolymorphicProps<T, TextFieldTextAreaProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldTextAreaProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.TextArea
|
||||
class={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
label: "data-[invalid]:text-destructive",
|
||||
description: "font-normal text-muted-foreground",
|
||||
error: "text-xs text-destructive"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "label"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type TextFieldLabelProps<T extends ValidComponent = "label"> =
|
||||
TextFieldPrimitive.TextFieldLabelProps<T> & { class?: string | undefined }
|
||||
|
||||
const TextFieldLabel = <T extends ValidComponent = "label">(
|
||||
props: PolymorphicProps<T, TextFieldLabelProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldLabelProps, ["class"])
|
||||
return <TextFieldPrimitive.Label class={cn(labelVariants(), local.class)} {...others} />
|
||||
}
|
||||
|
||||
type TextFieldDescriptionProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldDescriptionProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextFieldDescription = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldDescriptionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldDescriptionProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.Description
|
||||
class={cn(labelVariants({ variant: "description" }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TextFieldErrorMessageProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldErrorMessageProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextFieldErrorMessage = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldErrorMessageProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldErrorMessageProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.ErrorMessage
|
||||
class={cn(labelVariants({ variant: "error" }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
TextField,
|
||||
TextFieldInput,
|
||||
TextFieldTextArea,
|
||||
TextFieldLabel,
|
||||
TextFieldDescription,
|
||||
TextFieldErrorMessage
|
||||
}
|
||||
163
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { JSX, ValidComponent } from "solid-js"
|
||||
import { Match, splitProps, Switch } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as ToastPrimitive from "@kobalte/core/toast"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--kb-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--kb-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[opened]:animate-in data-[closed]:animate-out data-[swipe=end]:animate-out data-[closed]:fade-out-80 data-[closed]:slide-out-to-right-full data-[opened]:slide-in-from-top-full data-[opened]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
success: "success border-success-foreground bg-success text-success-foreground",
|
||||
warning: "warning border-warning-foreground bg-warning text-warning-foreground",
|
||||
error: "error border-error-foreground bg-error text-error-foreground"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
type ToastVariant = NonNullable<VariantProps<typeof toastVariants>["variant"]>
|
||||
|
||||
type ToastListProps<T extends ValidComponent = "ol"> = ToastPrimitive.ToastListProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const Toaster = <T extends ValidComponent = "ol">(
|
||||
props: PolymorphicProps<T, ToastListProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ToastListProps, ["class"])
|
||||
return (
|
||||
<Portal>
|
||||
<ToastPrimitive.Region>
|
||||
<ToastPrimitive.List
|
||||
class={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
</ToastPrimitive.Region>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
type ToastRootProps<T extends ValidComponent = "li"> = ToastPrimitive.ToastRootProps<T> &
|
||||
VariantProps<typeof toastVariants> & { class?: string | undefined }
|
||||
|
||||
const Toast = <T extends ValidComponent = "li">(props: PolymorphicProps<T, ToastRootProps<T>>) => {
|
||||
const [local, others] = splitProps(props as ToastRootProps, ["class", "variant"])
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
class={cn(toastVariants({ variant: local.variant }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ToastCloseButtonProps<T extends ValidComponent = "button"> =
|
||||
ToastPrimitive.ToastCloseButtonProps<T> & { class?: string | undefined }
|
||||
|
||||
const ToastClose = <T extends ValidComponent = "button">(
|
||||
props: PolymorphicProps<T, ToastCloseButtonProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ToastCloseButtonProps, ["class"])
|
||||
return (
|
||||
<ToastPrimitive.CloseButton
|
||||
class={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-destructive-foreground group-[.error]:text-error-foreground group-[.success]:text-success-foreground group-[.warning]:text-warning-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M18 6l-12 12" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</ToastPrimitive.CloseButton>
|
||||
)
|
||||
}
|
||||
|
||||
type ToastTitleProps<T extends ValidComponent = "div"> = ToastPrimitive.ToastTitleProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const ToastTitle = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, ToastTitleProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ToastTitleProps, ["class"])
|
||||
return <ToastPrimitive.Title class={cn("text-sm font-semibold", local.class)} {...others} />
|
||||
}
|
||||
|
||||
type ToastDescriptionProps<T extends ValidComponent = "div"> =
|
||||
ToastPrimitive.ToastDescriptionProps<T> & { class?: string | undefined }
|
||||
|
||||
const ToastDescription = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, ToastDescriptionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ToastDescriptionProps, ["class"])
|
||||
return <ToastPrimitive.Description class={cn("text-sm opacity-90", local.class)} {...others} />
|
||||
}
|
||||
|
||||
function showToast(props: {
|
||||
title?: JSX.Element
|
||||
description?: JSX.Element
|
||||
variant?: ToastVariant
|
||||
duration?: number
|
||||
}) {
|
||||
ToastPrimitive.toaster.show((data) => (
|
||||
<Toast toastId={data.toastId} variant={props.variant} duration={props.duration}>
|
||||
<div class="grid gap-1">
|
||||
{props.title && <ToastTitle>{props.title}</ToastTitle>}
|
||||
{props.description && <ToastDescription>{props.description}</ToastDescription>}
|
||||
</div>
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))
|
||||
}
|
||||
|
||||
function showToastPromise<T, U>(
|
||||
promise: Promise<T> | (() => Promise<T>),
|
||||
options: {
|
||||
loading?: JSX.Element
|
||||
success?: (data: T) => JSX.Element
|
||||
error?: (error: U) => JSX.Element
|
||||
duration?: number
|
||||
}
|
||||
) {
|
||||
const variant: { [key in ToastPrimitive.ToastPromiseState]: ToastVariant } = {
|
||||
pending: "default",
|
||||
fulfilled: "success",
|
||||
rejected: "error"
|
||||
}
|
||||
return ToastPrimitive.toaster.promise<T, U>(promise, (props) => (
|
||||
<Toast toastId={props.toastId} variant={variant[props.state]} duration={options.duration}>
|
||||
<Switch>
|
||||
<Match when={props.state === "pending"}>{options.loading}</Match>
|
||||
<Match when={props.state === "fulfilled"}>{options.success?.(props.data!)}</Match>
|
||||
<Match when={props.state === "rejected"}>{options.error?.(props.error!)}</Match>
|
||||
</Switch>
|
||||
</Toast>
|
||||
))
|
||||
}
|
||||
|
||||
export { Toaster, Toast, ToastClose, ToastTitle, ToastDescription, showToast, showToastPromise }
|
||||
35
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps, type Component } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as TooltipPrimitive from "@kobalte/core/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const Tooltip: Component<TooltipPrimitive.TooltipRootProps> = (props) => {
|
||||
return <TooltipPrimitive.Root gutter={4} {...props} />
|
||||
}
|
||||
|
||||
type TooltipContentProps<T extends ValidComponent = "div"> =
|
||||
TooltipPrimitive.TooltipContentProps<T> & { class?: string | undefined }
|
||||
|
||||
const TooltipContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TooltipContentProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TooltipContentProps, ["class"])
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
class={cn(
|
||||
"z-50 origin-[var(--kb-popover-content-transform-origin)] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent }
|
||||
108
src/features/app-detail/AppDetail.css
Normal file
@@ -0,0 +1,108 @@
|
||||
.app-detail {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-detail .bg-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-detail .bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
filter: blur(100px) brightness(1.2);
|
||||
}
|
||||
|
||||
.app-detail .info-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
position: relative;
|
||||
padding: 40px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.app-detail .bg-container:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
hsl(var(--background)) 0%,
|
||||
hsla(var(--background) / 0.6) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.app-detail .app-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.app-detail .app-name {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.app-detail .app-title-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-detail .divider {
|
||||
height: 18px;
|
||||
background-color: hsl(var(--border));
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.app-detail .social-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-detail .install-btn {
|
||||
width: 200px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.app-detail .description {
|
||||
display: block;
|
||||
max-height: 4.5em;
|
||||
line-height: 1.5em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: max-height 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.app-detail .trunk::before {
|
||||
content: '';
|
||||
float: right;
|
||||
width: 0px;
|
||||
height: 100%;
|
||||
margin-bottom: -20px;
|
||||
}
|
||||
|
||||
.app-detail .read-more-btn {
|
||||
float: right;
|
||||
clear: both;
|
||||
font-size: 14px;
|
||||
height: 20px;
|
||||
width: 40px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
152
src/features/app-detail/AppDetail.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { useAppDetailStore } from './store';
|
||||
import './AppDetail.css';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import ScreenshotCarousel from '@/components/ScreenshotCarousel';
|
||||
import { generateShareLinks, copy } from '@/lib/share';
|
||||
import { showToast } from "@/components/ui/toast"
|
||||
|
||||
const AppDetail: Component = () => {
|
||||
const params = useParams();
|
||||
const { app, loading } = useAppDetailStore(params.category, params.pkgname);
|
||||
|
||||
return (
|
||||
<div class="w-full h-full">
|
||||
{loading() ? (
|
||||
<div class="app-detail">
|
||||
<div class="info-container">
|
||||
<div class="app-title-container">
|
||||
<div class="flex-row-start gap-20px">
|
||||
<div class="app-icon bg-muted flex items-center justify-center overflow-hidden">
|
||||
<Skeleton width={64} height={64} radius={8} />
|
||||
</div>
|
||||
<div class="py-2" />
|
||||
<Skeleton height={32} width={200} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Skeleton height={40} width={96} radius={6} />
|
||||
<Skeleton height={40} width={96} radius={6} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="py-2">
|
||||
<Skeleton height={24} width={80} class="mb-2" />
|
||||
<Skeleton height={16} width={100} class="mt-2" />
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<Skeleton height={24} width={80} class="mb-2" />
|
||||
<div class="social-container text-sm text-muted-foreground">
|
||||
<Skeleton height={16} width={120} />
|
||||
<div class="divider" />
|
||||
<Skeleton height={16} width={120} />
|
||||
<div class="divider" />
|
||||
<Skeleton height={16} width={120} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="app-detail">
|
||||
<div class="bg-container">
|
||||
<div
|
||||
class="bg"
|
||||
style={{
|
||||
'background-image': `url(${app()?.Icon})`,
|
||||
'background-size': 'cover',
|
||||
'background-position': 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="info-container">
|
||||
<div class="app-title-container">
|
||||
<div class="flex-row-start gap-20px">
|
||||
<div class="app-icon bg-muted flex items-center justify-center overflow-hidden">
|
||||
{app()?.Icon ? (
|
||||
<img src={app()?.Icon} alt={app()?.Name} class="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div class="text-2xl font-bold text-muted-foreground">
|
||||
{app()?.Name?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="py-2" />
|
||||
<h2 class="app-name text-2xl font-bold pt-2">{app()?.Name}</h2>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button size="lg">安装</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="outline" size="lg">分享</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>分享应用</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={async () => {
|
||||
try {
|
||||
const links = await generateShareLinks(params.category, params.pkgname);
|
||||
await copy(links.spkLink);
|
||||
showToast({ description: 'SPK链接已复制到剪贴板', variant: "success" });
|
||||
} catch (error) {
|
||||
showToast({ description: '复制SPK链接失败', variant: "error" });
|
||||
}
|
||||
}}>复制SPK链接</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={async () => {
|
||||
try {
|
||||
const links = await generateShareLinks(params.category, params.pkgname);
|
||||
await copy(links.shareLink);
|
||||
showToast({ description: '分享链接已复制到剪贴板', variant: "success" });
|
||||
} catch (error) {
|
||||
showToast({ description: '复制分享链接失败', variant: "error" });
|
||||
}
|
||||
}}>复制分享链接</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={async () => {
|
||||
try {
|
||||
const links = await generateShareLinks(params.category, params.pkgname);
|
||||
await copy(links.shareIframe);
|
||||
showToast({ description: '嵌入代码已复制到剪贴板', variant: "success" });
|
||||
} catch (error) {
|
||||
showToast({ description: '复制嵌入代码失败', variant: "error" });
|
||||
}
|
||||
}}>复制嵌入代码</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 w-full">
|
||||
<div class="py-2">
|
||||
<h3 class="text-lg font-semibold">应用描述</h3>
|
||||
<p class="description text-muted-foreground">{app()?.More}</p>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<h3 class="text-lg font-semibold">应用信息</h3>
|
||||
<div class="social-container text-sm text-muted-foreground">
|
||||
<p>大小: {app()?.Size}</p>
|
||||
<div class="divider" />
|
||||
<p>分类: {app()?.Category}</p>
|
||||
<div class="divider" />
|
||||
<p>更新时间: {app()?.Update}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2 w-full">
|
||||
<h3 class="text-lg font-semibold">应用截图</h3>
|
||||
<ScreenshotCarousel
|
||||
screenshots={app()?.Screenshots?.map(url => ({
|
||||
url,
|
||||
title: app()?.Name || ''
|
||||
}))}
|
||||
loading={loading()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppDetail;
|
||||
35
src/features/app-detail/store.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getAppInfo } from '@/lib/api/app';
|
||||
import { getImgServerUrl } from '@/lib/api/server';
|
||||
import { AppDetail } from '@/types/app';
|
||||
import { createResource } from 'solid-js';
|
||||
|
||||
|
||||
const fetchAppDetail = async (category: string, pkgname: string): Promise<AppDetail> => {
|
||||
try {
|
||||
const appInfo = await getAppInfo(category, pkgname);
|
||||
|
||||
// 生成5张截图的链接
|
||||
const screenshots = Array.from({ length: 5 }, (_, index) =>
|
||||
`${getImgServerUrl()}/${appInfo.Category}/${appInfo.Pkgname}/screen_${index}.png`
|
||||
);
|
||||
|
||||
return {
|
||||
...appInfo,
|
||||
Screenshots: screenshots
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`获取应用详情失败: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const useAppDetailStore = (category: string, pkgname: string) => {
|
||||
const [app, { refetch }] = createResource<AppDetail>(() => fetchAppDetail(category, pkgname));
|
||||
const loading = () => app.loading;
|
||||
|
||||
return {
|
||||
app,
|
||||
loading,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
38
src/features/categories/Categories.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Component, createEffect, createSignal } from 'solid-js';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { fetchCategoryApps, useCategoriesStore } from './store';
|
||||
import AppList from '@/components/AppList';
|
||||
|
||||
const Categories: Component = () => {
|
||||
const params = useParams();
|
||||
const [categoryApps, setCategoryApps] = createSignal<any[]>([]);
|
||||
const [loadingApps, setLoadingApps] = createSignal(true);
|
||||
const { categories } = useCategoriesStore();
|
||||
|
||||
createEffect(async () => {
|
||||
if (params.id) {
|
||||
setLoadingApps(true);
|
||||
try {
|
||||
const apps = await fetchCategoryApps(params.id);
|
||||
setCategoryApps(apps);
|
||||
} finally {
|
||||
setLoadingApps(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 w-full h-full">
|
||||
<h1 class="text-2xl font-bold mb-6">
|
||||
{categories()?.find((category) => category.id === params.id)?.name_zh_cn}
|
||||
</h1>
|
||||
<AppList
|
||||
apps={categoryApps()}
|
||||
loading={loadingApps()}
|
||||
category={params.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Categories;
|
||||
23
src/features/categories/store.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createResource } from 'solid-js';
|
||||
import { getAllCategories, getCategoryApps } from '@/lib/api/category';
|
||||
import type { Category } from '@/types/category';
|
||||
import { AppItem } from '@/types/app';
|
||||
|
||||
const fetchCategories = async (): Promise<Category[]> => {
|
||||
return await getAllCategories();
|
||||
};
|
||||
|
||||
export const fetchCategoryApps = async (categoryId: string): Promise<AppItem[]> => {
|
||||
return await getCategoryApps(categoryId);
|
||||
};
|
||||
|
||||
export const useCategoriesStore = () => {
|
||||
const [categories, { refetch }] = createResource<Category[]>(fetchCategories);
|
||||
const loading = () => categories.loading;
|
||||
|
||||
return {
|
||||
categories,
|
||||
loading,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
18
src/features/home/Home.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { useHomeStore } from './store';
|
||||
import AppList from '@/components/AppList';
|
||||
import HomeCarousel from '@/components/HomeCarousel';
|
||||
|
||||
const Home: Component = () => {
|
||||
const { apps, loading, slides } = useHomeStore();
|
||||
|
||||
return (
|
||||
<div class="p-6 w-full h-full">
|
||||
<HomeCarousel slides={slides() ?? []} loading={loading()} />
|
||||
<h1 class="text-2xl font-bold mb-6">欢迎使用 Spark Store</h1>
|
||||
<AppList apps={apps() ?? []} loading={loading()} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
72
src/features/home/store.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { AppItem } from '@/types/app';
|
||||
import { HomeLink } from '@/types/home';
|
||||
import { createResource } from 'solid-js';
|
||||
import { getHomeLinks } from '@/lib/api/home';
|
||||
|
||||
const fetchApps = async (): Promise<AppItem[]> => {
|
||||
// 模拟从后端获取数据的延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
return [
|
||||
{
|
||||
pkgname: '1',
|
||||
name: 'Visual Studio Code',
|
||||
more: '轻量级但功能强大的代码编辑器',
|
||||
category: 'development',
|
||||
icon: '/icons/vscode.png',
|
||||
update: ''
|
||||
},
|
||||
{
|
||||
pkgname: '2',
|
||||
name: 'Firefox',
|
||||
more: '注重隐私的开源浏览器',
|
||||
category: 'development',
|
||||
icon: '/icons/firefox.png',
|
||||
update: ''
|
||||
},
|
||||
{
|
||||
pkgname: '3',
|
||||
name: 'GIMP',
|
||||
more: '功能丰富的图像编辑软件',
|
||||
category: 'development',
|
||||
icon: '/icons/gimp.png',
|
||||
update: ''
|
||||
},
|
||||
{
|
||||
pkgname: '4',
|
||||
name: 'Notion',
|
||||
more: '功能强大的笔记软件',
|
||||
category: 'development',
|
||||
icon: '/icons/notion.png',
|
||||
update: ''
|
||||
},
|
||||
{
|
||||
pkgname: '5',
|
||||
name: 'Slack',
|
||||
more: '团队沟通和协作工具',
|
||||
category: 'development',
|
||||
icon: '/icons/slack.png',
|
||||
update: ''
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getCarouselSlides = async (): Promise<HomeLink[]> => {
|
||||
return await getHomeLinks();
|
||||
};
|
||||
|
||||
export const useHomeStore = () => {
|
||||
const [apps, { refetch: refetchApps }] = createResource<AppItem[]>(fetchApps);
|
||||
const [slides, { refetch: refetchSlides }] = createResource<HomeLink[]>(getCarouselSlides);
|
||||
const loading = () => apps.loading || slides.loading;
|
||||
|
||||
return {
|
||||
apps,
|
||||
slides,
|
||||
loading,
|
||||
refetch: () => {
|
||||
refetchApps();
|
||||
refetchSlides();
|
||||
}
|
||||
};
|
||||
};
|
||||
46
src/features/search/Search.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Component, createEffect, createSignal } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import { searchAllApps } from '@/lib/api/app';
|
||||
import { AppItem } from '@/types/app';
|
||||
import AppList from '@/components/AppList';
|
||||
|
||||
const Search: Component = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchResults, setSearchResults] = createSignal<AppItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(async () => {
|
||||
const query = searchParams.q;
|
||||
if (query && typeof query === 'string') {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const results = await searchAllApps(query);
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '搜索失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 w-full h-full">
|
||||
<h1 class="text-2xl font-bold mb-6">
|
||||
搜索结果: {searchParams.q}
|
||||
</h1>
|
||||
{error() ? (
|
||||
<div class="text-red-500">{error()}</div>
|
||||
) : (
|
||||
<AppList
|
||||
apps={searchResults()}
|
||||
loading={loading()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
43
src/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { lazy } from "solid-js";
|
||||
import App from "./App";
|
||||
import { initServerConfig } from "./lib/api/server";
|
||||
|
||||
const routes = {
|
||||
path: "/",
|
||||
component: App,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: lazy(() => import("./features/home/Home")),
|
||||
},
|
||||
{
|
||||
path: "/categories/:id",
|
||||
component: lazy(() => import("./features/categories/Categories")),
|
||||
},
|
||||
{
|
||||
path: "/app/:category/:pkgname",
|
||||
component: lazy(() => import("./features/app-detail/AppDetail")),
|
||||
},
|
||||
{
|
||||
path: "/search",
|
||||
component: lazy(() => import("./features/search/Search")),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 初始化应用
|
||||
const init = async () => {
|
||||
// 等待服务器配置初始化完成
|
||||
await initServerConfig();
|
||||
|
||||
// 渲染应用
|
||||
render(
|
||||
() => <Router>{routes}</Router>,
|
||||
document.getElementById("root") as HTMLElement
|
||||
);
|
||||
};
|
||||
|
||||
init().catch(console.error);
|
||||
60
src/lib/api/app.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AppDetail, AppItem } from "@/types/app";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { retryOperation } from "../utils";
|
||||
|
||||
/**
|
||||
* 获取应用详细信息
|
||||
* @param category 应用分类
|
||||
* @param pkgname 应用包名
|
||||
* @returns Promise<AppInfoItem>
|
||||
*/
|
||||
export async function getAppInfo(category: string, pkgname: string): Promise<AppDetail> {
|
||||
try {
|
||||
return await retryOperation(async () => {
|
||||
const appInfo = await invoke<AppDetail>("get_app_info", {
|
||||
category,
|
||||
pkgname,
|
||||
});
|
||||
return appInfo;
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`获取应用信息失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索所有应用
|
||||
* @param query 搜索关键词
|
||||
* @returns Promise<AppItem[]>
|
||||
*/
|
||||
export async function searchAllApps(query: string): Promise<AppItem[]> {
|
||||
try {
|
||||
// 从后端获取数据并转换字段名称
|
||||
const rawApps = await retryOperation(async () => {
|
||||
return await invoke<Array<{
|
||||
More: string,
|
||||
Name: string,
|
||||
Pkgname: string,
|
||||
Tags?: string,
|
||||
Update: string,
|
||||
icon?: string,
|
||||
category?: string
|
||||
}>>("search_all_apps", { query });
|
||||
});
|
||||
|
||||
// 将后端返回的大写字段名转换为前端使用的小写格式
|
||||
return rawApps.map(app => ({
|
||||
more: app.More,
|
||||
name: app.Name,
|
||||
pkgname: app.Pkgname,
|
||||
tags: app.Tags,
|
||||
update: app.Update,
|
||||
icon: app.icon,
|
||||
category: app.category
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("搜索应用失败:", error);
|
||||
throw new Error(`搜索应用失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
46
src/lib/api/category.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Category } from "@/types/category";
|
||||
import { AppItem } from "@/types/app";
|
||||
import { retryOperation } from "../utils";
|
||||
|
||||
export const getAllCategories = async (): Promise<Category[]> => {
|
||||
try {
|
||||
return await retryOperation(async () => {
|
||||
return await invoke<Category[]>("get_all_categories");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取分类列表失败:", error);
|
||||
throw new Error("获取分类列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryApps = async (categoryId: string): Promise<AppItem[]> => {
|
||||
// 从后端获取数据并转换字段名称
|
||||
try {
|
||||
const rawApps = await retryOperation(async () => {
|
||||
return await invoke<Array<{
|
||||
More: string,
|
||||
Name: string,
|
||||
Pkgname: string,
|
||||
Tags?: string,
|
||||
Update: string,
|
||||
icon?: string,
|
||||
category?: string
|
||||
}>>("get_category_apps", { categoryId });
|
||||
});
|
||||
|
||||
// 将后端返回的大写字段名转换为前端使用的小写格式
|
||||
return rawApps.map(app => ({
|
||||
more: app.More,
|
||||
name: app.Name,
|
||||
pkgname: app.Pkgname,
|
||||
tags: app.Tags,
|
||||
update: app.Update,
|
||||
icon: app.icon,
|
||||
category: app.category
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("获取分类应用列表失败:", error);
|
||||
throw new Error("获取分类应用列表失败");
|
||||
}
|
||||
};
|
||||
21
src/lib/api/home.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { retryOperation } from "../utils";
|
||||
import { HomeLink, HomeLinkResponse } from "@/types/home";
|
||||
|
||||
export const getHomeLinks = async (): Promise<HomeLink[]> => {
|
||||
try {
|
||||
const links = await retryOperation(async () => {
|
||||
const result = await invoke<HomeLinkResponse[]>("get_home_links");
|
||||
// 将返回数据中的 url 字段转换为 linkUrl
|
||||
return result.map(link => ({
|
||||
...link,
|
||||
linkUrl: link.url,
|
||||
url: undefined
|
||||
}));
|
||||
});
|
||||
return links;
|
||||
} catch (error) {
|
||||
console.error("获取主页链接列表失败:", error);
|
||||
throw new Error("获取主页链接列表失败");
|
||||
}
|
||||
};
|
||||
50
src/lib/api/server.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// 存储服务器相关的全局变量
|
||||
let targetArchToStore: string;
|
||||
let jsonServerUrl: string;
|
||||
let imgServerUrl: string;
|
||||
|
||||
// 初始化函数,在应用启动时调用
|
||||
export async function initServerConfig() {
|
||||
targetArchToStore = await invoke('get_target_arch_to_store');
|
||||
jsonServerUrl = await invoke('get_json_server_url');
|
||||
imgServerUrl = await invoke('get_img_server_url');
|
||||
}
|
||||
|
||||
export function getTargetArchToStore(): string {
|
||||
if (!targetArchToStore) {
|
||||
throw new Error('服务器配置未初始化,请先调用 initServerConfig');
|
||||
}
|
||||
return targetArchToStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取JSON服务器URL
|
||||
* @returns 返回JSON服务器的完整URL
|
||||
*/
|
||||
export function getJsonServerUrl(): string {
|
||||
if (!jsonServerUrl) {
|
||||
throw new Error('服务器配置未初始化,请先调用 initServerConfig');
|
||||
}
|
||||
return jsonServerUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片服务器URL
|
||||
* @returns 返回图片服务器的完整URL
|
||||
*/
|
||||
export function getImgServerUrl(): string {
|
||||
if (!imgServerUrl) {
|
||||
throw new Error('服务器配置未初始化,请先调用 initServerConfig');
|
||||
}
|
||||
return imgServerUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 User-Agent
|
||||
* @returns 返回应用的 User-Agent 字符串
|
||||
*/
|
||||
export async function getUserAgent(): Promise<string> {
|
||||
return await invoke('get_user_agent');
|
||||
}
|
||||
18
src/lib/icon.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Package, Code, Globe, Palette, MessageCircle, Gamepad2, Image, Music, FileText, Grid, type Icon } from 'lucide-solid';
|
||||
|
||||
const iconMap: Record<string, typeof Icon> = {
|
||||
'code': Code,
|
||||
'globe': Globe,
|
||||
'palette': Palette,
|
||||
'message-circle': MessageCircle,
|
||||
'gamepad-2': Gamepad2,
|
||||
'image': Image,
|
||||
'music': Music,
|
||||
'file-text': FileText,
|
||||
'grid': Grid
|
||||
};
|
||||
|
||||
export const getIconComponent = (iconName?: string): typeof Icon => {
|
||||
if (!iconName) return Package;
|
||||
return iconMap[iconName] || Package;
|
||||
};
|
||||
24
src/lib/share.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
|
||||
export const getTargetArchToStore = async (): Promise<string> => {
|
||||
return await invoke<string>("get_target_arch_to_store");
|
||||
};
|
||||
|
||||
export const copy = async (text: string): Promise<void> => {
|
||||
console.log(text);
|
||||
await writeText(text);
|
||||
};
|
||||
|
||||
export const generateShareLinks = async (category: string, pkgname: string) => {
|
||||
const targetArch = await getTargetArchToStore();
|
||||
const spkLink = `spk://${targetArch}/${category}/${pkgname}`;
|
||||
const shareLink = `https://spk-resolv.spark-app.store/?spk=spk://${targetArch}/${category}/${pkgname}`;
|
||||
const shareIframe = `<iframe src="https://spk-resolv.spark-app.store/?spk=${encodeURIComponent(`spk://${targetArch}/${category}/${pkgname}`)}" height="350" width="100%" border="0"></iframe>`;
|
||||
|
||||
return {
|
||||
spkLink,
|
||||
shareLink,
|
||||
shareIframe
|
||||
};
|
||||
};
|
||||
26
src/lib/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const retryOperation = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3
|
||||
): Promise<T> => {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === maxRetries) break;
|
||||
// 等待一小段时间后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
35
src/types/app.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface AppItem {
|
||||
// 更多信息
|
||||
more: string;
|
||||
// 应用名称
|
||||
name: string;
|
||||
// 包名
|
||||
pkgname: string;
|
||||
// 标签
|
||||
tags?: string;
|
||||
// 更新信息
|
||||
update: string;
|
||||
// 图标
|
||||
icon?: string;
|
||||
// 分类
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface AppDetail {
|
||||
More: string;
|
||||
Name: string;
|
||||
Pkgname: string;
|
||||
Tags: string;
|
||||
Update: string;
|
||||
Icon: string;
|
||||
Category: string;
|
||||
Version: string;
|
||||
Filename: string;
|
||||
Torrent_address: string;
|
||||
Author: string;
|
||||
Contributor: string;
|
||||
Website: string;
|
||||
Size: string;
|
||||
DownloadTimes: number;
|
||||
Screenshots: string[];
|
||||
}
|
||||
5
src/types/category.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Category {
|
||||
id: string;
|
||||
icon: string;
|
||||
name_zh_cn: string;
|
||||
}
|
||||
15
src/types/home.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface HomeLink {
|
||||
name: string;
|
||||
more: string;
|
||||
imgUrl: string;
|
||||
type: string;
|
||||
linkUrl: string;
|
||||
}
|
||||
|
||||
export interface HomeLinkResponse {
|
||||
name: string;
|
||||
more: string;
|
||||
imgUrl: string;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
114
tailwind.config.cjs
Normal file
@@ -0,0 +1,114 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["variant", [".dark &", '[data-kb-theme="dark"] &']],
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px"
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))',
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))"
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))"
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))"
|
||||
},
|
||||
info: {
|
||||
DEFAULT: "hsl(var(--info))",
|
||||
foreground: "hsl(var(--info-foreground))"
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "hsl(var(--success))",
|
||||
foreground: "hsl(var(--success-foreground))"
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "hsl(var(--warning))",
|
||||
foreground: "hsl(var(--warning-foreground))"
|
||||
},
|
||||
error: {
|
||||
DEFAULT: "hsl(var(--error))",
|
||||
foreground: "hsl(var(--error-foreground))"
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))"
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))"
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))"
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))"
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
xl: "calc(var(--radius) + 4px)",
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)"
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--kb-accordion-content-height)" }
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--kb-accordion-content-height)" },
|
||||
to: { height: 0 }
|
||||
},
|
||||
"content-show": {
|
||||
from: { opacity: 0, transform: "scale(0.96)" },
|
||||
to: { opacity: 1, transform: "scale(1)" }
|
||||
},
|
||||
"content-hide": {
|
||||
from: { opacity: 1, transform: "scale(1)" },
|
||||
to: { opacity: 0, transform: "scale(0.96)" }
|
||||
},
|
||||
"caret-blink": {
|
||||
"0%,70%,100%": { opacity: "1" },
|
||||
"20%,50%": { opacity: "0" }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"content-show": "content-show 0.2s ease-out",
|
||||
"content-hide": "content-hide 0.2s ease-out",
|
||||
"caret-blink": "caret-blink 1.25s ease-out infinite"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")]
|
||||
}
|
||||
30
tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
ui.config.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://solid-ui.com/schema.json",
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"css": "@/App.css",
|
||||
"config": "tailwind.config.cjs",
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components/ui",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
39
vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
import { resolve } from "path";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [solid()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
"src": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||