604 lines
17 KiB
TypeScript
604 lines
17 KiB
TypeScript
import {
|
||
createSignal,
|
||
createMemo,
|
||
onMount,
|
||
Show,
|
||
For,
|
||
createEffect,
|
||
} from "solid-js";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||
|
||
type AppItem = {
|
||
name: string;
|
||
path: string;
|
||
source: string;
|
||
};
|
||
|
||
type View = "launcher" | "settings";
|
||
|
||
const COLS = 5;
|
||
|
||
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 gridRef: HTMLDivElement | undefined;
|
||
|
||
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);
|
||
queueMicrotask(() => inputRef?.focus());
|
||
}
|
||
});
|
||
|
||
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]: "" }));
|
||
}
|
||
};
|
||
|
||
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 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];
|
||
});
|
||
|
||
createEffect(() => {
|
||
const list = view() === "settings" ? pinnedApps() : filteredApps();
|
||
list.forEach((app) => loadIcon(app.path));
|
||
});
|
||
|
||
// Скрол до вибраного елемента
|
||
createEffect(() => {
|
||
const idx = selectedIndex();
|
||
const el = gridRef?.querySelector(`[data-index="${idx}"]`) as HTMLElement | null;
|
||
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) => {
|
||
if (view() === "settings") {
|
||
if (e.key === "Escape") {
|
||
setView("launcher");
|
||
setSelectedIndex(0);
|
||
queueMicrotask(() => inputRef?.focus());
|
||
}
|
||
return;
|
||
}
|
||
|
||
const list = filteredApps();
|
||
const len = list.length;
|
||
|
||
if (e.key === "Tab") {
|
||
e.preventDefault();
|
||
if (document.activeElement === inputRef) {
|
||
gridRef?.focus();
|
||
} else {
|
||
inputRef?.focus();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.key === "ArrowRight") {
|
||
e.preventDefault();
|
||
moveSelection(1);
|
||
gridRef?.focus();
|
||
} else if (e.key === "ArrowLeft") {
|
||
e.preventDefault();
|
||
moveSelection(-1);
|
||
gridRef?.focus();
|
||
} else if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
moveSelection(COLS);
|
||
gridRef?.focus();
|
||
} else if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
moveSelection(-COLS);
|
||
gridRef?.focus();
|
||
} else if (e.key === "Enter" && len > 0) {
|
||
e.preventDefault();
|
||
launchApp(list[selectedIndex()].path);
|
||
} else if (e.key === "Escape") {
|
||
getCurrentWindow().hide();
|
||
} else if ((e.ctrlKey || e.metaKey) && e.key === ",") {
|
||
e.preventDefault();
|
||
setView("settings");
|
||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "p") {
|
||
e.preventDefault();
|
||
const app = list[selectedIndex()];
|
||
if (app) togglePin(app.path);
|
||
} else if (
|
||
e.key.length === 1 &&
|
||
!e.ctrlKey &&
|
||
!e.metaKey &&
|
||
!e.altKey &&
|
||
document.activeElement !== inputRef
|
||
) {
|
||
inputRef?.focus();
|
||
}
|
||
};
|
||
|
||
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 (
|
||
<div style={s.root} onKeyDown={handleKeyDown} tabIndex={-1}>
|
||
{/* ========== LAUNCHER ========== */}
|
||
<Show when={view() === "launcher"}>
|
||
<div style={s.header}>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
placeholder="Пошук програм..."
|
||
value={searchQuery()}
|
||
onInput={handleInput}
|
||
autofocus
|
||
style={s.input}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
export default App; |