第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">×</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ゲームの実装を学びます。