first commit

This commit is contained in:
vitalii.litvinchuk
2026-09-18 09:42:16 +03:00
commit ac827c492f
41 changed files with 7178 additions and 0 deletions
+604
View File
@@ -0,0 +1,604 @@
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;