Initial release of 8BitDo Control Center
Provide a Linux desktop controller configuration app with evdev input, uinput remapping, profiles, calibration, and reconnect recovery. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
26
src/components/feedback/ConfirmDialogHost.tsx
Normal file
26
src/components/feedback/ConfirmDialogHost.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Button } from "@/components/common/Button";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
|
||||
export function ConfirmDialogHost() {
|
||||
const confirm = useUIStore((s) => s.confirm);
|
||||
const resolveConfirm = useUIStore((s) => s.resolveConfirm);
|
||||
|
||||
if (!confirm) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-[var(--shadow-panel)]">
|
||||
<h2 className="text-sm font-semibold text-fg">{confirm.title}</h2>
|
||||
<p className="mt-2 text-sm text-fg-muted">{confirm.message}</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => resolveConfirm(false)}>
|
||||
{confirm.cancelLabel ?? "Cancel"}
|
||||
</Button>
|
||||
<Button variant={confirm.danger ? "danger" : "primary"} onClick={() => resolveConfirm(true)}>
|
||||
{confirm.confirmLabel ?? "Confirm"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/feedback/ToastStack.tsx
Normal file
53
src/components/feedback/ToastStack.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
|
||||
import type { Toast, ToastVariant } from "@/stores/uiStore";
|
||||
import { useUIStore } from "@/stores/uiStore";
|
||||
|
||||
const VARIANT_ICON: Record<ToastVariant, typeof Info> = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
danger: XCircle,
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
||||
info: "border-border text-fg",
|
||||
success: "border-success/40 text-success",
|
||||
warning: "border-warning/40 text-warning",
|
||||
danger: "border-danger/40 text-danger",
|
||||
};
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }) {
|
||||
const dismissToast = useUIStore((s) => s.dismissToast);
|
||||
const Icon = VARIANT_ICON[toast.variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-2.5 rounded-xl border bg-surface px-4 py-3 shadow-[var(--shadow-panel)] ${VARIANT_CLASSES[toast.variant]}`}
|
||||
>
|
||||
<Icon size={16} className="mt-0.5 shrink-0" />
|
||||
<p className="flex-1 text-sm text-fg">{toast.message}</p>
|
||||
<button
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
className="cursor-pointer text-fg-subtle hover:text-fg"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastStack() {
|
||||
const toasts = useUIStore((s) => s.toasts);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-5 top-5 z-50 flex w-80 flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem toast={toast} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/feedback/UnsavedChangesBar.tsx
Normal file
46
src/components/feedback/UnsavedChangesBar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Check, RotateCcw, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/common/Button";
|
||||
import { useProfileStore } from "@/stores/profileStore";
|
||||
|
||||
/** Persistent Apply / Revert / Save Profile bar shown on every editable page. */
|
||||
export function UnsavedChangesBar() {
|
||||
const isDirty = useProfileStore((s) => s.isDirty);
|
||||
const activeProfile = useProfileStore((s) => s.activeProfile);
|
||||
const applyDraft = useProfileStore((s) => s.applyDraft);
|
||||
const revertDraft = useProfileStore((s) => s.revertDraft);
|
||||
const saveDraft = useProfileStore((s) => s.saveDraft);
|
||||
|
||||
if (!activeProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-border bg-surface-2 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{isDirty ? (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-warning" />
|
||||
<span className="text-fg">Unsaved changes to “{activeProfile.name}”</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-success" />
|
||||
<span className="text-fg-muted">
|
||||
Editing “{activeProfile.name}” · up to date
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" icon={<RotateCcw size={14} />} onClick={revertDraft} disabled={!isDirty}>
|
||||
Revert
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon={<Check size={14} />} onClick={applyDraft} disabled={!isDirty}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" icon={<Save size={14} />} onClick={() => void saveDraft()} disabled={!isDirty}>
|
||||
Save Profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user