diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/ui/src/components/instance-editor-modal.tsx | 18 | ||||
| -rw-r--r-- | packages/ui/src/components/sidebar.tsx | 12 | ||||
| -rw-r--r-- | packages/ui/src/components/ui/alert-dialog.tsx | 186 | ||||
| -rw-r--r-- | packages/ui/src/lib/effects/saturn.ts | 281 | ||||
| -rw-r--r-- | packages/ui/src/lib/tsrs-utils.ts | 67 | ||||
| -rw-r--r-- | packages/ui/src/pages/home-view.tsx | 94 |
6 files changed, 491 insertions, 167 deletions
diff --git a/packages/ui/src/components/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx index d964185..2a2bd7d 100644 --- a/packages/ui/src/components/instance-editor-modal.tsx +++ b/packages/ui/src/components/instance-editor-modal.tsx @@ -1,8 +1,8 @@ -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 { Button } from "@/components/ui/button"; import { Dialog, @@ -14,12 +14,11 @@ 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"; import type { Instance } from "../types/bindings/instance"; +import { deleteInstanceFile, listInstanceDirectory, openFileExplorer } from "@/client"; type Props = { open: boolean; @@ -94,11 +93,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 +131,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 +145,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/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/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/lib/effects/saturn.ts b/packages/ui/src/lib/effects/saturn.ts new file mode 100644 index 0000000..f7fcfe5 --- /dev/null +++ b/packages/ui/src/lib/effects/saturn.ts @@ -0,0 +1,281 @@ +export class SaturnEffect { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private width = 0; + private height = 0; + + // Particle storage + private xyz: Float32Array | null = null; // interleaved x,y,z + private types: Uint8Array | null = null; // 0 = planet, 1 = ring + private count = 0; + + // Animation + private animationId = 0; + private angle = 0; + private scaleFactor = 1; + + // Interaction + private isDragging = false; + private lastMouseX = 0; + private lastMouseTime = 0; + private mouseVelocities: number[] = []; + + // Speed control + private readonly baseSpeed = 0.005; + private currentSpeed = 0.005; + private rotationDirection = 1; + private readonly speedDecayRate = 0.992; + private readonly minSpeedMultiplier = 1; + private readonly maxSpeedMultiplier = 50; + private isStopped = false; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false }); + if (!ctx) { + throw new Error("Failed to get 2D context for SaturnEffect"); + } + this.ctx = ctx; + + // Initialize size & particles + this.resize(window.innerWidth, window.innerHeight); + this.initParticles(); + + this.animate = this.animate.bind(this); + this.animate(); + } + + // External interaction handlers (accept clientX) + handleMouseDown(clientX: number) { + this.isDragging = true; + this.lastMouseX = clientX; + this.lastMouseTime = performance.now(); + this.mouseVelocities = []; + } + + handleMouseMove(clientX: number) { + if (!this.isDragging) return; + const now = performance.now(); + const dt = now - this.lastMouseTime; + if (dt > 0) { + const dx = clientX - this.lastMouseX; + const velocity = dx / dt; + this.mouseVelocities.push(velocity); + if (this.mouseVelocities.length > 5) this.mouseVelocities.shift(); + // Rotate directly while dragging for immediate feedback + this.angle += dx * 0.002; + } + this.lastMouseX = clientX; + this.lastMouseTime = now; + } + + handleMouseUp() { + if (this.isDragging && this.mouseVelocities.length > 0) { + this.applyFlingVelocity(); + } + this.isDragging = false; + } + + handleTouchStart(clientX: number) { + this.handleMouseDown(clientX); + } + + handleTouchMove(clientX: number) { + this.handleMouseMove(clientX); + } + + handleTouchEnd() { + this.handleMouseUp(); + } + + // Resize canvas & scale (call on window resize) + resize(width: number, height: number) { + const dpr = window.devicePixelRatio || 1; + this.width = width; + this.height = height; + + // Update canvas pixel size and CSS size + this.canvas.width = Math.max(1, Math.floor(width * dpr)); + this.canvas.height = Math.max(1, Math.floor(height * dpr)); + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + + // Reset transform and scale for devicePixelRatio + this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset + this.ctx.scale(dpr, dpr); + + const minDim = Math.min(width, height); + this.scaleFactor = Math.max(1, minDim * 0.45); + } + + // Initialize particle arrays with reduced counts to keep performance reasonable + private initParticles() { + // Tuned particle counts for reasonable performance across platforms + const planetCount = 1000; + const ringCount = 2500; + this.count = planetCount + ringCount; + + this.xyz = new Float32Array(this.count * 3); + this.types = new Uint8Array(this.count); + + let idx = 0; + + // Planet points + for (let i = 0; i < planetCount; i++, idx++) { + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(Math.random() * 2 - 1); + const r = 1.0; + + this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta); + this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); + this.xyz[idx * 3 + 2] = r * Math.cos(phi); + + this.types[idx] = 0; + } + + // Ring points + const ringInner = 1.4; + const ringOuter = 2.3; + for (let i = 0; i < ringCount; i++, idx++) { + const angle = Math.random() * Math.PI * 2; + const dist = Math.sqrt( + Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + + ringInner * ringInner, + ); + + this.xyz[idx * 3] = dist * Math.cos(angle); + this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05; + this.xyz[idx * 3 + 2] = dist * Math.sin(angle); + + this.types[idx] = 1; + } + } + + // Map fling/velocity samples to a rotation speed and direction + private applyFlingVelocity() { + if (this.mouseVelocities.length === 0) return; + const avg = + this.mouseVelocities.reduce((a, b) => a + b, 0) / + this.mouseVelocities.length; + const flingThreshold = 0.3; + const stopThreshold = 0.1; + + if (Math.abs(avg) > flingThreshold) { + this.isStopped = false; + const newDir = avg > 0 ? 1 : -1; + if (newDir !== this.rotationDirection) this.rotationDirection = newDir; + const multiplier = Math.min( + this.maxSpeedMultiplier, + this.minSpeedMultiplier + Math.abs(avg) * 10, + ); + this.currentSpeed = this.baseSpeed * multiplier; + } else if (Math.abs(avg) < stopThreshold) { + this.isStopped = true; + this.currentSpeed = 0; + } + } + + // Main render loop + private animate() { + // Clear with full alpha to allow layering over background + this.ctx.clearRect(0, 0, this.width, this.height); + + // Standard composition + this.ctx.globalCompositeOperation = "source-over"; + + // Update rotation speed (decay) + if (!this.isDragging && !this.isStopped) { + if (this.currentSpeed > this.baseSpeed) { + this.currentSpeed = + this.baseSpeed + + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate; + if (this.currentSpeed - this.baseSpeed < 0.00001) { + this.currentSpeed = this.baseSpeed; + } + } + this.angle += this.currentSpeed * this.rotationDirection; + } + + // Center positions + const cx = this.width * 0.6; + const cy = this.height * 0.5; + + // Pre-calc rotations + const rotationY = this.angle; + const rotationX = 0.4; + const rotationZ = 0.15; + + const sinY = Math.sin(rotationY); + const cosY = Math.cos(rotationY); + const sinX = Math.sin(rotationX); + const cosX = Math.cos(rotationX); + const sinZ = Math.sin(rotationZ); + const cosZ = Math.cos(rotationZ); + + const fov = 1500; + const scaleFactor = this.scaleFactor; + + if (!this.xyz || !this.types) { + this.animationId = requestAnimationFrame(this.animate); + return; + } + + // Loop particles + for (let i = 0; i < this.count; i++) { + const x = this.xyz[i * 3]; + const y = this.xyz[i * 3 + 1]; + const z = this.xyz[i * 3 + 2]; + + // Scale to screen + const px = x * scaleFactor; + const py = y * scaleFactor; + const pz = z * scaleFactor; + + // Rotate Y then X then Z + const x1 = px * cosY - pz * sinY; + const z1 = pz * cosY + px * sinY; + const y2 = py * cosX - z1 * sinX; + const z2 = z1 * cosX + py * sinX; + const x3 = x1 * cosZ - y2 * sinZ; + const y3 = y2 * cosZ + x1 * sinZ; + const z3 = z2; + + const scale = fov / (fov + z3); + + if (z3 > -fov) { + const x2d = cx + x3 * scale; + const y2d = cy + y3 * scale; + + const type = this.types[i]; + const sizeBase = type === 0 ? 2.4 : 1.5; + const size = sizeBase * scale; + + let alpha = scale * scale * scale; + if (alpha > 1) alpha = 1; + if (alpha < 0.15) continue; + + if (type === 0) { + // Planet: warm-ish + this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`; + } else { + // Ring: cool-ish + this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`; + } + + // Render as small rectangles (faster than arc) + this.ctx.fillRect(x2d, y2d, size, size); + } + } + + this.animationId = requestAnimationFrame(this.animate); + } + + // Stop animations and release resources + destroy() { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = 0; + } + // Intentionally do not null out arrays to allow reuse if desired. + } +} diff --git a/packages/ui/src/lib/tsrs-utils.ts b/packages/ui/src/lib/tsrs-utils.ts deleted file mode 100644 index f48f851..0000000 --- a/packages/ui/src/lib/tsrs-utils.ts +++ /dev/null @@ -1,67 +0,0 @@ -export type Maybe<T> = T | null | undefined; - -export function toNumber( - value: Maybe<number | bigint | string>, - fallback = 0, -): number { - if (value === null || value === undefined) return fallback; - - if (typeof value === "number") { - if (Number.isFinite(value)) return value; - return fallback; - } - - if (typeof value === "bigint") { - // safe conversion for typical values (timestamps, sizes). Might overflow for huge bigint. - return Number(value); - } - - if (typeof value === "string") { - const n = Number(value); - return Number.isFinite(n) ? n : fallback; - } - - return fallback; -} - -/** - * Like `toNumber` but ensures non-negative result (clamps at 0). - */ -export function toNonNegativeNumber( - value: Maybe<number | bigint | string>, - fallback = 0, -): number { - const n = toNumber(value, fallback); - return n < 0 ? 0 : n; -} - -export function toDate( - value: Maybe<number | bigint | string>, - opts?: { isSeconds?: boolean }, -): Date | null { - if (value === null || value === undefined) return null; - - const isSeconds = opts?.isSeconds ?? true; - - // accept bigint, number, numeric string - const n = toNumber(value, NaN); - if (Number.isNaN(n)) return null; - - const ms = isSeconds ? Math.floor(n) * 1000 : Math.floor(n); - return new Date(ms); -} - -/** - * Convert a binding boolean-ish value (0/1, "true"/"false", boolean) to boolean. - */ -export function toBoolean(value: unknown, fallback = false): boolean { - if (value === null || value === undefined) return fallback; - if (typeof value === "boolean") return value; - if (typeof value === "number") return value !== 0; - if (typeof value === "string") { - const s = value.toLowerCase().trim(); - if (s === "true" || s === "1") return true; - if (s === "false" || s === "0") return false; - } - return fallback; -} diff --git a/packages/ui/src/pages/home-view.tsx b/packages/ui/src/pages/home-view.tsx index 4f80cb0..6060370 100644 --- a/packages/ui/src/pages/home-view.tsx +++ b/packages/ui/src/pages/home-view.tsx @@ -1,18 +1,11 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { BottomBar } from "@/components/bottom-bar"; -import type { SaturnEffect } from "@/lib/effects/SaturnEffect"; -import { useGameStore } from "../stores/game-store"; -import { useReleasesStore } from "../stores/releases-store"; +import { useSaturnEffect } from "@/components/particle-background"; export function HomeView() { - const gameStore = useGameStore(); - const releasesStore = useReleasesStore(); const [mouseX, setMouseX] = useState(0); const [mouseY, setMouseY] = useState(0); - - useEffect(() => { - releasesStore.loadReleases(); - }, [releasesStore.loadReleases]); + const saturn = useSaturnEffect(); const handleMouseMove = (e: React.MouseEvent) => { const x = (e.clientX / window.innerWidth) * 2 - 1; @@ -21,100 +14,42 @@ export function HomeView() { setMouseY(y); // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions - try { - const saturn = ( - window as unknown as { - getSaturnEffect?: () => SaturnEffect; - } - ).getSaturnEffect?.(); - if (saturn?.handleMouseMove) { - saturn.handleMouseMove(e.clientX); - } - } catch { - /* best-effort, ignore errors from effect */ - } + saturn?.handleMouseMove(e.clientX); }; const handleSaturnMouseDown = (e: React.MouseEvent) => { - try { - const saturn = (window as any).getSaturnEffect?.(); - if (saturn?.handleMouseDown) { - saturn.handleMouseDown(e.clientX); - } - } catch { - /* ignore */ - } + saturn?.handleMouseDown(e.clientX); }; const handleSaturnMouseUp = () => { - try { - const saturn = (window as any).getSaturnEffect?.(); - if (saturn?.handleMouseUp) { - saturn.handleMouseUp(); - } - } catch { - /* ignore */ - } + saturn?.handleMouseUp(); }; const handleSaturnMouseLeave = () => { // Treat leaving the area as mouse-up for the effect - try { - const saturn = (window as any).getSaturnEffect?.(); - if (saturn?.handleMouseUp) { - saturn.handleMouseUp(); - } - } catch { - /* ignore */ - } + saturn?.handleMouseUp(); }; const handleSaturnTouchStart = (e: React.TouchEvent) => { if (e.touches && e.touches.length === 1) { - try { const clientX = e.touches[0].clientX; - const saturn = (window as any).getSaturnEffect?.(); - if (saturn?.handleTouchStart) { - saturn.handleTouchStart(clientX); - } - } catch { - /* ignore */ - } + saturn?.handleTouchStart(clientX); } }; const handleSaturnTouchMove = (e: React.TouchEvent) => { if (e.touches && e.touches.length === 1) { - try { const clientX = e.touches[0].clientX; - const saturn = (window as any).getSaturnEffect?.(); - if (saturn?.handleTouchMove) { - saturn.handleTouchMove(clientX); - } - } catch { - /* ignore */ - } + saturn?.handleTouchMove(clientX); } }; const handleSaturnTouchEnd = () => { - try { - const saturn = (window as any).getSaturnEffect?.(); - if (saturn?.handleTouchEnd) { - saturn.handleTouchEnd(); - } - } catch { - /* ignore */ - } + saturn?.handleTouchEnd(); }; return ( - <div - className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth" - style={{ - overflow: releasesStore.isLoading ? "hidden" : "auto", - }} - > + <div className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth"> {/* Hero Section (Full Height) - Interactive area */} <div role="tab" @@ -150,13 +85,6 @@ export function HomeView() { <div className="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm"> Java Edition </div> - <div className="h-4 w-px bg-white/20"></div> - <div className="text-sm text-zinc-400"> - Latest Release{" "} - <span className="text-white font-medium"> - {gameStore.latestRelease?.id || "..."} - </span> - </div> </div> </div> |