第4章:フロントエンド実装

はじめに

ft_transcendenceのフロントエンドは、Single Page Application (SPA) として実装します。本章では、純粋なJavaScript/TypeScriptを使用したSPA開発を学びます。

---

1. プロジェクト構造

1.1 ディレクトリ構成

frontend/
├── src/
│   ├── index.html          # エントリーポイント
│   ├── main.ts             # アプリケーション初期化
│   ├── router.ts           # クライアントサイドルーター
│   ├── store.ts            # 状態管理
│   ├── api.ts              # API クライアント
│   ├── socket.ts           # WebSocket クライアント
│   ├── components/         # 再利用可能コンポーネント
│   │   ├── base/
│   │   │   └── Component.ts
│   │   ├── Header.ts
│   │   ├── Sidebar.ts
│   │   ├── Modal.ts
│   │   └── ...
│   ├── views/              # ページコンポーネント
│   │   ├── Home.ts
│   │   ├── Profile.ts
│   │   ├── Game.ts
│   │   ├── Chat.ts
│   │   └── ...
│   ├── services/           # ビジネスロジック
│   │   ├── AuthService.ts
│   │   ├── GameService.ts
│   │   └── ChatService.ts
│   └── styles/             # スタイルシート
│       ├── main.css
│       └── components/
├── public/                 # 静的ファイル
│   ├── images/
│   └── fonts/
├── package.json
├── tsconfig.json
└── webpack.config.js

1.2 HTMLエントリーポイント

<!-- src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ft_transcendence</title>
    <link rel="stylesheet" href="/styles/main.css">
</head>
<body>
    <div id="app">
        <!-- アプリケーションがここにマウントされる -->
        <div class="loading">Loading...</div>
    </div>

    <script type="module" src="/main.js"></script>
</body>
</html>

1.3 メインエントリー

// src/main.ts
import { Router } from './router';
import { Store } from './store';
import { SocketClient } from './socket';
import { AuthService } from './services/AuthService';

// グローバルインスタンス
export const store = new Store();
export const router = new Router();
export const socket = new SocketClient();
export const authService = new AuthService();

// アプリケーション初期化
async function initApp() {
    // 認証状態チェック
    await authService.checkAuth();

    // ルート定義
    router.addRoute('/', () => import('./views/Home'));
    router.addRoute('/login', () => import('./views/Login'));
    router.addRoute('/profile', () => import('./views/Profile'), { auth: true });
    router.addRoute('/profile/:id', () => import('./views/UserProfile'));
    router.addRoute('/game', () => import('./views/Game'), { auth: true });
    router.addRoute('/chat', () => import('./views/Chat'), { auth: true });
    router.addRoute('/settings', () => import('./views/Settings'), { auth: true });
    router.addRoute('/2fa', () => import('./views/TwoFactor'));
    router.addRoute('*', () => import('./views/NotFound'));

    // 初期ルーティング
    router.navigate(window.location.pathname);

    // ナビゲーションリスナー
    window.addEventListener('popstate', () => {
        router.navigate(window.location.pathname, false);
    });
}

// DOM Ready
document.addEventListener('DOMContentLoaded', initApp);

---

2. クライアントサイドルーター

2.1 ルーター実装

// src/router.ts
import { store } from './main';

interface Route {
    path: string;
    loader: () => Promise<{ default: typeof View }>;
    options?: {
        auth?: boolean;
    };
}

interface RouteMatch {
    route: Route;
    params: Record<string, string>;
}

export class Router {
    private routes: Route[] = [];
    private currentView: View | null = null;

    addRoute(
        path: string,
        loader: () => Promise<{ default: typeof View }>,
        options?: { auth?: boolean }
    ): void {
        this.routes.push({ path, loader, options });
    }

    async navigate(path: string, pushState = true): Promise<void> {
        const match = this.matchRoute(path);

        if (!match) {
            // 404
            const notFound = this.routes.find(r => r.path === '*');
            if (notFound) {
                await this.loadView(notFound, {});
            }
            return;
        }

        const { route, params } = match;

        // 認証チェック
        if (route.options?.auth && !store.getState().user) {
            // ログインページにリダイレクト
            this.navigate('/login');
            return;
        }

        if (pushState) {
            history.pushState(null, '', path);
        }

        await this.loadView(route, params);
    }

    private matchRoute(path: string): RouteMatch | null {
        for (const route of this.routes) {
            if (route.path === '*') continue;

            const params = this.extractParams(route.path, path);
            if (params !== null) {
                return { route, params };
            }
        }
        return null;
    }

    private extractParams(
        routePath: string,
        actualPath: string
    ): Record<string, string> | null {
        const routeParts = routePath.split('/');
        const pathParts = actualPath.split('/');

        if (routeParts.length !== pathParts.length) {
            return null;
        }

        const params: Record<string, string> = {};

        for (let i = 0; i < routeParts.length; i++) {
            if (routeParts[i].startsWith(':')) {
                params[routeParts[i].slice(1)] = pathParts[i];
            } else if (routeParts[i] !== pathParts[i]) {
                return null;
            }
        }

        return params;
    }

    private async loadView(
        route: Route,
        params: Record<string, string>
    ): Promise<void> {
        // 現在のビューをアンマウント
        if (this.currentView) {
            this.currentView.unmount();
        }

        // ローディング表示
        const app = document.getElementById('app')!;
        app.innerHTML = '<div class="loading">Loading...</div>';

        // 新しいビューをロード
        const module = await route.loader();
        const ViewClass = module.default;

        this.currentView = new ViewClass(params);
        this.currentView.mount(app);
    }
}

// ナビゲーション用ヘルパー
export function navigateTo(path: string): void {
    import('./main').then(({ router }) => {
        router.navigate(path);
    });
}

2.2 リンクコンポーネント

// src/components/Link.ts
import { navigateTo } from '../router';

export function Link(props: {
    href: string;
    children: string;
    className?: string;
}): HTMLAnchorElement {
    const link = document.createElement('a');
    link.href = props.href;
    link.textContent = props.children;
    if (props.className) {
        link.className = props.className;
    }

    link.addEventListener('click', (e) => {
        e.preventDefault();
        navigateTo(props.href);
    });

    return link;
}

---

3. 状態管理

3.1 Store実装

// src/store.ts

export interface AppState {
    user: User | null;
    friends: User[];
    onlineFriends: number[];
    notifications: Notification[];
    game: GameState | null;
    chat: {
        channels: Channel[];
        activeChannel: number | null;
        messages: Record<number, Message[]>;
    };
}

type Listener = (state: AppState) => void;

export class Store {
    private state: AppState = {
        user: null,
        friends: [],
        onlineFriends: [],
        notifications: [],
        game: null,
        chat: {
            channels: [],
            activeChannel: null,
            messages: {},
        },
    };

    private listeners: Set<Listener> = new Set();

    getState(): AppState {
        return this.state;
    }

    setState(partial: Partial<AppState>): void {
        this.state = { ...this.state, ...partial };
        this.notify();
    }

    updateChat(update: Partial<AppState['chat']>): void {
        this.state = {
            ...this.state,
            chat: { ...this.state.chat, ...update },
        };
        this.notify();
    }

    subscribe(listener: Listener): () => void {
        this.listeners.add(listener);
        return () => this.listeners.delete(listener);
    }

    private notify(): void {
        this.listeners.forEach(listener => listener(this.state));
    }
}

// セレクター
export function selectUser(state: AppState): User | null {
    return state.user;
}

export function selectOnlineFriends(state: AppState): User[] {
    return state.friends.filter(f => state.onlineFriends.includes(f.id));
}

export function selectActiveMessages(state: AppState): Message[] {
    const channelId = state.chat.activeChannel;
    if (!channelId) return [];
    return state.chat.messages[channelId] || [];
}

3.2 リアクティブな更新

// コンポーネントでの使用例
class ProfileView extends View {
    private unsubscribe: (() => void) | null = null;

    mount(container: HTMLElement): void {
        super.mount(container);

        // ストアの変更を監視
        this.unsubscribe = store.subscribe((state) => {
            this.render(state);
        });

        // 初回レンダリング
        this.render(store.getState());
    }

    unmount(): void {
        if (this.unsubscribe) {
            this.unsubscribe();
            this.unsubscribe = null;
        }
        super.unmount();
    }

    private render(state: AppState): void {
        if (!this.container) return;

        const user = state.user;
        if (!user) return;

        this.container.innerHTML = `
            <div class="profile">
                <img src="${user.avatarUrl}" alt="${user.username}">
                <h1>${user.username}</h1>
                <div class="stats">
                    <span>Wins: ${user.wins}</span>
                    <span>Losses: ${user.losses}</span>
                    <span>Level: ${user.ladderLevel}</span>
                </div>
            </div>
        `;
    }
}

---

4. APIクライアント

4.1 HTTPクライアント

// src/api.ts

const BASE_URL = '/api';

interface RequestOptions {
    method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
    body?: any;
    headers?: Record<string, string>;
}

class ApiError extends Error {
    constructor(
        public status: number,
        public message: string,
        public data?: any
    ) {
        super(message);
    }
}

async function request<T>(
    endpoint: string,
    options: RequestOptions = {}
): Promise<T> {
    const { method = 'GET', body, headers = {} } = options;

    const config: RequestInit = {
        method,
        headers: {
            'Content-Type': 'application/json',
            ...headers,
        },
        credentials: 'include', // Cookieを含める
    };

    if (body) {
        config.body = JSON.stringify(body);
    }

    const response = await fetch(`${BASE_URL}${endpoint}`, config);

    if (!response.ok) {
        const error = await response.json().catch(() => ({}));
        throw new ApiError(
            response.status,
            error.message || 'Request failed',
            error
        );
    }

    // 204 No Content
    if (response.status === 204) {
        return {} as T;
    }

    return response.json();
}

// API メソッド
export const api = {
    // 認証
    auth: {
        login42: () => {
            window.location.href = `${BASE_URL}/auth/login/42`;
        },
        logout: () => request('/auth/logout', { method: 'POST' }),
        getMe: () => request<User>('/auth/me'),
        enable2FA: () => request<{ qrCode: string }>('/auth/2fa/enable', { method: 'POST' }),
        verify2FA: (code: string) =>
            request('/auth/2fa/verify', { method: 'POST', body: { code } }),
    },

    // ユーザー
    users: {
        getAll: () => request<User[]>('/users'),
        getById: (id: number) => request<User>(`/users/${id}`),
        update: (data: Partial<User>) =>
            request<User>('/users/me', { method: 'PATCH', body: data }),
        uploadAvatar: async (file: File) => {
            const formData = new FormData();
            formData.append('avatar', file);

            const response = await fetch(`${BASE_URL}/users/me/avatar`, {
                method: 'POST',
                body: formData,
                credentials: 'include',
            });

            if (!response.ok) {
                throw new ApiError(response.status, 'Upload failed');
            }

            return response.json();
        },
        getMatchHistory: (id: number) =>
            request<GameHistory[]>(`/users/${id}/match-history`),
    },

    // フレンド
    friends: {
        getAll: () => request<User[]>('/friends'),
        add: (id: number) => request(`/friends/${id}`, { method: 'POST' }),
        remove: (id: number) => request(`/friends/${id}`, { method: 'DELETE' }),
    },

    // ブロック
    blocks: {
        getAll: () => request<User[]>('/blocks'),
        add: (id: number) => request(`/blocks/${id}`, { method: 'POST' }),
        remove: (id: number) => request(`/blocks/${id}`, { method: 'DELETE' }),
    },

    // チャンネル
    channels: {
        getAll: () => request<Channel[]>('/channels'),
        create: (data: CreateChannelDto) =>
            request<Channel>('/channels', { method: 'POST', body: data }),
        getMessages: (id: number, before?: number) =>
            request<Message[]>(`/channels/${id}/messages?before=${before || ''}`),
    },

    // ゲーム
    games: {
        getLeaderboard: () => request<User[]>('/games/leaderboard'),
    },
};

---

5. WebSocketクライアント

5.1 Socket.io クライアント

// src/socket.ts
import { io, Socket } from 'socket.io-client';
import { store } from './main';

export class SocketClient {
    private socket: Socket | null = null;
    private listeners: Map<string, Set<Function>> = new Map();

    connect(): void {
        if (this.socket?.connected) return;

        const token = this.getToken();
        if (!token) return;

        this.socket = io('/', {
            auth: { token },
            transports: ['websocket'],
        });

        this.setupListeners();
    }

    disconnect(): void {
        this.socket?.disconnect();
        this.socket = null;
    }

    private setupListeners(): void {
        if (!this.socket) return;

        this.socket.on('connect', () => {
            console.log('WebSocket connected');
        });

        this.socket.on('disconnect', () => {
            console.log('WebSocket disconnected');
        });

        // フレンドステータス更新
        this.socket.on('friend:status', (data: { userId: number; status: string }) => {
            const state = store.getState();
            if (data.status === 'online') {
                store.setState({
                    onlineFriends: [...state.onlineFriends, data.userId],
                });
            } else {
                store.setState({
                    onlineFriends: state.onlineFriends.filter(id => id !== data.userId),
                });
            }
        });

        // チャットメッセージ
        this.socket.on('chat:message:new', (message: Message) => {
            const state = store.getState();
            const channelMessages = state.chat.messages[message.channelId] || [];
            store.updateChat({
                messages: {
                    ...state.chat.messages,
                    [message.channelId]: [...channelMessages, message],
                },
            });
        });

        // ゲーム状態更新
        this.socket.on('game:state', (gameState: GameState) => {
            store.setState({ game: gameState });
        });

        // 通知
        this.socket.on('notification', (notification: Notification) => {
            const state = store.getState();
            store.setState({
                notifications: [...state.notifications, notification],
            });
        });
    }

    // イベント送信
    emit(event: string, data?: any): void {
        this.socket?.emit(event, data);
    }

    // Promise ベースの emit
    emitAsync<T>(event: string, data?: any): Promise<T> {
        return new Promise((resolve, reject) => {
            this.socket?.emit(event, data, (response: any) => {
                if (response.success) {
                    resolve(response.data);
                } else {
                    reject(new Error(response.error));
                }
            });
        });
    }

    // カスタムイベントリスナー
    on(event: string, callback: Function): () => void {
        if (!this.listeners.has(event)) {
            this.listeners.set(event, new Set());
            this.socket?.on(event, (...args) => {
                this.listeners.get(event)?.forEach(cb => cb(...args));
            });
        }

        this.listeners.get(event)!.add(callback);

        return () => {
            this.listeners.get(event)?.delete(callback);
        };
    }

    private getToken(): string | null {
        // Cookie から JWT を取得(HttpOnlyでない場合)
        // またはストアから取得
        return null; // 実際の実装ではトークンを返す
    }
}

// チャット操作
export const chatSocket = {
    joinChannel: (channelId: number, password?: string) =>
        socket.emitAsync('chat:join', { channelId, password }),

    leaveChannel: (channelId: number) =>
        socket.emitAsync('chat:leave', { channelId }),

    sendMessage: (channelId: number, content: string) =>
        socket.emitAsync('chat:message', { channelId, content }),

    typing: (channelId: number) =>
        socket.emit('chat:typing', { channelId }),
};

// ゲーム操作
export const gameSocket = {
    joinQueue: () => socket.emitAsync('game:queue:join'),
    leaveQueue: () => socket.emit('game:queue:leave'),
    ready: (gameId: string) => socket.emit('game:ready', { gameId }),
    paddleMove: (gameId: string, y: number) =>
        socket.emit('game:paddle:move', { gameId, y }),
};

---

6. コンポーネント設計

6.1 基底コンポーネント

// src/components/base/Component.ts

export abstract class Component {
    protected element: HTMLElement | null = null;
    protected props: Record<string, any>;
    protected children: Component[] = [];

    constructor(props: Record<string, any> = {}) {
        this.props = props;
    }

    abstract render(): HTMLElement;

    mount(container: HTMLElement): void {
        this.element = this.render();
        container.appendChild(this.element);
        this.afterMount();
    }

    unmount(): void {
        this.beforeUnmount();
        this.children.forEach(child => child.unmount());
        this.element?.remove();
        this.element = null;
    }

    update(newProps: Record<string, any>): void {
        this.props = { ...this.props, ...newProps };
        if (this.element && this.element.parentElement) {
            const parent = this.element.parentElement;
            const newElement = this.render();
            parent.replaceChild(newElement, this.element);
            this.element = newElement;
        }
    }

    protected afterMount(): void {}
    protected beforeUnmount(): void {}
}

export abstract class View extends Component {
    protected container: HTMLElement | null = null;
    protected params: Record<string, string>;

    constructor(params: Record<string, string> = {}) {
        super({});
        this.params = params;
    }

    mount(container: HTMLElement): void {
        this.container = container;
        container.innerHTML = '';
        super.mount(container);
    }
}

6.2 具体的なコンポーネント

// src/components/Header.ts
import { Component } from './base/Component';
import { Link } from './Link';
import { store } from '../main';

export class Header extends Component {
    render(): HTMLElement {
        const header = document.createElement('header');
        header.className = 'header';

        const user = store.getState().user;

        header.innerHTML = `
            <div class="header-content">
                <div class="logo">
                    <a href="/">ft_transcendence</a>
                </div>
                <nav class="nav">
                    ${user ? this.renderUserNav(user) : this.renderGuestNav()}
                </nav>
            </div>
        `;

        // リンクイベントを設定
        header.querySelectorAll('a[data-nav]').forEach(link => {
            link.addEventListener('click', (e) => {
                e.preventDefault();
                const href = (e.target as HTMLAnchorElement).getAttribute('href');
                if (href) navigateTo(href);
            });
        });

        return header;
    }

    private renderUserNav(user: User): string {
        return `
            <a href="/game" data-nav>Play</a>
            <a href="/chat" data-nav>Chat</a>
            <a href="/profile" data-nav>
                <img src="${user.avatarUrl}" alt="${user.username}" class="avatar-small">
                ${user.username}
            </a>
            <button class="btn-logout" id="logout-btn">Logout</button>
        `;
    }

    private renderGuestNav(): string {
        return `
            <button class="btn-login" id="login-btn">Login with 42</button>
        `;
    }

    afterMount(): void {
        // ログインボタン
        document.getElementById('login-btn')?.addEventListener('click', () => {
            api.auth.login42();
        });

        // ログアウトボタン
        document.getElementById('logout-btn')?.addEventListener('click', async () => {
            await api.auth.logout();
            store.setState({ user: null });
            navigateTo('/');
        });
    }
}

6.3 モーダルコンポーネント

// src/components/Modal.ts
import { Component } from './base/Component';

interface ModalProps {
    title: string;
    content: string | HTMLElement;
    onClose?: () => void;
    buttons?: Array<{
        text: string;
        className?: string;
        onClick: () => void;
    }>;
}

export class Modal extends Component {
    constructor(props: ModalProps) {
        super(props);
    }

    render(): HTMLElement {
        const overlay = document.createElement('div');
        overlay.className = 'modal-overlay';

        const modal = document.createElement('div');
        modal.className = 'modal';

        modal.innerHTML = `
            <div class="modal-header">
                <h2>${this.props.title}</h2>
                <button class="modal-close">&times;</button>
            </div>
            <div class="modal-content"></div>
            <div class="modal-footer"></div>
        `;

        // コンテンツ設定
        const contentEl = modal.querySelector('.modal-content')!;
        if (typeof this.props.content === 'string') {
            contentEl.innerHTML = this.props.content;
        } else {
            contentEl.appendChild(this.props.content);
        }

        // ボタン設定
        const footerEl = modal.querySelector('.modal-footer')!;
        if (this.props.buttons) {
            this.props.buttons.forEach(btn => {
                const button = document.createElement('button');
                button.textContent = btn.text;
                button.className = btn.className || 'btn';
                button.addEventListener('click', btn.onClick);
                footerEl.appendChild(button);
            });
        }

        // 閉じるボタン
        modal.querySelector('.modal-close')?.addEventListener('click', () => {
            this.close();
        });

        // オーバーレイクリックで閉じる
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) {
                this.close();
            }
        });

        overlay.appendChild(modal);
        return overlay;
    }

    close(): void {
        this.props.onClose?.();
        this.unmount();
    }

    // 静的メソッドで簡単に使用
    static show(props: ModalProps): Modal {
        const modal = new Modal(props);
        modal.mount(document.body);
        return modal;
    }

    static confirm(title: string, message: string): Promise<boolean> {
        return new Promise((resolve) => {
            Modal.show({
                title,
                content: message,
                buttons: [
                    {
                        text: 'Cancel',
                        className: 'btn-secondary',
                        onClick: () => resolve(false),
                    },
                    {
                        text: 'Confirm',
                        className: 'btn-primary',
                        onClick: () => resolve(true),
                    },
                ],
                onClose: () => resolve(false),
            });
        });
    }
}

---

7. ビュー実装

7.1 ホームビュー

// src/views/Home.ts
import { View } from '../components/base/Component';
import { Header } from '../components/Header';
import { store } from '../main';
import { api } from '../api';

export default class HomeView extends View {
    private leaderboard: User[] = [];

    async render(): Promise<HTMLElement> {
        const container = document.createElement('div');
        container.className = 'home-view';

        // ヘッダー
        const header = new Header();
        this.children.push(header);

        // リーダーボード取得
        this.leaderboard = await api.games.getLeaderboard();

        container.innerHTML = `
            <header class="header-placeholder"></header>
            <main class="main-content">
                <section class="hero">
                    <h1>Welcome to ft_transcendence</h1>
                    <p>Play Pong, chat with friends, and climb the leaderboard!</p>
                    ${store.getState().user
                        ? '<a href="/game" class="btn-primary btn-large" data-nav>Play Now</a>'
                        : '<button class="btn-primary btn-large" id="hero-login">Login with 42</button>'
                    }
                </section>

                <section class="leaderboard">
                    <h2>Leaderboard</h2>
                    <div class="leaderboard-list">
                        ${this.renderLeaderboard()}
                    </div>
                </section>

                <section class="features">
                    <div class="feature">
                        <h3>Real-time Pong</h3>
                        <p>Play classic Pong with friends in real-time multiplayer matches.</p>
                    </div>
                    <div class="feature">
                        <h3>Chat Channels</h3>
                        <p>Create and join chat channels, send direct messages.</p>
                    </div>
                    <div class="feature">
                        <h3>Competitive Ladder</h3>
                        <p>Climb the ranks and prove you're the best!</p>
                    </div>
                </section>
            </main>
        `;

        return container;
    }

    private renderLeaderboard(): string {
        return this.leaderboard.slice(0, 10).map((user, index) => `
            <div class="leaderboard-item">
                <span class="rank">#${index + 1}</span>
                <img src="${user.avatarUrl}" alt="${user.username}" class="avatar-small">
                <span class="username">${user.username}</span>
                <span class="stats">
                    ${user.wins}W / ${user.losses}L
                </span>
                <span class="level">Lv.${user.ladderLevel}</span>
            </div>
        `).join('');
    }

    afterMount(): void {
        // ヘッダーをマウント
        const headerPlaceholder = this.element?.querySelector('.header-placeholder');
        if (headerPlaceholder && this.children[0]) {
            this.children[0].mount(headerPlaceholder as HTMLElement);
        }

        // ログインボタン
        document.getElementById('hero-login')?.addEventListener('click', () => {
            api.auth.login42();
        });

        // ナビゲーションリンク
        this.element?.querySelectorAll('[data-nav]').forEach(link => {
            link.addEventListener('click', (e) => {
                e.preventDefault();
                const href = (e.target as HTMLAnchorElement).getAttribute('href');
                if (href) navigateTo(href);
            });
        });
    }
}

7.2 チャットビュー

// src/views/Chat.ts
import { View } from '../components/base/Component';
import { store, socket } from '../main';
import { api } from '../api';
import { chatSocket } from '../socket';

export default class ChatView extends View {
    private channels: Channel[] = [];
    private activeChannel: Channel | null = null;
    private messages: Message[] = [];
    private unsubscribes: (() => void)[] = [];

    async render(): Promise<HTMLElement> {
        const container = document.createElement('div');
        container.className = 'chat-view';

        // チャンネル一覧取得
        this.channels = await api.channels.getAll();

        container.innerHTML = `
            <div class="chat-layout">
                <aside class="channel-sidebar">
                    <h3>Channels</h3>
                    <button class="btn-create-channel">+ Create Channel</button>
                    <ul class="channel-list">
                        ${this.renderChannels()}
                    </ul>
                </aside>
                <main class="chat-main">
                    <div class="chat-header">
                        <h2 id="channel-name">Select a channel</h2>
                    </div>
                    <div class="messages" id="messages-container">
                        <!-- メッセージがここに表示される -->
                    </div>
                    <form class="message-form" id="message-form">
                        <input type="text" id="message-input"
                               placeholder="Type a message..." disabled>
                        <button type="submit" disabled>Send</button>
                    </form>
                </main>
                <aside class="members-sidebar" id="members-sidebar">
                    <!-- メンバーリスト -->
                </aside>
            </div>
        `;

        return container;
    }

    private renderChannels(): string {
        return this.channels.map(channel => `
            <li class="channel-item ${channel.id === this.activeChannel?.id ? 'active' : ''}"
                data-channel-id="${channel.id}">
                <span class="channel-icon">${channel.type === 'direct' ? '@' : '#'}</span>
                <span class="channel-name">${channel.name}</span>
            </li>
        `).join('');
    }

    private renderMessages(): string {
        const user = store.getState().user;
        const blockedUsers = store.getState().blocks || [];

        return this.messages
            .filter(msg => !blockedUsers.some(b => b.id === msg.sender.id))
            .map(msg => `
                <div class="message ${msg.sender.id === user?.id ? 'own' : ''}">
                    <img src="${msg.sender.avatarUrl}" alt="${msg.sender.username}"
                         class="avatar-small">
                    <div class="message-content">
                        <div class="message-header">
                            <span class="username">${msg.sender.username}</span>
                            <span class="time">${this.formatTime(msg.createdAt)}</span>
                        </div>
                        <p class="message-text">${this.escapeHtml(msg.content)}</p>
                    </div>
                </div>
            `).join('');
    }

    afterMount(): void {
        // チャンネルクリック
        this.element?.querySelectorAll('.channel-item').forEach(item => {
            item.addEventListener('click', async () => {
                const channelId = parseInt(item.getAttribute('data-channel-id')!);
                await this.selectChannel(channelId);
            });
        });

        // メッセージ送信
        const form = document.getElementById('message-form') as HTMLFormElement;
        const input = document.getElementById('message-input') as HTMLInputElement;

        form.addEventListener('submit', async (e) => {
            e.preventDefault();
            if (!this.activeChannel || !input.value.trim()) return;

            await chatSocket.sendMessage(this.activeChannel.id, input.value);
            input.value = '';
        });

        // タイピング通知
        let typingTimeout: number | null = null;
        input.addEventListener('input', () => {
            if (this.activeChannel) {
                chatSocket.typing(this.activeChannel.id);
            }
        });

        // 新規メッセージ受信
        const unsubMsg = socket.on('chat:message:new', (message: Message) => {
            if (message.channelId === this.activeChannel?.id) {
                this.messages.push(message);
                this.updateMessages();
            }
        });
        this.unsubscribes.push(unsubMsg);

        // チャンネル作成ボタン
        this.element?.querySelector('.btn-create-channel')?.addEventListener('click', () => {
            this.showCreateChannelModal();
        });
    }

    private async selectChannel(channelId: number): Promise<void> {
        // 前のチャンネルから離脱
        if (this.activeChannel) {
            await chatSocket.leaveChannel(this.activeChannel.id);
        }

        // 新しいチャンネルに参加
        this.activeChannel = this.channels.find(c => c.id === channelId) || null;
        if (!this.activeChannel) return;

        await chatSocket.joinChannel(channelId);

        // メッセージ取得
        this.messages = await api.channels.getMessages(channelId);

        // UI更新
        document.getElementById('channel-name')!.textContent =
            `#${this.activeChannel.name}`;

        const input = document.getElementById('message-input') as HTMLInputElement;
        const submitBtn = this.element?.querySelector('.message-form button') as HTMLButtonElement;
        input.disabled = false;
        submitBtn.disabled = false;

        this.updateMessages();
        this.updateChannelList();
    }

    private updateMessages(): void {
        const container = document.getElementById('messages-container');
        if (container) {
            container.innerHTML = this.renderMessages();
            container.scrollTop = container.scrollHeight;
        }
    }

    private updateChannelList(): void {
        const list = this.element?.querySelector('.channel-list');
        if (list) {
            list.innerHTML = this.renderChannels();
            // イベントリスナー再設定
            list.querySelectorAll('.channel-item').forEach(item => {
                item.addEventListener('click', async () => {
                    const channelId = parseInt(item.getAttribute('data-channel-id')!);
                    await this.selectChannel(channelId);
                });
            });
        }
    }

    private escapeHtml(text: string): string {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

    private formatTime(date: string | Date): string {
        return new Date(date).toLocaleTimeString([], {
            hour: '2-digit',
            minute: '2-digit',
        });
    }

    beforeUnmount(): void {
        // イベントリスナー解除
        this.unsubscribes.forEach(unsub => unsub());

        // チャンネルから離脱
        if (this.activeChannel) {
            chatSocket.leaveChannel(this.activeChannel.id);
        }
    }
}

---

8. スタイリング

8.1 CSSの基本構造

/* src/styles/main.css */

/* リセット */
*, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

/* CSS変数 */
:root {
    /* カラー */
    --primary: #6366f1;
    --primary-hover: #4f46e5;
    --secondary: #64748b;
    --success: #22c55e;
    --danger: #ef4444;
    --warning: #f59e0b;

    /* 背景 */
    --bg-primary: #0f172a;
    --bg-secondary: #1e293b;
    --bg-tertiary: #334155;

    /* テキスト */
    --text-primary: #f8fafc;
    --text-secondary: #94a3b8;
    --text-muted: #64748b;

    /* ボーダー */
    --border-color: #334155;

    /* スペーシング */
    --spacing-xs: 0.25rem;
    --spacing-sm: 0.5rem;
    --spacing-md: 1rem;
    --spacing-lg: 1.5rem;
    --spacing-xl: 2rem;

    /* フォント */
    --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    --font-mono: 'Fira Code', monospace;

    /* 角丸 */
    --radius-sm: 0.25rem;
    --radius-md: 0.5rem;
    --radius-lg: 1rem;
    --radius-full: 9999px;

    /* シャドウ */
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
    --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}

body {
    font-family: var(--font-family);
    background-color: var(--bg-primary);
    color: var(--text-primary);
    line-height: 1.6;
}

/* ボタン */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: var(--spacing-sm) var(--spacing-md);
    border: none;
    border-radius: var(--radius-md);
    font-size: 0.875rem;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s;
}

.btn-primary {
    background-color: var(--primary);
    color: white;
}

.btn-primary:hover {
    background-color: var(--primary-hover);
}

.btn-secondary {
    background-color: var(--bg-tertiary);
    color: var(--text-primary);
}

.btn-large {
    padding: var(--spacing-md) var(--spacing-xl);
    font-size: 1rem;
}

/* アバター */
.avatar-small {
    width: 32px;
    height: 32px;
    border-radius: var(--radius-full);
    object-fit: cover;
}

.avatar-medium {
    width: 48px;
    height: 48px;
    border-radius: var(--radius-full);
    object-fit: cover;
}

.avatar-large {
    width: 96px;
    height: 96px;
    border-radius: var(--radius-full);
    object-fit: cover;
}

/* ヘッダー */
.header {
    position: sticky;
    top: 0;
    z-index: 100;
    background-color: var(--bg-secondary);
    border-bottom: 1px solid var(--border-color);
}

.header-content {
    max-width: 1200px;
    margin: 0 auto;
    padding: var(--spacing-md) var(--spacing-lg);
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.nav {
    display: flex;
    align-items: center;
    gap: var(--spacing-lg);
}

.nav a {
    color: var(--text-secondary);
    text-decoration: none;
    transition: color 0.2s;
}

.nav a:hover {
    color: var(--text-primary);
}

8.2 チャットスタイル

/* チャットレイアウト */
.chat-layout {
    display: grid;
    grid-template-columns: 240px 1fr 200px;
    height: calc(100vh - 60px);
}

.channel-sidebar {
    background-color: var(--bg-secondary);
    border-right: 1px solid var(--border-color);
    padding: var(--spacing-md);
}

.channel-list {
    list-style: none;
    margin-top: var(--spacing-md);
}

.channel-item {
    display: flex;
    align-items: center;
    padding: var(--spacing-sm) var(--spacing-md);
    border-radius: var(--radius-md);
    cursor: pointer;
    transition: background-color 0.2s;
}

.channel-item:hover {
    background-color: var(--bg-tertiary);
}

.channel-item.active {
    background-color: var(--primary);
}

.chat-main {
    display: flex;
    flex-direction: column;
}

.chat-header {
    padding: var(--spacing-md) var(--spacing-lg);
    border-bottom: 1px solid var(--border-color);
}

.messages {
    flex: 1;
    overflow-y: auto;
    padding: var(--spacing-lg);
}

.message {
    display: flex;
    gap: var(--spacing-md);
    margin-bottom: var(--spacing-md);
}

.message.own {
    flex-direction: row-reverse;
}

.message-content {
    max-width: 70%;
    background-color: var(--bg-secondary);
    padding: var(--spacing-sm) var(--spacing-md);
    border-radius: var(--radius-lg);
}

.message.own .message-content {
    background-color: var(--primary);
}

.message-form {
    display: flex;
    gap: var(--spacing-md);
    padding: var(--spacing-md) var(--spacing-lg);
    border-top: 1px solid var(--border-color);
}

.message-form input {
    flex: 1;
    padding: var(--spacing-sm) var(--spacing-md);
    background-color: var(--bg-tertiary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    color: var(--text-primary);
}

.message-form input:focus {
    outline: none;
    border-color: var(--primary);
}

---

まとめ

本章で学んだこと:

  • プロジェクト構造: SPA向けディレクトリ構成
  • クライアントサイドルーター: History API活用
  • 状態管理: シンプルなStore実装
  • APIクライアント: fetch ラッパー
  • WebSocket: リアルタイム通信
  • コンポーネント設計: 基底クラス、再利用
  • ビュー実装: ホーム、チャット
  • スタイリング: CSS変数、モダンCSS

次章では、Pongゲームの実装を学びます。