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