Update index.html: 5-tab UI with Netlify, inference, ChatGPT import

#4
by luguog - opened
Files changed (1) hide show
  1. index.html +393 -0
index.html ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LocalSpace Deployer β€” DMG, GGUF, Sites & Inference</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0a0a0f; --bg-card: #14141a; --bg-hover: #1c1c24; --border: #2a2a35;
10
+ --text: #e2e2e8; --text-dim: #888898; --text-muted: #5a5a68;
11
+ --accent: #6366f1; --accent-hover: #818cf8; --accent-dim: rgba(99,102,241,0.15);
12
+ --green: #22c55e; --green-dim: rgba(34,197,94,0.15);
13
+ --red: #ef4444; --red-dim: rgba(239,68,68,0.15);
14
+ --yellow: #eab300; --yellow-dim: rgba(234,179,0,0.15);
15
+ --cyan: #06b6d4; --cyan-dim: rgba(6,182,212,0.15);
16
+ --radius: 12px; --radius-sm: 8px;
17
+ }
18
+ * { box-sizing: border-box; margin: 0; padding: 0; }
19
+ body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; min-height: 100vh; line-height: 1.6; }
20
+ a { color: var(--accent-hover); text-decoration: none; }
21
+ a:hover { text-decoration: underline; }
22
+ nav { position: sticky; top: 0; z-index: 100; background: rgba(10,10,15,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); padding: 14px 24px; display: flex; align-items: center; justify-content: space-between; }
23
+ nav .logo { font-weight: 700; font-size: 1.1rem; display: flex; align-items: center; gap: 8px; }
24
+ nav .logo span { color: var(--accent); }
25
+ nav .links { display: flex; gap: 20px; align-items: center; }
26
+ nav .links a { color: var(--text-dim); font-size: 0.9rem; }
27
+ nav .links a:hover { color: var(--text); text-decoration: none; }
28
+ nav .cta { background: var(--accent); color: #fff; padding: 7px 16px; border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 600; }
29
+ nav .cta:hover { background: var(--accent-hover); text-decoration: none; }
30
+ .hero { text-align: center; padding: 80px 24px 60px; max-width: 720px; margin: 0 auto; }
31
+ .hero h1 { font-size: 2.8rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 16px; background: linear-gradient(135deg, #e2e2e8 0%, #818cf8 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
32
+ .hero p { font-size: 1.15rem; color: var(--text-dim); margin-bottom: 32px; }
33
+ .hero .badges { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 40px; }
34
+ .hero .badge { background: var(--bg-card); border: 1px solid var(--border); padding: 6px 14px; border-radius: 999px; font-size: 0.82rem; color: var(--text-dim); }
35
+ .hero .badge strong { color: var(--text); }
36
+ .features { max-width: 900px; margin: 0 auto 60px; display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; padding: 0 24px; }
37
+ .feature { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; transition: border-color 0.2s; }
38
+ .feature:hover { border-color: var(--accent); }
39
+ .feature .icon { font-size: 1.6rem; margin-bottom: 12px; }
40
+ .feature h3 { font-size: 1rem; font-weight: 600; margin-bottom: 6px; }
41
+ .feature p { font-size: 0.88rem; color: var(--text-dim); }
42
+ .section { max-width: 900px; margin: 0 auto 80px; padding: 0 24px; }
43
+ .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
44
+ .section-header h2 { font-size: 1.4rem; font-weight: 700; }
45
+ .section-header .count { background: var(--bg-card); border: 1px solid var(--border); padding: 4px 12px; border-radius: 999px; font-size: 0.82rem; color: var(--text-dim); }
46
+ .tabs { display: flex; gap: 4px; margin-bottom: 24px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px; }
47
+ .tab { flex: 1; padding: 10px 16px; border-radius: var(--radius-sm); font-size: 0.88rem; font-weight: 500; color: var(--text-dim); cursor: pointer; border: none; background: none; text-align: center; }
48
+ .tab:hover { color: var(--text); }
49
+ .tab.active { background: var(--accent); color: #fff; }
50
+ .tab-content { display: none; }
51
+ .tab-content.active { display: block; }
52
+ .dropzone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 48px 24px; text-align: center; cursor: pointer; transition: all 0.2s; background: var(--bg-card); }
53
+ .dropzone:hover, .dropzone.drag { border-color: var(--accent); background: var(--accent-dim); }
54
+ .dropzone .dz-icon { font-size: 2.5rem; margin-bottom: 12px; }
55
+ .dropzone .dz-text { font-size: 1.05rem; font-weight: 600; margin-bottom: 4px; }
56
+ .dropzone .dz-sub { font-size: 0.85rem; color: var(--text-dim); }
57
+ .dropzone .dz-formats { margin-top: 16px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
58
+ .dropzone .dz-format { background: var(--bg); border: 1px solid var(--border); padding: 4px 10px; border-radius: 6px; font-size: 0.78rem; color: var(--text-dim); }
59
+ .dropzone input[type=file] { display: none; }
60
+ .upload-progress { margin-top: 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; display: none; }
61
+ .upload-progress.active { display: block; }
62
+ .upload-progress .bar-wrap { background: var(--bg); border-radius: 999px; height: 6px; overflow: hidden; margin-top: 8px; }
63
+ .upload-progress .bar { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.2s; width: 0%; }
64
+ .upload-progress .status { font-size: 0.88rem; color: var(--text-dim); }
65
+ .creator-box { margin-top: 24px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; }
66
+ .creator-box h3 { font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
67
+ .creator-box p { font-size: 0.85rem; color: var(--text-dim); margin-bottom: 16px; }
68
+ .creator-box .row { display: flex; gap: 10px; flex-wrap: wrap; }
69
+ .creator-box input { flex: 1; min-width: 180px; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 14px; border-radius: var(--radius-sm); font-size: 0.9rem; outline: none; }
70
+ .creator-box input:focus { border-color: var(--accent); }
71
+ .creator-box button { background: var(--accent); color: #fff; border: none; padding: 10px 20px; border-radius: var(--radius-sm); font-size: 0.9rem; font-weight: 600; cursor: pointer; }
72
+ .creator-box button:hover { background: var(--accent-hover); }
73
+ .creator-box button:disabled { opacity: 0.5; }
74
+ .inference-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; }
75
+ .inference-panel .inf-status { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; font-size: 0.88rem; }
76
+ .inference-panel .inf-status .dot { width: 8px; height: 8px; border-radius: 50%; }
77
+ .inference-panel .inf-status .dot.on { background: var(--green); }
78
+ .inference-panel .inf-status .dot.off { background: var(--red); }
79
+ .inference-panel .model-select { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
80
+ .inference-panel select { flex: 1; min-width: 200px; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 14px; border-radius: var(--radius-sm); font-size: 0.9rem; outline: none; }
81
+ .inference-panel .chat-box { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; min-height: 200px; max-height: 400px; overflow-y: auto; margin-bottom: 12px; }
82
+ .inference-panel .chat-msg { margin-bottom: 12px; }
83
+ .inference-panel .chat-msg .role { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; margin-bottom: 2px; }
84
+ .inference-panel .chat-msg .role.user { color: var(--accent); }
85
+ .inference-panel .chat-msg .role.assistant { color: var(--green); }
86
+ .inference-panel .chat-msg .bubble { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; font-size: 0.88rem; white-space: pre-wrap; }
87
+ .inference-panel .chat-input { display: flex; gap: 10px; }
88
+ .inference-panel .chat-input input { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 14px; border-radius: var(--radius-sm); font-size: 0.9rem; outline: none; }
89
+ .inference-panel .chat-input button { background: var(--accent); color: #fff; border: none; padding: 10px 20px; border-radius: var(--radius-sm); font-size: 0.9rem; font-weight: 600; cursor: pointer; }
90
+ .inference-panel .chat-input button:disabled { opacity: 0.5; }
91
+ .inference-panel .inf-hint { font-size: 0.82rem; color: var(--text-muted); margin-top: 12px; padding: 10px; background: var(--bg); border-radius: 8px; border: 1px solid var(--border); }
92
+ .apps-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; margin-top: 24px; }
93
+ .app-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; transition: border-color 0.2s, transform 0.15s; }
94
+ .app-card:hover { border-color: var(--accent); transform: translateY(-2px); }
95
+ .app-card .card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 12px; }
96
+ .app-card .card-title { font-weight: 600; font-size: 0.95rem; word-break: break-all; }
97
+ .app-card .card-type { font-size: 0.72rem; padding: 2px 8px; border-radius: 999px; font-weight: 600; white-space: nowrap; margin-left: 8px; }
98
+ .app-card .card-type.dmg { background: var(--accent-dim); color: var(--accent-hover); }
99
+ .app-card .card-type.gguf { background: var(--green-dim); color: var(--green); }
100
+ .app-card .card-type.webapp { background: var(--yellow-dim); color: var(--yellow); }
101
+ .app-card .card-type.site { background: var(--cyan-dim); color: var(--cyan); }
102
+ .app-card .card-type.chatgpt { background: var(--green-dim); color: var(--green); }
103
+ .app-card .card-meta { display: flex; flex-wrap: wrap; gap: 12px; font-size: 0.8rem; color: var(--text-dim); margin-bottom: 12px; }
104
+ .app-card .card-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; margin-bottom: 12px; }
105
+ .app-card .card-status.ok { color: var(--green); }
106
+ .app-card .card-status.err { color: var(--red); }
107
+ .app-card .card-status .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
108
+ .app-card .card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
109
+ .app-card .card-actions a, .app-card .card-actions button { background: var(--bg-hover); border: 1px solid var(--border); color: var(--text); padding: 6px 12px; border-radius: var(--radius-sm); font-size: 0.82rem; text-decoration: none; cursor: pointer; }
110
+ .app-card .card-actions a:hover, .app-card .card-actions button:hover { background: var(--border); text-decoration: none; }
111
+ .app-card .card-actions .danger:hover { background: var(--red-dim); border-color: var(--red); }
112
+ .app-card .card-details { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; font-size: 0.82rem; color: var(--text-dim); }
113
+ .app-card .card-details .detail-row { display: flex; justify-content: space-between; padding: 2px 0; }
114
+ .app-card .card-details .detail-row strong { color: var(--text); }
115
+ .app-card .gguf-meta { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; font-size: 0.8rem; color: var(--text-dim); max-height: 200px; overflow-y: auto; }
116
+ .app-card .gguf-meta .meta-row { display: flex; justify-content: space-between; padding: 2px 0; }
117
+ .app-card .gguf-meta .meta-row strong { color: var(--text); }
118
+ .empty { text-align: center; padding: 48px 24px; color: var(--text-muted); }
119
+ .empty .icon { font-size: 2rem; margin-bottom: 8px; }
120
+ footer { border-top: 1px solid var(--border); padding: 32px 24px; text-align: center; font-size: 0.82rem; color: var(--text-muted); }
121
+ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 200; padding: 24px; }
122
+ .modal-overlay.active { display: flex; }
123
+ .modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); max-width: 700px; width: 100%; max-height: 80vh; overflow-y: auto; padding: 28px; }
124
+ .modal h2 { font-size: 1.2rem; margin-bottom: 16px; }
125
+ .modal .close { float: right; cursor: pointer; color: var(--text-dim); font-size: 1.3rem; }
126
+ .modal .close:hover { color: var(--text); }
127
+ .modal pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; overflow-x: auto; font-size: 0.82rem; color: var(--text-dim); }
128
+ @media (max-width: 640px) { .hero h1 { font-size: 2rem; } .hero p { font-size: 1rem; } .features { grid-template-columns: 1fr; } .apps-grid { grid-template-columns: 1fr; } nav .links a:not(.cta) { display: none; } .tab { font-size: 0.78rem; padding: 8px 8px; } }
129
+ .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; }
130
+ @keyframes spin { to { transform: rotate(360deg); } }
131
+ </style>
132
+ </head>
133
+ <body>
134
+ <nav>
135
+ <div class="logo">⬑ <span>LocalSpace</span> Deployer</div>
136
+ <div class="links">
137
+ <a href="#features">Features</a>
138
+ <a href="#deploy">Deploy</a>
139
+ <a href="https://huggingface.co/spaces/josephrw/localspace-deployer" target="_blank">HF Space</a>
140
+ <a href="#deploy" class="cta">Get Started</a>
141
+ </div>
142
+ </nav>
143
+
144
+ <section class="hero">
145
+ <h1>Drag a DMG, GGUF, or ZIP.<br>It gets opened on the server.</h1>
146
+ <p>Upload macOS disk images, GGUF model files, or static site ZIPs. LocalSpace extracts, inspects, serves, and runs inference β€” all in one Hugging Face Space. No Gradio. No API keys. Pure FastAPI.</p>
147
+ <div class="badges">
148
+ <div class="badge"><strong>DMG</strong> extraction</div>
149
+ <div class="badge"><strong>GGUF</strong> inference</div>
150
+ <div class="badge"><strong>Tiny Netlify</strong> site deploy</div>
151
+ <div class="badge"><strong>ChatGPT export</strong> β†’ dApp</div>
152
+ <div class="badge"><strong>Persistent</strong> storage</div>
153
+ </div>
154
+ </section>
155
+
156
+ <section class="features" id="features">
157
+ <div class="feature"><div class="icon">πŸ“¦</div><h3>DMG Extraction</h3><p>Upload .dmg β†’ 7z extracts server-side, finds .app bundles, reads Info.plist, lists files, SHA-256 hashes.</p></div>
158
+ <div class="feature"><div class="icon">🧠</div><h3>GGUF Inference</h3><p>Upload .gguf β†’ parse metadata, load with llama-cpp-python, serve OpenAI-compatible /api/inference/chat endpoint.</p></div>
159
+ <div class="feature"><div class="icon">🌐</div><h3>Tiny Netlify</h3><p>Upload a static site ZIP β†’ get a hosted URL. Base-tag injection, full MIME support, file tree browsing.</p></div>
160
+ <div class="feature"><div class="icon">πŸ’¬</div><h3>ChatGPT β†’ dApp</h3><p>Upload ChatGPT export (conversations.json or .zip) β†’ get a browsable dApp with sidebar, search, and message viewer.</p></div>
161
+ <div class="feature"><div class="icon">πŸ”—</div><h3>Web App β†’ DMG</h3><p>Paste any URL β†’ get a macOS .app bundle wrapper. Creates Info.plist, executable, and HTML fallback.</p></div>
162
+ <div class="feature"><div class="icon">πŸ’Ύ</div><h3>Persistent Storage</h3><p>Auto-detects /data mount on HF Spaces. Falls back to local storage. All artifacts survive restarts when mounted.</p></div>
163
+ </section>
164
+
165
+ <section class="section" id="deploy">
166
+ <div class="section-header">
167
+ <h2>Artifact Deployer</h2>
168
+ <div class="count" id="app-count">0 artifacts</div>
169
+ </div>
170
+ <div class="tabs">
171
+ <button class="tab active" onclick="switchTab(event,'dmg-gguf')">πŸ“¦ DMG / GGUF</button>
172
+ <button class="tab" onclick="switchTab(event,'site')">🌐 Deploy Site</button>
173
+ <button class="tab" onclick="switchTab(event,'chatgpt')">πŸ’¬ ChatGPT Export</button>
174
+ <button class="tab" onclick="switchTab(event,'inference')">🧠 Inference</button>
175
+ <button class="tab" onclick="switchTab(event,'webapp')">πŸ”— Web App</button>
176
+ </div>
177
+
178
+ <div class="tab-content active" id="tab-dmg-gguf">
179
+ <div class="dropzone" id="dropzone">
180
+ <div class="dz-icon">⬆️</div>
181
+ <div class="dz-text">Drop a DMG or GGUF file here</div>
182
+ <div class="dz-sub">or click to browse</div>
183
+ <div class="dz-formats"><div class="dz-format">.dmg</div><div class="dz-format">.gguf</div></div>
184
+ <input type="file" id="file-input" accept=".dmg,.gguf">
185
+ </div>
186
+ <div class="upload-progress" id="upload-progress">
187
+ <div class="status" id="upload-status">Uploading...</div>
188
+ <div class="bar-wrap"><div class="bar" id="upload-bar"></div></div>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="tab-content" id="tab-site">
193
+ <div class="dropzone" id="site-dropzone">
194
+ <div class="dz-icon">🌐</div>
195
+ <div class="dz-text">Drop a static site ZIP here</div>
196
+ <div class="dz-sub">index.html will be served at /site/{id}/</div>
197
+ <div class="dz-formats"><div class="dz-format">.zip</div></div>
198
+ <input type="file" id="site-file-input" accept=".zip">
199
+ </div>
200
+ <div class="upload-progress" id="site-upload-progress">
201
+ <div class="status" id="site-upload-status">Deploying...</div>
202
+ <div class="bar-wrap"><div class="bar" id="site-upload-bar"></div></div>
203
+ </div>
204
+ </div>
205
+
206
+ <div class="tab-content" id="tab-chatgpt">
207
+ <div class="dropzone" id="chatgpt-dropzone">
208
+ <div class="dz-icon">πŸ’¬</div>
209
+ <div class="dz-text">Drop a ChatGPT export here</div>
210
+ <div class="dz-sub">conversations.json or export ZIP β†’ browsable dApp</div>
211
+ <div class="dz-formats"><div class="dz-format">.json</div><div class="dz-format">.zip</div></div>
212
+ <input type="file" id="chatgpt-file-input" accept=".json,.zip">
213
+ </div>
214
+ <div class="upload-progress" id="chatgpt-upload-progress">
215
+ <div class="status" id="chatgpt-upload-status">Importing...</div>
216
+ <div class="bar-wrap"><div class="bar" id="chatgpt-upload-bar"></div></div>
217
+ </div>
218
+ </div>
219
+
220
+ <div class="tab-content" id="tab-inference">
221
+ <div class="inference-panel">
222
+ <div class="inf-status"><span class="dot" id="inf-dot"></span><span id="inf-status-text">Checking...</span></div>
223
+ <div class="model-select">
224
+ <select id="inf-model-select"><option value="">Select a GGUF model...</option></select>
225
+ <button onclick="loadModel()" id="inf-load-btn">Load Model</button>
226
+ </div>
227
+ <div class="chat-box" id="inf-chat-box"><div style="color:var(--text-muted);text-align:center;padding:40px">Load a GGUF model to start chatting</div></div>
228
+ <div class="chat-input">
229
+ <input type="text" id="inf-chat-input" placeholder="Type a message..." onkeydown="if(event.key==='Enter')sendChat()" disabled>
230
+ <button onclick="sendChat()" id="inf-send-btn" disabled>Send</button>
231
+ </div>
232
+ <div class="inf-hint" id="inf-hint"></div>
233
+ </div>
234
+ </div>
235
+
236
+ <div class="tab-content" id="tab-webapp">
237
+ <div class="creator-box">
238
+ <h3>πŸ”— Create macOS App from URL</h3>
239
+ <p>Paste a web app URL to generate a .app bundle wrapper that opens it on macOS.</p>
240
+ <div class="row">
241
+ <input type="text" id="webapp-url" placeholder="https://your-web-app-url.com">
242
+ <input type="text" id="webapp-name" placeholder="App name" style="max-width:180px">
243
+ <button id="webapp-btn" onclick="createWebapp()">Create App</button>
244
+ </div>
245
+ </div>
246
+ </div>
247
+
248
+ <div class="apps-grid" id="apps-grid"></div>
249
+ </section>
250
+
251
+ <div class="modal-overlay" id="modal-overlay" onclick="if(event.target===this)closeModal()">
252
+ <div class="modal"><span class="close" onclick="closeModal()">&times;</span><h2 id="modal-title">Details</h2><div id="modal-body"></div></div>
253
+ </div>
254
+
255
+ <footer>
256
+ LocalSpace Deployer β€” DMG, GGUF, Sites & Inference on Hugging Face Spaces<br>
257
+ <a href="https://huggingface.co/spaces/josephrw/localspace-deployer" target="_blank">huggingface.co/spaces/josephrw/localspace-deployer</a>
258
+ </footer>
259
+
260
+ <script>
261
+ let apps = [];
262
+ let loadedModelId = null;
263
+
264
+ document.addEventListener('DOMContentLoaded', () => { loadApps(); setupDropzones(); checkInference(); });
265
+
266
+ function switchTab(e, name) {
267
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
268
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
269
+ e.target.classList.add('active');
270
+ document.getElementById('tab-' + name).classList.add('active');
271
+ }
272
+
273
+ function setupDropzones() {
274
+ setupDropzone('dropzone', 'file-input', uploadDmgGguf);
275
+ setupDropzone('site-dropzone', 'site-file-input', uploadSite);
276
+ setupDropzone('chatgpt-dropzone', 'chatgpt-file-input', uploadChatGpt);
277
+ }
278
+ function setupDropzone(dzId, inputId, handler) {
279
+ const dz = document.getElementById(dzId); const input = document.getElementById(inputId);
280
+ if (!dz || !input) return;
281
+ dz.addEventListener('click', () => input.click());
282
+ input.addEventListener('change', e => { if (e.target.files[0]) handler(e.target.files[0]); });
283
+ dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });
284
+ dz.addEventListener('dragleave', () => dz.classList.remove('drag'));
285
+ dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('drag'); if (e.dataTransfer.files[0]) handler(e.dataTransfer.files[0]); });
286
+ }
287
+
288
+ function uploadFile(file, url, progId, barId, statusId, okMsg) {
289
+ const prog = document.getElementById(progId); const bar = document.getElementById(barId); const status = document.getElementById(statusId);
290
+ prog.classList.add('active'); bar.style.width = '0%'; status.textContent = `Uploading ${file.name}...`;
291
+ const fd = new FormData(); fd.append('file', file);
292
+ const xhr = new XMLHttpRequest(); xhr.open('POST', url);
293
+ xhr.upload.onprogress = e => { if (e.lengthComputable) { const pct = Math.round((e.loaded/e.total)*100); bar.style.width = pct+'%'; status.textContent = `Uploading ${file.name}... ${pct}%`; } };
294
+ xhr.onload = () => {
295
+ if (xhr.status === 201) { const d = JSON.parse(xhr.responseText); status.textContent = `βœ“ ${d.message||okMsg}`; bar.style.width = '100%'; setTimeout(() => prog.classList.remove('active'), 2500); loadApps(); }
296
+ else { let m='Upload failed'; try{m=JSON.parse(xhr.responseText).error||m;}catch{} status.textContent = `βœ— ${m}`; setTimeout(() => prog.classList.remove('active'), 4000); }
297
+ };
298
+ xhr.onerror = () => { status.textContent = 'βœ— Network error'; setTimeout(() => prog.classList.remove('active'), 3000); };
299
+ xhr.send(fd);
300
+ }
301
+ function uploadDmgGguf(f) { const e=f.name.toLowerCase().split('.').pop(); if(e!=='dmg'&&e!=='gguf'){alert('Only .dmg and .gguf');return;} uploadFile(f,'/api/upload','upload-progress','upload-bar','upload-status','Uploaded.'); }
302
+ function uploadSite(f) { if(!f.name.toLowerCase().endsWith('.zip')){alert('Only .zip');return;} uploadFile(f,'/api/deploy-site','site-upload-progress','site-upload-bar','site-upload-status','Site deployed.'); }
303
+ function uploadChatGpt(f) { const e=f.name.toLowerCase().split('.').pop(); if(e!=='json'&&e!=='zip'){alert('Only .json or .zip');return;} uploadFile(f,'/api/import/chatgpt','chatgpt-upload-progress','chatgpt-upload-bar','chatgpt-upload-status','dApp deployed.'); }
304
+
305
+ async function loadApps() {
306
+ try { const r = await fetch('/api/apps'); const d = await r.json(); apps = d.apps||[]; renderApps(); updateModelSelect(); } catch(e) { console.error(e); }
307
+ }
308
+ function renderApps() {
309
+ const g = document.getElementById('apps-grid'); const c = document.getElementById('app-count');
310
+ c.textContent = `${apps.length} artifact${apps.length!==1?'s':''}`;
311
+ if (!apps.length) { g.innerHTML = `<div class="empty" style="grid-column:1/-1"><div class="icon">πŸ“¦</div><div>No artifacts yet. Upload something to get started.</div></div>`; return; }
312
+ g.innerHTML = apps.map(renderCard).join('');
313
+ }
314
+ function renderCard(a) {
315
+ const t = a.file_type||'dmg';
316
+ const tc = ['dmg','gguf','webapp','site','chatgpt'].includes(t)?t:'dmg';
317
+ const tl = {dmg:'DMG',gguf:'GGUF',webapp:'WEB APP',site:'SITE',chatgpt:'CHATGPT'}[t]||t.toUpperCase();
318
+ const ok = a.opened||a.is_webapp;
319
+ const st = a.opened ? (t==='gguf'?'Inspected':'Opened') : (a.is_webapp?'Packaged':(a.error||'Failed'));
320
+ const sc = ok?'ok':'err';
321
+ let det = '';
322
+ if (t==='dmg'&&a.apps&&a.apps.length) { const app=a.apps[0]; det=`<div class="card-details"><div class="detail-row"><span>Bundle</span><strong>${esc(app.bundle_name||'β€”')}</strong></div><div class="detail-row"><span>Bundle ID</span><strong>${esc(app.bundle_id||'β€”')}</strong></div><div class="detail-row"><span>Version</span><strong>${esc(app.version||'β€”')}</strong></div><div class="detail-row"><span>Files</span><strong>${app.file_count||0}</strong></div></div>`; }
323
+ if (t==='site') { det=`<div class="card-details"><div class="detail-row"><span>Files</span><strong>${a.file_count||0}</strong></div><div class="detail-row"><span>Total size</span><strong>${a.total_size_human||'β€”'}</strong></div><div class="detail-row"><span>Index</span><strong>${esc(a.index_file||'index.html')}</strong></div></div>`; }
324
+ if (t==='chatgpt') { det=`<div class="card-details"><div class="detail-row"><span>Conversations</span><strong>${a.conversation_count||0}</strong></div><div class="detail-row"><span>Messages</span><strong>${a.total_messages||0}</strong></div></div>`; }
325
+ let gm = '';
326
+ if (t==='gguf'&&a.gguf&&a.gguf.valid) { const g=a.gguf; const mr=Object.entries(g.metadata||{}).slice(0,15).map(([k,v])=>`<div class="meta-row"><span>${esc(k)}</span><strong>${esc(String(v.value)).substring(0,60)}</strong></div>`).join(''); gm=`<div class="gguf-meta"><div class="meta-row"><span>Architecture</span><strong>${esc(g.architecture||'β€”')}</strong></div><div class="meta-row"><span>Version</span><strong>GGUF v${g.version}</strong></div><div class="meta-row"><span>Tensors</span><strong>${g.tensor_count}</strong></div><div class="meta-row"><span>KV pairs</span><strong>${g.kv_count}</strong></div>${g.context_length?`<div class="meta-row"><span>Context</span><strong>${g.context_length}</strong></div>`:''}${mr}</div>`; }
327
+ let pl = '';
328
+ if (a.has_preview) { if (t==='site') pl=`<a href="/site/${a.app_id}/" target="_blank">🌐 Visit</a>`; else if (t==='chatgpt') pl=`<a href="/dapp/${a.app_id}/" target="_blank">πŸ’¬ Open dApp</a>`; else pl=`<a href="/app/${a.app_id}" target="_blank">β–Ά Preview</a>`; }
329
+ const dl=`<a href="/api/download/${a.app_id}" download>⬇ Download</a>`;
330
+ const db=(t==='dmg'||t==='gguf'||t==='site')?`<button onclick="showDetails('${a.app_id}')">πŸ“‹ Details</button>`:'';
331
+ const lb=(t==='gguf')?`<button onclick="loadModelById('${a.app_id}')">🧠 Load</button>`:'';
332
+ const xb=`<button class="danger" onclick="deleteApp('${a.app_id}')">πŸ—‘ Delete</button>`;
333
+ return `<div class="app-card"><div class="card-header"><div class="card-title">${esc(a.filename||a.app_name||'Unknown')}</div><div class="card-type ${tc}">${tl}</div></div><div class="card-meta"><span>πŸ“ ${a.size_human||formatBytes(a.size)}</span><span>πŸ• ${timeAgo(a.created_at)}</span></div><div class="card-status ${sc}"><span class="dot"></span>${esc(st)}</div>${det}${gm}<div class="card-actions">${pl}${dl}${lb}${db}${xb}</div></div>`;
334
+ }
335
+
336
+ async function checkInference() {
337
+ try {
338
+ const r = await fetch('/api/inference/status'); const d = await r.json();
339
+ const dot = document.getElementById('inf-dot'); const txt = document.getElementById('inf-status-text'); const hint = document.getElementById('inf-hint');
340
+ if (d.available) { dot.className='dot on'; txt.textContent=`Inference available β€” ${d.loaded_models} model(s) loaded`; hint.textContent='Select a GGUF model and click Load Model.'; }
341
+ else { dot.className='dot off'; txt.textContent='Inference unavailable β€” llama-cpp-python not installed'; hint.textContent='Add llama-cpp-python to requirements.txt to enable inference.'; }
342
+ updateModelSelect();
343
+ } catch(e) { document.getElementById('inf-dot').className='dot off'; document.getElementById('inf-status-text').textContent='Failed to check'; }
344
+ }
345
+ function updateModelSelect() {
346
+ const sel = document.getElementById('inf-model-select'); if (!sel) return;
347
+ const gguf = apps.filter(a => a.file_type==='gguf');
348
+ sel.innerHTML = '<option value="">Select a GGUF model...</option>' + gguf.map(a => `<option value="${a.app_id}">${esc(a.filename)}</option>`).join('');
349
+ }
350
+ async function loadModelById(appId) {
351
+ try { const r = await fetch(`/api/inference/load/${appId}`, { method: 'POST' }); const d = await r.json();
352
+ if (r.ok) { loadedModelId = appId; document.getElementById('inf-model-select').value = appId; document.getElementById('inf-chat-input').disabled = false; document.getElementById('inf-send-btn').disabled = false; document.getElementById('inf-chat-box').innerHTML = `<div style="color:var(--green);text-align:center;padding:20px">βœ“ ${d.message}</div>`; }
353
+ else { alert(d.error||'Failed to load'); }
354
+ } catch(e) { alert('Error: '+e.message); }
355
+ }
356
+ async function loadModel() { const sel = document.getElementById('inf-model-select'); if (!sel.value) { alert('Select a model'); return; } await loadModelById(sel.value); }
357
+ async function sendChat() {
358
+ const inp = document.getElementById('inf-chat-input'); const msg = inp.value.trim(); if (!msg||!loadedModelId) return;
359
+ inp.value = ''; const box = document.getElementById('inf-chat-box');
360
+ box.innerHTML += `<div class="chat-msg"><div class="role user">You</div><div class="bubble">${esc(msg)}</div></div>`; box.scrollTop = box.scrollHeight;
361
+ const btn = document.getElementById('inf-send-btn'); btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
362
+ try { const r = await fetch(`/api/inference/chat/${loadedModelId}`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ messages: [{role:'user',content:msg}], max_tokens: 512 }) });
363
+ const d = await r.json();
364
+ if (r.ok && d.choices && d.choices[0]) { box.innerHTML += `<div class="chat-msg"><div class="role assistant">Assistant</div><div class="bubble">${esc(d.choices[0].message.content)}</div></div>`; }
365
+ else { box.innerHTML += `<div class="chat-msg"><div class="role assistant">Error</div><div class="bubble">${esc(d.error||'Failed')}</div></div>`; }
366
+ box.scrollTop = box.scrollHeight;
367
+ } catch(e) { box.innerHTML += `<div class="chat-msg"><div class="role assistant">Error</div><div class="bubble">${esc(e.message)}</div></div>`; }
368
+ btn.disabled = false; btn.textContent = 'Send';
369
+ }
370
+
371
+ async function createWebapp() {
372
+ const url = document.getElementById('webapp-url').value.trim(); const name = document.getElementById('webapp-name').value.trim()||'WebApp'; const btn = document.getElementById('webapp-btn');
373
+ if (!url) { alert('URL required'); return; }
374
+ btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Creating...';
375
+ try { const r = await fetch('/api/create-webapp', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ url, app_name: name }) });
376
+ const d = await r.json(); if (r.ok) { document.getElementById('webapp-url').value=''; document.getElementById('webapp-name').value=''; loadApps(); } else { alert(d.error||'Failed'); }
377
+ } catch(e) { alert('Error: '+e.message); }
378
+ btn.disabled = false; btn.textContent = 'Create App';
379
+ }
380
+
381
+ function showDetails(appId) { const a = apps.find(x=>x.app_id===appId); if(!a) return; document.getElementById('modal-title').textContent = a.filename||a.app_name||'Details';
382
+ let b=''; if (a.gguf) b=`<pre>${esc(JSON.stringify(a.gguf,null,2))}</pre>`; else if (a.apps&&a.apps.length) b=`<pre>${esc(JSON.stringify(a.apps,null,2))}</pre>`; else if (a.tree) b=`<pre>${esc(JSON.stringify(a.tree,null,2))}</pre>`; else b=`<pre>${esc(JSON.stringify(a,null,2))}</pre>`;
383
+ document.getElementById('modal-body').innerHTML = b; document.getElementById('modal-overlay').classList.add('active');
384
+ }
385
+ function closeModal() { document.getElementById('modal-overlay').classList.remove('active'); }
386
+ async function deleteApp(appId) { if(!confirm('Delete?')) return; try { await fetch(`/api/apps/${appId}`,{method:'DELETE'}); loadApps(); } catch(e) { alert('Failed: '+e.message); } }
387
+
388
+ function esc(s) { if(!s) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
389
+ function formatBytes(n) { if(!n) return '0 B'; const u=['B','KB','MB','GB','TB']; let i=0; while(n>=1024&&i<u.length-1){n/=1024;i++;} return n.toFixed(1)+' '+u[i]; }
390
+ function timeAgo(ts) { if(!ts) return 'β€”'; const d=Date.now()/1000-ts; if(d<60) return 'just now'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; }
391
+ </script>
392
+ </body>
393
+ </html>