feat: simplify demo app and add metadata flags
Kim committed
Nov 15, 2025 at 11:31 UTC
cc77c9813bdeecd804a097a20ca6b151b63320ff
5 files changed
+208
-876
cmd/demo-app/main.go
+63
-131
@@ -2,14 +2,16 @@ package main
2
3
import (
4
"embed"
5
+ "encoding/json"
6
"flag"
7
"fmt"
8
"io/fs"
9
"net/http"
10
"os"
11
"os/signal"
11
- "sync"
12
+ "strings"
13
"syscall"
14
+ "time"
15
16
"github.com/gorilla/websocket"
17
"github.com/rs/zerolog/log"
@@ -24,158 +26,61 @@ var (
26
flagServerURL string
27
flagPort int
28
flagName string
29
+ flagDesc string
30
+ flagTags string
31
+ flagOwner string
32
+ flagHide bool
33
)
34
35
func main() {
30
- // Define flags equivalent to previous Cobra flags
36
flag.StringVar(&flagServerURL, "server-url", "ws://localhost:4017/relay", "relay websocket URL")
32
- flag.IntVar(&flagPort, "port", 8092, "local paint HTTP port")
37
+ flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
38
flag.StringVar(&flagName, "name", "demo-app", "backend display name")
39
+ flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
40
+ flag.StringVar(&flagTags, "tags", "demo,connectivity", "comma-separated lease tags")
41
+ flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
42
+ flag.BoolVar(&flagHide, "hide", false, "hide this lease from listings")
43
44
flag.Parse()
45
37
- if err := runPaint(); err != nil {
38
- log.Fatal().Err(err).Msg("execute paint command")
46
+ if err := runDemo(); err != nil {
47
+ log.Fatal().Err(err).Msg("execute demo command")
48
}
49
}
50
42
-// DrawMessage represents a drawing action
43
-type DrawMessage struct {
44
- Type string `json:"type"` // "draw", "shape", "text", or "clear"
45
- X float64 `json:"x,omitempty"`
46
- Y float64 `json:"y,omitempty"`
47
- PrevX float64 `json:"prevX,omitempty"`
48
- PrevY float64 `json:"prevY,omitempty"`
49
- StartX float64 `json:"startX,omitempty"`
50
- StartY float64 `json:"startY,omitempty"`
51
- EndX float64 `json:"endX,omitempty"`
52
- EndY float64 `json:"endY,omitempty"`
53
- Mode string `json:"mode,omitempty"` // "line", "circle", "rectangle"
54
- Text string `json:"text,omitempty"` // for text type
55
- Color string `json:"color,omitempty"`
56
- Width int `json:"width,omitempty"`
57
- Canvas string `json:"canvas,omitempty"` // for initial state
58
-}
59
-
60
-// Canvas holds the current drawing state
61
-type Canvas struct {
62
- mu sync.RWMutex
63
- clients map[*websocket.Conn]bool
64
- wg sync.WaitGroup
65
- history []DrawMessage
66
-}
67
-
68
-func newCanvas() *Canvas {
69
- return &Canvas{
70
- clients: make(map[*websocket.Conn]bool),
71
- history: make([]DrawMessage, 0),
72
- }
73
-}
74
-
75
-func (c *Canvas) register(conn *websocket.Conn) {
76
- c.mu.Lock()
77
- defer c.mu.Unlock()
78
- c.clients[conn] = true
79
-
80
- // Send history to new client
81
- for _, msg := range c.history {
82
- err := conn.WriteJSON(msg)
83
- if err != nil {
84
- log.Error().Err(err).Msg("write to client")
85
- }
86
- }
87
-}
88
-
89
-func (c *Canvas) unregister(conn *websocket.Conn) {
90
- c.mu.Lock()
91
- defer c.mu.Unlock()
92
- if _, ok := c.clients[conn]; ok {
93
- delete(c.clients, conn)
94
- err := conn.Close()
95
- if err != nil {
96
- log.Error().Err(err).Msg("close client")
97
- }
98
- }
99
-}
100
-
101
-func (c *Canvas) broadcast(msg DrawMessage) {
102
- c.mu.Lock()
103
- defer c.mu.Unlock()
104
-
105
- // Store in history
106
- switch msg.Type {
107
- case "draw", "shape", "text":
108
- c.history = append(c.history, msg)
109
- case "clear":
110
- c.history = make([]DrawMessage, 0)
111
- }
112
-
113
- // Broadcast to all clients
114
- for client := range c.clients {
115
- err := client.WriteJSON(msg)
116
- if err != nil {
117
- log.Error().Err(err).Msg("write to client")
118
- err = client.Close()
119
- if err != nil {
120
- log.Error().Err(err).Msg("close client")
121
- }
122
- delete(c.clients, client)
123
- }
124
- }
125
-}
126
-
127
-func (c *Canvas) closeAll() {
128
- c.mu.Lock()
129
- defer c.mu.Unlock()
130
- for client := range c.clients {
131
- err := client.Close()
132
- if err != nil {
133
- log.Error().Err(err).Msg("close client")
134
- }
135
- }
136
- c.clients = make(map[*websocket.Conn]bool)
137
-}
138
-
139
-func (c *Canvas) wait() {
140
- c.wg.Wait()
141
-}
142
-
51
var upgrader = websocket.Upgrader{
52
CheckOrigin: func(r *http.Request) bool {
53
return true
54
},
55
}
56
149
-func (c *Canvas) handleWS(w http.ResponseWriter, r *http.Request) {
57
+// handleWS is a minimal WebSocket echo handler to verify bidirectional connectivity.
58
+func handleWS(w http.ResponseWriter, r *http.Request) {
59
conn, err := upgrader.Upgrade(w, r, nil)
60
if err != nil {
61
log.Error().Err(err).Msg("upgrade websocket")
62
return
63
}
155
-
156
- c.register(conn)
157
- c.wg.Add(1)
158
-
159
- defer func() {
160
- c.unregister(conn)
161
- c.wg.Done()
162
- }()
64
+ defer conn.Close()
65
66
for {
165
- var msg DrawMessage
166
- err := conn.ReadJSON(&msg)
67
+ messageType, data, err := conn.ReadMessage()
68
if err != nil {
69
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
169
- log.Error().Err(err).Msg("read message")
70
+ log.Error().Err(err).Msg("read websocket message")
71
}
72
break
73
}
173
- c.broadcast(msg)
74
+
75
+ if err := conn.WriteMessage(messageType, data); err != nil {
76
+ log.Error().Err(err).Msg("write websocket message")
77
+ break
78
+ }
79
}
80
}
81
177
-func runPaint() error {
178
- // 1) Create credential for this paint app
82
+func runDemo() error {
83
+ // 1) Create credential for this demo app
84
cred := sdk.NewCredential()
85
86
// 2) Create SDK client and connect to relay(s)
@@ -187,15 +92,31 @@ func runPaint() error {
92
}
93
defer client.Close()
94
95
+ // Derive tags slice from comma-separated flag
96
+ var tags []string
97
+ for _, t := range strings.Split(flagTags, ",") {
98
+ t = strings.TrimSpace(t)
99
+ if t != "" {
100
+ tags = append(tags, t)
101
+ }
102
+ }
103
+
104
// 3) Register lease and obtain a net.Listener that accepts relayed connections
191
- listener, err := client.Listen(cred, flagName, []string{"http/1.1"}, sdk.WithDescription("Portal demo paint app"), sdk.WithTags([]string{"demo", "paint"}), sdk.WithOwner("PortalApp Developer"), sdk.WithCountry("KR"), sdk.WithHide(false))
105
+ listener, err := client.Listen(
106
+ cred,
107
+ flagName,
108
+ []string{"http/1.1"},
109
+ sdk.WithDescription(flagDesc),
110
+ sdk.WithTags(tags),
111
+ sdk.WithOwner(flagOwner),
112
+ sdk.WithHide(flagHide),
113
+ )
114
if err != nil {
115
return fmt.Errorf("listen: %w", err)
116
}
117
defer listener.Close()
118
119
// 4) Setup HTTP handler
198
- canvas := newCanvas()
120
mux := http.NewServeMux()
121
122
// Serve static files from embedded filesystem
@@ -204,10 +125,24 @@ func runPaint() error {
125
return fmt.Errorf("create static fs: %w", err)
126
}
127
mux.Handle("/", http.FileServer(http.FS(staticFS)))
207
- mux.HandleFunc("/ws", canvas.handleWS)
128
+
129
+ // Simple HTTP ping endpoint for connectivity checks
130
+ mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) {
131
+ w.Header().Set("Content-Type", "application/json")
132
+ resp := map[string]any{
133
+ "message": "pong",
134
+ "time": time.Now().UTC().Format(time.RFC3339),
135
+ }
136
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
137
+ log.Error().Err(err).Msg("write ping response")
138
+ }
139
+ })
140
+
141
+ // WebSocket echo endpoint for bidirectional test
142
+ mux.HandleFunc("/ws", handleWS)
143
144
// 5) Serve HTTP over relay listener
210
- log.Info().Msgf("[paint] serving HTTP over relay; lease=%s id=%s", flagName, cred.ID())
145
+ log.Info().Msgf("[demo] serving HTTP over relay; lease=%s id=%s", flagName, cred.ID())
146
147
srvErr := make(chan error, 1)
148
go func() {
@@ -219,16 +154,13 @@ func runPaint() error {
154
155
select {
156
case <-sig:
222
- log.Info().Msg("[paint] shutting down...")
157
+ log.Info().Msg("[demo] shutting down...")
158
case err := <-srvErr:
159
if err != nil {
225
- log.Error().Err(err).Msg("[paint] http serve error")
160
+ log.Error().Err(err).Msg("[demo] http serve error")
161
}
162
}
163
229
- canvas.closeAll()
230
- canvas.wait()
231
-
232
- log.Info().Msg("[paint] shutdown complete")
164
+ log.Info().Msg("[demo] shutdown complete")
165
return nil
166
}
cmd/demo-app/static/app.js
deleted
-425
@@ -1,425 +0,0 @@
1
-const canvas = document.getElementById('canvas');
2
-const ctx = canvas.getContext('2d');
3
-const colorPicker = document.getElementById('color');
4
-const widthSlider = document.getElementById('width');
5
-const widthDisplay = document.getElementById('widthDisplay');
6
-const clearBtn = document.getElementById('clearBtn');
7
-const statusDiv = document.getElementById('status');
8
-const modeButtons = document.querySelectorAll('.mode-btn');
9
-
10
-let isDrawing = false;
11
-let lastX = 0;
12
-let lastY = 0;
13
-let startX = 0;
14
-let startY = 0;
15
-let ws = null;
16
-let currentMode = 'pen';
17
-let snapshot = null;
18
-
19
-// Responsive canvas sizing
20
-function resizeCanvas() {
21
- const wrapper = document.querySelector('.canvas-wrapper');
22
- const maxWidth = wrapper.clientWidth;
23
- const maxHeight = wrapper.clientHeight;
24
-
25
- // Use full available space - try to maximize canvas size
26
- let width = maxWidth;
27
- let height = maxHeight;
28
-
29
- // Calculate which dimension is the limiting factor
30
- const widthRatio = maxWidth / maxHeight;
31
- const targetRatio = 16 / 9;
32
-
33
- if (widthRatio > targetRatio) {
34
- // Width is larger, constrain by height
35
- width = Math.floor(maxHeight * targetRatio);
36
- height = maxHeight;
37
- } else {
38
- // Height is larger, constrain by width
39
- width = maxWidth;
40
- height = Math.floor(maxWidth / targetRatio);
41
- }
42
-
43
- // Only resize if dimensions changed significantly
44
- if (Math.abs(canvas.width - width) < 10 && Math.abs(canvas.height - height) < 10) return;
45
-
46
- // Store current canvas data
47
- const tempCanvas = document.createElement('canvas');
48
- const tempCtx = tempCanvas.getContext('2d');
49
- tempCanvas.width = canvas.width;
50
- tempCanvas.height = canvas.height;
51
- tempCtx.drawImage(canvas, 0, 0);
52
-
53
- // Resize canvas
54
- canvas.width = width;
55
- canvas.height = height;
56
-
57
- // Restore canvas data (scaled)
58
- ctx.drawImage(tempCanvas, 0, 0, tempCanvas.width, tempCanvas.height, 0, 0, width, height);
59
-}
60
-
61
-// Initialize canvas size after DOM is ready
62
-setTimeout(() => {
63
- resizeCanvas();
64
-}, 100);
65
-
66
-// Handle window resize
67
-let resizeTimeout;
68
-window.addEventListener('resize', () => {
69
- clearTimeout(resizeTimeout);
70
- resizeTimeout = setTimeout(() => {
71
- resizeCanvas();
72
- }, 250);
73
-});
74
-
75
-// Mode selection
76
-modeButtons.forEach(btn => {
77
- btn.addEventListener('click', () => {
78
- modeButtons.forEach(b => b.classList.remove('active'));
79
- btn.classList.add('active');
80
- currentMode = btn.dataset.mode;
81
- });
82
-});
83
-
84
-// Color preset selection
85
-const colorPresets = document.querySelectorAll('.color-preset');
86
-colorPresets.forEach(btn => {
87
- btn.addEventListener('click', () => {
88
- const color = btn.dataset.color;
89
- colorPicker.value = color;
90
- });
91
-});
92
-
93
-// Update width display
94
-widthSlider.addEventListener('input', (e) => {
95
- widthDisplay.textContent = e.target.value;
96
-});
97
-
98
-// WebSocket connection
99
-function connect() {
100
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
101
- const basePath = location.pathname.endsWith('/') ? location.pathname : (location.pathname + '/');
102
- ws = new WebSocket(protocol + '//' + window.location.host + basePath + 'ws');
103
-
104
- ws.onopen = () => {
105
- statusDiv.textContent = '✓ Connected - Draw to collaborate!';
106
- statusDiv.className = 'status connected';
107
- };
108
-
109
- ws.onclose = () => {
110
- statusDiv.textContent = '✗ Disconnected - Reconnecting...';
111
- statusDiv.className = 'status disconnected';
112
- setTimeout(connect, 2000);
113
- };
114
-
115
- ws.onerror = (err) => {
116
- console.error('WebSocket error:', err);
117
- };
118
-
119
- ws.onmessage = (event) => {
120
- const msg = JSON.parse(event.data);
121
-
122
- if (msg.type === 'draw') {
123
- drawLine(msg.prevX, msg.prevY, msg.x, msg.y, msg.color, msg.width);
124
- } else if (msg.type === 'shape') {
125
- drawShape(msg.mode, msg.startX, msg.startY, msg.endX, msg.endY, msg.color, msg.width);
126
- } else if (msg.type === 'text') {
127
- drawText(msg.text, msg.x, msg.y, msg.color, msg.width);
128
- } else if (msg.type === 'clear') {
129
- ctx.clearRect(0, 0, canvas.width, canvas.height);
130
- }
131
- };
132
-}
133
-
134
-// Drawing functions
135
-function drawLine(x1, y1, x2, y2, color, width) {
136
- ctx.beginPath();
137
- ctx.strokeStyle = color;
138
- ctx.lineWidth = width;
139
- ctx.lineCap = 'round';
140
- ctx.lineJoin = 'round';
141
- ctx.moveTo(x1, y1);
142
- ctx.lineTo(x2, y2);
143
- ctx.stroke();
144
-}
145
-
146
-function drawShape(mode, startX, startY, endX, endY, color, width) {
147
- ctx.strokeStyle = color;
148
- ctx.lineWidth = width;
149
- ctx.lineCap = 'round';
150
- ctx.lineJoin = 'round';
151
-
152
- if (mode === 'line') {
153
- ctx.beginPath();
154
- ctx.moveTo(startX, startY);
155
- ctx.lineTo(endX, endY);
156
- ctx.stroke();
157
- } else if (mode === 'rectangle') {
158
- ctx.beginPath();
159
- ctx.rect(startX, startY, endX - startX, endY - startY);
160
- ctx.stroke();
161
- } else if (mode === 'circle') {
162
- const radius = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2));
163
- ctx.beginPath();
164
- ctx.arc(startX, startY, radius, 0, 2 * Math.PI);
165
- ctx.stroke();
166
- }
167
-}
168
-
169
-function drawText(text, x, y, color, size) {
170
- ctx.fillStyle = color;
171
- ctx.font = `${size * 4}px sans-serif`;
172
- ctx.fillText(text, x, y);
173
-}
174
-
175
-function getMousePos(e) {
176
- const rect = canvas.getBoundingClientRect();
177
- const scaleX = canvas.width / rect.width;
178
- const scaleY = canvas.height / rect.height;
179
-
180
- return {
181
- x: (e.clientX - rect.left) * scaleX,
182
- y: (e.clientY - rect.top) * scaleY
183
- };
184
-}
185
-
186
-function getTouchPos(e) {
187
- const rect = canvas.getBoundingClientRect();
188
- const scaleX = canvas.width / rect.width;
189
- const scaleY = canvas.height / rect.height;
190
- const touch = e.touches[0];
191
-
192
- return {
193
- x: (touch.clientX - rect.left) * scaleX,
194
- y: (touch.clientY - rect.top) * scaleY
195
- };
196
-}
197
-
198
-// Mouse events
199
-canvas.addEventListener('mousedown', (e) => {
200
- const pos = getMousePos(e);
201
-
202
- if (currentMode === 'text') {
203
- const text = prompt('Enter text:');
204
- if (text) {
205
- const color = colorPicker.value;
206
- const width = parseInt(widthSlider.value);
207
-
208
- // Optimistic: draw locally first
209
- drawText(text, pos.x, pos.y, color, width);
210
-
211
- const msg = {
212
- type: 'text',
213
- text: text,
214
- x: pos.x,
215
- y: pos.y,
216
- color: color,
217
- width: width
218
- };
219
-
220
- if (ws && ws.readyState === WebSocket.OPEN) {
221
- ws.send(JSON.stringify(msg));
222
- }
223
- }
224
- return;
225
- }
226
-
227
- isDrawing = true;
228
- startX = pos.x;
229
- startY = pos.y;
230
- lastX = pos.x;
231
- lastY = pos.y;
232
-
233
- // Save canvas state for shape preview
234
- if (currentMode !== 'pen' && currentMode !== 'eraser') {
235
- snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
236
- }
237
-});
238
-
239
-canvas.addEventListener('mousemove', (e) => {
240
- if (!isDrawing) return;
241
-
242
- const pos = getMousePos(e);
243
- const color = colorPicker.value;
244
- const width = parseInt(widthSlider.value);
245
-
246
- if (currentMode === 'pen' || currentMode === 'eraser') {
247
- // Pen/Eraser mode: continuous drawing
248
- const drawColor = currentMode === 'eraser' ? '#ffffff' : color;
249
- const drawWidth = currentMode === 'eraser' ? width * 3 : width;
250
-
251
- drawLine(lastX, lastY, pos.x, pos.y, drawColor, drawWidth);
252
-
253
- const msg = {
254
- type: 'draw',
255
- prevX: lastX,
256
- prevY: lastY,
257
- x: pos.x,
258
- y: pos.y,
259
- color: drawColor,
260
- width: drawWidth
261
- };
262
-
263
- if (ws && ws.readyState === WebSocket.OPEN) {
264
- ws.send(JSON.stringify(msg));
265
- }
266
-
267
- lastX = pos.x;
268
- lastY = pos.y;
269
- } else {
270
- // Shape mode: preview while dragging
271
- ctx.putImageData(snapshot, 0, 0);
272
- drawShape(currentMode, startX, startY, pos.x, pos.y, color, width);
273
- }
274
-});
275
-
276
-canvas.addEventListener('mouseup', (e) => {
277
- if (!isDrawing) return;
278
- isDrawing = false;
279
-
280
- // Send final shape to server
281
- if (currentMode !== 'pen' && currentMode !== 'eraser') {
282
- const pos = getMousePos(e);
283
- const msg = {
284
- type: 'shape',
285
- mode: currentMode,
286
- startX: startX,
287
- startY: startY,
288
- endX: pos.x,
289
- endY: pos.y,
290
- color: colorPicker.value,
291
- width: parseInt(widthSlider.value)
292
- };
293
-
294
- if (ws && ws.readyState === WebSocket.OPEN) {
295
- ws.send(JSON.stringify(msg));
296
- }
297
- }
298
-});
299
-
300
-canvas.addEventListener('mouseout', () => {
301
- isDrawing = false;
302
-});
303
-
304
-// Touch events
305
-canvas.addEventListener('touchstart', (e) => {
306
- e.preventDefault();
307
- const pos = getTouchPos(e);
308
-
309
- if (currentMode === 'text') {
310
- const text = prompt('Enter text:');
311
- if (text) {
312
- const color = colorPicker.value;
313
- const width = parseInt(widthSlider.value);
314
-
315
- drawText(text, pos.x, pos.y, color, width);
316
-
317
- const msg = {
318
- type: 'text',
319
- text: text,
320
- x: pos.x,
321
- y: pos.y,
322
- color: color,
323
- width: width
324
- };
325
-
326
- if (ws && ws.readyState === WebSocket.OPEN) {
327
- ws.send(JSON.stringify(msg));
328
- }
329
- }
330
- return;
331
- }
332
-
333
- isDrawing = true;
334
- startX = pos.x;
335
- startY = pos.y;
336
- lastX = pos.x;
337
- lastY = pos.y;
338
-
339
- if (currentMode !== 'pen' && currentMode !== 'eraser') {
340
- snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
341
- }
342
-});
343
-
344
-canvas.addEventListener('touchmove', (e) => {
345
- e.preventDefault();
346
- if (!isDrawing) return;
347
-
348
- const pos = getTouchPos(e);
349
- const color = colorPicker.value;
350
- const width = parseInt(widthSlider.value);
351
-
352
- if (currentMode === 'pen' || currentMode === 'eraser') {
353
- const drawColor = currentMode === 'eraser' ? '#ffffff' : color;
354
- const drawWidth = currentMode === 'eraser' ? width * 3 : width;
355
-
356
- drawLine(lastX, lastY, pos.x, pos.y, drawColor, drawWidth);
357
-
358
- const msg = {
359
- type: 'draw',
360
- prevX: lastX,
361
- prevY: lastY,
362
- x: pos.x,
363
- y: pos.y,
364
- color: drawColor,
365
- width: drawWidth
366
- };
367
-
368
- if (ws && ws.readyState === WebSocket.OPEN) {
369
- ws.send(JSON.stringify(msg));
370
- }
371
-
372
- lastX = pos.x;
373
- lastY = pos.y;
374
- } else {
375
- ctx.putImageData(snapshot, 0, 0);
376
- drawShape(currentMode, startX, startY, pos.x, pos.y, color, width);
377
- }
378
-});
379
-
380
-canvas.addEventListener('touchend', (e) => {
381
- e.preventDefault();
382
- if (!isDrawing) return;
383
- isDrawing = false;
384
-
385
- // Send final shape to server
386
- if (currentMode !== 'pen' && currentMode !== 'eraser' && e.changedTouches.length > 0) {
387
- const touch = e.changedTouches[0];
388
- const rect = canvas.getBoundingClientRect();
389
- const scaleX = canvas.width / rect.width;
390
- const scaleY = canvas.height / rect.height;
391
- const endX = (touch.clientX - rect.left) * scaleX;
392
- const endY = (touch.clientY - rect.top) * scaleY;
393
-
394
- const msg = {
395
- type: 'shape',
396
- mode: currentMode,
397
- startX: startX,
398
- startY: startY,
399
- endX: endX,
400
- endY: endY,
401
- color: colorPicker.value,
402
- width: parseInt(widthSlider.value)
403
- };
404
-
405
- if (ws && ws.readyState === WebSocket.OPEN) {
406
- ws.send(JSON.stringify(msg));
407
- }
408
- }
409
-});
410
-
411
-// Clear button
412
-clearBtn.addEventListener('click', () => {
413
- if (confirm('Clear the entire canvas? This will affect all users.')) {
414
- // Optimistic: clear locally first
415
- ctx.clearRect(0, 0, canvas.width, canvas.height);
416
-
417
- const msg = { type: 'clear' };
418
- if (ws && ws.readyState === WebSocket.OPEN) {
419
- ws.send(JSON.stringify(msg));
420
- }
421
- }
422
-});
423
-
424
-// Initialize
425
-connect();
cmd/demo-app/static/index.html
+84
-52
@@ -6,71 +6,103 @@
6
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
7
<meta name="apple-mobile-web-app-capable" content="yes">
8
<meta name="mobile-web-app-capable" content="yes">
9
- <title>Demo App</title>
9
+ <title>Portal Demo Connectivity</title>
10
<link rel="stylesheet" href="style.css">
11
</head>
12
13
<body>
14
<div class="container">
15
- <h1>🎨 Demo App</h1>
15
+ <h1>Portal Demo Connectivity</h1>
16
17
<div class="toolbar">
18
- <div class="mode-buttons">
19
- <button class="mode-btn active" data-mode="pen" title="Pen">✏️</button>
20
- <button class="mode-btn" data-mode="eraser" title="Eraser">🧹</button>
21
- <button class="mode-btn" data-mode="circle" title="Circle">⭕</button>
22
- <button class="mode-btn" data-mode="rectangle" title="Rectangle">▢</button>
23
- <button class="mode-btn" data-mode="line" title="Line">📏</button>
24
- <button class="mode-btn" data-mode="text" title="Text">T</button>
25
- </div>
26
-
27
- <div class="color-section">
28
- <label for="color">Color:</label>
29
- <input type="color" id="color" value="#000000">
30
- <div class="color-presets">
31
- <button class="color-preset" data-color="#000000" style="background: #000000;"
32
- title="Black"></button>
33
- <button class="color-preset" data-color="#ffffff"
34
- style="background: #ffffff; border: 1px solid #ddd;" title="White"></button>
35
- <button class="color-preset" data-color="#ef4444" style="background: #ef4444;" title="Red"></button>
36
- <button class="color-preset" data-color="#f97316" style="background: #f97316;"
37
- title="Orange"></button>
38
- <button class="color-preset" data-color="#eab308" style="background: #eab308;"
39
- title="Yellow"></button>
40
- <button class="color-preset" data-color="#22c55e" style="background: #22c55e;"
41
- title="Green"></button>
42
- <button class="color-preset" data-color="#06b6d4" style="background: #06b6d4;"
43
- title="Cyan"></button>
44
- <button class="color-preset" data-color="#3b82f6" style="background: #3b82f6;"
45
- title="Blue"></button>
46
- <button class="color-preset" data-color="#8b5cf6" style="background: #8b5cf6;"
47
- title="Purple"></button>
48
- <button class="color-preset" data-color="#ec4899" style="background: #ec4899;"
49
- title="Pink"></button>
50
- <button class="color-preset" data-color="#a855f7" style="background: #a855f7;"
51
- title="Violet"></button>
52
- <button class="color-preset" data-color="#6b7280" style="background: #6b7280;"
53
- title="Gray"></button>
54
- </div>
55
- </div>
56
-
57
- <div>
58
- <label for="width">Width:</label>
59
- <input type="range" id="width" min="1" max="20" value="3">
60
- <span class="width-display" id="widthDisplay">3</span>
61
- </div>
62
-
63
- <button id="clearBtn" class="danger">Clear Canvas</button>
18
+ <button id="httpPingBtn">HTTP Ping</button>
19
+ <button id="wsConnectBtn">WS Connect</button>
20
+ <button id="wsSendBtn" disabled>WS Send "hello"</button>
21
</div>
22
23
<div class="canvas-wrapper">
67
- <canvas id="canvas"></canvas>
24
+ <pre id="log" class="log"></pre>
25
</div>
26
70
- <div id="status" class="status">Connecting...</div>
27
+ <div id="status" class="status">Idle</div>
28
</div>
29
73
- <script src="app.js"></script>
30
+ <script>
31
+ const statusEl = document.getElementById('status');
32
+ const logEl = document.getElementById('log');
33
+ const httpPingBtn = document.getElementById('httpPingBtn');
34
+ const wsConnectBtn = document.getElementById('wsConnectBtn');
35
+ const wsSendBtn = document.getElementById('wsSendBtn');
36
+
37
+ let ws = null;
38
+
39
+ function log(message) {
40
+ const time = new Date().toISOString();
41
+ logEl.textContent += `[${time}] ${message}\n`;
42
+ logEl.scrollTop = logEl.scrollHeight;
43
+ }
44
+
45
+ httpPingBtn.addEventListener('click', async () => {
46
+ statusEl.textContent = 'HTTP: Pinging...';
47
+ try {
48
+ const res = await fetch('/api/ping');
49
+ const json = await res.json();
50
+ statusEl.textContent = 'HTTP: OK';
51
+ log(`HTTP /api/ping -> ${JSON.stringify(json)}`);
52
+ } catch (err) {
53
+ statusEl.textContent = 'HTTP: Error';
54
+ log(`HTTP error: ${err}`);
55
+ }
56
+ });
57
+
58
+ wsConnectBtn.addEventListener('click', () => {
59
+ if (ws && ws.readyState === WebSocket.OPEN) {
60
+ ws.close();
61
+ return;
62
+ }
63
+
64
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
65
+ const basePath = location.pathname.endsWith('/') ? location.pathname : (location.pathname + '/');
66
+ const url = protocol + '//' + window.location.host + basePath + 'ws';
67
+
68
+ statusEl.textContent = 'WS: Connecting...';
69
+ log(`WS connecting to ${url}`);
70
+
71
+ ws = new WebSocket(url);
72
+
73
+ ws.onopen = () => {
74
+ statusEl.textContent = 'WS: Connected';
75
+ log('WS connected');
76
+ wsSendBtn.disabled = false;
77
+ wsConnectBtn.textContent = 'WS Disconnect';
78
+ };
79
+
80
+ ws.onclose = () => {
81
+ statusEl.textContent = 'WS: Disconnected';
82
+ log('WS disconnected');
83
+ wsSendBtn.disabled = true;
84
+ wsConnectBtn.textContent = 'WS Connect';
85
+ };
86
+
87
+ ws.onerror = (err) => {
88
+ log(`WS error: ${err.message || err}`);
89
+ };
90
+
91
+ ws.onmessage = (event) => {
92
+ log(`WS recv: ${event.data}`);
93
+ };
94
+ });
95
+
96
+ wsSendBtn.addEventListener('click', () => {
97
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
98
+ log('WS send skipped: not connected');
99
+ return;
100
+ }
101
+ const msg = 'hello';
102
+ ws.send(msg);
103
+ log(`WS send: ${msg}`);
104
+ });
105
+ </script>
106
</body>
107
76
-</html>
\ No newline at end of file
108
+</html>
cmd/demo-app/static/style.css
+59
-258
@@ -1,284 +1,85 @@
1
* {
2
- margin: 0;
3
- padding: 0;
4
- box-sizing: border-box;
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
}
6
7
body {
8
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
9
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
10
- min-height: 100vh;
11
- min-height: 100dvh; /* Dynamic viewport height for mobile */
12
- display: flex;
13
- flex-direction: column;
14
- align-items: center;
15
- justify-content: center;
16
- padding: 5px;
17
- overflow: hidden;
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
9
+ background: #f5f5f5;
10
+ min-height: 100vh;
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ padding: 16px;
15
}
16
17
.container {
21
- background: white;
22
- border-radius: 12px;
23
- box-shadow: 0 20px 60px rgba(0,0,0,0.3);
24
- padding: 10px;
25
- width: 100%;
26
- height: calc(100vh - 10px);
27
- height: calc(100dvh - 10px);
28
- display: flex;
29
- flex-direction: column;
30
- overflow: hidden;
18
+ width: 100%;
19
+ max-width: 520px;
20
+ background: #ffffff;
21
+ border-radius: 8px;
22
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
23
+ padding: 16px;
24
+ display: flex;
25
+ flex-direction: column;
26
}
27
28
h1 {
34
- text-align: center;
35
- color: #667eea;
36
- margin-bottom: 8px;
37
- font-size: 1.2em;
38
- flex-shrink: 0;
39
-}
40
-
41
-@media (min-width: 768px) {
42
- body {
43
- padding: 10px;
44
- }
45
-
46
- .container {
47
- padding: 15px;
48
- height: calc(100vh - 20px);
49
- height: calc(100dvh - 20px);
50
- }
51
-
52
- h1 {
53
- font-size: 1.5em;
54
- margin-bottom: 12px;
55
- }
29
+ font-size: 18px;
30
+ margin-bottom: 12px;
31
+ color: #333333;
32
+ text-align: center;
33
}
34
35
.toolbar {
59
- display: flex;
60
- gap: 6px;
61
- margin-bottom: 8px;
62
- padding: 6px;
63
- background: #f8f9fa;
64
- border-radius: 6px;
65
- flex-wrap: wrap;
66
- align-items: center;
67
- justify-content: center;
68
- flex-shrink: 0;
69
-}
70
-
71
-@media (min-width: 768px) {
72
- .toolbar {
73
- gap: 10px;
74
- padding: 10px;
75
- margin-bottom: 10px;
76
- justify-content: flex-start;
77
- }
78
-}
79
-
80
-.mode-buttons {
81
- display: flex;
82
- gap: 3px;
83
- flex-wrap: wrap;
84
-}
85
-
86
-@media (min-width: 768px) {
87
- .mode-buttons {
88
- gap: 5px;
89
- }
90
-}
91
-
92
-.mode-btn {
93
- padding: 6px 10px;
94
- border: 2px solid #dee2e6;
95
- border-radius: 6px;
96
- background: white;
97
- cursor: pointer;
98
- transition: all 0.2s;
99
- font-size: 16px;
100
- min-width: 40px;
101
-}
102
-
103
-@media (min-width: 768px) {
104
- .mode-btn {
105
- padding: 8px 12px;
106
- font-size: 18px;
107
- }
108
-}
109
-
110
-.mode-btn:hover {
111
- background: #e9ecef;
112
-}
113
-
114
-.mode-btn.active {
115
- background: #667eea;
116
- color: white;
117
- border-color: #667eea;
118
-}
119
-
120
-.toolbar label {
121
- font-weight: 600;
122
- color: #495057;
123
- margin-right: 5px;
124
-}
125
-
126
-.color-section {
127
- display: flex;
128
- align-items: center;
129
- gap: 8px;
130
- flex-wrap: wrap;
131
-}
132
-
133
-.toolbar input[type="color"] {
134
- width: 40px;
135
- height: 35px;
136
- border: 2px solid #dee2e6;
137
- border-radius: 4px;
138
- cursor: pointer;
139
-}
140
-
141
-.color-presets {
142
- display: flex;
143
- gap: 4px;
144
- flex-wrap: wrap;
145
-}
146
-
147
-.color-preset {
148
- width: 24px;
149
- height: 24px;
150
- border-radius: 4px;
151
- border: 2px solid transparent;
152
- cursor: pointer;
153
- transition: all 0.2s;
154
- padding: 0;
155
-}
156
-
157
-.color-preset:hover {
158
- transform: scale(1.15);
159
- box-shadow: 0 2px 4px rgba(0,0,0,0.2);
160
-}
161
-
162
-.color-preset:active {
163
- transform: scale(1.05);
164
-}
165
-
166
-@media (min-width: 768px) {
167
- .color-preset {
168
- width: 28px;
169
- height: 28px;
170
- }
171
-}
172
-
173
-@media (min-width: 768px) {
174
- .toolbar input[type="color"] {
175
- width: 50px;
176
- height: 40px;
177
- }
178
-}
179
-
180
-.toolbar input[type="range"] {
181
- width: 100px;
182
-}
183
-
184
-@media (min-width: 768px) {
185
- .toolbar input[type="range"] {
186
- width: 150px;
187
- }
36
+ display: flex;
37
+ flex-wrap: wrap;
38
+ gap: 8px;
39
+ margin-bottom: 12px;
40
+ justify-content: center;
41
}
42
43
.toolbar button {
191
- padding: 8px 15px;
192
- border: none;
193
- border-radius: 6px;
194
- background: #667eea;
195
- color: white;
196
- font-weight: 600;
197
- cursor: pointer;
198
- transition: all 0.2s;
199
- font-size: 14px;
200
-}
201
-
202
-@media (min-width: 768px) {
203
- .toolbar button {
204
- padding: 10px 20px;
205
- font-size: 16px;
206
- }
44
+ padding: 6px 12px;
45
+ font-size: 13px;
46
+ border-radius: 4px;
47
+ border: 1px solid #d0d7de;
48
+ background: #ffffff;
49
+ cursor: pointer;
50
+ transition: background 0.15s ease, border-color 0.15s ease;
51
}
52
53
.toolbar button:hover {
210
- background: #5568d3;
211
- transform: translateY(-1px);
212
-}
213
-
214
-.toolbar button.danger {
215
- background: #dc3545;
216
-}
217
-
218
-.toolbar button.danger:hover {
219
- background: #c82333;
54
+ background: #f3f4f6;
55
+ border-color: #c3ccd6;
56
}
57
58
.canvas-wrapper {
223
- flex: 1;
224
- display: flex;
225
- align-items: center;
226
- justify-content: center;
227
- overflow: hidden;
228
- min-height: 0;
229
-}
230
-
231
-#canvas {
232
- border: 2px solid #dee2e6;
233
- border-radius: 8px;
234
- cursor: crosshair;
235
- display: block;
236
- background: white;
237
- touch-action: none;
238
- max-width: 100%;
239
- max-height: 100%;
240
- object-fit: contain;
241
-}
242
-
243
-@media (min-width: 768px) {
244
- #canvas {
245
- border: 3px solid #dee2e6;
246
- }
59
+ border-radius: 4px;
60
+ border: 1px solid #e1e4e8;
61
+ background: #111827;
62
+ padding: 8px;
63
+ height: 220px;
64
+ overflow: auto;
65
+}
66
+
67
+.log {
68
+ width: 100%;
69
+ height: 100%;
70
+ border: none;
71
+ background: transparent;
72
+ color: #e5e7eb;
73
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
74
+ "Liberation Mono", "Courier New", monospace;
75
+ font-size: 12px;
76
+ white-space: pre-wrap;
77
+ word-break: break-all;
78
}
79
80
.status {
250
- margin-top: 6px;
251
- padding: 6px;
252
- flex-shrink: 0;
253
- background: #e7f3ff;
254
- border-radius: 6px;
255
- text-align: center;
256
- font-size: 12px;
257
- color: #004085;
258
-}
259
-
260
-@media (min-width: 768px) {
261
- .status {
262
- margin-top: 8px;
263
- padding: 8px;
264
- font-size: 14px;
265
- }
266
-}
267
-
268
-.status.connected {
269
- background: #d4edda;
270
- color: #155724;
271
-}
272
-
273
-.status.disconnected {
274
- background: #f8d7da;
275
- color: #721c24;
276
-}
277
-
278
-.width-display {
279
- display: inline-block;
280
- min-width: 30px;
281
- text-align: center;
282
- font-weight: 600;
283
- color: #667eea;
81
+ margin-top: 10px;
82
+ font-size: 13px;
83
+ color: #4b5563;
84
+ text-align: right;
85
}
sdk/sdk.go
+2
-10
@@ -175,16 +175,14 @@ type Metadata struct {
175
Tags []string `json:"tags"`
176
Thumbnail string `json:"thumbnail"`
177
Owner string `json:"owner"`
178
- Country string `json:"country"`
179
- Hide bool `json:"hide"` // 고수다 숨김 여부
178
+ Hide bool `json:"hide"`
179
}
180
181
func (m Metadata) isEmpty() bool {
182
return m.Description == "" &&
183
len(m.Tags) == 0 &&
184
m.Thumbnail == "" &&
186
- m.Owner == "" &&
187
- m.Country == ""
185
+ m.Owner == ""
186
}
187
188
type MetadataOption func(*Metadata)
@@ -213,12 +211,6 @@ func WithOwner(owner string) MetadataOption {
211
}
212
}
213
216
-func WithCountry(country string) MetadataOption {
217
- return func(m *Metadata) {
218
- m.Country = country
219
- }
220
-}
221
-
214
func WithHide(hide bool) MetadataOption {
215
return func(m *Metadata) {
216
m.Hide = hide