added ability to drag apps wherever on desktop theme

This commit is contained in:
Martin Dvořák 2026-09-23 23:55:30 +02:00
commit cd1766619d
3 changed files with 566 additions and 83 deletions

View file

@ -6,7 +6,7 @@ import { APPS, APP_BY_ID, createWebsiteApp, type AppDef } from "./apps";
import WinWindow, { type WinState } from "./WinWindow";
import Clock from "./Clock";
import { useTheme } from "./useTheme";
import { useDragOrder } from "./useDragOrder";
import { useDesktopIcons } from "./useDesktopIcons";
import { WALLPAPER, friends } from "../data";
const TASKBAR_HEIGHT = 48;
@ -24,6 +24,16 @@ interface Shortcut {
image?: string;
}
interface IconEntry {
id: string;
label: string;
title: string;
glyph: ReactNode;
color?: string;
image?: string;
onOpen: () => void;
}
const SERVICE_SHORTCUTS: Shortcut[] = [
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty", color: "#1257a0", desc: "Server Management" },
{ id: "svc-ai", icon: "🦾", label: "Llama.cpp", url: "https://ai.dvorakmortonxd.dev/", color: "#157347", desc: "Local AI Server" },
@ -43,6 +53,13 @@ const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({
desc: f.desc,
}));
/** Stable list of every desktop icon, used to seed the placement grid. */
const ICON_KEYS = [
...LEFT_APPS.map((a) => a.id),
...SERVICE_SHORTCUTS.map((s) => s.id),
...FRIEND_SHORTCUTS.map((f) => f.id),
];
export default function WindowsDesktop() {
const { theme, toggle } = useTheme();
@ -67,13 +84,13 @@ export default function WindowsDesktop() {
const [closingIds, setClosingIds] = useState<number[]>([]);
const [minimizingIds, setMinimizingIds] = useState<number[]>([]);
const [leftApps, setLeftApps] = useState(LEFT_APPS);
const [serviceItems, setServiceItems] = useState(SERVICE_SHORTCUTS);
const [friendItems, setFriendItems] = useState(FRIEND_SHORTCUTS);
const leftApps = LEFT_APPS;
const serviceItems = SERVICE_SHORTCUTS;
const friendItems = FRIEND_SHORTCUTS;
const leftDrag = useDragOrder(setLeftApps);
const serviceDrag = useDragOrder(setServiceItems);
const friendDrag = useDragOrder(setFriendItems);
const desktopRef = useRef<HTMLDivElement>(null);
const { positions, dragId, onPointerDown, consumeDrag } =
useDesktopIcons(desktopRef, ICON_KEYS);
const [dynamicApps, setDynamicApps] = useState<Record<string, AppDef>>({});
const getApp = (id: string): AppDef | undefined => APP_BY_ID[id] ?? dynamicApps[id];
@ -236,13 +253,79 @@ export default function WindowsDesktop() {
const wallpaper = `url('${WALLPAPER}')`;
const iconClick = (drag: ReturnType<typeof useDragOrder>, id: string, action: () => void) => {
if (drag.consumeDragged()) return;
const iconClick = (id: string, action: () => void) => {
if (consumeDrag()) return;
activateIcon(id, action);
};
const leftEntries: IconEntry[] = leftApps.map((app) => ({
id: app.id,
label: app.title,
title: `Open ${app.title}`,
glyph: app.icon,
color: app.color,
onOpen: () => openApp(app.id),
}));
const serviceEntries: IconEntry[] = serviceItems.map((s) => ({
id: s.id,
label: s.label,
title: s.label,
glyph: s.icon,
onOpen: () => openShortcut(s),
}));
const friendEntries: IconEntry[] = friendItems.map((f) => ({
id: f.id,
label: f.label,
title: f.label,
glyph: "🫂",
image: f.image,
onOpen: () => openShortcut(f),
}));
const allEntries = [...leftEntries, ...serviceEntries, ...friendEntries];
const renderIcon = (entry: IconEntry, interactive = true) => {
const pos = positions[entry.id];
return (
<button
key={entry.id}
type="button"
data-drag-id={entry.id}
className={
"win-icon" +
(selectedIcon === entry.id ? " selected" : "") +
(dragId === entry.id ? " dragging" : "")
}
style={interactive && pos ? { left: pos.x, top: pos.y } : undefined}
onPointerDown={interactive ? onPointerDown(entry.id) : undefined}
onClick={interactive ? () => iconClick(entry.id, entry.onOpen) : undefined}
tabIndex={interactive ? undefined : -1}
title={entry.title}
>
<span
className="win-icon-glyph"
style={entry.color ? { background: entry.color } : undefined}
>
{entry.image ? (
<Image
src={entry.image}
alt={entry.label}
width={38}
height={38}
className="win-icon-img"
draggable={false}
/>
) : (
entry.glyph
)}
</span>
<span className="win-icon-label">{entry.label}</span>
</button>
);
};
return (
<div
ref={desktopRef}
className="win-desktop"
style={{ backgroundImage: wallpaper }}
onPointerDown={(e) => {
@ -254,83 +337,20 @@ export default function WindowsDesktop() {
>
<div className="win-wallpaper-tint" aria-hidden="true" />
{/* Desktop icons */}
<div className="win-icons" onPointerDown={(e) => e.stopPropagation()}>
{leftApps.map((app) => (
<button
key={app.id}
type="button"
data-drag-id={app.id}
className={
"win-icon" +
(selectedIcon === app.id ? " selected" : "") +
(leftDrag.dragId === app.id ? " dragging" : "")
}
onPointerDown={leftDrag.onPointerDown(app.id)}
onClick={() => iconClick(leftDrag, app.id, () => openApp(app.id))}
title={`Open ${app.title}`}
>
<span className="win-icon-glyph" style={{ background: app.color }}>
{app.icon}
</span>
<span className="win-icon-label">{app.title}</span>
</button>
))}
{/* Desktop icons. The three stacks below are rendered once so the grid
can be measured from them, then the same icons are shown as an
absolutely positioned grid. */}
<div className="win-icons win-measure" aria-hidden="true">
{leftEntries.map((e) => renderIcon(e, false))}
</div>
<div className="win-icons win-icons-tr win-measure" aria-hidden="true">
{serviceEntries.map((e) => renderIcon(e, false))}
</div>
<div className="win-icons win-icons-br win-measure" aria-hidden="true">
{friendEntries.map((e) => renderIcon(e, false))}
</div>
{/* Services - top right */}
<div
className="win-icons win-icons-tr"
onPointerDown={(e) => e.stopPropagation()}
>
{serviceItems.map((s) => (
<button
key={s.id}
type="button"
data-drag-id={s.id}
className={
"win-icon" +
(selectedIcon === s.id ? " selected" : "") +
(serviceDrag.dragId === s.id ? " dragging" : "")
}
onPointerDown={serviceDrag.onPointerDown(s.id)}
title={s.label}
onClick={() => iconClick(serviceDrag, s.id, () => openShortcut(s))}
>
<span className="win-icon-glyph">{s.icon}</span>
<span className="win-icon-label">{s.label}</span>
</button>
))}
</div>
{/* Friends - bottom right */}
<div
className="win-icons win-icons-br"
onPointerDown={(e) => e.stopPropagation()}
>
{friendItems.map((f) => (
<button
key={f.id}
type="button"
data-drag-id={f.id}
className={
"win-icon" +
(selectedIcon === f.id ? " selected" : "") +
(friendDrag.dragId === f.id ? " dragging" : "")
}
onPointerDown={friendDrag.onPointerDown(f.id)}
title={f.label}
onClick={() => iconClick(friendDrag, f.id, () => openShortcut(f))}
>
<span className="win-icon-glyph">
{f.image && (
<Image src={f.image} alt={f.label} width={38} height={38} className="win-icon-img" />
)}
</span>
<span className="win-icon-label">{f.label}</span>
</button>
))}
</div>
<div className="win-icon-layer">{allEntries.map((e) => renderIcon(e))}</div>
{/* Windows */}
<div className="win-layer">