@jxrstudios/jxr 1.0.10 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/jxr.js +6 -0
- package/dist/index.js +57 -2
- package/dist/jxr-server-manager.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/jxr-server-manager.ts +65 -2
- package/zzz_react_template/App.tsx +43 -156
- package/zzz_react_template/components/ErrorBoundary.tsx +62 -0
- package/zzz_react_template/components/ManusDialog.tsx +85 -0
- package/zzz_react_template/components/Map.tsx +155 -0
- package/zzz_react_template/components/jxr/CodeEditor.tsx +313 -0
- package/zzz_react_template/components/jxr/FileExplorer.tsx +230 -0
- package/zzz_react_template/components/jxr/IDEShell.tsx +159 -0
- package/zzz_react_template/components/jxr/LandingPage.tsx +414 -0
- package/zzz_react_template/components/jxr/LivePreview.tsx +169 -0
- package/zzz_react_template/components/jxr/PerformanceDashboard.tsx +379 -0
- package/zzz_react_template/components/jxr/TopBar.tsx +149 -0
- package/zzz_react_template/components/ui/accordion.tsx +64 -0
- package/zzz_react_template/components/ui/alert-dialog.tsx +155 -0
- package/zzz_react_template/components/ui/alert.tsx +66 -0
- package/zzz_react_template/components/ui/aspect-ratio.tsx +9 -0
- package/zzz_react_template/components/ui/avatar.tsx +51 -0
- package/zzz_react_template/components/ui/badge.tsx +46 -0
- package/zzz_react_template/components/ui/breadcrumb.tsx +109 -0
- package/zzz_react_template/components/ui/button-group.tsx +83 -0
- package/zzz_react_template/components/ui/button.tsx +60 -0
- package/zzz_react_template/components/ui/calendar.tsx +211 -0
- package/zzz_react_template/components/ui/card.tsx +92 -0
- package/zzz_react_template/components/ui/carousel.tsx +239 -0
- package/zzz_react_template/components/ui/chart.tsx +355 -0
- package/zzz_react_template/components/ui/checkbox.tsx +30 -0
- package/zzz_react_template/components/ui/collapsible.tsx +31 -0
- package/zzz_react_template/components/ui/command.tsx +184 -0
- package/zzz_react_template/components/ui/context-menu.tsx +250 -0
- package/zzz_react_template/components/ui/dialog.tsx +209 -0
- package/zzz_react_template/components/ui/drawer.tsx +133 -0
- package/zzz_react_template/components/ui/dropdown-menu.tsx +255 -0
- package/zzz_react_template/components/ui/empty.tsx +104 -0
- package/zzz_react_template/components/ui/field.tsx +242 -0
- package/zzz_react_template/components/ui/form.tsx +168 -0
- package/zzz_react_template/components/ui/hover-card.tsx +42 -0
- package/zzz_react_template/components/ui/input-group.tsx +168 -0
- package/zzz_react_template/components/ui/input-otp.tsx +75 -0
- package/zzz_react_template/components/ui/input.tsx +70 -0
- package/zzz_react_template/components/ui/item.tsx +193 -0
- package/zzz_react_template/components/ui/kbd.tsx +28 -0
- package/zzz_react_template/components/ui/label.tsx +22 -0
- package/zzz_react_template/components/ui/menubar.tsx +274 -0
- package/zzz_react_template/components/ui/navigation-menu.tsx +168 -0
- package/zzz_react_template/components/ui/pagination.tsx +127 -0
- package/zzz_react_template/components/ui/popover.tsx +46 -0
- package/zzz_react_template/components/ui/progress.tsx +29 -0
- package/zzz_react_template/components/ui/radio-group.tsx +43 -0
- package/zzz_react_template/components/ui/resizable.tsx +54 -0
- package/zzz_react_template/components/ui/scroll-area.tsx +56 -0
- package/zzz_react_template/components/ui/select.tsx +185 -0
- package/zzz_react_template/components/ui/separator.tsx +26 -0
- package/zzz_react_template/components/ui/sheet.tsx +139 -0
- package/zzz_react_template/components/ui/sidebar.tsx +734 -0
- package/zzz_react_template/components/ui/skeleton.tsx +13 -0
- package/zzz_react_template/components/ui/slider.tsx +61 -0
- package/zzz_react_template/components/ui/sonner.tsx +23 -0
- package/zzz_react_template/components/ui/spinner.tsx +16 -0
- package/zzz_react_template/components/ui/switch.tsx +29 -0
- package/zzz_react_template/components/ui/table.tsx +114 -0
- package/zzz_react_template/components/ui/tabs.tsx +64 -0
- package/zzz_react_template/components/ui/textarea.tsx +67 -0
- package/zzz_react_template/components/ui/toggle-group.tsx +73 -0
- package/zzz_react_template/components/ui/toggle.tsx +45 -0
- package/zzz_react_template/components/ui/tooltip.tsx +59 -0
- package/zzz_react_template/const.ts +17 -0
- package/zzz_react_template/contexts/JXRContext.tsx +264 -0
- package/zzz_react_template/contexts/ThemeContext.tsx +64 -0
- package/zzz_react_template/hooks/useComposition.ts +81 -0
- package/zzz_react_template/hooks/useMobile.tsx +21 -0
- package/zzz_react_template/hooks/usePersistFn.ts +20 -0
- package/zzz_react_template/index.css +518 -11
- package/zzz_react_template/lib/jxr-runtime/index.ts +201 -0
- package/zzz_react_template/lib/jxr-runtime/module-resolver.ts +520 -0
- package/zzz_react_template/lib/jxr-runtime/moq-transport.ts +267 -0
- package/zzz_react_template/lib/jxr-runtime/web-crypto.ts +279 -0
- package/zzz_react_template/lib/jxr-runtime/worker-pool.ts +321 -0
- package/zzz_react_template/lib/utils.ts +6 -0
- package/zzz_react_template/main.tsx +4 -9
- package/zzz_react_template/pages/Docs.tsx +955 -0
- package/zzz_react_template/pages/Home.tsx +1080 -0
- package/zzz_react_template/pages/NotFound.tsx +105 -0
- package/zzz_react_template/tsconfig.json +24 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
|
|
3
|
+
*
|
|
4
|
+
* USAGE FROM PARENT COMPONENT:
|
|
5
|
+
* ======
|
|
6
|
+
*
|
|
7
|
+
* const mapRef = useRef<google.maps.Map | null>(null);
|
|
8
|
+
*
|
|
9
|
+
* <MapView
|
|
10
|
+
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
|
|
11
|
+
* initialZoom={15}
|
|
12
|
+
* onMapReady={(map) => {
|
|
13
|
+
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
|
|
14
|
+
* </MapView>
|
|
15
|
+
*
|
|
16
|
+
* ======
|
|
17
|
+
* Available Libraries and Core Features:
|
|
18
|
+
* -------------------------------
|
|
19
|
+
* 📍 MARKER (from `marker` library)
|
|
20
|
+
* - Attaches to map using { map, position }
|
|
21
|
+
* new google.maps.marker.AdvancedMarkerElement({
|
|
22
|
+
* map,
|
|
23
|
+
* position: { lat: 37.7749, lng: -122.4194 },
|
|
24
|
+
* title: "San Francisco",
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* -------------------------------
|
|
28
|
+
* 🏢 PLACES (from `places` library)
|
|
29
|
+
* - Does not attach directly to map; use data with your map manually.
|
|
30
|
+
* const place = new google.maps.places.Place({ id: PLACE_ID });
|
|
31
|
+
* await place.fetchFields({ fields: ["displayName", "location"] });
|
|
32
|
+
* map.setCenter(place.location);
|
|
33
|
+
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
|
|
34
|
+
*
|
|
35
|
+
* -------------------------------
|
|
36
|
+
* 🧭 GEOCODER (from `geocoding` library)
|
|
37
|
+
* - Standalone service; manually apply results to map.
|
|
38
|
+
* const geocoder = new google.maps.Geocoder();
|
|
39
|
+
* geocoder.geocode({ address: "New York" }, (results, status) => {
|
|
40
|
+
* if (status === "OK" && results[0]) {
|
|
41
|
+
* map.setCenter(results[0].geometry.location);
|
|
42
|
+
* new google.maps.marker.AdvancedMarkerElement({
|
|
43
|
+
* map,
|
|
44
|
+
* position: results[0].geometry.location,
|
|
45
|
+
* });
|
|
46
|
+
* }
|
|
47
|
+
* });
|
|
48
|
+
*
|
|
49
|
+
* -------------------------------
|
|
50
|
+
* 📐 GEOMETRY (from `geometry` library)
|
|
51
|
+
* - Pure utility functions; not attached to map.
|
|
52
|
+
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
|
|
53
|
+
*
|
|
54
|
+
* -------------------------------
|
|
55
|
+
* 🛣️ ROUTES (from `routes` library)
|
|
56
|
+
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
|
|
57
|
+
* const directionsService = new google.maps.DirectionsService();
|
|
58
|
+
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
|
|
59
|
+
* directionsService.route(
|
|
60
|
+
* { origin, destination, travelMode: "DRIVING" },
|
|
61
|
+
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
|
|
62
|
+
* );
|
|
63
|
+
*
|
|
64
|
+
* -------------------------------
|
|
65
|
+
* 🌦️ MAP LAYERS (attach directly to map)
|
|
66
|
+
* - new google.maps.TrafficLayer().setMap(map);
|
|
67
|
+
* - new google.maps.TransitLayer().setMap(map);
|
|
68
|
+
* - new google.maps.BicyclingLayer().setMap(map);
|
|
69
|
+
*
|
|
70
|
+
* -------------------------------
|
|
71
|
+
* ✅ SUMMARY
|
|
72
|
+
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
|
|
73
|
+
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
|
|
74
|
+
* - “data-only” → Place, Geometry utilities.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/// <reference types="@types/google.maps" />
|
|
78
|
+
|
|
79
|
+
import { useEffect, useRef } from "react";
|
|
80
|
+
import { usePersistFn } from "@/hooks/usePersistFn";
|
|
81
|
+
import { cn } from "@/lib/utils";
|
|
82
|
+
|
|
83
|
+
declare global {
|
|
84
|
+
interface Window {
|
|
85
|
+
google?: typeof google;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
|
|
90
|
+
const FORGE_BASE_URL =
|
|
91
|
+
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
|
|
92
|
+
"https://forge.butterfly-effect.dev";
|
|
93
|
+
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
|
|
94
|
+
|
|
95
|
+
function loadMapScript() {
|
|
96
|
+
return new Promise(resolve => {
|
|
97
|
+
const script = document.createElement("script");
|
|
98
|
+
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
|
|
99
|
+
script.async = true;
|
|
100
|
+
script.crossOrigin = "anonymous";
|
|
101
|
+
script.onload = () => {
|
|
102
|
+
resolve(null);
|
|
103
|
+
script.remove(); // Clean up immediately
|
|
104
|
+
};
|
|
105
|
+
script.onerror = () => {
|
|
106
|
+
console.error("Failed to load Google Maps script");
|
|
107
|
+
};
|
|
108
|
+
document.head.appendChild(script);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface MapViewProps {
|
|
113
|
+
className?: string;
|
|
114
|
+
initialCenter?: google.maps.LatLngLiteral;
|
|
115
|
+
initialZoom?: number;
|
|
116
|
+
onMapReady?: (map: google.maps.Map) => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function MapView({
|
|
120
|
+
className,
|
|
121
|
+
initialCenter = { lat: 37.7749, lng: -122.4194 },
|
|
122
|
+
initialZoom = 12,
|
|
123
|
+
onMapReady,
|
|
124
|
+
}: MapViewProps) {
|
|
125
|
+
const mapContainer = useRef<HTMLDivElement>(null);
|
|
126
|
+
const map = useRef<google.maps.Map | null>(null);
|
|
127
|
+
|
|
128
|
+
const init = usePersistFn(async () => {
|
|
129
|
+
await loadMapScript();
|
|
130
|
+
if (!mapContainer.current) {
|
|
131
|
+
console.error("Map container not found");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
map.current = new window.google.maps.Map(mapContainer.current, {
|
|
135
|
+
zoom: initialZoom,
|
|
136
|
+
center: initialCenter,
|
|
137
|
+
mapTypeControl: true,
|
|
138
|
+
fullscreenControl: true,
|
|
139
|
+
zoomControl: true,
|
|
140
|
+
streetViewControl: true,
|
|
141
|
+
mapId: "DEMO_MAP_ID",
|
|
142
|
+
});
|
|
143
|
+
if (onMapReady) {
|
|
144
|
+
onMapReady(map.current);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
init();
|
|
150
|
+
}, [init]);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JXR.js — Code Editor Component
|
|
3
|
+
* Design: LavaFlow OS — Thermal Precision + Edge Command
|
|
4
|
+
* Lightweight code editor with syntax highlighting and tab management
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
8
|
+
import { useJXR } from '@/contexts/JXRContext';
|
|
9
|
+
import { X, Save, FileCode2 } from 'lucide-react';
|
|
10
|
+
import { cn } from '@/lib/utils';
|
|
11
|
+
import { toast } from 'sonner';
|
|
12
|
+
|
|
13
|
+
// ─── Syntax highlighter ───────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function highlight(code: string, language: string): string {
|
|
16
|
+
if (!['tsx', 'ts', 'jsx', 'js'].includes(language)) {
|
|
17
|
+
return escapeHtml(code);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let result = escapeHtml(code);
|
|
21
|
+
|
|
22
|
+
// Keywords
|
|
23
|
+
result = result.replace(
|
|
24
|
+
/\b(import|export|from|default|const|let|var|function|return|if|else|for|while|class|extends|new|typeof|instanceof|async|await|try|catch|throw|interface|type|enum|implements|abstract|readonly|private|public|protected|static|override|declare|namespace|module|as|in|of|void|null|undefined|true|false|this|super)\b/g,
|
|
25
|
+
'<span class="syn-keyword">$1</span>'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// JSX tags
|
|
29
|
+
result = result.replace(
|
|
30
|
+
/(<\/?[A-Z][A-Za-z0-9.]*)/g,
|
|
31
|
+
'<span class="syn-component">$1</span>'
|
|
32
|
+
);
|
|
33
|
+
result = result.replace(
|
|
34
|
+
/(<\/?[a-z][a-z0-9-]*)/g,
|
|
35
|
+
'<span class="syn-tag">$1</span>'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Strings
|
|
39
|
+
result = result.replace(
|
|
40
|
+
/("[^&]*"|'[^&]*'|`[^`]*`)/g,
|
|
41
|
+
'<span class="syn-string">$1</span>'
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Comments
|
|
45
|
+
result = result.replace(
|
|
46
|
+
/(\/\/[^\n]*)/g,
|
|
47
|
+
'<span class="syn-comment">$1</span>'
|
|
48
|
+
);
|
|
49
|
+
result = result.replace(
|
|
50
|
+
/(\/\*[\s\S]*?\*\/)/g,
|
|
51
|
+
'<span class="syn-comment">$1</span>'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Numbers
|
|
55
|
+
result = result.replace(
|
|
56
|
+
/\b(\d+\.?\d*)\b/g,
|
|
57
|
+
'<span class="syn-number">$1</span>'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Function names
|
|
61
|
+
result = result.replace(
|
|
62
|
+
/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/g,
|
|
63
|
+
'<span class="syn-function">$1</span>'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Types (PascalCase)
|
|
67
|
+
result = result.replace(
|
|
68
|
+
/\b([A-Z][A-Za-z0-9]*)\b/g,
|
|
69
|
+
'<span class="syn-type">$1</span>'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function escapeHtml(str: string): string {
|
|
76
|
+
return str
|
|
77
|
+
.replace(/&/g, '&')
|
|
78
|
+
.replace(/</g, '<')
|
|
79
|
+
.replace(/>/g, '>')
|
|
80
|
+
.replace(/"/g, '"')
|
|
81
|
+
.replace(/'/g, ''');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Tab bar ──────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function TabBar() {
|
|
87
|
+
const { openTabs, activeFile, openFile, closeFile } = useJXR();
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="flex items-center overflow-x-auto border-b border-border bg-card scrollbar-thin">
|
|
91
|
+
{openTabs.map((tab) => {
|
|
92
|
+
const isActive = tab.path === activeFile?.path;
|
|
93
|
+
const name = tab.path.split('/').pop() ?? tab.path;
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
key={tab.path}
|
|
97
|
+
className={cn(
|
|
98
|
+
'group flex items-center gap-1.5 px-3 py-2 border-r border-border cursor-pointer',
|
|
99
|
+
'text-xs font-mono whitespace-nowrap transition-colors duration-100 shrink-0',
|
|
100
|
+
isActive
|
|
101
|
+
? 'bg-background text-foreground border-t-2 border-t-lava'
|
|
102
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
|
103
|
+
)}
|
|
104
|
+
onClick={() => openFile(tab.path)}
|
|
105
|
+
>
|
|
106
|
+
<FileCode2 className="w-3 h-3 shrink-0" />
|
|
107
|
+
<span>{name}</span>
|
|
108
|
+
{tab.dirty && (
|
|
109
|
+
<span className="w-1.5 h-1.5 rounded-full bg-lava shrink-0" />
|
|
110
|
+
)}
|
|
111
|
+
<button
|
|
112
|
+
className={cn(
|
|
113
|
+
'ml-0.5 p-0.5 rounded hover:bg-muted transition-opacity',
|
|
114
|
+
isActive ? 'opacity-60 hover:opacity-100' : 'opacity-0 group-hover:opacity-60 hover:!opacity-100'
|
|
115
|
+
)}
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
closeFile(tab.path);
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<X className="w-3 h-3" />
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Editor ───────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export function CodeEditor() {
|
|
133
|
+
const { activeFile, saveFile, openTabs } = useJXR();
|
|
134
|
+
const [content, setContent] = useState('');
|
|
135
|
+
const [isDirty, setIsDirty] = useState(false);
|
|
136
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
137
|
+
const highlightRef = useRef<HTMLDivElement>(null);
|
|
138
|
+
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
139
|
+
|
|
140
|
+
// Sync content when active file changes
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (activeFile) {
|
|
143
|
+
setContent(activeFile.content);
|
|
144
|
+
setIsDirty(false);
|
|
145
|
+
}
|
|
146
|
+
}, [activeFile?.path]);
|
|
147
|
+
|
|
148
|
+
// Auto-save after 1.5s of inactivity
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!isDirty || !activeFile) return;
|
|
151
|
+
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
|
152
|
+
saveTimeout.current = setTimeout(() => {
|
|
153
|
+
saveFile(activeFile.path, content);
|
|
154
|
+
setIsDirty(false);
|
|
155
|
+
}, 1500);
|
|
156
|
+
return () => {
|
|
157
|
+
if (saveTimeout.current) clearTimeout(saveTimeout.current);
|
|
158
|
+
};
|
|
159
|
+
}, [content, isDirty, activeFile, saveFile]);
|
|
160
|
+
|
|
161
|
+
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
162
|
+
setContent(e.target.value);
|
|
163
|
+
setIsDirty(true);
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const handleKeyDown = useCallback(
|
|
167
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
168
|
+
// Tab key → insert 2 spaces
|
|
169
|
+
if (e.key === 'Tab') {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
const start = e.currentTarget.selectionStart;
|
|
172
|
+
const end = e.currentTarget.selectionEnd;
|
|
173
|
+
const newContent = content.substring(0, start) + ' ' + content.substring(end);
|
|
174
|
+
setContent(newContent);
|
|
175
|
+
setIsDirty(true);
|
|
176
|
+
requestAnimationFrame(() => {
|
|
177
|
+
if (textareaRef.current) {
|
|
178
|
+
textareaRef.current.selectionStart = start + 2;
|
|
179
|
+
textareaRef.current.selectionEnd = start + 2;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// Ctrl/Cmd+S → save
|
|
184
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
if (activeFile) {
|
|
187
|
+
saveFile(activeFile.path, content);
|
|
188
|
+
setIsDirty(false);
|
|
189
|
+
toast.success('Saved', { duration: 1000 });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
[content, activeFile, saveFile]
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Sync scroll between textarea and highlight layer
|
|
197
|
+
const handleScroll = useCallback(() => {
|
|
198
|
+
if (highlightRef.current && textareaRef.current) {
|
|
199
|
+
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
|
200
|
+
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
|
201
|
+
}
|
|
202
|
+
}, []);
|
|
203
|
+
|
|
204
|
+
const lines = content.split('\n');
|
|
205
|
+
const highlighted = activeFile
|
|
206
|
+
? highlight(content, activeFile.language)
|
|
207
|
+
: escapeHtml(content);
|
|
208
|
+
|
|
209
|
+
if (openTabs.length === 0) {
|
|
210
|
+
return (
|
|
211
|
+
<div className="flex flex-col h-full">
|
|
212
|
+
<div className="flex-1 flex items-center justify-center bg-background">
|
|
213
|
+
<div className="text-center space-y-3">
|
|
214
|
+
<FileCode2 className="w-12 h-12 text-muted-foreground/30 mx-auto" />
|
|
215
|
+
<p className="text-sm text-muted-foreground">Select a file to edit</p>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div className="flex flex-col h-full bg-background">
|
|
224
|
+
<TabBar />
|
|
225
|
+
|
|
226
|
+
{/* Editor area */}
|
|
227
|
+
<div className="flex-1 overflow-hidden relative">
|
|
228
|
+
{activeFile ? (
|
|
229
|
+
<div className="flex h-full">
|
|
230
|
+
{/* Line numbers */}
|
|
231
|
+
<div
|
|
232
|
+
className="select-none text-right pr-3 pl-3 py-4 text-xs font-mono text-muted-foreground/40 bg-card/50 border-r border-border overflow-hidden"
|
|
233
|
+
style={{ minWidth: '3rem', lineHeight: '1.6' }}
|
|
234
|
+
>
|
|
235
|
+
{lines.map((_, i) => (
|
|
236
|
+
<div key={i}>{i + 1}</div>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{/* Code area */}
|
|
241
|
+
<div className="flex-1 relative overflow-hidden">
|
|
242
|
+
{/* Syntax highlight layer */}
|
|
243
|
+
<div
|
|
244
|
+
ref={highlightRef}
|
|
245
|
+
className="absolute inset-0 px-4 py-4 text-xs font-mono overflow-auto pointer-events-none whitespace-pre"
|
|
246
|
+
style={{ lineHeight: '1.6', color: 'transparent' }}
|
|
247
|
+
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
248
|
+
/>
|
|
249
|
+
|
|
250
|
+
{/* Actual textarea */}
|
|
251
|
+
<textarea
|
|
252
|
+
ref={textareaRef}
|
|
253
|
+
className={cn(
|
|
254
|
+
'absolute inset-0 w-full h-full px-4 py-4',
|
|
255
|
+
'text-xs font-mono bg-transparent text-foreground',
|
|
256
|
+
'resize-none outline-none border-none',
|
|
257
|
+
'caret-lava selection:bg-lava/30',
|
|
258
|
+
'overflow-auto whitespace-pre'
|
|
259
|
+
)}
|
|
260
|
+
style={{ lineHeight: '1.6', caretColor: 'var(--lava)' }}
|
|
261
|
+
value={content}
|
|
262
|
+
onChange={handleChange}
|
|
263
|
+
onKeyDown={handleKeyDown}
|
|
264
|
+
onScroll={handleScroll}
|
|
265
|
+
spellCheck={false}
|
|
266
|
+
autoComplete="off"
|
|
267
|
+
autoCorrect="off"
|
|
268
|
+
autoCapitalize="off"
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
) : (
|
|
273
|
+
<div className="flex items-center justify-center h-full">
|
|
274
|
+
<p className="text-sm text-muted-foreground">No file selected</p>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{/* Status bar */}
|
|
280
|
+
{activeFile && (
|
|
281
|
+
<div className="flex items-center justify-between px-4 py-1 border-t border-border bg-card text-[10px] text-muted-foreground">
|
|
282
|
+
<div className="flex items-center gap-3">
|
|
283
|
+
<span className="font-mono">{activeFile.language.toUpperCase()}</span>
|
|
284
|
+
<span>{lines.length} lines</span>
|
|
285
|
+
<span>{(activeFile.size / 1024).toFixed(1)} KB</span>
|
|
286
|
+
</div>
|
|
287
|
+
<div className="flex items-center gap-2">
|
|
288
|
+
{isDirty && (
|
|
289
|
+
<span className="text-lava flex items-center gap-1">
|
|
290
|
+
<span className="w-1.5 h-1.5 rounded-full bg-lava inline-block" />
|
|
291
|
+
Unsaved
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
294
|
+
<span>UTF-8</span>
|
|
295
|
+
<span>LF</span>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{/* Syntax highlight CSS */}
|
|
301
|
+
<style>{`
|
|
302
|
+
.syn-keyword { color: #c792ea; }
|
|
303
|
+
.syn-component { color: #82aaff; }
|
|
304
|
+
.syn-tag { color: #f07178; }
|
|
305
|
+
.syn-string { color: #c3e88d; }
|
|
306
|
+
.syn-comment { color: #546e7a; font-style: italic; }
|
|
307
|
+
.syn-number { color: #f78c6c; }
|
|
308
|
+
.syn-function { color: #82aaff; }
|
|
309
|
+
.syn-type { color: #ffcb6b; }
|
|
310
|
+
`}</style>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JXR.js — File Explorer Component
|
|
3
|
+
* Design: LavaFlow OS — Thermal Precision + Edge Command
|
|
4
|
+
* Sidebar file tree with context menu, drag-and-drop ready
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback } from 'react';
|
|
8
|
+
import { useJXR } from '@/contexts/JXRContext';
|
|
9
|
+
import type { VirtualFile, VirtualDirectory } from '@/lib/jxr-runtime';
|
|
10
|
+
import {
|
|
11
|
+
ChevronRight,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
FileCode2,
|
|
14
|
+
FileJson,
|
|
15
|
+
FileText,
|
|
16
|
+
Folder,
|
|
17
|
+
FolderOpen,
|
|
18
|
+
Plus,
|
|
19
|
+
Trash2,
|
|
20
|
+
RefreshCw,
|
|
21
|
+
} from 'lucide-react';
|
|
22
|
+
import { cn } from '@/lib/utils';
|
|
23
|
+
import { toast } from 'sonner';
|
|
24
|
+
|
|
25
|
+
function isVirtualFile(node: VirtualFile | VirtualDirectory): node is VirtualFile {
|
|
26
|
+
return 'content' in node;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getFileIcon(file: VirtualFile) {
|
|
30
|
+
const iconClass = 'w-3.5 h-3.5 shrink-0';
|
|
31
|
+
switch (file.language) {
|
|
32
|
+
case 'tsx':
|
|
33
|
+
case 'jsx':
|
|
34
|
+
return <FileCode2 className={cn(iconClass, 'text-cyan-400')} />;
|
|
35
|
+
case 'ts':
|
|
36
|
+
case 'js':
|
|
37
|
+
return <FileCode2 className={cn(iconClass, 'text-yellow-400')} />;
|
|
38
|
+
case 'json':
|
|
39
|
+
return <FileJson className={cn(iconClass, 'text-green-400')} />;
|
|
40
|
+
case 'css':
|
|
41
|
+
return <FileCode2 className={cn(iconClass, 'text-pink-400')} />;
|
|
42
|
+
default:
|
|
43
|
+
return <FileText className={cn(iconClass, 'text-muted-foreground')} />;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getFileName(path: string): string {
|
|
48
|
+
return path.split('/').pop() ?? path;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TreeNodeProps {
|
|
52
|
+
node: VirtualFile | VirtualDirectory;
|
|
53
|
+
depth: number;
|
|
54
|
+
activePath: string | null;
|
|
55
|
+
onFileClick: (file: VirtualFile) => void;
|
|
56
|
+
onDelete: (path: string) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function TreeNode({ node, depth, activePath, onFileClick, onDelete }: TreeNodeProps) {
|
|
60
|
+
const [expanded, setExpanded] = useState(true);
|
|
61
|
+
const indent = depth * 12;
|
|
62
|
+
|
|
63
|
+
if (isVirtualFile(node)) {
|
|
64
|
+
const isActive = node.path === activePath;
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={cn(
|
|
68
|
+
'group flex items-center gap-1.5 px-2 py-[3px] cursor-pointer rounded-sm text-xs',
|
|
69
|
+
'hover:bg-sidebar-accent/60 transition-colors duration-100',
|
|
70
|
+
isActive && 'bg-sidebar-accent text-lava border-l-2 border-lava'
|
|
71
|
+
)}
|
|
72
|
+
style={{ paddingLeft: `${indent + 8}px` }}
|
|
73
|
+
onClick={() => onFileClick(node)}
|
|
74
|
+
>
|
|
75
|
+
{getFileIcon(node)}
|
|
76
|
+
<span className={cn(
|
|
77
|
+
'flex-1 truncate font-mono',
|
|
78
|
+
isActive ? 'text-lava' : 'text-sidebar-foreground/80'
|
|
79
|
+
)}>
|
|
80
|
+
{getFileName(node.path)}
|
|
81
|
+
</span>
|
|
82
|
+
<button
|
|
83
|
+
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
|
|
84
|
+
onClick={(e) => {
|
|
85
|
+
e.stopPropagation();
|
|
86
|
+
onDelete(node.path);
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<Trash2 className="w-3 h-3" />
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Directory node
|
|
96
|
+
return (
|
|
97
|
+
<div>
|
|
98
|
+
<div
|
|
99
|
+
className={cn(
|
|
100
|
+
'flex items-center gap-1.5 px-2 py-[3px] cursor-pointer rounded-sm text-xs',
|
|
101
|
+
'hover:bg-sidebar-accent/40 transition-colors duration-100'
|
|
102
|
+
)}
|
|
103
|
+
style={{ paddingLeft: `${indent + 4}px` }}
|
|
104
|
+
onClick={() => setExpanded((e) => !e)}
|
|
105
|
+
>
|
|
106
|
+
{expanded ? (
|
|
107
|
+
<ChevronDown className="w-3 h-3 text-muted-foreground shrink-0" />
|
|
108
|
+
) : (
|
|
109
|
+
<ChevronRight className="w-3 h-3 text-muted-foreground shrink-0" />
|
|
110
|
+
)}
|
|
111
|
+
{expanded ? (
|
|
112
|
+
<FolderOpen className="w-3.5 h-3.5 text-lava shrink-0" />
|
|
113
|
+
) : (
|
|
114
|
+
<Folder className="w-3.5 h-3.5 text-lava/70 shrink-0" />
|
|
115
|
+
)}
|
|
116
|
+
<span className="text-sidebar-foreground/90 font-medium">
|
|
117
|
+
{node.name}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
{expanded && (
|
|
121
|
+
<div>
|
|
122
|
+
{node.children.map((child) => (
|
|
123
|
+
<TreeNode
|
|
124
|
+
key={isVirtualFile(child) ? child.path : child.path}
|
|
125
|
+
node={child}
|
|
126
|
+
depth={depth + 1}
|
|
127
|
+
activePath={activePath}
|
|
128
|
+
onFileClick={onFileClick}
|
|
129
|
+
onDelete={onDelete}
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function FileExplorer() {
|
|
139
|
+
const { fileTree, openFile, deleteFile, createFile, activeFile } = useJXR();
|
|
140
|
+
const [newFileName, setNewFileName] = useState('');
|
|
141
|
+
const [showNewFile, setShowNewFile] = useState(false);
|
|
142
|
+
|
|
143
|
+
const handleFileClick = useCallback(
|
|
144
|
+
(file: VirtualFile) => {
|
|
145
|
+
openFile(file.path);
|
|
146
|
+
},
|
|
147
|
+
[openFile]
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const handleDelete = useCallback(
|
|
151
|
+
(path: string) => {
|
|
152
|
+
deleteFile(path);
|
|
153
|
+
toast.success(`Deleted ${path.split('/').pop()}`);
|
|
154
|
+
},
|
|
155
|
+
[deleteFile]
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const handleCreateFile = useCallback(() => {
|
|
159
|
+
if (!newFileName.trim()) return;
|
|
160
|
+
const path = `/src/${newFileName.trim()}`;
|
|
161
|
+
const ext = newFileName.split('.').pop() ?? 'ts';
|
|
162
|
+
const templates: Record<string, string> = {
|
|
163
|
+
tsx: `export default function ${newFileName.replace('.tsx', '')}() {\n return <div>Hello from ${newFileName}</div>;\n}\n`,
|
|
164
|
+
ts: `// ${newFileName}\nexport {};\n`,
|
|
165
|
+
css: `/* ${newFileName} */\n`,
|
|
166
|
+
json: `{}\n`,
|
|
167
|
+
};
|
|
168
|
+
createFile(path, templates[ext] ?? '');
|
|
169
|
+
setNewFileName('');
|
|
170
|
+
setShowNewFile(false);
|
|
171
|
+
}, [newFileName, createFile]);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div className="flex flex-col h-full bg-sidebar">
|
|
175
|
+
{/* Header */}
|
|
176
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-sidebar-border">
|
|
177
|
+
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
178
|
+
Explorer
|
|
179
|
+
</span>
|
|
180
|
+
<div className="flex items-center gap-1">
|
|
181
|
+
<button
|
|
182
|
+
className="p-1 hover:text-lava transition-colors rounded"
|
|
183
|
+
onClick={() => setShowNewFile((v) => !v)}
|
|
184
|
+
title="New file"
|
|
185
|
+
>
|
|
186
|
+
<Plus className="w-3.5 h-3.5" />
|
|
187
|
+
</button>
|
|
188
|
+
<button
|
|
189
|
+
className="p-1 hover:text-lava transition-colors rounded"
|
|
190
|
+
title="Refresh"
|
|
191
|
+
>
|
|
192
|
+
<RefreshCw className="w-3.5 h-3.5" />
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* New file input */}
|
|
198
|
+
{showNewFile && (
|
|
199
|
+
<div className="px-3 py-2 border-b border-sidebar-border">
|
|
200
|
+
<input
|
|
201
|
+
autoFocus
|
|
202
|
+
className="w-full bg-input text-xs font-mono px-2 py-1 rounded border border-border focus:border-lava outline-none"
|
|
203
|
+
placeholder="filename.tsx"
|
|
204
|
+
value={newFileName}
|
|
205
|
+
onChange={(e) => setNewFileName(e.target.value)}
|
|
206
|
+
onKeyDown={(e) => {
|
|
207
|
+
if (e.key === 'Enter') handleCreateFile();
|
|
208
|
+
if (e.key === 'Escape') setShowNewFile(false);
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Tree */}
|
|
215
|
+
<div className="flex-1 overflow-y-auto py-1 scrollbar-thin">
|
|
216
|
+
{fileTree ? (
|
|
217
|
+
<TreeNode
|
|
218
|
+
node={fileTree}
|
|
219
|
+
depth={0}
|
|
220
|
+
activePath={activeFile?.path ?? null}
|
|
221
|
+
onFileClick={handleFileClick}
|
|
222
|
+
onDelete={handleDelete}
|
|
223
|
+
/>
|
|
224
|
+
) : (
|
|
225
|
+
<div className="px-4 py-3 text-xs text-muted-foreground">Loading...</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|