feat: Implement application launcher with pinned apps functionality
- Added `AppItem` struct to represent application details. - Implemented `collect_lnk_one_level` function to gather .lnk files from specified directories. - Created `get_apps` command to retrieve applications from the Taskbar and Start Menu. - Developed `launch_app` command to open applications. - Introduced `get_app_icon` command to fetch application icons in base64 format. - Added functionality to manage pinned applications with `get_pinned_apps` and `set_pinned_apps` commands. - Implemented system tray with toggle window functionality. - Created UI components for displaying application icons and tiles. - Developed hooks for managing application state, drag-and-drop reordering, and search functionality. - Styled components for a cohesive user interface.
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct AppItem {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collect_lnk_one_level(dir: &Path, source: &str, apps: &mut Vec<AppItem>) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// 1) .lnk прямо в цій папці
|
||||||
|
if path.is_file()
|
||||||
|
&& path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.eq_ignore_ascii_case("lnk"))
|
||||||
|
== Some(true)
|
||||||
|
{
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
apps.push(AppItem {
|
||||||
|
name,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) один рівень глибше: Programs\SomeFolder\*.lnk
|
||||||
|
if path.is_dir() {
|
||||||
|
let Ok(sub_entries) = std::fs::read_dir(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for sub in sub_entries.flatten() {
|
||||||
|
let sub_path = sub.path();
|
||||||
|
if sub_path.is_file()
|
||||||
|
&& sub_path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.eq_ignore_ascii_case("lnk"))
|
||||||
|
== Some(true)
|
||||||
|
{
|
||||||
|
let name = sub_path
|
||||||
|
.file_stem()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
apps.push(AppItem {
|
||||||
|
name,
|
||||||
|
path: sub_path.to_string_lossy().to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_apps() -> Vec<AppItem> {
|
||||||
|
let mut apps = Vec::new();
|
||||||
|
|
||||||
|
if let Some(mut path) = dirs::data_dir() {
|
||||||
|
path.push(r"Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar");
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let file_path = entry.path();
|
||||||
|
if file_path.extension().unwrap_or_default() == "lnk" {
|
||||||
|
let name = file_path
|
||||||
|
.file_stem()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
apps.push(AppItem {
|
||||||
|
name,
|
||||||
|
path: file_path.to_string_lossy().to_string(),
|
||||||
|
source: "Taskbar".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mut path) = dirs::data_dir() {
|
||||||
|
path.push(r"Microsoft\Windows\Start Menu\Programs");
|
||||||
|
collect_lnk_one_level(&path, "Start Menu", &mut apps);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(program_data) = std::env::var("ProgramData") {
|
||||||
|
let path =
|
||||||
|
std::path::PathBuf::from(program_data).join(r"Microsoft\Windows\Start Menu\Programs");
|
||||||
|
collect_lnk_one_level(&path, "Start Menu (All)", &mut apps);
|
||||||
|
}
|
||||||
|
|
||||||
|
apps
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn launch_app(path: String) -> Result<(), String> {
|
||||||
|
open::that(path).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use windows_icons::{get_icon_base64_by_path_with_size, IconSize};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_app_icon(path: String) -> Result<String, String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let result = get_icon_base64_by_path_with_size(&path, IconSize::Large)
|
||||||
|
.or_else(|_| windows_icons::get_icon_base64_by_path(&path));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(b64) => {
|
||||||
|
if b64.starts_with("data:") {
|
||||||
|
Ok(b64)
|
||||||
|
} else {
|
||||||
|
Ok(format!("data:image/png;base64,{}", b64))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => Ok(String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-174
@@ -1,129 +1,20 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use std::fs;
|
mod apps;
|
||||||
use std::path::PathBuf;
|
mod icons;
|
||||||
use tauri::AppHandle;
|
mod pinned;
|
||||||
use tauri::{
|
mod tray;
|
||||||
menu::{Menu, MenuItem},
|
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
|
||||||
Manager,
|
|
||||||
};
|
|
||||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||||
use windows_icons::{get_icon_base64_by_path_with_size, IconSize};
|
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct AppItem {
|
|
||||||
name: String,
|
|
||||||
path: String,
|
|
||||||
source: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_apps() -> Vec<AppItem> {
|
|
||||||
let mut apps = Vec::new();
|
|
||||||
|
|
||||||
if let Some(mut path) = dirs::data_dir() {
|
|
||||||
path.push("Microsoft\\Internet Explorer\\Quick Launch\\User Pinned\\TaskBar");
|
|
||||||
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&path) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let file_path = entry.path();
|
|
||||||
if file_path.extension().unwrap_or_default() == "lnk" {
|
|
||||||
let name = file_path
|
|
||||||
.file_stem()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
apps.push(AppItem {
|
|
||||||
name,
|
|
||||||
path: file_path.to_string_lossy().to_string(),
|
|
||||||
source: "Taskbar".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(mut path) = dirs::data_dir() {
|
|
||||||
path.push("Microsoft\\Windows\\Start Menu\\Programs");
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&path) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let file_path = entry.path();
|
|
||||||
if file_path.extension().unwrap_or_default() == "lnk" {
|
|
||||||
let name = file_path
|
|
||||||
.file_stem()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
apps.push(AppItem {
|
|
||||||
name,
|
|
||||||
path: file_path.to_string_lossy().to_string(),
|
|
||||||
source: "Start Menu".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apps
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn launch_app(path: String) -> Result<(), String> {
|
|
||||||
open::that(path).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pinned_file(app: &AppHandle) -> Result<PathBuf, String> {
|
|
||||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
||||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
||||||
Ok(dir.join("pinned.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_pinned_apps(app: AppHandle) -> Result<Vec<String>, String> {
|
|
||||||
let path = pinned_file(&app)?;
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let data = fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
|
||||||
serde_json::from_str(&data).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn set_pinned_apps(app: AppHandle, paths: Vec<String>) -> Result<(), String> {
|
|
||||||
let path = pinned_file(&app)?;
|
|
||||||
let data = serde_json::to_string_pretty(&paths).map_err(|e| e.to_string())?;
|
|
||||||
fs::write(&path, data).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn get_app_icon(path: String) -> Result<String, String> {
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let result = get_icon_base64_by_path_with_size(&path, IconSize::Large)
|
|
||||||
.or_else(|_| windows_icons::get_icon_base64_by_path(&path));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(b64) => {
|
|
||||||
if b64.starts_with("data:") {
|
|
||||||
Ok(b64)
|
|
||||||
} else {
|
|
||||||
Ok(format!("data:image/png;base64,{}", b64))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => Ok(String::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_apps,
|
apps::get_apps,
|
||||||
launch_app,
|
apps::launch_app,
|
||||||
get_pinned_apps,
|
pinned::get_pinned_apps,
|
||||||
set_pinned_apps,
|
pinned::set_pinned_apps,
|
||||||
get_app_icon
|
icons::get_app_icon
|
||||||
])
|
])
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_autostart::Builder::new()
|
tauri_plugin_autostart::Builder::new()
|
||||||
@@ -134,18 +25,10 @@ fn main() {
|
|||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_global_shortcut::Builder::new()
|
tauri_plugin_global_shortcut::Builder::new()
|
||||||
.with_handler(|app, shortcut, event| {
|
.with_handler(|app, shortcut, event| {
|
||||||
if event.state == ShortcutState::Pressed {
|
if event.state == ShortcutState::Pressed
|
||||||
if shortcut.matches(Modifiers::ALT, Code::Space) {
|
&& shortcut.matches(Modifiers::ALT, Code::Space)
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
{
|
||||||
let is_visible = window.is_visible().unwrap_or(false);
|
tray::toggle_window(app);
|
||||||
if is_visible {
|
|
||||||
window.hide().unwrap();
|
|
||||||
} else {
|
|
||||||
window.show().unwrap();
|
|
||||||
window.set_focus().unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build(),
|
.build(),
|
||||||
@@ -154,49 +37,7 @@ fn main() {
|
|||||||
let alt_space = Shortcut::new(Some(Modifiers::ALT), Code::Space);
|
let alt_space = Shortcut::new(Some(Modifiers::ALT), Code::Space);
|
||||||
app.global_shortcut().register(alt_space)?;
|
app.global_shortcut().register(alt_space)?;
|
||||||
|
|
||||||
let show_i = MenuItem::with_id(app, "show", "Показати/Сховати", true, None::<&str>)?;
|
tray::setup_tray(app)?;
|
||||||
let quit_i = MenuItem::with_id(app, "quit", "Вийти", true, None::<&str>)?;
|
|
||||||
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
|
|
||||||
|
|
||||||
TrayIconBuilder::new()
|
|
||||||
.icon(app.default_window_icon().unwrap().clone())
|
|
||||||
.menu(&menu)
|
|
||||||
.show_menu_on_left_click(false)
|
|
||||||
.on_tray_icon_event(|tray, event| match event {
|
|
||||||
TrayIconEvent::Click {
|
|
||||||
button: MouseButton::Left,
|
|
||||||
button_state: MouseButtonState::Up,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let app_handle = tray.app_handle();
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let is_visible = window.is_visible().unwrap_or(false);
|
|
||||||
if is_visible {
|
|
||||||
window.hide().unwrap();
|
|
||||||
} else {
|
|
||||||
window.show().unwrap();
|
|
||||||
window.set_focus().unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
})
|
|
||||||
.on_menu_event(|app_handle, event| match event.id.as_ref() {
|
|
||||||
"quit" => std::process::exit(0),
|
|
||||||
"show" => {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let is_visible = window.is_visible().unwrap_or(false);
|
|
||||||
if is_visible {
|
|
||||||
window.hide().unwrap();
|
|
||||||
} else {
|
|
||||||
window.show().unwrap();
|
|
||||||
window.set_focus().unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
})
|
|
||||||
.build(app)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
pub fn pinned_file(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dir.join("pinned.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_pinned_apps(app: AppHandle) -> Result<Vec<String>, String> {
|
||||||
|
let path = pinned_file(&app)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
|
serde_json::from_str(&data).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_pinned_apps(app: AppHandle, paths: Vec<String>) -> Result<(), String> {
|
||||||
|
let path = pinned_file(&app)?;
|
||||||
|
let data = serde_json::to_string_pretty(&paths).map_err(|e| e.to_string())?;
|
||||||
|
fs::write(&path, data).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
AppHandle, Manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn toggle_window(app_handle: &AppHandle) {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let is_visible = window.is_visible().unwrap_or(false);
|
||||||
|
if is_visible {
|
||||||
|
let _ = window.hide();
|
||||||
|
} else {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let show_i = MenuItem::with_id(app, "show", "Показати/Сховати", true, None::<&str>)?;
|
||||||
|
let quit_i = MenuItem::with_id(app, "quit", "Вийти", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
|
||||||
|
|
||||||
|
let default_icon = app
|
||||||
|
.default_window_icon()
|
||||||
|
.ok_or("default window icon missing")?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(default_icon)
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_tray_icon_event(|tray, event| match event {
|
||||||
|
TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let app_handle = tray.app_handle();
|
||||||
|
toggle_window(app_handle);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_menu_event(|app_handle, event| match event.id.as_ref() {
|
||||||
|
"quit" => std::process::exit(0),
|
||||||
|
"show" => {
|
||||||
|
toggle_window(app_handle);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"decorations": false,
|
"decorations": false,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"center": true,
|
"center": true,
|
||||||
"skipTaskbar": true
|
"skipTaskbar": true,
|
||||||
|
"dragDropEnabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+64
-528
@@ -1,179 +1,65 @@
|
|||||||
import {
|
import { createEffect } from "solid-js";
|
||||||
createSignal,
|
|
||||||
createMemo,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
For,
|
|
||||||
createEffect,
|
|
||||||
} from "solid-js";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { COLS } from "./types";
|
||||||
type AppItem = {
|
import { styles as s } from "./styles";
|
||||||
name: string;
|
import { useApps } from "./hooks/useApps";
|
||||||
path: string;
|
import { useSearch } from "./hooks/useSearch";
|
||||||
source: string;
|
import { useIcons } from "./hooks/useIcons";
|
||||||
};
|
import { useDragReorder } from "./hooks/useDragReorder";
|
||||||
|
import { LauncherView } from "./components/LauncherView";
|
||||||
type View = "launcher" | "settings";
|
|
||||||
|
|
||||||
const COLS = 5;
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [apps, setApps] = createSignal<AppItem[]>([]);
|
|
||||||
const [pinnedPaths, setPinnedPaths] = createSignal<string[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = createSignal("");
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
||||||
const [view, setView] = createSignal<View>("launcher");
|
|
||||||
const [loading, setLoading] = createSignal(true);
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
const [iconCache, setIconCache] = createSignal<Record<string, string>>({});
|
|
||||||
|
|
||||||
const [dragIndex, setDragIndex] = createSignal<number | null>(null);
|
|
||||||
const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
|
|
||||||
|
|
||||||
let inputRef: HTMLInputElement | undefined;
|
let inputRef: HTMLInputElement | undefined;
|
||||||
let gridRef: HTMLDivElement | undefined;
|
let gridRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
onMount(async () => {
|
const {
|
||||||
try {
|
apps,
|
||||||
const [loadedApps, pinned] = await Promise.all([
|
pinnedPaths,
|
||||||
invoke<AppItem[]>("get_apps"),
|
loading,
|
||||||
invoke<string[]>("get_pinned_apps").catch(() => [] as string[]),
|
error,
|
||||||
]);
|
pinnedApps,
|
||||||
setApps(loadedApps);
|
persistPins,
|
||||||
setPinnedPaths(pinned);
|
togglePin,
|
||||||
} catch (e) {
|
launchApp,
|
||||||
setError(String(e));
|
} = useApps(() => inputRef?.focus());
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
queueMicrotask(() => inputRef?.focus());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadIcon = async (path: string) => {
|
const {
|
||||||
if (iconCache()[path] !== undefined) return;
|
searchQuery,
|
||||||
try {
|
selectedIndex,
|
||||||
const dataUrl = await invoke<string>("get_app_icon", { path });
|
setSelectedIndex,
|
||||||
setIconCache((prev) => ({ ...prev, [path]: dataUrl || "" }));
|
filteredApps,
|
||||||
} catch {
|
moveSelection,
|
||||||
setIconCache((prev) => ({ ...prev, [path]: "" }));
|
handleInput,
|
||||||
}
|
resetSearch,
|
||||||
};
|
} = useSearch(apps, pinnedApps, pinnedPaths);
|
||||||
|
|
||||||
const pinnedApps = createMemo(() => {
|
const { iconCache, loadIcon } = useIcons();
|
||||||
const order = pinnedPaths();
|
|
||||||
const byPath = new Map(apps().map((a) => [a.path, a]));
|
|
||||||
return order.map((p) => byPath.get(p)).filter((a): a is AppItem => !!a);
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredApps = createMemo(() => {
|
const isSearchActive = () => searchQuery().trim().length > 0;
|
||||||
const query = searchQuery().toLowerCase().trim();
|
|
||||||
const all = apps();
|
|
||||||
const pinnedSet = new Set(pinnedPaths());
|
|
||||||
|
|
||||||
if (!query) return pinnedApps();
|
const {
|
||||||
|
dragIndex,
|
||||||
const matched = all.filter((app) =>
|
dragOverIndex,
|
||||||
app.name.toLowerCase().includes(query)
|
canDrag,
|
||||||
);
|
handleDragStart,
|
||||||
const pinnedMatched = matched.filter((a) => pinnedSet.has(a.path));
|
handleDragOver,
|
||||||
const rest = matched.filter((a) => !pinnedSet.has(a.path));
|
handleDrop,
|
||||||
return [...pinnedMatched, ...rest];
|
handleDragEnd,
|
||||||
});
|
} = useDragReorder(pinnedPaths, persistPins, isSearchActive);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const list = view() === "settings" ? pinnedApps() : filteredApps();
|
filteredApps().forEach((app) => loadIcon(app.path));
|
||||||
list.forEach((app) => loadIcon(app.path));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Скрол до вибраного елемента
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const idx = selectedIndex();
|
const idx = selectedIndex();
|
||||||
const el = gridRef?.querySelector(`[data-index="${idx}"]`) as HTMLElement | null;
|
const el = gridRef?.querySelector(
|
||||||
|
`[data-index="${idx}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const launchApp = async (path: string) => {
|
|
||||||
try {
|
|
||||||
await invoke("launch_app", { path });
|
|
||||||
setSearchQuery("");
|
|
||||||
setSelectedIndex(0);
|
|
||||||
await getCurrentWindow().hide();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Помилка запуску:", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistPins = async (next: string[]) => {
|
|
||||||
const prev = pinnedPaths();
|
|
||||||
setPinnedPaths(next);
|
|
||||||
try {
|
|
||||||
await invoke("set_pinned_apps", { paths: next });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setPinnedPaths(prev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePin = (path: string) => {
|
|
||||||
const current = pinnedPaths();
|
|
||||||
const next = current.includes(path)
|
|
||||||
? current.filter((p) => p !== path)
|
|
||||||
: [...current, path];
|
|
||||||
persistPins(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePinned = (path: string) => {
|
|
||||||
persistPins(pinnedPaths().filter((p) => p !== path));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragStart = (index: number) => setDragIndex(index);
|
|
||||||
const onDragOver = (e: DragEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragOverIndex(index);
|
|
||||||
};
|
|
||||||
const onDrop = (index: number) => {
|
|
||||||
const from = dragIndex();
|
|
||||||
if (from === null || from === index) {
|
|
||||||
setDragIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const list = [...pinnedPaths()];
|
|
||||||
const [item] = list.splice(from, 1);
|
|
||||||
list.splice(index, 0, item);
|
|
||||||
persistPins(list);
|
|
||||||
setDragIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
};
|
|
||||||
const onDragEnd = () => {
|
|
||||||
setDragIndex(null);
|
|
||||||
setDragOverIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveSelection = (delta: number) => {
|
|
||||||
const len = filteredApps().length;
|
|
||||||
if (len === 0) return;
|
|
||||||
setSelectedIndex((i) => {
|
|
||||||
const next = i + delta;
|
|
||||||
if (next < 0) return 0;
|
|
||||||
if (next >= len) return len - 1;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (view() === "settings") {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setView("launcher");
|
|
||||||
setSelectedIndex(0);
|
|
||||||
queueMicrotask(() => inputRef?.focus());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = filteredApps();
|
const list = filteredApps();
|
||||||
const len = list.length;
|
const len = list.length;
|
||||||
|
|
||||||
@@ -205,12 +91,9 @@ function App() {
|
|||||||
gridRef?.focus();
|
gridRef?.focus();
|
||||||
} else if (e.key === "Enter" && len > 0) {
|
} else if (e.key === "Enter" && len > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
launchApp(list[selectedIndex()].path);
|
launchApp(list[selectedIndex()].path, resetSearch);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
getCurrentWindow().hide();
|
getCurrentWindow().hide();
|
||||||
} else if ((e.ctrlKey || e.metaKey) && e.key === ",") {
|
|
||||||
e.preventDefault();
|
|
||||||
setView("settings");
|
|
||||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "p") {
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "p") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const app = list[selectedIndex()];
|
const app = list[selectedIndex()];
|
||||||
@@ -226,378 +109,31 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInput = (e: Event) => {
|
|
||||||
setSearchQuery((e.target as HTMLInputElement).value);
|
|
||||||
setSelectedIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- стилі ----------
|
|
||||||
const s = {
|
|
||||||
root: {
|
|
||||||
width: "100%",
|
|
||||||
height: "100vh",
|
|
||||||
background: "#1a1a1a",
|
|
||||||
color: "#e8e8e8",
|
|
||||||
"font-family":
|
|
||||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
display: "flex",
|
|
||||||
"flex-direction": "column",
|
|
||||||
overflow: "hidden",
|
|
||||||
"user-select": "none",
|
|
||||||
} as const,
|
|
||||||
header: {
|
|
||||||
padding: "12px 12px 8px",
|
|
||||||
"border-bottom": "1px solid #2e2e2e",
|
|
||||||
"flex-shrink": 0,
|
|
||||||
} as const,
|
|
||||||
input: {
|
|
||||||
width: "100%",
|
|
||||||
padding: "11px 14px",
|
|
||||||
"font-size": "16px",
|
|
||||||
"border-radius": "10px",
|
|
||||||
border: "1px solid #3a3a3a",
|
|
||||||
background: "#252525",
|
|
||||||
color: "#fff",
|
|
||||||
outline: "none",
|
|
||||||
"box-sizing": "border-box",
|
|
||||||
} as const,
|
|
||||||
grid: {
|
|
||||||
flex: 1,
|
|
||||||
"overflow-y": "auto",
|
|
||||||
padding: "12px",
|
|
||||||
display: "grid",
|
|
||||||
"grid-template-columns": `repeat(${COLS}, 1fr)`,
|
|
||||||
gap: "10px",
|
|
||||||
"align-content": "start",
|
|
||||||
outline: "none",
|
|
||||||
} as const,
|
|
||||||
tile: (selected: boolean, pinned: boolean) =>
|
|
||||||
({
|
|
||||||
display: "flex",
|
|
||||||
"flex-direction": "column",
|
|
||||||
"align-items": "center",
|
|
||||||
"justify-content": "flex-start",
|
|
||||||
gap: "6px",
|
|
||||||
padding: "10px 6px 8px",
|
|
||||||
"border-radius": "12px",
|
|
||||||
cursor: "pointer",
|
|
||||||
background: selected ? "#2a4060" : "transparent",
|
|
||||||
border: selected
|
|
||||||
? "1px solid #4a9eff"
|
|
||||||
: pinned
|
|
||||||
? "1px solid #2a3a50"
|
|
||||||
: "1px solid transparent",
|
|
||||||
transition: "background 0.08s ease, border-color 0.08s ease",
|
|
||||||
"min-width": 0,
|
|
||||||
position: "relative",
|
|
||||||
}) as const,
|
|
||||||
iconWrap: {
|
|
||||||
width: "48px",
|
|
||||||
height: "48px",
|
|
||||||
"border-radius": "10px",
|
|
||||||
overflow: "hidden",
|
|
||||||
background: "#2a2a2a",
|
|
||||||
display: "flex",
|
|
||||||
"align-items": "center",
|
|
||||||
"justify-content": "center",
|
|
||||||
"flex-shrink": 0,
|
|
||||||
} as const,
|
|
||||||
iconImg: {
|
|
||||||
width: "48px",
|
|
||||||
height: "48px",
|
|
||||||
"object-fit": "contain",
|
|
||||||
} as const,
|
|
||||||
iconFallback: {
|
|
||||||
"font-size": "18px",
|
|
||||||
color: "#888",
|
|
||||||
"font-weight": 600,
|
|
||||||
} as const,
|
|
||||||
tileName: {
|
|
||||||
"font-size": "11px",
|
|
||||||
"font-weight": 500,
|
|
||||||
"text-align": "center",
|
|
||||||
"line-height": 1.25,
|
|
||||||
color: "#ccc",
|
|
||||||
width: "100%",
|
|
||||||
overflow: "hidden",
|
|
||||||
"text-overflow": "ellipsis",
|
|
||||||
"white-space": "nowrap",
|
|
||||||
"padding-left": "2px",
|
|
||||||
"padding-right": "2px",
|
|
||||||
} as const,
|
|
||||||
pinBadge: {
|
|
||||||
position: "absolute",
|
|
||||||
top: "4px",
|
|
||||||
right: "4px",
|
|
||||||
"font-size": "10px",
|
|
||||||
color: "#4a9eff",
|
|
||||||
"line-height": 1,
|
|
||||||
} as const,
|
|
||||||
empty: {
|
|
||||||
"grid-column": `1 / -1`,
|
|
||||||
padding: "40px 16px",
|
|
||||||
"text-align": "center",
|
|
||||||
color: "#666",
|
|
||||||
"font-size": "13px",
|
|
||||||
"line-height": 1.5,
|
|
||||||
"white-space": "pre-line",
|
|
||||||
} as const,
|
|
||||||
settingsHeader: {
|
|
||||||
display: "flex",
|
|
||||||
"align-items": "center",
|
|
||||||
"justify-content": "space-between",
|
|
||||||
padding: "12px 14px",
|
|
||||||
"border-bottom": "1px solid #2e2e2e",
|
|
||||||
"flex-shrink": 0,
|
|
||||||
} as const,
|
|
||||||
backBtn: {
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid #444",
|
|
||||||
color: "#ccc",
|
|
||||||
padding: "5px 10px",
|
|
||||||
"border-radius": "6px",
|
|
||||||
cursor: "pointer",
|
|
||||||
"font-size": "12px",
|
|
||||||
} as const,
|
|
||||||
sectionTitle: {
|
|
||||||
padding: "10px 14px 4px",
|
|
||||||
"font-size": "11px",
|
|
||||||
color: "#666",
|
|
||||||
"text-transform": "uppercase",
|
|
||||||
"letter-spacing": "0.06em",
|
|
||||||
} as const,
|
|
||||||
settingsList: {
|
|
||||||
flex: 1,
|
|
||||||
"overflow-y": "auto",
|
|
||||||
padding: "8px",
|
|
||||||
margin: 0,
|
|
||||||
"list-style": "none",
|
|
||||||
} as const,
|
|
||||||
settingsItem: (isDragOver: boolean) =>
|
|
||||||
({
|
|
||||||
display: "flex",
|
|
||||||
"align-items": "center",
|
|
||||||
gap: "10px",
|
|
||||||
padding: "8px 10px",
|
|
||||||
"border-radius": "8px",
|
|
||||||
background: isDragOver ? "#2a3a4a" : "transparent",
|
|
||||||
"border-left": "3px solid #4a9eff",
|
|
||||||
margin: "2px 0",
|
|
||||||
}) as const,
|
|
||||||
settingsName: {
|
|
||||||
flex: 1,
|
|
||||||
"min-width": 0,
|
|
||||||
"font-size": "13px",
|
|
||||||
"font-weight": 500,
|
|
||||||
overflow: "hidden",
|
|
||||||
"text-overflow": "ellipsis",
|
|
||||||
"white-space": "nowrap",
|
|
||||||
} as const,
|
|
||||||
settingsSource: {
|
|
||||||
"font-size": "11px",
|
|
||||||
color: "#777",
|
|
||||||
overflow: "hidden",
|
|
||||||
"text-overflow": "ellipsis",
|
|
||||||
"white-space": "nowrap",
|
|
||||||
} as const,
|
|
||||||
iconBtn: {
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "#666",
|
|
||||||
cursor: "pointer",
|
|
||||||
"font-size": "14px",
|
|
||||||
padding: "4px 6px",
|
|
||||||
"line-height": 1,
|
|
||||||
} as const,
|
|
||||||
footer: {
|
|
||||||
padding: "6px 12px",
|
|
||||||
"border-top": "1px solid #2e2e2e",
|
|
||||||
"font-size": "11px",
|
|
||||||
color: "#555",
|
|
||||||
display: "flex",
|
|
||||||
"justify-content": "space-between",
|
|
||||||
"align-items": "center",
|
|
||||||
"flex-shrink": 0,
|
|
||||||
} as const,
|
|
||||||
dragHandle: {
|
|
||||||
color: "#555",
|
|
||||||
cursor: "grab",
|
|
||||||
"font-size": "14px",
|
|
||||||
padding: "0 4px",
|
|
||||||
} as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AppIcon = (props: { path: string; name: string; size?: number }) => {
|
|
||||||
const src = () => iconCache()[props.path];
|
|
||||||
const letter = () => (props.name[0] || "?").toUpperCase();
|
|
||||||
const size = props.size ?? 48;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...s.iconWrap,
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={src()}
|
|
||||||
fallback={
|
|
||||||
<span style={{ ...s.iconFallback, "font-size": `${size * 0.38}px` }}>
|
|
||||||
{letter()}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={src()}
|
|
||||||
alt=""
|
|
||||||
draggable={false}
|
|
||||||
style={{
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
"object-fit": "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={s.root} onKeyDown={handleKeyDown} tabIndex={-1}>
|
<div style={s.root} onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||||
{/* ========== LAUNCHER ========== */}
|
<LauncherView
|
||||||
<Show when={view() === "launcher"}>
|
inputRef={(el) => (inputRef = el)}
|
||||||
<div style={s.header}>
|
gridRef={(el) => (gridRef = el)}
|
||||||
<input
|
searchQuery={searchQuery}
|
||||||
ref={inputRef}
|
onInput={(e) => handleInput((e.target as HTMLInputElement).value)}
|
||||||
type="text"
|
loading={loading}
|
||||||
placeholder="Пошук програм..."
|
error={error}
|
||||||
value={searchQuery()}
|
apps={filteredApps}
|
||||||
onInput={handleInput}
|
pinnedPaths={pinnedPaths}
|
||||||
autofocus
|
selectedIndex={selectedIndex}
|
||||||
style={s.input}
|
iconCache={iconCache}
|
||||||
|
onLaunch={(path) => launchApp(path, resetSearch)}
|
||||||
|
onSelect={setSelectedIndex}
|
||||||
|
onTogglePin={togglePin}
|
||||||
|
dragIndex={dragIndex}
|
||||||
|
dragOverIndex={dragOverIndex}
|
||||||
|
canDrag={canDrag}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={gridRef} style={s.grid} tabIndex={0}>
|
|
||||||
<Show when={loading()}>
|
|
||||||
<div style={s.empty}>Завантаження...</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!loading() && error()}>
|
|
||||||
<div style={s.empty}>Помилка: {error()}</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!loading() && !error() && filteredApps().length === 0}>
|
|
||||||
<div style={s.empty}>
|
|
||||||
{searchQuery().trim()
|
|
||||||
? "Нічого не знайдено"
|
|
||||||
: "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={filteredApps()}>
|
|
||||||
{(app, index) => {
|
|
||||||
const isPinned = () => pinnedPaths().includes(app.path);
|
|
||||||
const isSelected = () => index() === selectedIndex();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-index={index()}
|
|
||||||
style={s.tile(isSelected(), isPinned())}
|
|
||||||
onClick={() => launchApp(app.path)}
|
|
||||||
onMouseEnter={() => setSelectedIndex(index())}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
togglePin(app.path);
|
|
||||||
}}
|
|
||||||
title={app.name}
|
|
||||||
>
|
|
||||||
<Show when={isPinned()}>
|
|
||||||
<span style={s.pinBadge}>★</span>
|
|
||||||
</Show>
|
|
||||||
<AppIcon path={app.path} name={app.name} />
|
|
||||||
<div style={s.tileName}>{app.name}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={s.footer}>
|
|
||||||
<span>←→↑↓ · Enter · Ctrl+P пін · ПКМ пін</span>
|
|
||||||
<span
|
|
||||||
style={{ cursor: "pointer", color: "#777" }}
|
|
||||||
onClick={() => setView("settings")}
|
|
||||||
>
|
|
||||||
Ctrl+, налаштування
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* ========== SETTINGS ========== */}
|
|
||||||
<Show when={view() === "settings"}>
|
|
||||||
<div style={s.settingsHeader}>
|
|
||||||
<button style={s.backBtn} onClick={() => setView("launcher")}>
|
|
||||||
← Назад
|
|
||||||
</button>
|
|
||||||
<span style={{ "font-weight": 600, "font-size": "14px" }}>
|
|
||||||
Закріплені
|
|
||||||
</span>
|
|
||||||
<div style={{ width: "64px" }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={s.sectionTitle}>
|
|
||||||
Перетягни для зміни порядку · {pinnedApps().length}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul style={s.settingsList}>
|
|
||||||
<Show when={pinnedApps().length === 0}>
|
|
||||||
<div style={s.empty}>
|
|
||||||
Немає закріплених.
|
|
||||||
{"\n"}
|
|
||||||
У пошуку натисни Ctrl+P або ПКМ на плитці.
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={pinnedApps()}>
|
|
||||||
{(app, index) => {
|
|
||||||
const isOver = () => dragOverIndex() === index();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
draggable={true}
|
|
||||||
style={s.settingsItem(isOver())}
|
|
||||||
onDragStart={() => onDragStart(index())}
|
|
||||||
onDragOver={(e) => onDragOver(e, index())}
|
|
||||||
onDrop={() => onDrop(index())}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
>
|
|
||||||
<span style={s.dragHandle}>⠿</span>
|
|
||||||
<AppIcon path={app.path} name={app.name} size={28} />
|
|
||||||
<div style={{ flex: 1, "min-width": 0 }}>
|
|
||||||
<div style={s.settingsName}>{app.name}</div>
|
|
||||||
<div style={s.settingsSource}>{app.source}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
tabIndex={-1}
|
|
||||||
style={{ ...s.iconBtn, color: "#c55" }}
|
|
||||||
title="Видалити"
|
|
||||||
onClick={() => removePinned(app.path)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div style={s.footer}>
|
|
||||||
<span>Esc — назад</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import { styles as s } from "../styles";
|
||||||
|
|
||||||
|
type AppIconProps = {
|
||||||
|
src?: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppIcon(props: AppIconProps) {
|
||||||
|
const letter = () => (props.name[0] || "?").toUpperCase();
|
||||||
|
const size = () => props.size ?? 48;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...s.iconWrap,
|
||||||
|
width: `${size()}px`,
|
||||||
|
height: `${size()}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.src}
|
||||||
|
fallback={
|
||||||
|
<span style={{ ...s.iconFallback, "font-size": `${size() * 0.38}px` }}>
|
||||||
|
{letter()}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={props.src}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
width: `${size()}px`,
|
||||||
|
height: `${size()}px`,
|
||||||
|
"object-fit": "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import { AppItem } from "../types";
|
||||||
|
import { styles as s } from "../styles";
|
||||||
|
import { AppIcon } from "./AppIcon";
|
||||||
|
|
||||||
|
type AppTileProps = {
|
||||||
|
app: AppItem;
|
||||||
|
index: number;
|
||||||
|
iconSrc?: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
isDragOver: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
draggable: boolean;
|
||||||
|
onLaunch: (path: string) => void;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
onTogglePin: (path: string) => void;
|
||||||
|
onDragStart: (e: DragEvent, index: number) => void;
|
||||||
|
onDragOver: (e: DragEvent, index: number) => void;
|
||||||
|
onDrop: (e: DragEvent, index: number) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppTile(props: AppTileProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-index={props.index}
|
||||||
|
draggable={props.draggable}
|
||||||
|
style={s.tile(
|
||||||
|
props.isSelected,
|
||||||
|
props.isPinned,
|
||||||
|
props.isDragOver,
|
||||||
|
props.isDragging
|
||||||
|
)}
|
||||||
|
onClick={() => props.onLaunch(props.app.path)}
|
||||||
|
onMouseEnter={() => props.onSelect(props.index)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
props.onTogglePin(props.app.path);
|
||||||
|
}}
|
||||||
|
onDragStart={(e) => props.onDragStart(e, props.index)}
|
||||||
|
onDragOver={(e) => props.onDragOver(e, props.index)}
|
||||||
|
onDrop={(e) => props.onDrop(e, props.index)}
|
||||||
|
onDragEnd={props.onDragEnd}
|
||||||
|
title={props.app.name}
|
||||||
|
>
|
||||||
|
<Show when={props.isPinned}>
|
||||||
|
<span style={s.pinBadge}>★</span>
|
||||||
|
</Show>
|
||||||
|
<AppIcon src={props.iconSrc} name={props.app.name} />
|
||||||
|
<div style={s.tileName}>{props.app.name}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Accessor, For, Show } from "solid-js";
|
||||||
|
import { AppItem } from "../types";
|
||||||
|
import { styles as s } from "../styles";
|
||||||
|
import { AppTile } from "./AppTile";
|
||||||
|
|
||||||
|
type LauncherViewProps = {
|
||||||
|
inputRef: (el: HTMLInputElement) => void;
|
||||||
|
gridRef: (el: HTMLDivElement) => void;
|
||||||
|
searchQuery: Accessor<string>;
|
||||||
|
onInput: (e: Event) => void;
|
||||||
|
loading: Accessor<boolean>;
|
||||||
|
error: Accessor<string | null>;
|
||||||
|
apps: Accessor<AppItem[]>;
|
||||||
|
pinnedPaths: Accessor<string[]>;
|
||||||
|
selectedIndex: Accessor<number>;
|
||||||
|
iconCache: Accessor<Record<string, string>>;
|
||||||
|
onLaunch: (path: string) => void;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
onTogglePin: (path: string) => void;
|
||||||
|
dragIndex: Accessor<number | null>;
|
||||||
|
dragOverIndex: Accessor<number | null>;
|
||||||
|
canDrag: Accessor<boolean>;
|
||||||
|
onDragStart: (e: DragEvent, index: number) => void;
|
||||||
|
onDragOver: (e: DragEvent, index: number) => void;
|
||||||
|
onDrop: (e: DragEvent, index: number) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LauncherView(props: LauncherViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={s.header}>
|
||||||
|
<input
|
||||||
|
ref={props.inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Пошук програм..."
|
||||||
|
value={props.searchQuery()}
|
||||||
|
onInput={props.onInput}
|
||||||
|
autofocus
|
||||||
|
style={s.input}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={props.gridRef} style={s.grid} tabIndex={0}>
|
||||||
|
<Show when={props.loading()}>
|
||||||
|
<div style={s.empty}>Завантаження...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.loading() && props.error()}>
|
||||||
|
<div style={s.empty}>Помилка: {props.error()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={!props.loading() && !props.error() && props.apps().length === 0}
|
||||||
|
>
|
||||||
|
<div style={s.empty}>
|
||||||
|
{props.searchQuery().trim()
|
||||||
|
? "Нічого не знайдено"
|
||||||
|
: "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={props.apps()}>
|
||||||
|
{(app, index) => {
|
||||||
|
const isPinned = () => props.pinnedPaths().includes(app.path);
|
||||||
|
const isSelected = () => index() === props.selectedIndex();
|
||||||
|
const isOver = () => props.dragOverIndex() === index();
|
||||||
|
const isDragging = () => props.dragIndex() === index();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppTile
|
||||||
|
app={app}
|
||||||
|
index={index()}
|
||||||
|
iconSrc={props.iconCache()[app.path]}
|
||||||
|
isSelected={isSelected()}
|
||||||
|
isPinned={isPinned()}
|
||||||
|
isDragOver={isOver()}
|
||||||
|
isDragging={isDragging()}
|
||||||
|
draggable={props.canDrag()}
|
||||||
|
onLaunch={props.onLaunch}
|
||||||
|
onSelect={props.onSelect}
|
||||||
|
onTogglePin={props.onTogglePin}
|
||||||
|
onDragStart={props.onDragStart}
|
||||||
|
onDragOver={props.onDragOver}
|
||||||
|
onDrop={props.onDrop}
|
||||||
|
onDragEnd={props.onDragEnd}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={s.footer}>
|
||||||
|
<span>←→↑↓ · Enter · Ctrl+P / ПКМ пін</span>
|
||||||
|
<span style={{ color: "#777" }}>
|
||||||
|
Перетягування плиток для зміни порядку
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createSignal, createMemo, onMount } from "solid-js";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { AppItem } from "../types";
|
||||||
|
|
||||||
|
export function useApps(onInitialFocus?: () => void) {
|
||||||
|
const [apps, setApps] = createSignal<AppItem[]>([]);
|
||||||
|
const [pinnedPaths, setPinnedPaths] = createSignal<string[]>([]);
|
||||||
|
const [loading, setLoading] = createSignal(true);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [loadedApps, pinned] = await Promise.all([
|
||||||
|
invoke<AppItem[]>("get_apps"),
|
||||||
|
invoke<string[]>("get_pinned_apps").catch(() => [] as string[]),
|
||||||
|
]);
|
||||||
|
setApps(loadedApps);
|
||||||
|
setPinnedPaths(pinned);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
if (onInitialFocus) {
|
||||||
|
queueMicrotask(onInitialFocus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinnedApps = createMemo(() => {
|
||||||
|
const order = pinnedPaths();
|
||||||
|
const byPath = new Map(apps().map((a) => [a.path, a]));
|
||||||
|
return order.map((p) => byPath.get(p)).filter((a): a is AppItem => !!a);
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistPins = async (next: string[]) => {
|
||||||
|
const prev = pinnedPaths();
|
||||||
|
setPinnedPaths(next);
|
||||||
|
try {
|
||||||
|
await invoke("set_pinned_apps", { paths: next });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setPinnedPaths(prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePin = (path: string) => {
|
||||||
|
const current = pinnedPaths();
|
||||||
|
const next = current.includes(path)
|
||||||
|
? current.filter((p) => p !== path)
|
||||||
|
: [...current, path];
|
||||||
|
persistPins(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const launchApp = async (path: string, onLaunched?: () => void) => {
|
||||||
|
try {
|
||||||
|
await invoke("launch_app", { path });
|
||||||
|
onLaunched?.();
|
||||||
|
await getCurrentWindow().hide();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Помилка запуску:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
apps,
|
||||||
|
pinnedPaths,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
pinnedApps,
|
||||||
|
persistPins,
|
||||||
|
togglePin,
|
||||||
|
launchApp,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { createSignal, Accessor } from "solid-js";
|
||||||
|
|
||||||
|
export function useDragReorder(
|
||||||
|
pinnedPaths: Accessor<string[]>,
|
||||||
|
persistPins: (paths: string[]) => void,
|
||||||
|
isSearchActive: Accessor<boolean>
|
||||||
|
) {
|
||||||
|
const [dragIndex, setDragIndex] = createSignal<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
|
||||||
|
|
||||||
|
const canDrag = () => !isSearchActive();
|
||||||
|
|
||||||
|
const handleDragStart = (e: DragEvent, index: number) => {
|
||||||
|
if (!canDrag()) return;
|
||||||
|
setDragIndex(index);
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/plain", String(index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent, index: number) => {
|
||||||
|
if (!canDrag()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
}
|
||||||
|
setDragOverIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent, dropIndex: number) => {
|
||||||
|
if (!canDrag()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let from = dragIndex();
|
||||||
|
if (from === null && e.dataTransfer) {
|
||||||
|
const data = e.dataTransfer.getData("text/plain");
|
||||||
|
if (data !== "") {
|
||||||
|
const parsed = Number(data);
|
||||||
|
if (!Number.isNaN(parsed)) from = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
|
||||||
|
if (from === null || from === dropIndex) return;
|
||||||
|
|
||||||
|
const list = [...pinnedPaths()];
|
||||||
|
if (
|
||||||
|
from < 0 ||
|
||||||
|
from >= list.length ||
|
||||||
|
dropIndex < 0 ||
|
||||||
|
dropIndex >= list.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [moved] = list.splice(from, 1);
|
||||||
|
list.splice(dropIndex, 0, moved);
|
||||||
|
persistPins(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDragIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragIndex,
|
||||||
|
dragOverIndex,
|
||||||
|
canDrag,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handleDragEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export function useIcons() {
|
||||||
|
const [iconCache, setIconCache] = createSignal<Record<string, string>>({});
|
||||||
|
|
||||||
|
const loadIcon = async (path: string) => {
|
||||||
|
if (iconCache()[path] !== undefined) return;
|
||||||
|
try {
|
||||||
|
const dataUrl = await invoke<string>("get_app_icon", { path });
|
||||||
|
setIconCache((prev) => ({ ...prev, [path]: dataUrl || "" }));
|
||||||
|
} catch {
|
||||||
|
setIconCache((prev) => ({ ...prev, [path]: "" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
iconCache,
|
||||||
|
loadIcon,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createSignal, createMemo, Accessor } from "solid-js";
|
||||||
|
import { AppItem } from "../types";
|
||||||
|
|
||||||
|
export function useSearch(
|
||||||
|
apps: Accessor<AppItem[]>,
|
||||||
|
pinnedApps: Accessor<AppItem[]>,
|
||||||
|
pinnedPaths: Accessor<string[]>
|
||||||
|
) {
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||||
|
|
||||||
|
const filteredApps = createMemo(() => {
|
||||||
|
const query = searchQuery().toLowerCase().trim();
|
||||||
|
const all = apps();
|
||||||
|
const pinnedSet = new Set(pinnedPaths());
|
||||||
|
|
||||||
|
if (!query) return pinnedApps();
|
||||||
|
|
||||||
|
const matched = all.filter((app) =>
|
||||||
|
app.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
const pinnedMatched = matched.filter((a) => pinnedSet.has(a.path));
|
||||||
|
const rest = matched.filter((a) => !pinnedSet.has(a.path));
|
||||||
|
return [...pinnedMatched, ...rest];
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveSelection = (delta: number) => {
|
||||||
|
const len = filteredApps().length;
|
||||||
|
if (len === 0) return;
|
||||||
|
setSelectedIndex((i) => {
|
||||||
|
const next = i + delta;
|
||||||
|
if (next < 0) return 0;
|
||||||
|
if (next >= len) return len - 1;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = (value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSearch = () => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
selectedIndex,
|
||||||
|
setSelectedIndex,
|
||||||
|
filteredApps,
|
||||||
|
moveSelection,
|
||||||
|
handleInput,
|
||||||
|
resetSearch,
|
||||||
|
};
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
import { COLS } from "./types";
|
||||||
|
|
||||||
|
export const styles = {
|
||||||
|
root: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100vh",
|
||||||
|
background: "#1a1a1a",
|
||||||
|
color: "#e8e8e8",
|
||||||
|
"font-family":
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
display: "flex",
|
||||||
|
"flex-direction": "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
"user-select": "none",
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
header: {
|
||||||
|
padding: "12px 12px 8px",
|
||||||
|
"border-bottom": "1px solid #2e2e2e",
|
||||||
|
"flex-shrink": 0,
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
input: {
|
||||||
|
width: "100%",
|
||||||
|
padding: "11px 14px",
|
||||||
|
"font-size": "16px",
|
||||||
|
"border-radius": "10px",
|
||||||
|
border: "1px solid #3a3a3a",
|
||||||
|
background: "#252525",
|
||||||
|
color: "#fff",
|
||||||
|
outline: "none",
|
||||||
|
"box-sizing": "border-box",
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
grid: {
|
||||||
|
flex: 1,
|
||||||
|
"overflow-y": "auto",
|
||||||
|
padding: "12px",
|
||||||
|
display: "grid",
|
||||||
|
"grid-template-columns": `repeat(${COLS}, 1fr)`,
|
||||||
|
gap: "10px",
|
||||||
|
"align-content": "start",
|
||||||
|
outline: "none",
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
tile: (
|
||||||
|
selected: boolean,
|
||||||
|
pinned: boolean,
|
||||||
|
isDragOver: boolean = false,
|
||||||
|
isDragging: boolean = false
|
||||||
|
) =>
|
||||||
|
({
|
||||||
|
display: "flex",
|
||||||
|
"flex-direction": "column",
|
||||||
|
"align-items": "center",
|
||||||
|
"justify-content": "flex-start",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "10px 6px 8px",
|
||||||
|
"border-radius": "12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: isDragOver
|
||||||
|
? "#2a4d6f"
|
||||||
|
: selected
|
||||||
|
? "#2a4060"
|
||||||
|
: "transparent",
|
||||||
|
border: isDragOver
|
||||||
|
? "1px dashed #6bb1ff"
|
||||||
|
: selected
|
||||||
|
? "1px solid #4a9eff"
|
||||||
|
: pinned
|
||||||
|
? "1px solid #2a3a50"
|
||||||
|
: "1px solid transparent",
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
transition: "background 0.08s ease, border-color 0.08s ease, opacity 0.08s ease",
|
||||||
|
"min-width": 0,
|
||||||
|
position: "relative",
|
||||||
|
}) as const,
|
||||||
|
|
||||||
|
iconWrap: {
|
||||||
|
width: "48px",
|
||||||
|
height: "48px",
|
||||||
|
"border-radius": "10px",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "#2a2a2a",
|
||||||
|
display: "flex",
|
||||||
|
"align-items": "center",
|
||||||
|
"justify-content": "center",
|
||||||
|
"flex-shrink": 0,
|
||||||
|
"pointer-events": "none",
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
iconImg: {
|
||||||
|
width: "48px",
|
||||||
|
height: "48px",
|
||||||
|
"object-fit": "contain",
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
iconFallback: {
|
||||||
|
"font-size": "18px",
|
||||||
|
color: "#888",
|
||||||
|
"font-weight": 600,
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
tileName: {
|
||||||
|
"font-size": "11px",
|
||||||
|
"font-weight": 500,
|
||||||
|
"text-align": "center",
|
||||||
|
"line-height": 1.25,
|
||||||
|
color: "#ccc",
|
||||||
|
width: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
"text-overflow": "ellipsis",
|
||||||
|
"white-space": "nowrap",
|
||||||
|
"padding-left": "2px",
|
||||||
|
"padding-right": "2px",
|
||||||
|
"pointer-events": "none",
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
pinBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "4px",
|
||||||
|
right: "4px",
|
||||||
|
"font-size": "10px",
|
||||||
|
color: "#4a9eff",
|
||||||
|
"line-height": 1,
|
||||||
|
"pointer-events": "none",
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
empty: {
|
||||||
|
"grid-column": `1 / -1`,
|
||||||
|
padding: "40px 16px",
|
||||||
|
"text-align": "center",
|
||||||
|
color: "#666",
|
||||||
|
"font-size": "13px",
|
||||||
|
"line-height": 1.5,
|
||||||
|
"white-space": "pre-line",
|
||||||
|
} as const,
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
padding: "6px 12px",
|
||||||
|
"border-top": "1px solid #2e2e2e",
|
||||||
|
"font-size": "11px",
|
||||||
|
color: "#555",
|
||||||
|
display: "flex",
|
||||||
|
"justify-content": "space-between",
|
||||||
|
"align-items": "center",
|
||||||
|
"flex-shrink": 0,
|
||||||
|
} as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type AppItem = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COLS = 5;
|
||||||
Reference in New Issue
Block a user