본문으로 건너뛰기
cccskills
무료GitHub에 공개

full-stack-panel-authoring

Add a complete dashboard panel in plain TypeScript, from server routes and data services to the visible component and registration. The workflow checks existing routing patterns and includes request failure handling, caching, and saved panel state.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

원문원문 설명 보기

Single cohesive workflow for creating complete dashboard panel features in vanilla TypeScript — covers project-structure exploration, server dispatch pattern detection and wiring (Express or manual http.createServer), Panel base class component authoring with CircuitBreaker resilience and localStorage persistence, panel registration in index.ts, and safe file writing (including emoji workarounds).

설치 방법 보기

이럴 때 유용합니다

  • Adding a dashboard panel across server and browser code
  • Connecting routes to Express or http.createServer
  • Adding caching and cooldowns after repeated failures
  • Restoring panel size and collapsed state after reload

포함된 파일(2)

  • SKILL.md22.2 KB
  • .skill_id61 B

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

Unified Full-Stack Panel Authoring

One guide for the complete workflow: backend route → service → server wiring → Panel component with resilience → registration. Eliminates the need to consult separate skills for what is always a single cohesive task.


When to Use

  • Adding a new panel type to a vanilla-TypeScript dashboard
  • Creating a feature that spans all layers: server route, service, component, registration
  • Ensuring pattern-consistency across the stack
  • Projects using a manual http.createServer dispatch loop or Express

Quick Reference — File Checklist

#FileAction
1server/routes/{feature}-route.tsCreate
2src/services/{feature}.tsCreate
3src/components/{Feature}Panel.tsCreate
4src/components/index.tsUpdate — add export
5server/index.tsUpdate — import + dispatch case
6src/utils/circuit-breaker.tsCreate if absent
7src/utils/sparkline.tsCreate if absent

Step 1 — Explore Project Structure

Run these commands before writing a single line of code. Reading existing patterns prevents style drift and reveals the exact dispatch mechanism.

# Locate server entry point (critical for wiring)
find . -name "index.ts" -o -name "index.js" -o -name "server.ts" -o -name "server.js" \
  | grep -v node_modules | head -10

# Find existing route handlers to use as pattern files
find server/routes -name "*.ts" -o -name "*.js" 2>/dev/null | head -10

# Find existing service files
find src/services -name "*.ts" -o -name "*.js" 2>/dev/null | head -10

# Find existing Panel components
find src/components -name "*Panel.ts" 2>/dev/null | head -10

# Find the Panel base class
find src -name "Panel.ts" 2>/dev/null | head -5

# Find component registration index
find src/components -name "index.ts" 2>/dev/null | head -3

# Check for CircuitBreaker utility
find src/utils -name "circuit-breaker.ts" 2>/dev/null

# Check framework (should NOT find React in vanilla TS projects)
grep -s '"react"' package.json | head -3

Read 1-2 examples from each layer before generating any code:

cat server/routes/<existing>-route.ts
cat src/services/<existing>.ts
cat src/components/<existing>Panel.ts
cat src/components/index.ts
cat server/index.ts          # most important: reveals dispatch pattern

Step 2 — Detect Server Dispatch Pattern

Read server/index.ts (or server/index.js, src/server.ts) and classify:

# Express signals
grep -n "express\|app\.use\|router\." server/index.ts 2>/dev/null

# Manual http.createServer signals
grep -n "createServer\|switch.*url\|switch.*pathname\|req\.url\|case '/" server/index.ts 2>/dev/null
Pattern in fileWiring method (Step 5)
app.use('/api/x', xRouter)5A - Express
switch(pathname) { case '/api/x':5B - Manual switch/case
if (url.startsWith('/api/x'))5C - Manual if/else chain
const handlers = { '/api/x': fn }5D - Handler map

NOTE: Misidentifying the dispatch pattern is the most common cause of silent 404s. If unsure, read the full server entry point before proceeding.


Step 3 — Create the Server Route Handler

Use the manual dispatch template for http.createServer projects (most common in this codebase). Use the Express template only when Express is confirmed.

Manual dispatch (TypeScript) — preferred template

// server/routes/{feature}-route.ts
import { IncomingMessage, ServerResponse } from 'http';
import { fetch{Feature}Data } from '../../src/services/{feature}';

export async function handle{Feature}Request(
  req: IncomingMessage,
  res: ServerResponse,
  action?: string
): Promise<void> {
  try {
    const data = await fetch{Feature}Data(action);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ success: true, data }));
  } catch (err) {
    res.writeHead(500, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      success: false,
      error: err instanceof Error ? err.message : String(err)
    }));
  }
}

Express template (only when confirmed)

// server/routes/{feature}-route.ts
import { Router } from 'express';
import { fetch{Feature}Data } from '../../src/services/{feature}';

const router = Router();

router.get('/', async (req, res) => {
  try {
    const data = await fetch{Feature}Data(req.query.action as string | undefined);
    res.json({ success: true, data });
  } catch (err) {
    res.status(500).json({ success: false, error: (err as Error).message });
  }
});

export default router;

Step 4 — Create the Service Layer

The service owns data fetching and transformation. The CircuitBreaker (Step 6) wraps the service call at the component level — keep the service itself simple.

// src/services/{feature}.ts

export interface {Feature}Item {
  id: string;
  // define your data shape matching the API response
}

/**
 * Fetch {feature} data from the server API.
 * @param action  Optional sub-action / filter parameter.
 */
export async function fetch{Feature}Data(action?: string): Promise<{Feature}Item[]> {
  const url = `/api/panels/{feature}${action ? `?action=${encodeURIComponent(action)}` : ''}`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${localStorage.getItem('token') ?? ''}` }
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const { data } = await res.json() as { data: {Feature}Item[] };
  return data;
}

Step 5 — Wire the Route Handler into the Server

This step is the most frequently missed and causes features to silently return 404.

After creating the route file, open server/index.ts and add both an import and a dispatch entry.

5A - Express

// server/index.ts  -- excerpt
import {feature}Router from './routes/{feature}-route';

// Add alongside other app.use() calls:
app.use('/api/panels/{feature}', {feature}Router);

5B - Manual switch/case (most common pattern)

// server/index.ts  -- excerpt showing the additions

// 1. Add import near the top with other route imports
import { handle{Feature}Request } from './routes/{feature}-route';

// 2. Inside the switch / if-else dispatch block, add a new case
// Find the block that looks like:
//   switch (pathname) {
//     case '/api/panels/health': ...
//     case '/api/panels/email':  ...
// Add:
    case '/api/panels/{feature}':
      await handle{Feature}Request(req, res, action);
      break;

5C - Manual if/else chain

// server/index.ts  -- excerpt
import { handle{Feature}Request } from './routes/{feature}-route';

// Add a new else-if branch:
} else if (url.startsWith('/api/panels/{feature}')) {
  await handle{Feature}Request(req, res, action);
}

5D - Handler map

// server/index.ts  -- excerpt
import { handle{Feature}Request } from './routes/{feature}-route';

const handlers: Record<string, HandlerFn> = {
  // ... existing entries ...
  '/api/panels/{feature}': handle{Feature}Request,
};

Verification: After wiring, start the server and curl /api/panels/{feature} — you should receive JSON, not a 404.


Step 6 — Create the Panel Component

Architecture overview

Panel (base class)
+-- element: HTMLElement (.panel)
    +-- header (.panel-header)
    |   +-- headerLeft (.panel-header-left)
    |   |   +-- title (.panel-title)
    |   +-- countEl (.panel-count)  [optional]
    +-- content (.panel-content)

Resilience layers

LayerWhen to use
isFetching guardAlways -- prevents duplicate concurrent requests
fetchWithRetry()When you own the raw fetch and want per-request retry
CircuitBreaker.execute()When a service may fail repeatedly; serves stale cache during cooldown
saveState() / loadState()When collapsed/size state should survive page reloads

Complete Panel component template

// src/components/{Feature}Panel.ts
import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
import { fetch{Feature}Data, {Feature}Item } from '../services/{feature}';

// Module-level breaker -- persists cache and failure state between re-renders
const breaker = createCircuitBreaker<{Feature}Item[]>({
  name: '{Feature}',
  cacheTtlMs: 60_000,      // serve cache for 1 min
  cooldownMs: 5 * 60_000,  // open circuit for 5 min after maxFailures
  maxFailures: 2,
});

export class {Feature}Panel extends Panel {
  private refreshTimer: ReturnType<typeof setInterval> | null = null;

  constructor() {
    super({ id: '{feature}', title: '{Feature Display Name}', showCount: true });
    this.loadState();                                  // restore collapsed/size
    this.fetchData();
    this.refreshTimer = setInterval(() => this.fetchData(), 60_000);
  }

  private async fetchData(): Promise<void> {
    if (this.isFetching) return;
    this.setFetching(true);
    try {
      // One-liner: wraps existing service call with caching + circuit-break
      const items = await breaker.execute(() => fetch{Feature}Data(), []);
      this.setCount(items.length);
      this.render(items);
    } catch (err) {
      this.showError(
        err instanceof Error ? err.message : 'Failed to load',
        () => {
          this.retryAttempts = 0;
          this.retryDelay = 1000;
          this.fetchData();
        }
      );
    } finally {
      this.setFetching(false);
    }
  }

  private render(items: {Feature}Item[]): void {
    if (items.length === 0) {
      this.setContent('<div class="panel-empty">No items found.</div>');
      return;
    }

    const container = document.createElement('div');
    container.className = '{feature}-panel';

    for (const item of items) {
      const row = document.createElement('div');
      row.className = '{feature}-panel__row';
      // Build DOM from item fields -- no JSX, no innerHTML with untrusted data:
      const label = document.createElement('span');
      label.className = '{feature}-panel__label';
      label.textContent = item.id;          // replace with real fields
      row.appendChild(label);
      container.appendChild(row);
    }

    this.content.innerHTML = '';
    this.content.appendChild(container);
    this.saveState();
  }

  destroy(): void {
    if (this.refreshTimer !== null) clearInterval(this.refreshTimer);
    super.destroy();
  }
}

Key rules for vanilla TS panels (no React):

  • Extend Panel base class; use setContent() / showError() / setCount() / showLoading() instead of React state.
  • Build DOM via document.createElement -- not JSX, not innerHTML with untrusted strings.
  • Lifecycle: constructor -> fetchData() -> render() -> destroy(). No useEffect.
  • File extension is .ts, not .jsx/.tsx.

Step 7 — Register the Component in index.ts

Export the new panel from the components barrel file so the dashboard can discover it.

// src/components/index.ts  -- add alongside existing exports
export { {Feature}Panel } from './{Feature}Panel';

Then instantiate and mount in the dashboard bootstrap (follow the existing pattern):

// src/index.ts or src/dashboard.ts  -- excerpt
import { {Feature}Panel } from './components';

const {feature}Panel = new {Feature}Panel();
document.getElementById('{feature}-slot')?.appendChild({feature}Panel.getElement());

Step 8 — Add Utility Files (if absent)

Only create these if find src/utils -name "circuit-breaker.ts" returns nothing.

src/utils/circuit-breaker.ts

interface CircuitState { failures: number; cooldownUntil: number; }
interface CacheEntry<T> { data: T; timestamp: number; }

export interface CircuitBreakerOptions {
  name: string;
  maxFailures?: number;  // default 2
  cooldownMs?: number;   // default 5 min
  cacheTtlMs?: number;   // default 10 min
}

export class CircuitBreaker<T> {
  private state: CircuitState = { failures: 0, cooldownUntil: 0 };
  private cache: CacheEntry<T> | null = null;
  private readonly name: string;
  private readonly maxFailures: number;
  private readonly cooldownMs: number;
  private readonly cacheTtlMs: number;

  constructor(options: CircuitBreakerOptions) {
    this.name        = options.name;
    this.maxFailures = options.maxFailures ?? 2;
    this.cooldownMs  = options.cooldownMs  ?? 5 * 60 * 1000;
    this.cacheTtlMs  = options.cacheTtlMs  ?? 10 * 60 * 1000;
  }

  isOnCooldown(): boolean {
    if (Date.now() < this.state.cooldownUntil) return true;
    if (this.state.cooldownUntil > 0) this.state = { failures: 0, cooldownUntil: 0 };
    return false;
  }

  getCached(): T | null {
    if (this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) return this.cache.data;
    return null;
  }

  recordSuccess(data: T): void {
    this.state = { failures: 0, cooldownUntil: 0 };
    this.cache = { data, timestamp: Date.now() };
  }

  recordFailure(): void {
    this.state.failures++;
    if (this.state.failures >= this.maxFailures) {
      this.state.cooldownUntil = Date.now() + this.cooldownMs;
      console.warn(`[${this.name}] Circuit open -- cooldown ${this.cooldownMs / 1000}s`);
    }
  }

  async execute<R extends T>(fn: () => Promise<R>, defaultValue: R): Promise<R> {
    if (this.isOnCooldown()) return (this.getCached() as R) ?? defaultValue;
    const cached = this.getCached();
    if (cached !== null) return cached as R;
    try {
      const result = await fn();
      this.recordSuccess(result);
      return result;
    } catch (e) {
      console.error(`[${this.name}] Request failed:`, e);
      this.recordFailure();
      return defaultValue;
    }
  }
}

export function createCircuitBreaker<T>(options: CircuitBreakerOptions): CircuitBreaker<T> {
  return new CircuitBreaker<T>(options);
}

src/utils/sparkline.ts (optional — for numeric trend data)

/**
 * Returns an inline SVG sparkline string, or '' if data is too short.
 * Embed directly in HTML template strings.
 */
export function miniSparkline(
  data: number[] | undefined,
  change: number | null,
  w = 50,
  h = 16
): string {
  if (!data || data.length < 2) return '';
  const min   = Math.min(...data);
  const max   = Math.max(...data);
  const range = max - min || 1;
  const color = change != null && change >= 0 ? 'var(--green)' : 'var(--red)';
  const points = data.map((v, i) => {
    const x = (i / (data.length - 1)) * w;
    const y = h - ((v - min) / range) * (h - 2) - 1;
    return `${x.toFixed(1)},${y.toFixed(1)}`;
  }).join(' ');
  return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
    `<polyline points="${points}" fill="none" stroke="${color}" ` +
    `stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}

Step 9 — Panel Base Class (if absent)

Only create src/components/Panel.ts if no existing base class is found.

// src/components/Panel.ts
export interface PanelOptions {
  id: string;
  title: string;
  showCount?: boolean;
  className?: string;
}

export class Panel {
  protected element: HTMLElement;
  protected content: HTMLElement;
  protected header: HTMLElement;
  protected countEl: HTMLElement | null = null;
  protected panelId: string;
  private _fetching = false;

  // Retry state (used by fetchWithRetry)
  protected retryAttempts = 0;
  protected retryDelay = 1000;
  private maxRetries = 3;

  constructor(options: PanelOptions) {
    this.panelId = options.id;
    this.element = document.createElement('div');
    this.element.className = `panel ${options.className ?? ''}`;
    this.element.dataset.panel = options.id;

    this.header = document.createElement('div');
    this.header.className = 'panel-header';

    const headerLeft = document.createElement('div');
    headerLeft.className = 'panel-header-left';
    const title = document.createElement('span');
    title.className = 'panel-title';
    title.textContent = options.title;
    headerLeft.appendChild(title);
    this.header.appendChild(headerLeft);

    if (options.showCount) {
      this.countEl = document.createElement('span');
      this.countEl.className = 'panel-count';
      this.countEl.textContent = '0';
      this.header.appendChild(this.countEl);
    }

    this.content = document.createElement('div');
    this.content.className = 'panel-content';
    this.content.id = `${options.id}Content`;

    this.element.appendChild(this.header);
    this.element.appendChild(this.content);
    this.showLoading();
  }

  public getElement(): HTMLElement { return this.element; }

  public showLoading(message = 'Loading...'): void {
    this.content.innerHTML = `
      <div class="panel-loading">
        <div class="panel-loading-spinner"></div>
        <div class="panel-loading-text">${message}</div>
      </div>`;
  }

  public showError(message = 'Failed to load', onRetry?: () => void): void {
    this.content.innerHTML = `
      <div class="panel-error-state">
        <div class="panel-error-msg">${message}</div>
        ${onRetry ? '<button class="panel-retry-btn" data-panel-retry>Retry</button>' : ''}
      </div>`;
    if (onRetry) {
      this.content.querySelector('[data-panel-retry]')?.addEventListener('click', onRetry);
    }
  }

  public setContent(html: string): void { this.content.innerHTML = html; }

  public setCount(count: number): void {
    if (this.countEl) this.countEl.textContent = count.toString();
  }

  public show(): void { this.element.classList.remove('hidden'); }
  public hide(): void { this.element.classList.add('hidden'); }
  public destroy(): void { this.element.remove(); }

  protected setFetching(v: boolean): void { this._fetching = v; }
  protected get isFetching(): boolean { return this._fetching; }

  protected async fetchWithRetry(url: string): Promise<unknown> {
    while (this.retryAttempts < this.maxRetries) {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return await response.json();
      } catch (error) {
        this.retryAttempts++;
        if (this.retryAttempts >= this.maxRetries) {
          throw new Error(`Failed after ${this.maxRetries} attempts: ${(error as Error).message}`);
        }
        await new Promise(resolve => setTimeout(resolve, this.retryDelay));
        this.retryDelay *= 2;
      }
    }
  }

  public saveState(): void {
    localStorage.setItem(`panelState_${this.panelId}`, JSON.stringify({
      isExpanded: !this.element.classList.contains('collapsed'),
      width: this.element.style.width,
      height: this.element.style.height,
    }));
  }

  public loadState(): void {
    const raw = localStorage.getItem(`panelState_${this.panelId}`);
    if (!raw) return;
    const { isExpanded, width, height } = JSON.parse(raw) as {
      isExpanded: boolean; width: string; height: string;
    };
    if (!isExpanded) this.element.classList.add('collapsed');
    if (width)  this.element.style.width  = width;
    if (height) this.element.style.height = height;
  }
}

Step 10 — Safe File Writing

Known tool issue: write_file (and some shell tools) fail silently or with "unknown error" when file content contains multi-byte Unicode characters such as emoji (e.g., document, chart, checkmark emoji).

Rules for safe content

  1. Never use emoji in generated source code. Replace with plain-text equivalents:

    • Document emoji -> [doc] or (file)
    • Chart emoji -> [chart]
    • Checkmark emoji -> [ok] or (done)
    • Warning emoji -> [warn] or WARNING:
  2. Use ASCII substitutes in comments instead of Unicode box-drawing characters.

  3. Test-write suspect content to a throwaway file first if unsure:

    echo "test content" > /tmp/write_test.txt && cat /tmp/write_test.txt
    
  4. Recovery pattern -- if a write_file call fails:

    • Strip all non-ASCII characters from the content.
    • Retry the write.
    • If still failing, split the file into smaller chunks and concatenate with cat.

Validation Checklist

Before considering the feature complete, verify each layer:

Server route
  [ ] File created at server/routes/{feature}-route.ts
  [ ] Exports handle{Feature}Request function
  [ ] Returns { success: true, data } on success
  [ ] Returns { success: false, error } on failure with correct HTTP status

Service
  [ ] File created at src/services/{feature}.ts
  [ ] Exports fetch{Feature}Data function
  [ ] Throws on non-ok HTTP status (so CircuitBreaker can record failures)

Server wiring
  [ ] Import added to server/index.ts
  [ ] Dispatch case/route added (matching the detected pattern)
  [ ] curl /api/panels/{feature} returns JSON (not 404)

Panel component
  [ ] Extends Panel base class
  [ ] Uses isFetching guard
  [ ] CircuitBreaker wraps the service call
  [ ] showError() called with retry callback on failure
  [ ] destroy() clears interval and calls super.destroy()

Registration
  [ ] Export added to src/components/index.ts
  [ ] Panel instantiated and mounted in dashboard bootstrap

File safety
  [ ] No emoji in any generated source file
  [ ] All write_file calls succeeded (check file size > 0)

Common Mistakes

MistakeSymptomFix
Skipping Step 5 (server wiring)Feature returns 404Add import + dispatch entry to server/index.ts
Wrong dispatch pattern404 or runtime errorRe-read server/index.ts; use grep from Step 2
Emoji in source fileswrite_file silent failureReplace all emoji with ASCII equivalents
React template in vanilla TS projectCompile errorsConfirm no React in package.json; use Panel base class template
Creating Panel.ts when one existsOverwrites base classCheck with find src -name "Panel.ts" first
Not calling super.destroy()Memory leakAlways chain super.destroy() in subclass destroy()
CircuitBreaker declared inside classCache lost between fetchesDeclare const breaker = ... at module level outside the class

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

Prepare audio stems, the separate parts of a track, for mixing by matching their durations with silence, loops, or crossfades. Verify each generated and processed file before moving to the next production step.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Match audio parts with different durations
  • Extend drums and melodic parts using suitable methods
  • Check audio specifications before mixing
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

Produce audio in separate stems, meaning individual instrument tracks, and verify each output as you go. Align track lengths with silence, loops, or crossfades, then apply effects, mix a master, and package the files.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Align instrument tracks to a reference audio file
  • Choose how to extend tracks that are too short
  • Verify audio properties after generation and effects
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

Build serverless endpoints that relay external API requests for a dashboard while keeping API keys on the server. Includes Vercel examples for input checks, caching, cross-origin access, and consistent JSON responses.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Connect a dashboard to external APIs without exposing API keys
  • Create endpoints for stock quotes and news
  • Standardize API responses and error handling
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

Produce a master track and separate audio parts through a structured Python workflow. Apply filters and reverb, export WAV files with consistent settings, package them in a ZIP archive, and check that the deliverables meet the requested specifications.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Export a master track with separate audio parts
  • Apply filters and reverb to audio
  • Check duration and format across deliverables
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

Keep document creation moving when web searches and page retrieval repeatedly fail. The skill preserves the original task, uses the model’s existing knowledge, and clearly identifies information that could not be checked against external sources.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Continue drafting after repeated web retrieval failures
  • Preserve task requirements during network errors
  • Mark information that could not be externally verified
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

When Python execution repeatedly fails in execute_code_sandbox, use a file-based alternative: save the script, run it through the shell, and capture results or errors. The guidance covers paths, imports, timeouts, structured output, and optional cleanup.

한국어 요약은 준비 중입니다. 영어 요약을 표시하고 있습니다.

  • Retry Python work through the shell after repeated sandbox failures
  • Run longer tasks with an adjusted timeout
  • Execute Python work that needs multiple files or system utilities
HKUDS/OpenSpace7.8천2026년 8월 13일 업데이트

HKUDS의 스킬 모두 보기

이 스킬의 문제 신고