aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
authorNtskwK <natsukawa247@outlook.com>2026-03-30 17:28:40 +0800
committerNtskwK <natsukawa247@outlook.com>2026-03-30 17:28:40 +0800
commit0c689afe68792fafca67746b9ece2a06760c6069 (patch)
tree8d0feac4fec8c8ac06994f28949915d348eb3cc9 /packages/ui/src/components
parent382dfc68f1ecb09f277f82b0b2e0b466e1c79d06 (diff)
parentc4dc0676d794bca2613be282867d369328ebf073 (diff)
downloadDropOut-0c689afe68792fafca67746b9ece2a06760c6069.tar.gz
DropOut-0c689afe68792fafca67746b9ece2a06760c6069.zip
Merge branch 'main' of https://github.com/HydroRoll-Team/DropOut into chore/docs
Diffstat (limited to 'packages/ui/src/components')
-rw-r--r--packages/ui/src/components/bottom-bar.tsx44
-rw-r--r--packages/ui/src/components/game-console.tsx.bk (renamed from packages/ui/src/components/game-console.tsx)0
-rw-r--r--packages/ui/src/components/instance-creation-modal.tsx544
-rw-r--r--packages/ui/src/components/instance-editor-modal.tsx21
-rw-r--r--packages/ui/src/components/particle-background.tsx68
-rw-r--r--packages/ui/src/components/sidebar.tsx12
-rw-r--r--packages/ui/src/components/ui/accordion.tsx77
-rw-r--r--packages/ui/src/components/ui/alert-dialog.tsx186
-rw-r--r--packages/ui/src/components/ui/button.tsx2
-rw-r--r--packages/ui/src/components/ui/field.tsx44
-rw-r--r--packages/ui/src/components/ui/label.tsx2
11 files changed, 351 insertions, 649 deletions
diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
index 2746e00..f73ace4 100644
--- a/packages/ui/src/components/bottom-bar.tsx
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -1,10 +1,10 @@
import { Play, User, XIcon } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/models/auth";
+import { useGameStore } from "@/models/game";
import { useInstanceStore } from "@/models/instance";
-import { useGameStore } from "@/stores/game-store";
import { LoginModal } from "./login-modal";
import { Button } from "./ui/button";
import {
@@ -19,31 +19,17 @@ import { Spinner } from "./ui/spinner";
export function BottomBar() {
const account = useAuthStore((state) => state.account);
- const instances = useInstanceStore((state) => state.instances);
- const activeInstance = useInstanceStore((state) => state.activeInstance);
- const setActiveInstance = useInstanceStore(
- (state) => state.setActiveInstance,
- );
- const selectedVersion = useGameStore((state) => state.selectedVersion);
- const setSelectedVersion = useGameStore((state) => state.setSelectedVersion);
- const startGame = useGameStore((state) => state.startGame);
- const stopGame = useGameStore((state) => state.stopGame);
- const runningInstanceId = useGameStore((state) => state.runningInstanceId);
- const launchingInstanceId = useGameStore(
- (state) => state.launchingInstanceId,
- );
- const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId);
-
- const [showLoginModal, setShowLoginModal] = useState(false);
- useEffect(() => {
- const nextVersion = activeInstance?.versionId ?? "";
- if (selectedVersion === nextVersion) {
- return;
- }
+ const { instances, activeInstance, setActiveInstance } = useInstanceStore();
+ const {
+ runningInstanceId,
+ launchingInstanceId,
+ stoppingInstanceId,
+ startGame,
+ stopGame,
+ } = useGameStore();
- setSelectedVersion(nextVersion);
- }, [activeInstance?.versionId, selectedVersion, setSelectedVersion]);
+ const [showLoginModal, setShowLoginModal] = useState(false);
const handleInstanceChange = useCallback(
async (instanceId: string) => {
@@ -74,13 +60,7 @@ export function BottomBar() {
return;
}
- await startGame(
- account,
- () => setShowLoginModal(true),
- activeInstance.id,
- selectedVersion || activeInstance.versionId,
- () => undefined,
- );
+ await startGame(activeInstance.id, activeInstance.versionId ?? "");
};
const handleStopGame = async () => {
diff --git a/packages/ui/src/components/game-console.tsx b/packages/ui/src/components/game-console.tsx.bk
index 6980c8c..6980c8c 100644
--- a/packages/ui/src/components/game-console.tsx
+++ b/packages/ui/src/components/game-console.tsx.bk
diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx
deleted file mode 100644
index 7c46d0f..0000000
--- a/packages/ui/src/components/instance-creation-modal.tsx
+++ /dev/null
@@ -1,544 +0,0 @@
-import { Loader2, Search } from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { toast } from "sonner";
-import {
- getFabricLoadersForVersion,
- getForgeVersionsForGame,
- installFabric,
- installForge,
- installVersion,
-} from "@/client";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { useInstanceStore } from "@/models/instance";
-import { useGameStore } from "@/stores/game-store";
-import type {
- FabricLoaderEntry,
- ForgeVersion as ForgeVersionEntry,
- Version,
-} from "@/types";
-
-interface Props {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-export function InstanceCreationModal({ open, onOpenChange }: Props) {
- const gameStore = useGameStore();
- const instancesStore = useInstanceStore();
-
- // Steps: 1 = name, 2 = version, 3 = mod loader
- const [step, setStep] = useState<number>(1);
-
- // Step 1
- const [instanceName, setInstanceName] = useState<string>("");
-
- // Step 2
- const [versionSearch, setVersionSearch] = useState<string>("");
- const [versionFilter, setVersionFilter] = useState<
- "all" | "release" | "snapshot"
- >("release");
- const [selectedVersionUI, setSelectedVersionUI] = useState<Version | null>(
- null,
- );
-
- // Step 3
- const [modLoaderType, setModLoaderType] = useState<
- "vanilla" | "fabric" | "forge"
- >("vanilla");
- const [fabricLoaders, setFabricLoaders] = useState<FabricLoaderEntry[]>([]);
- const [forgeVersions, setForgeVersions] = useState<ForgeVersionEntry[]>([]);
- const [selectedFabricLoader, setSelectedFabricLoader] = useState<string>("");
- const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>("");
- const [loadingLoaders, setLoadingLoaders] = useState(false);
-
- const loadModLoaders = useCallback(async () => {
- if (!selectedVersionUI) return;
- setLoadingLoaders(true);
- setFabricLoaders([]);
- setForgeVersions([]);
- try {
- if (modLoaderType === "fabric") {
- const loaders = await getFabricLoadersForVersion(selectedVersionUI.id);
- setFabricLoaders(loaders || []);
- if (loaders && loaders.length > 0) {
- setSelectedFabricLoader(loaders[0].loader.version);
- } else {
- setSelectedFabricLoader("");
- }
- } else if (modLoaderType === "forge") {
- const versions = await getForgeVersionsForGame(selectedVersionUI.id);
- setForgeVersions(versions || []);
- if (versions && versions.length > 0) {
- // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here.
- setSelectedForgeLoader(versions[0].version);
- } else {
- setSelectedForgeLoader("");
- }
- }
- } catch (e) {
- console.error("Failed to load mod loaders:", e);
- toast.error("Failed to fetch mod loader versions");
- } finally {
- setLoadingLoaders(false);
- }
- }, [modLoaderType, selectedVersionUI]);
-
- // When entering step 3 and a base version exists, fetch loaders if needed
- useEffect(() => {
- if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) {
- loadModLoaders();
- }
- }, [step, modLoaderType, selectedVersionUI, loadModLoaders]);
-
- // Creating state
- const [creating, setCreating] = useState(false);
- const [errorMessage, setErrorMessage] = useState<string>("");
-
- // Derived filtered versions
- const filteredVersions = useMemo(() => {
- const all = gameStore.versions || [];
- let list = all.slice();
- if (versionFilter !== "all") {
- list = list.filter((v) => v.type === versionFilter);
- }
- if (versionSearch.trim()) {
- const q = versionSearch.trim().toLowerCase().replace(/。/g, ".");
- list = list.filter((v) => v.id.toLowerCase().includes(q));
- }
- return list;
- }, [gameStore.versions, versionFilter, versionSearch]);
-
- // Reset when opened/closed
- useEffect(() => {
- if (open) {
- // ensure versions are loaded
- gameStore.loadVersions();
- setStep(1);
- setInstanceName("");
- setVersionSearch("");
- setVersionFilter("release");
- setSelectedVersionUI(null);
- setModLoaderType("vanilla");
- setFabricLoaders([]);
- setForgeVersions([]);
- setSelectedFabricLoader("");
- setSelectedForgeLoader("");
- setErrorMessage("");
- setCreating(false);
- }
- }, [open, gameStore.loadVersions]);
-
- function validateStep1(): boolean {
- if (!instanceName.trim()) {
- setErrorMessage("Please enter an instance name");
- return false;
- }
- setErrorMessage("");
- return true;
- }
-
- function validateStep2(): boolean {
- if (!selectedVersionUI) {
- setErrorMessage("Please select a Minecraft version");
- return false;
- }
- setErrorMessage("");
- return true;
- }
-
- async function handleNext() {
- setErrorMessage("");
- if (step === 1) {
- if (!validateStep1()) return;
- setStep(2);
- } else if (step === 2) {
- if (!validateStep2()) return;
- setStep(3);
- }
- }
-
- function handleBack() {
- setErrorMessage("");
- setStep((s) => Math.max(1, s - 1));
- }
-
- async function handleCreate() {
- if (!validateStep1() || !validateStep2()) return;
- setCreating(true);
- setErrorMessage("");
-
- try {
- // Step 1: create instance
- const instance = await instancesStore.create(instanceName.trim());
-
- // If selectedVersion provided, install it
- if (selectedVersionUI && instance) {
- try {
- await installVersion(instance?.id, selectedVersionUI.id);
- } catch (err) {
- console.error("Failed to install base version:", err);
- // continue - instance created but version install failed
- toast.error(
- `Failed to install version ${selectedVersionUI.id}: ${String(err)}`,
- );
- }
- }
-
- // If mod loader selected, install it
- if (modLoaderType === "fabric" && selectedFabricLoader && instance) {
- try {
- await installFabric(
- instance?.id,
- selectedVersionUI?.id ?? "",
- selectedFabricLoader,
- );
- } catch (err) {
- console.error("Failed to install Fabric:", err);
- toast.error(`Failed to install Fabric: ${String(err)}`);
- }
- } else if (modLoaderType === "forge" && selectedForgeLoader && instance) {
- try {
- await installForge(
- instance?.id,
- selectedVersionUI?.id ?? "",
- selectedForgeLoader,
- );
- } catch (err) {
- console.error("Failed to install Forge:", err);
- toast.error(`Failed to install Forge: ${String(err)}`);
- }
- }
-
- // Refresh instances list
- await instancesStore.refresh();
-
- toast.success("Instance created successfully");
- onOpenChange(false);
- } catch (e) {
- console.error("Failed to create instance:", e);
- setErrorMessage(String(e));
- toast.error(`Failed to create instance: ${e}`);
- } finally {
- setCreating(false);
- }
- }
-
- // UI pieces
- const StepIndicator = () => (
- <div className="flex gap-2 w-full">
- <div
- className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`}
- />
- <div
- className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`}
- />
- <div
- className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`}
- />
- </div>
- );
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden">
- <DialogHeader>
- <DialogTitle>Create New Instance</DialogTitle>
- <DialogDescription>
- Multi-step wizard — create an instance and optionally install a
- version or mod loader.
- </DialogDescription>
- </DialogHeader>
-
- <div className="px-6">
- <div className="pt-4 pb-6">
- <StepIndicator />
- </div>
-
- {/* Step 1 - Name */}
- {step === 1 && (
- <div className="space-y-4">
- <div>
- <label
- htmlFor="instance-name"
- className="block text-sm font-medium mb-2"
- >
- Instance Name
- </label>
- <Input
- id="instance-name"
- placeholder="My Minecraft Instance"
- value={instanceName}
- onChange={(e) => setInstanceName(e.target.value)}
- disabled={creating}
- />
- </div>
- <p className="text-xs text-muted-foreground">
- Give your instance a memorable name.
- </p>
- </div>
- )}
-
- {/* Step 2 - Version selection */}
- {step === 2 && (
- <div className="space-y-4">
- <div className="flex gap-3">
- <div className="relative flex-1">
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
- <Input
- value={versionSearch}
- onChange={(e) => setVersionSearch(e.target.value)}
- placeholder="Search versions..."
- className="pl-9"
- />
- </div>
-
- <div className="flex gap-2">
- <Button
- type="button"
- variant={versionFilter === "all" ? "default" : "outline"}
- onClick={() => setVersionFilter("all")}
- >
- All
- </Button>
- <Button
- type="button"
- variant={
- versionFilter === "release" ? "default" : "outline"
- }
- onClick={() => setVersionFilter("release")}
- >
- Release
- </Button>
- <Button
- type="button"
- variant={
- versionFilter === "snapshot" ? "default" : "outline"
- }
- onClick={() => setVersionFilter("snapshot")}
- >
- Snapshot
- </Button>
- </div>
- </div>
-
- <ScrollArea className="max-h-[36vh]">
- <div className="space-y-2 py-2">
- {gameStore.versions.length === 0 ? (
- <div className="flex items-center justify-center py-8 text-muted-foreground">
- <Loader2 className="animate-spin mr-2" />
- Loading versions...
- </div>
- ) : filteredVersions.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- No matching versions found
- </div>
- ) : (
- filteredVersions.map((v) => {
- const isSelected = selectedVersionUI?.id === v.id;
- return (
- <button
- key={v.id}
- type="button"
- onClick={() => setSelectedVersionUI(v)}
- className={`w-full text-left p-3 rounded-lg border transition-colors ${
- isSelected
- ? "bg-indigo-50 dark:bg-indigo-600/20 border-indigo-200"
- : "bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60"
- }`}
- >
- <div className="flex items-center justify-between">
- <div>
- <div className="font-mono font-bold">{v.id}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {v.type}{" "}
- {v.releaseTime
- ? ` • ${new Date(v.releaseTime).toLocaleDateString()}`
- : ""}
- </div>
- </div>
- {v.javaVersion && (
- <div className="text-sm">
- Java {v.javaVersion}
- </div>
- )}
- </div>
- </button>
- );
- })
- )}
- </div>
- </ScrollArea>
- </div>
- )}
-
- {/* Step 3 - Mod loader */}
- {step === 3 && (
- <div className="space-y-4">
- <div>
- <div className="text-sm font-medium mb-2">Mod Loader Type</div>
- <div className="flex gap-3">
- <Button
- type="button"
- variant={
- modLoaderType === "vanilla" ? "default" : "outline"
- }
- onClick={() => setModLoaderType("vanilla")}
- >
- Vanilla
- </Button>
- <Button
- type="button"
- variant={modLoaderType === "fabric" ? "default" : "outline"}
- onClick={() => setModLoaderType("fabric")}
- >
- Fabric
- </Button>
- <Button
- type="button"
- variant={modLoaderType === "forge" ? "default" : "outline"}
- onClick={() => setModLoaderType("forge")}
- >
- Forge
- </Button>
- </div>
- </div>
-
- {modLoaderType === "fabric" && (
- <div>
- {loadingLoaders ? (
- <div className="flex items-center gap-2">
- <Loader2 className="animate-spin" />
- Loading Fabric versions...
- </div>
- ) : fabricLoaders.length > 0 ? (
- <div className="space-y-2">
- <select
- value={selectedFabricLoader}
- onChange={(e) =>
- setSelectedFabricLoader(e.target.value)
- }
- className="w-full px-3 py-2 rounded border bg-transparent"
- >
- {fabricLoaders.map((f) => (
- <option
- key={f.loader.version}
- value={f.loader.version}
- >
- {f.loader.version}{" "}
- {f.loader.stable ? "(Stable)" : "(Beta)"}
- </option>
- ))}
- </select>
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">
- No Fabric loaders available for this version
- </p>
- )}
- </div>
- )}
-
- {modLoaderType === "forge" && (
- <div>
- {loadingLoaders ? (
- <div className="flex items-center gap-2">
- <Loader2 className="animate-spin" />
- Loading Forge versions...
- </div>
- ) : forgeVersions.length > 0 ? (
- <div className="space-y-2">
- <select
- value={selectedForgeLoader}
- onChange={(e) => setSelectedForgeLoader(e.target.value)}
- className="w-full px-3 py-2 rounded border bg-transparent"
- >
- {forgeVersions.map((f) => (
- // binding ForgeVersion uses `version` as the identifier
- <option key={f.version} value={f.version}>
- {f.version}
- </option>
- ))}
- </select>
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">
- No Forge versions available for this version
- </p>
- )}
- </div>
- )}
- </div>
- )}
-
- {errorMessage && (
- <div className="text-sm text-red-400 mt-3">{errorMessage}</div>
- )}
- </div>
-
- <DialogFooter>
- <div className="w-full flex justify-between items-center">
- <div>
- <Button
- type="button"
- variant="ghost"
- onClick={() => {
- // cancel
- onOpenChange(false);
- }}
- disabled={creating}
- >
- Cancel
- </Button>
- </div>
-
- <div className="flex gap-2">
- {step > 1 && (
- <Button
- type="button"
- variant="outline"
- onClick={handleBack}
- disabled={creating}
- >
- Back
- </Button>
- )}
-
- {step < 3 ? (
- <Button type="button" onClick={handleNext} disabled={creating}>
- Next
- </Button>
- ) : (
- <Button
- type="button"
- onClick={handleCreate}
- disabled={creating}
- >
- {creating ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- Creating...
- </>
- ) : (
- "Create"
- )}
- </Button>
- )}
- </div>
- </div>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- );
-}
-
-export default InstanceCreationModal;
diff --git a/packages/ui/src/components/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx
index ad0c809..105d7e9 100644
--- a/packages/ui/src/components/instance-editor-modal.tsx
+++ b/packages/ui/src/components/instance-editor-modal.tsx
@@ -1,8 +1,12 @@
-import { invoke } from "@tauri-apps/api/core";
+import { toNumber } from "es-toolkit/compat";
import { Folder, Loader2, Save, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
-
+import {
+ deleteInstanceFile,
+ listInstanceDirectory,
+ openFileExplorer,
+} from "@/client";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -14,8 +18,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
-
-import { toNumber } from "@/lib/tsrs-utils";
import { useInstanceStore } from "@/models/instance";
import { useSettingsStore } from "@/models/settings";
import type { FileInfo } from "../types/bindings/core";
@@ -94,11 +96,8 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
if (!instance) return;
setLoadingFiles(true);
try {
- const files = await invoke<FileInfo[]>("list_instance_directory", {
- instanceId: instance.id,
- folder,
- });
- setFileList(files || []);
+ const files = await listInstanceDirectory(instance.id, folder);
+ setFileList(files);
} catch (err) {
console.error("Failed to load files:", err);
toast.error(`Failed to load files: ${String(err)}`);
@@ -135,7 +134,7 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
}
setDeletingPath(filePath);
try {
- await invoke("delete_instance_file", { path: filePath });
+ await deleteInstanceFile(filePath);
// refresh the currently selected folder
await loadFileList(selectedFileFolder);
toast.success("Deleted");
@@ -149,7 +148,7 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
async function openInExplorer(filePath: string) {
try {
- await invoke("open_file_explorer", { path: filePath });
+ await openFileExplorer(filePath);
} catch (err) {
console.error("Failed to open in explorer:", err);
toast.error(`Failed to open file explorer: ${String(err)}`);
diff --git a/packages/ui/src/components/particle-background.tsx b/packages/ui/src/components/particle-background.tsx
index 2e0b15a..2bf6793 100644
--- a/packages/ui/src/components/particle-background.tsx
+++ b/packages/ui/src/components/particle-background.tsx
@@ -1,63 +1,55 @@
-import { useEffect, useRef } from "react";
-import { SaturnEffect } from "../lib/effects/SaturnEffect";
+import { createContext, useContext, useEffect, useRef, useState } from "react";
+import { SaturnEffect } from "@/lib/effects/saturn";
-export function ParticleBackground() {
+const SaturnEffectContext = createContext<SaturnEffect | null>(null);
+
+export function useSaturnEffect() {
+ return useContext(SaturnEffectContext);
+}
+
+export function ParticleBackground({
+ children,
+}: {
+ children?: React.ReactNode;
+}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
- const effectRef = useRef<SaturnEffect | null>(null);
+ const [effect, setEffect] = useState<SaturnEffect | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
- // Instantiate SaturnEffect and attach to canvas
- let effect: SaturnEffect | null = null;
+ let saturnEffect: SaturnEffect | null = null;
try {
- effect = new SaturnEffect(canvas);
- effectRef.current = effect;
+ saturnEffect = new SaturnEffect(canvas);
+ setEffect(saturnEffect);
} catch (err) {
- // If effect fails, silently degrade (keep background blank)
- // eslint-disable-next-line no-console
console.warn("SaturnEffect initialization failed:", err);
}
const resizeHandler = () => {
- if (effectRef.current) {
- try {
- effectRef.current.resize(window.innerWidth, window.innerHeight);
- } catch {
- // ignore
- }
- }
+ saturnEffect?.resize(window.innerWidth, window.innerHeight);
};
window.addEventListener("resize", resizeHandler);
- // Expose getter for HomeView interactions (getSaturnEffect)
- // HomeView will call window.getSaturnEffect()?.handleMouseDown/Move/Up
- (
- window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
- ).getSaturnEffect = () => effectRef.current;
-
return () => {
window.removeEventListener("resize", resizeHandler);
- if (effectRef.current) {
- try {
- effectRef.current.destroy();
- } catch {
- // ignore
- }
- }
- effectRef.current = null;
- (
- window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
- ).getSaturnEffect = undefined;
+ saturnEffect?.destroy();
+
+ setEffect(null);
};
}, []);
return (
- <canvas
- ref={canvasRef}
- className="absolute inset-0 z-0 pointer-events-none"
- />
+ <SaturnEffectContext.Provider value={effect}>
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 -z-10 pointer-events-none"
+ />
+ {children}
+ </SaturnEffectContext.Provider>
);
}
+
+export default ParticleBackground;
diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx
index d81156f..e615274 100644
--- a/packages/ui/src/components/sidebar.tsx
+++ b/packages/ui/src/components/sidebar.tsx
@@ -23,10 +23,6 @@ function NavItem({ Icon, label, to }: NavItemProps) {
const location = useLocation();
const isActive = location.pathname === to;
- const handleClick = () => {
- navigate(to);
- };
-
return (
<Button
variant="ghost"
@@ -35,7 +31,7 @@ function NavItem({ Icon, label, to }: NavItemProps) {
isActive && "relative bg-accent",
)}
size="lg"
- onClick={handleClick}
+ onClick={() => navigate(to)}
>
<Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} />
<span className="hidden lg:block text-sm relative z-10">{label}</span>
@@ -185,7 +181,11 @@ export function Sidebar() {
<div className="w-full lg:px-3 flex-1 flex flex-col justify-end">
<DropdownMenu>
- <DropdownMenuTrigger render={renderUserAvatar()} className="w-full">
+ <DropdownMenuTrigger
+ render={renderUserAvatar()}
+ nativeButton={false}
+ className="w-full"
+ >
Open
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="right" sideOffset={20}>
diff --git a/packages/ui/src/components/ui/accordion.tsx b/packages/ui/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..02ba45c
--- /dev/null
+++ b/packages/ui/src/components/ui/accordion.tsx
@@ -0,0 +1,77 @@
+import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
+import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
+ return (
+ <AccordionPrimitive.Root
+ data-slot="accordion"
+ className={cn("flex w-full flex-col", className)}
+ {...props}
+ />
+ );
+}
+
+function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
+ return (
+ <AccordionPrimitive.Item
+ data-slot="accordion-item"
+ className={cn("not-last:border-b", className)}
+ {...props}
+ />
+ );
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: AccordionPrimitive.Trigger.Props) {
+ return (
+ <AccordionPrimitive.Header className="flex">
+ <AccordionPrimitive.Trigger
+ data-slot="accordion-trigger"
+ className={cn(
+ "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-none border border-transparent py-2.5 text-left text-xs font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronDownIcon
+ data-slot="accordion-trigger-icon"
+ className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
+ />
+ <ChevronUpIcon
+ data-slot="accordion-trigger-icon"
+ className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
+ />
+ </AccordionPrimitive.Trigger>
+ </AccordionPrimitive.Header>
+ );
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: AccordionPrimitive.Panel.Props) {
+ return (
+ <AccordionPrimitive.Panel
+ data-slot="accordion-content"
+ className="overflow-hidden text-xs data-open:animate-accordion-down data-closed:animate-accordion-up"
+ {...props}
+ >
+ <div
+ className={cn(
+ "h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ </AccordionPrimitive.Panel>
+ );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/packages/ui/src/components/ui/alert-dialog.tsx b/packages/ui/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..27c9f77
--- /dev/null
+++ b/packages/ui/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
+import type * as React from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
+ );
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+ <AlertDialogPrimitive.Backdrop
+ data-slot="alert-dialog-overlay"
+ className={cn(
+ "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm";
+}) {
+ return (
+ <AlertDialogPortal>
+ <AlertDialogOverlay />
+ <AlertDialogPrimitive.Popup
+ data-slot="alert-dialog-content"
+ data-size={size}
+ className={cn(
+ "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-none bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+ className,
+ )}
+ {...props}
+ />
+ </AlertDialogPortal>
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-dialog-header"
+ className={cn(
+ "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-dialog-footer"
+ className={cn(
+ "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-dialog-media"
+ className={cn(
+ "mb-2 inline-flex size-10 items-center justify-center rounded-none bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
+ return (
+ <AlertDialogPrimitive.Title
+ data-slot="alert-dialog-title"
+ className={cn(
+ "text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
+ return (
+ <AlertDialogPrimitive.Description
+ data-slot="alert-dialog-description"
+ className={cn(
+ "text-xs/relaxed text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps<typeof Button>) {
+ return (
+ <Button
+ data-slot="alert-dialog-action"
+ className={cn(className)}
+ {...props}
+ />
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
+ return (
+ <AlertDialogPrimitive.Close
+ data-slot="alert-dialog-cancel"
+ className={cn(className)}
+ render={<Button variant={variant} size={size} />}
+ {...props}
+ />
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx
index 7dee494..60ad9ca 100644
--- a/packages/ui/src/components/ui/button.tsx
+++ b/packages/ui/src/components/ui/button.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
+ "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
diff --git a/packages/ui/src/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx
index 84505da..1214480 100644
--- a/packages/ui/src/components/ui/field.tsx
+++ b/packages/ui/src/components/ui/field.tsx
@@ -9,7 +9,7 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
<fieldset
data-slot="field-set"
className={cn(
- "gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
+ "flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
@@ -40,7 +40,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-group"
className={cn(
- "gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
+ "group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
@@ -49,15 +49,15 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
}
const fieldVariants = cva(
- "data-[invalid=true]:text-destructive gap-2 group/field flex w-full",
+ "group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
- "flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
- "flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
@@ -72,7 +72,9 @@ function Field({
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
+ // biome-ignore lint/a11y/useSemanticElements: shadcn component
<div
+ role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
@@ -86,7 +88,7 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-content"
className={cn(
- "gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
+ "group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className,
)}
{...props}
@@ -96,18 +98,26 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
function FieldLabel({
className,
+ required,
+ children,
...props
-}: React.ComponentProps<typeof Label>) {
+}: React.ComponentProps<typeof Label> & {
+ required?: boolean;
+}) {
return (
<Label
data-slot="field-label"
className={cn(
- "has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug",
+ "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
+ aria-required={!!required}
{...props}
- />
+ >
+ {children}
+ {required && <span className="text-red-700 dark:text-red-500">*</span>}
+ </Label>
);
}
@@ -116,7 +126,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="field-label"
className={cn(
- "gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
+ "flex w-fit items-center gap-2 text-xs/relaxed leading-snug group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
@@ -129,9 +139,9 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p
data-slot="field-description"
className={cn(
- "text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance",
+ "text-left text-xs/relaxed leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
- "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
@@ -151,7 +161,7 @@ function FieldSeparator({
data-slot="field-separator"
data-content={!!children}
className={cn(
- "-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative",
+ "relative -my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
@@ -159,7 +169,7 @@ function FieldSeparator({
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
- className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
+ className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
@@ -198,7 +208,9 @@ function FieldError({
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error) =>
- error?.message && <li key={error.message}>{error.message}</li>,
+ error?.message && (
+ <li key={`field-error-${error.message}`}>{error.message}</li>
+ ),
)}
</ul>
);
@@ -212,7 +224,7 @@ function FieldError({
<div
role="alert"
data-slot="field-error"
- className={cn("text-destructive text-xs font-normal", className)}
+ className={cn("text-xs font-normal text-destructive", className)}
{...props}
>
{content}
diff --git a/packages/ui/src/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx
index 9a998c7..0d40c81 100644
--- a/packages/ui/src/components/ui/label.tsx
+++ b/packages/ui/src/components/ui/label.tsx
@@ -8,7 +8,7 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
<label
data-slot="label"
className={cn(
- "gap-2 text-xs leading-none group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
+ "flex items-center gap-2 text-xs leading-none select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}