2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

💥 爆破ボタンを作った話:Cloudflare Workersで作る深夜テンションプロジェクト

Last updated at Posted at 2025-06-10

こんにちは、たんぽぽです。
普段は教育系アプリのエンジニアをしながら、京都の大学で学園祭実行委員としても活動しています。

この前、他大学の実行委員室で雑談しているときにふと思いました。

「リア充爆破したくね?」

…とまあ、そんな軽いノリで始まったのがこのプロジェクトです。

⚠️注意書き

実際に試す場合はcloudflareは従量課金制ということを理解して行なってください。

詳細はこちら

1クリックで1通信するため、たくさん押されるとそれだけ課金されて懐が痛みます。
皆様が真似する時は遊びで破産するということがないようにしましょう...
スクリーンショット 2025-06-11 8.22.21.png

🫠 ちょっとだけ個人的な話

この記事を書く1週間前、彼女と別れました。

それが関係あるかって?めちゃくちゃあります。
心にぽっかり穴が開いた僕は、無意識に“爆破”というキーワードに惹かれていたのかもしれません。

🎯 作ったもの

  • 誰でも「爆破ターゲット」を登録できる
  • 登録されたターゲットを「爆破ボタン」で爆破可能
  • 爆破回数が記録されてランキングに反映
  • X(Twitter)共有やURLコピーも対応
  • OGPでSNS映えする爆破ページ生成

🧠 技術スタック

  • Cloudflare Workers:軽量で高速なサーバーレス実行環境
  • KV Storage:爆破対象データとカウント記録
  • R2:画像ストレージとして利用
  • HTML/CSS/JS:OOPベースでUI構築
  • OGP生成:SVGベースで動的にOpen Graph対応

🏗️ アーキテクチャ図

スクリーンショット 2025-06-10 12.48.38.png

超ざっくり言うと、

/api/register: ターゲットを登録(画像と名前)
/api/explode: 爆破ボタン押下時のカウント加算
/api/ranking: ランキング一覧
/api/og-image: OGP画像をSVGで生成
/images/: R2上に保存された画像へのルーティング

それ以外はHTMLでレンダリング

💥 Workers のAPI設計

メインのエントリーポイント

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const path = url.pathname;

    const corsHeaders = {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    };

    if (request.method === 'OPTIONS') {
      return new Response(null, { headers: corsHeaders });
    }

    try {
      if (path.startsWith('/api/')) {
        const response = await handleApiRequest(request, env, path);
        Object.entries(corsHeaders).forEach(([k, v]) => {
          response.headers.set(k, v);
        });
        return response;
      }

      if (path.startsWith('/images/')) {
        return await handleImageRequest(request, env, path);
      }

      return await handleStaticFile(request, env);
    } catch (e) {
      return new Response(JSON.stringify({ error: e.message }), {
        status: 500,
        headers: { 'Content-Type': 'application/json', ...corsHeaders },
      });
    }
  },
};

📝 登録API /api/register

ターゲットをフォームで登録できます。

async function registerTarget(request, env) {
  const formData = await request.formData();
  const name = formData.get('name');
  const image = formData.get('image');

  const id = generateUniqueId();
  const ext = getFileExtension(image.type);
  const imageKey = `images/${id}.${ext}`;

  await env.EXPLOSION_R2.put(imageKey, image.stream(), {
    httpMetadata: { contentType: image.type },
  });

  const target = {
    id,
    name,
    imageUrl: `/${imageKey}`,
    explosionCount: 0,
    createdAt: new Date().toISOString(),
  };

  await env.EXPLOSION_KV.put(`target:${id}`, JSON.stringify(target));

  return new Response(JSON.stringify({ id, target }), { status: 201 });
}

💣 爆破API /api/explode

async function explodeTarget(request, env) {
  const { id } = await request.json();

  const target = await env.EXPLOSION_KV.get(`target:${id}`, 'json');
  if (!target) return new Response('Not Found', { status: 404 });

  target.explosionCount = (target.explosionCount || 0) + 1;
  target.lastExplosion = new Date().toISOString();

  await env.EXPLOSION_KV.put(`target:${id}`, JSON.stringify(target));

  return new Response(JSON.stringify({
    explosionCount: target.explosionCount,
    success: true,
  }));
}

📈 ランキング /api/ranking

async function getRanking(request, env) {
  const { keys } = await env.EXPLOSION_KV.list({ prefix: 'target:' });
  const targets = [];

  for (const key of keys) {
    const t = await env.EXPLOSION_KV.get(key.name, 'json');
    if (t) targets.push(t);
  }

  targets.sort((a, b) => (b.explosionCount || 0) - (a.explosionCount || 0));
  return new Response(JSON.stringify(targets.slice(0, 20)));
}

🌐 OGP画像 /api/og-image

SVGで画像を返して、TwitterやLINEのシェアに対応。

async function generateOGImage(request, env) {
  const svg = `<svg ...>💥 爆破ボタン 💥</svg>`;
  return new Response(svg, {
    headers: {
      'Content-Type': 'image/svg+xml',
      'Cache-Control': 'public, max-age=86400',
    },
  });
}

📸 UIもあるよ!

  • シンプルなSPA
  • 登録フォーム、爆破画面、ランキングページ
  • Twitterシェアボタン、URLコピー、爆破エフェクトも搭載
フルコードはこちら
export default {
    async fetch(request, env, ctx) {
        const url = new URL(request.url);
        const path = url.pathname;

        // CORS設定
        const corsHeaders = {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type',
        };

        // OPTIONSリクエスト(CORS preflight)への対応
        if (request.method === 'OPTIONS') {
            return new Response(null, { headers: corsHeaders });
        }

        try {
            // APIルーティング
            if (path.startsWith('/api/')) {
                const response = await handleApiRequest(request, env, path);
                // APIレスポンスにCORSヘッダーを追加
                Object.entries(corsHeaders).forEach(([key, value]) => {
                    response.headers.set(key, value);
                });
                return response;
            }

            // 画像ファイルの配信
            if (path.startsWith('/images/')) {
                return await handleImageRequest(request, env, path);
            }

            // HTMLファイルの配信(すべてのその他のリクエスト)
            return await handleStaticFile(request, env);

        } catch (error) {
            console.error('Worker error:', error);
            return new Response(JSON.stringify({ 
                error: 'Internal server error',
                message: error.message 
            }), {
                status: 500,
                headers: { 'Content-Type': 'application/json', ...corsHeaders }
            });
        }
    }
};

async function handleApiRequest(request, env, path) {
    const method = request.method;

    switch (path) {
        case '/api/target':
            if (method === 'GET') {
                return await getTarget(request, env);
            }
            break;

        case '/api/explode':
            if (method === 'POST') {
                return await explodeTarget(request, env);
            }
            break;

        case '/api/ranking':
            if (method === 'GET') {
                return await getRanking(request, env);
            }
            break;

        case '/api/register':
            if (method === 'POST') {
                return await registerTarget(request, env);
            }
            break;

        case '/api/og-image':
            if (method === 'GET') {
                return await generateOGImage(request, env);
            }
            break;

        default:
            return new Response(JSON.stringify({ error: 'Endpoint not found' }), {
                status: 404,
                headers: { 'Content-Type': 'application/json' }
            });
    }

    return new Response(JSON.stringify({ error: 'Method not allowed' }), {
        status: 405,
        headers: { 'Content-Type': 'application/json' }
    });
}

async function getTarget(request, env) {
    const url = new URL(request.url);
    const id = url.searchParams.get('id');

    if (!id) {
        return new Response(JSON.stringify({ error: 'ID is required' }), {
            status: 400,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    try {
        const target = await env.EXPLOSION_KV.get(`target:${id}`, 'json');
        
        if (!target) {
            return new Response(JSON.stringify({ error: 'Target not found' }), {
                status: 404,
                headers: { 'Content-Type': 'application/json' }
            });
        }

        return new Response(JSON.stringify(target), {
            headers: { 'Content-Type': 'application/json' }
        });
    } catch (error) {
        console.error('Error getting target:', error);
        return new Response(JSON.stringify({ error: 'Failed to get target' }), {
            status: 500,
            headers: { 'Content-Type': 'application/json' }
        });
    }
}

async function explodeTarget(request, env) {
    try {
        const { id } = await request.json();

        if (!id) {
            return new Response(JSON.stringify({ error: 'ID is required' }), {
                status: 400,
                headers: { 'Content-Type': 'application/json' }
            });
        }

        // 既存のターゲットデータを取得
        const target = await env.EXPLOSION_KV.get(`target:${id}`, 'json');
        
        if (!target) {
            return new Response(JSON.stringify({ error: 'Target not found' }), {
                status: 404,
                headers: { 'Content-Type': 'application/json' }
            });
        }

        // 爆破回数を増加
        target.explosionCount = (target.explosionCount || 0) + 1;
        target.lastExplosion = new Date().toISOString();

        // KVに保存
        await env.EXPLOSION_KV.put(`target:${id}`, JSON.stringify(target));

        console.log(`Target ${id} exploded! Count: ${target.explosionCount}`);

        return new Response(JSON.stringify({ 
            explosionCount: target.explosionCount,
            message: 'Target exploded successfully!',
            success: true
        }), {
            headers: { 'Content-Type': 'application/json' }
        });

    } catch (error) {
        console.error('Error exploding target:', error);
        return new Response(JSON.stringify({ 
            error: 'Failed to explode target',
            message: error.message 
        }), {
            status: 500,
            headers: { 'Content-Type': 'application/json' }
        });
    }
}

async function getRanking(request, env) {
    try {
        // KVから全てのターゲットを取得
        const { keys } = await env.EXPLOSION_KV.list({ prefix: 'target:' });
        const targets = [];

        // 各ターゲットのデータを取得
        for (const key of keys) {
            try {
                const target = await env.EXPLOSION_KV.get(key.name, 'json');
                if (target) {
                    targets.push(target);
                }
            } catch (error) {
                console.error(`Error fetching target ${key.name}:`, error);
            }
        }

        // 爆破回数でソート(降順)
        targets.sort((a, b) => (b.explosionCount || 0) - (a.explosionCount || 0));

        // 上位20件を返す
        const ranking = targets.slice(0, 20);

        return new Response(JSON.stringify(ranking), {
            headers: { 'Content-Type': 'application/json' }
        });

    } catch (error) {
        console.error('Error getting ranking:', error);
        return new Response(JSON.stringify({ error: 'Failed to get ranking' }), {
            status: 500,
            headers: { 'Content-Type': 'application/json' }
        });
    }
}

async function registerTarget(request, env) {
    try {
        const formData = await request.formData();
        const name = formData.get('name');
        const imageFile = formData.get('image');

        if (!name || !imageFile) {
            return new Response(JSON.stringify({ error: 'Name and image are required' }), {
                status: 400,
                headers: { 'Content-Type': 'application/json' }
            });
        }

        // 画像ファイルの検証
        if (!imageFile.type || !imageFile.type.startsWith('image/')) {
            return new Response(JSON.stringify({ error: 'Invalid image file' }), {
                status: 400,
                headers: { 'Content-Type': 'application/json' }
            });
        }

        // ユニークIDを生成
        const id = generateUniqueId();
        const imageKey = `images/${id}.${getFileExtension(imageFile.type)}`;

        try {
            // R2に画像をアップロード
            await env.EXPLOSION_R2.put(imageKey, imageFile.stream(), {
                httpMetadata: {
                    contentType: imageFile.type,
                },
            });
        } catch (r2Error) {
            console.error('R2 upload error:', r2Error);
            return new Response(JSON.stringify({ error: 'Failed to upload image' }), {
                status: 500,
                headers: { 'Content-Type': 'application/json' }
            });
        }

        // ターゲットデータを作成
        const target = {
            id: id,
            name: name.trim(),
            imageUrl: `/${imageKey}`,
            explosionCount: 0,
            createdAt: new Date().toISOString()
        };

        try {
            // KVに保存
            await env.EXPLOSION_KV.put(`target:${id}`, JSON.stringify(target));
        } catch (kvError) {
            console.error('KV save error:', kvError);
            // R2からアップロードした画像を削除(ロールバック)
            try {
                await env.EXPLOSION_R2.delete(imageKey);
            } catch (deleteError) {
                console.error('Error deleting image during rollback:', deleteError);
            }
            
            return new Response(JSON.stringify({ error: 'Failed to save target data' }), {
                status: 500,
                headers: { 'Content-Type': 'application/json' }
            });
        }

        return new Response(JSON.stringify({ 
            id: id, 
            message: 'Target registered successfully!',
            target: target
        }), {
            status: 201,
            headers: { 'Content-Type': 'application/json' }
        });

    } catch (error) {
        console.error('Error registering target:', error);
        return new Response(JSON.stringify({ error: 'Failed to register target' }), {
            status: 500,
            headers: { 'Content-Type': 'application/json' }
        });
    }
}

async function handleImageRequest(request, env, path) {
    const imageKey = path.substring(1); // 先頭の '/' を除去
    
    try {
        const object = await env.EXPLOSION_R2.get(imageKey);
        
        if (!object) {
            return new Response('Image not found', { status: 404 });
        }

        const headers = new Headers();
        object.writeHttpMetadata(headers);
        headers.set('etag', object.httpEtag);
        headers.set('Cache-Control', 'public, max-age=31536000');
        headers.set('Access-Control-Allow-Origin', '*');

        return new Response(object.body, { headers });
    } catch (error) {
        console.error('Error serving image:', error);
        return new Response('Failed to serve image', { status: 500 });
    }
}

async function handleStaticFile(request, env) {
    const url = new URL(request.url);
    const targetId = url.searchParams.get('id');
    
    // OGP用のメタデータを取得
    let ogpData = {
        title: '💥 爆破ボタン 💥',
        description: '誰でも爆破したいターゲットを登録でき、爆破ボタンを押すとカウントされるWebアプリです。',
        image: `${url.origin}/api/og-image`,
        url: request.url,
        siteName: '爆破ボタン'
    };

    // ターゲットページの場合、専用のOGPデータを取得
    if (targetId) {
        try {
            const target = await env.EXPLOSION_KV.get(`target:${targetId}`, 'json');
            if (target) {
                ogpData.title = `💥 ${target.name}を爆破!`;
                ogpData.description = `「${target.name}」の爆破回数: ${target.explosionCount || 0}回 - あなたも爆破してみませんか?`;
                ogpData.image = `${url.origin}${target.imageUrl}`;
            }
        } catch (error) {
            console.error('Error fetching target for OGP:', error);
        }
    }

    const htmlContent = generateHTML(ogpData);

    return new Response(htmlContent, {
        headers: { 
            'Content-Type': 'text/html; charset=utf-8',
            'Cache-Control': 'no-cache' // デバッグ中はキャッシュを無効化
        }
    });
}

function generateHTML(ogpData) {
    return `<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>${ogpData.title}</title>
    
    <!-- 基本メタタグ -->
    <meta name="description" content="${ogpData.description}">
    <meta name="keywords" content="爆破,ボタン,ゲーム,カウンター,ランキング">
    <meta name="author" content="kurosangurasu">
    
    <!-- OGP (Open Graph Protocol) -->
    <meta property="og:type" content="website">
    <meta property="og:title" content="${ogpData.title}">
    <meta property="og:description" content="${ogpData.description}">
    <meta property="og:image" content="${ogpData.image}">
    <meta property="og:url" content="${ogpData.url}">
    <meta property="og:site_name" content="${ogpData.siteName}">
    <meta property="og:locale" content="ja_JP">
    
    <!-- Twitter Card -->
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:site" content="@kurosangurasu">
    <meta name="twitter:creator" content="@kurosangurasu">
    <meta name="twitter:title" content="${ogpData.title}">
    <meta name="twitter:description" content="${ogpData.description}">
    <meta name="twitter:image" content="${ogpData.image}">
    
    <!-- Facebook/Meta -->
    <meta property="fb:app_id" content="">
    
    <!-- その他のメタタグ -->
    <meta name="robots" content="index, follow">
    <meta name="theme-color" content="#667eea">
    <link rel="canonical" href="${ogpData.url}">
    
    <!-- Favicon -->
    <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>💥</text></svg>">
    
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Arial', sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            color: #333;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
        }

        header {
            text-align: center;
            margin-bottom: 30px;
        }

        header h1 {
            color: #fff;
            font-size: 2.5rem;
            margin-bottom: 20px;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
        }

        nav {
            display: flex;
            justify-content: center;
            gap: 15px;
            margin-bottom: 20px;
        }

        .nav-btn {
            padding: 10px 20px;
            background: rgba(255,255,255,0.9);
            border: none;
            border-radius: 25px;
            cursor: pointer;
            font-weight: bold;
            transition: all 0.3s ease;
        }

        .nav-btn:hover {
            background: #fff;
            transform: translateY(-2px);
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
        }

        .nav-btn.active {
            background: #ff6b6b;
            color: white;
        }

        main {
            background: rgba(255,255,255,0.95);
            border-radius: 20px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
        }

        .page {
            display: none;
        }

        .page.active {
            display: block;
        }

        .target-display {
            text-align: center;
            max-width: 600px;
            margin: 0 auto;
        }

        .target-img {
            width: 300px;
            height: 300px;
            object-fit: cover;
            border-radius: 15px;
            margin-bottom: 20px;
            box-shadow: 0 8px 20px rgba(0,0,0,0.3);
        }

        #targetName {
            font-size: 2rem;
            margin-bottom: 20px;
            color: #333;
        }

        .explosion-count {
            font-size: 1.5rem;
            margin-bottom: 30px;
            color: #666;
        }

        #explosionCount {
            font-weight: bold;
            color: #ff6b6b;
            font-size: 2rem;
        }

        .explode-btn {
            position: relative;
            padding: 20px 40px;
            font-size: 1.5rem;
            font-weight: bold;
            background: linear-gradient(45deg, #ff6b6b, #ff8e53);
            color: white;
            border: none;
            border-radius: 50px;
            cursor: pointer;
            overflow: hidden;
            transition: all 0.3s ease;
            box-shadow: 0 8px 20px rgba(255, 107, 107, 0.4);
        }

        .explode-btn:hover {
            transform: scale(1.05);
            box-shadow: 0 12px 25px rgba(255, 107, 107, 0.6);
        }

        .explode-btn:active {
            transform: scale(0.95);
        }

        .explode-btn:disabled {
            opacity: 0.7;
            cursor: not-allowed;
            transform: none;
        }

        .explosion-effect {
            position: absolute;
            top: 50%;
            left: 50%;
            width: 0;
            height: 0;
            background: radial-gradient(circle, #ffff00, #ff6b6b, transparent);
            border-radius: 50%;
            transform: translate(-50%, -50%);
            opacity: 0;
            transition: all 0.5s ease;
        }

        .explode-btn.exploding .explosion-effect {
            width: 200px;
            height: 200px;
            opacity: 1;
        }

        /* ソーシャルボタン */
        .social-buttons {
            margin-top: 30px;
            display: flex;
            gap: 15px;
            justify-content: center;
            flex-wrap: wrap;
        }

        .twitter-btn, .share-btn {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 12px 20px;
            border: none;
            border-radius: 25px;
            cursor: pointer;
            font-weight: bold;
            transition: all 0.3s ease;
            text-decoration: none;
        }

        .twitter-btn {
            background: linear-gradient(45deg, #1da1f2, #0d8bd9);
            color: white;
        }

        .twitter-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 15px rgba(29, 161, 242, 0.4);
        }

        .share-btn {
            background: linear-gradient(45deg, #28a745, #20c997);
            color: white;
        }

        .share-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 15px rgba(40, 167, 69, 0.4);
        }

        .twitter-icon, .share-icon {
            font-size: 1.2rem;
        }

        /* 通知メッセージ */
        .notification {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: rgba(0, 0, 0, 0.8);
            color: white;
            padding: 15px 25px;
            border-radius: 10px;
            z-index: 1002;
            font-size: 1.1rem;
            opacity: 0;
            transition: opacity 0.3s ease;
        }

        .notification.show {
            opacity: 1;
        }

        /* フッター */
        .footer {
            margin-top: 40px;
            padding: 20px 0;
            text-align: center;
            border-top: 1px solid rgba(255, 255, 255, 0.2);
        }

        .footer-content {
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 10px;
            flex-wrap: wrap;
        }

        .footer-text {
            color: rgba(255, 255, 255, 0.8);
            font-size: 0.9rem;
            margin: 0;
        }

        .footer-link {
            display: flex;
            align-items: center;
            gap: 5px;
            color: #fff;
            text-decoration: none;
            padding: 8px 15px;
            background: rgba(29, 161, 242, 0.2);
            border: 1px solid rgba(29, 161, 242, 0.3);
            border-radius: 20px;
            transition: all 0.3s ease;
            font-weight: bold;
        }

        .footer-link:hover {
            background: rgba(29, 161, 242, 0.4);
            border-color: rgba(29, 161, 242, 0.6);
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(29, 161, 242, 0.3);
        }

        .footer-link .twitter-icon {
            font-size: 1.1rem;
        }

        .welcome-section {
            text-align: center;
        }

        .welcome-section h2 {
            font-size: 2rem;
            margin-bottom: 20px;
            color: #333;
        }

        .welcome-section p {
            font-size: 1.2rem;
            margin-bottom: 30px;
            color: #666;
        }

        .action-buttons {
            display: flex;
            justify-content: center;
            gap: 20px;
            flex-wrap: wrap;
        }

        .action-btn {
            padding: 15px 30px;
            font-size: 1.2rem;
            font-weight: bold;
            background: linear-gradient(45deg, #667eea, #764ba2);
            color: white;
            border: none;
            border-radius: 25px;
            cursor: pointer;
            transition: all 0.3s ease;
        }

        .action-btn:hover {
            transform: translateY(-3px);
            box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
        }

        .register-form {
            max-width: 500px;
            margin: 0 auto;
        }

        .register-form h2 {
            text-align: center;
            margin-bottom: 30px;
            color: #333;
        }

        .form-group {
            margin-bottom: 25px;
        }

        .form-group label {
            display: block;
            margin-bottom: 8px;
            font-weight: bold;
            color: #333;
        }

        .form-group input[type="text"],
        .form-group input[type="file"] {
            width: 100%;
            padding: 12px;
            border: 2px solid #ddd;
            border-radius: 8px;
            font-size: 1rem;
            transition: border-color 0.3s ease;
        }

        .form-group input:focus {
            outline: none;
            border-color: #667eea;
        }

        .image-preview {
            margin-top: 15px;
            text-align: center;
        }

        .image-preview img {
            max-width: 200px;
            max-height: 200px;
            border-radius: 8px;
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
        }

        .submit-btn {
            width: 100%;
            padding: 15px;
            font-size: 1.2rem;
            font-weight: bold;
            background: linear-gradient(45deg, #4ecdc4, #44a08d);
            color: white;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.3s ease;
        }

        .submit-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 15px rgba(78, 205, 196, 0.4);
        }

        .ranking-section h2 {
            text-align: center;
            margin-bottom: 30px;
            color: #333;
        }

        .ranking-list {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
            gap: 20px;
        }

        .ranking-item {
            background: white;
            border-radius: 15px;
            padding: 20px;
            text-align: center;
            box-shadow: 0 4px 15px rgba(0,0,0,0.1);
            transition: all 0.3s ease;
            cursor: pointer;
            position: relative;
        }

        .ranking-item:hover {
            transform: translateY(-5px);
            box-shadow: 0 8px 25px rgba(0,0,0,0.2);
        }

        .ranking-item img {
            width: 80px;
            height: 80px;
            object-fit: cover;
            border-radius: 50%;
            margin-bottom: 15px;
        }

        .ranking-item h3 {
            margin-bottom: 10px;
            color: #333;
        }

        .ranking-item .count {
            font-size: 1.5rem;
            font-weight: bold;
            color: #ff6b6b;
        }

        .ranking-item .rank {
            position: absolute;
            top: 10px;
            left: 10px;
            background: #ffd700;
            color: #333;
            width: 30px;
            height: 30px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-weight: bold;
        }

        .loading {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.5);
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            z-index: 1000;
        }

        .spinner {
            width: 50px;
            height: 50px;
            border: 5px solid #f3f3f3;
            border-top: 5px solid #ff6b6b;
            border-radius: 50%;
            animation: spin 1s linear infinite;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        .loading p {
            color: white;
            margin-top: 20px;
            font-size: 1.2rem;
        }

        .error-message {
            position: fixed;
            top: 20px;
            right: 20px;
            background: #ff6b6b;
            color: white;
            padding: 15px 20px;
            border-radius: 8px;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            z-index: 1001;
        }

        .success-message {
            position: fixed;
            top: 20px;
            right: 20px;
            background: #4ecdc4;
            color: white;
            padding: 15px 20px;
            border-radius: 8px;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            z-index: 1001;
        }

        @media (max-width: 768px) {
            .container {
                padding: 10px;
            }
            
            header h1 {
                font-size: 2rem;
            }
            
            .nav-btn {
                padding: 8px 15px;
                font-size: 0.9rem;
            }
            
            .target-img {
                width: 250px;
                height: 250px;
            }
            
            .action-buttons {
                flex-direction: column;
                align-items: center;
            }
            
            .action-btn {
                width: 100%;
                max-width: 300px;
            }
            
            .footer-content {
                flex-direction: column;
                gap: 15px;
            }
            
            .footer-text {
                font-size: 0.8rem;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <header>
            <h1>💥 爆破ボタン 💥</h1>
            <nav>
                <button id="homeBtn" class="nav-btn">ホーム</button>
                <button id="registerBtn" class="nav-btn">新規登録</button>
                <button id="rankingBtn" class="nav-btn">ランキング</button>
            </nav>
        </header>

        <main>
            <!-- ターゲット爆破ページ -->
            <div id="targetPage" class="page" style="display: none;">
                <div class="target-display">
                    <img id="targetImage" src="" alt="Target Image" class="target-img">
                    <h2 id="targetName"></h2>
                    <div class="explosion-count">
                        <span>爆破回数: </span>
                        <span id="explosionCount">0</span>
                    </div>
                    <button id="explodeBtn" class="explode-btn">
                        <span class="btn-text">💥 爆破する 💥</span>
                        <div class="explosion-effect"></div>
                    </button>
                    
                    <!-- Twitter拡散ボタン -->
                    <div class="social-buttons">
                        <button id="twitterBtn" class="twitter-btn">
                            <span class="twitter-icon">🐦</span>
                            <span>爆破結果をツイート</span>
                        </button>
                        <button id="shareBtn" class="share-btn">
                            <span class="share-icon">📤</span>
                            <span>URLをコピー</span>
                        </button>
                    </div>
                </div>
            </div>

            <!-- ホームページ -->
            <div id="homePage" class="page">
                <div class="welcome-section">
                    <h2>爆破したいターゲットを選んでください</h2>
                    <p>新しいターゲットを登録するか、既存のターゲットを爆破しましょう!</p>
                    <div class="action-buttons">
                        <button class="action-btn register-action">新規ターゲット登録</button>
                        <button class="action-btn ranking-action">ランキングを見る</button>
                    </div>
                </div>
            </div>

            <!-- 新規登録ページ -->
            <div id="registerPage" class="page" style="display: none;">
                <div class="register-form">
                    <h2>新しいターゲットを登録</h2>
                    <form id="registerForm" enctype="multipart/form-data">
                        <div class="form-group">
                            <label for="targetNameInput">ターゲット名:</label>
                            <input type="text" id="targetNameInput" name="name" required maxlength="50">
                        </div>
                        <div class="form-group">
                            <label for="targetImageInput">画像をアップロード:</label>
                            <input type="file" id="targetImageInput" name="image" accept="image/png,image/jpeg,image/jpg,image/webp" required>
                            <div class="image-preview">
                                <img id="previewImage" style="display: none;">
                            </div>
                        </div>
                        <button type="submit" class="submit-btn">登録する</button>
                    </form>
                </div>
            </div>

            <!-- ランキングページ -->
            <div id="rankingPage" class="page" style="display: none;">
                <div class="ranking-section">
                    <h2>🏆 爆破ランキング 🏆</h2>
                    <div id="rankingList" class="ranking-list">
                        <!-- ランキングデータがここに表示される -->
                    </div>
                </div>
            </div>
        </main>

        <!-- フッター -->
        <footer class="footer">
            <div class="footer-content">
                <p class="footer-text">Made with 💖 by</p>
                <a href="https://x.com/kurosangurasu" target="_blank" rel="noopener noreferrer" class="footer-link">
                    <span class="twitter-icon">🐦</span>
                    <span>@kurosangurasu</span>
                </a>
            </div>
        </footer>

        <!-- ローディング表示 -->
        <div id="loading" class="loading" style="display: none;">
            <div class="spinner"></div>
            <p>処理中...</p>
        </div>

        <!-- エラーメッセージ -->
        <div id="errorMessage" class="error-message" style="display: none;"></div>
        
        <!-- 成功メッセージ -->
        <div id="successMessage" class="success-message" style="display: none;"></div>
        
        <!-- 通知メッセージ -->
        <div id="notification" class="notification"></div>
    </div>

    <script>
        class ExplosionApp {
            constructor() {
                this.currentTargetId = null;
                this.currentTarget = null;
                this.init();
            }

            init() {
                this.bindEvents();
                this.handleRoute();
            }

            bindEvents() {
                // ナビゲーションボタン
                document.getElementById('homeBtn').addEventListener('click', (e) => {
                    e.preventDefault();
                    this.navigateToHome();
                });
                document.getElementById('registerBtn').addEventListener('click', (e) => {
                    e.preventDefault();
                    this.showPage('register');
                });
                document.getElementById('rankingBtn').addEventListener('click', (e) => {
                    e.preventDefault();
                    this.showPage('ranking');
                });

                // アクションボタン
                document.querySelector('.register-action').addEventListener('click', (e) => {
                    e.preventDefault();
                    this.showPage('register');
                });
                document.querySelector('.ranking-action').addEventListener('click', (e) => {
                    e.preventDefault();
                    this.showPage('ranking');
                });

                // 爆破ボタン
                document.getElementById('explodeBtn').addEventListener('click', (e) => {
                    e.preventDefault();
                    this.explodeTarget();
                });

                // ソーシャルボタン
                document.getElementById('twitterBtn').addEventListener('click', (e) => {
                    e.preventDefault();
                    try {
                        this.shareToTwitter();
                    } catch (error) {
                        console.error('Twitter share error:', error);
                        this.showError('Twitter共有でエラーが発生しました: ' + error.message);
                    }
                });

                document.getElementById('shareBtn').addEventListener('click', (e) => {
                    e.preventDefault();
                    try {
                        this.copyUrl();
                    } catch (error) {
                        console.error('URL copy error:', error);
                        this.showError('URL取得でエラーが発生しました: ' + error.message);
                    }
                });

                // 登録フォーム
                document.getElementById('registerForm').addEventListener('submit', (e) => this.handleRegister(e));
                document.getElementById('targetImageInput').addEventListener('change', (e) => this.previewImage(e));
            }

            navigateToHome() {
                // URLからidパラメータを削除
                const url = new URL(window.location);
                url.searchParams.delete('id');
                window.history.pushState({}, '', url.pathname + url.search);
                
                // ホームページを表示
                this.currentTargetId = null;
                this.currentTarget = null;
                this.showPage('home');
                
                // OGPをデフォルトに戻す
                this.updateOGP({
                    title: '💥 爆破ボタン 💥',
                    description: '誰でも爆破したいターゲットを登録でき、爆破ボタンを押すとカウントされるWebアプリです。',
                    image: \`\${window.location.origin}/api/og-image\`,
                    url: window.location.href
                });
            }

            handleRoute() {
                const urlParams = new URLSearchParams(window.location.search);
                const targetId = urlParams.get('id');

                if (targetId && targetId !== this.currentTargetId) {
                    this.currentTargetId = targetId;
                    this.loadTarget(targetId);
                } else if (!targetId) {
                    this.currentTargetId = null;
                    this.currentTarget = null;
                    this.showPage('home');
                }
            }

            showPage(pageName) {
                console.log('Showing page:', pageName);
                
                // 全てのページを非表示
                document.querySelectorAll('.page').forEach(page => {
                    page.classList.remove('active');
                    page.style.display = 'none';
                });

                // ナビゲーションボタンのアクティブ状態をリセット
                document.querySelectorAll('.nav-btn').forEach(btn => btn.classList.remove('active'));

                // 指定されたページを表示
                const targetPage = document.getElementById(pageName + 'Page');
                if (targetPage) {
                    targetPage.classList.add('active');
                    targetPage.style.display = 'block';
                }

                // 対応するナビゲーションボタンをアクティブに
                const navBtn = document.getElementById(pageName + 'Btn');
                if (navBtn) {
                    navBtn.classList.add('active');
                }

                // ページ固有の処理(無限ループを防ぐため条件を追加)
                if (pageName === 'ranking') {
                    this.loadRanking();
                }
                // targetページの処理はloadTarget()で行うため、ここでは実行しない
            }

            async loadTarget(id) {
                // 既に同じターゲットが読み込み済みの場合はスキップ
                if (this.currentTarget && this.currentTarget.id === id) {
                    this.showPage('target');
                    return;
                }

                this.showLoading(true);
                
                try {
                    console.log('Loading target:', id);
                    const response = await fetch('/api/target?id=' + encodeURIComponent(id));
                    
                    if (!response.ok) {
                        const errorData = await response.json();
                        throw new Error(errorData.error || 'ターゲットが見つかりません');
                    }

                    const target = await response.json();
                    console.log('Target loaded:', target);
                    
                    this.currentTarget = target;
                    this.currentTargetId = id;
                    this.displayTarget(target);
                    this.showPage('target');
                } catch (error) {
                    console.error('Error loading target:', error);
                    this.showError('ターゲットの読み込みに失敗しました: ' + error.message);
                    this.navigateToHome();
                } finally {
                    this.showLoading(false);
                }
            }

            displayTarget(target) {
                console.log('Displaying target:', target);
                document.getElementById('targetImage').src = target.imageUrl;
                document.getElementById('targetImage').alt = target.name;
                document.getElementById('targetName').textContent = target.name;
                document.getElementById('explosionCount').textContent = target.explosionCount || 0;
                
                // OGPメタタグを動的に更新
                this.updateOGP({
                    title: \`💥 \${target.name}を爆破!\`,
                    description: \`\${target.name}」の爆破回数: \${target.explosionCount || 0}回 - あなたも爆破してみませんか?\`,
                    image: \`\${window.location.origin}\${target.imageUrl}\`,
                    url: window.location.href
                });
            }

            async explodeTarget() {
                if (!this.currentTargetId) {
                    console.error('No current target ID');
                    return;
                }

                const btn = document.getElementById('explodeBtn');
                const countElement = document.getElementById('explosionCount');
                
                console.log('Exploding target:', this.currentTargetId);
                
                // ボタンを無効化してエフェクトを開始
                btn.disabled = true;
                btn.classList.add('exploding');

                try {
                    const response = await fetch('/api/explode', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify({ id: this.currentTargetId })
                    });

                    console.log('Explode response status:', response.status);

                    if (!response.ok) {
                        const errorData = await response.json();
                        console.error('Explode error:', errorData);
                        throw new Error(errorData.error || '爆破処理に失敗しました');
                    }

                    const result = await response.json();
                    console.log('Explode result:', result);

                    // 即座にフロントエンドの数値を更新
                    const newCount = result.explosionCount;
                    countElement.textContent = newCount;
                    
                    // 現在のターゲットデータも更新
                    if (this.currentTarget) {
                        this.currentTarget.explosionCount = newCount;
                    }

                    // 成功メッセージを表示
                    this.showSuccess('💥 爆破成功! カウント: ' + newCount);

                    // 爆破エフェクトを一定時間後に削除
                    setTimeout(() => {
                        btn.classList.remove('exploding');
                        btn.disabled = false;
                    }, 500);

                } catch (error) {
                    console.error('Error exploding target:', error);
                    this.showError('爆破に失敗しました: ' + error.message);
                    
                    // エラー時もエフェクトを削除
                    setTimeout(() => {
                        btn.classList.remove('exploding');
                        btn.disabled = false;
                    }, 500);
                }
            }

            async handleRegister(event) {
                event.preventDefault();
                this.showLoading(true);

                const submitBtn = event.target.querySelector('.submit-btn');
                const originalText = submitBtn.textContent;
                submitBtn.disabled = true;
                submitBtn.textContent = '登録中...';

                const formData = new FormData();
                const nameInput = document.getElementById('targetNameInput');
                const imageInput = document.getElementById('targetImageInput');

                // クライアントサイドバリデーション
                if (!nameInput.value.trim()) {
                    this.showError('ターゲット名を入力してください');
                    this.resetSubmitButton(submitBtn, originalText);
                    this.showLoading(false);
                    return;
                }

                if (!imageInput.files[0]) {
                    this.showError('画像を選択してください');
                    this.resetSubmitButton(submitBtn, originalText);
                    this.showLoading(false);
                    return;
                }

                // ファイルサイズチェック(5MB)
                if (imageInput.files[0].size > 5 * 1024 * 1024) {
                    this.showError('画像ファイルは5MB以下にしてください');
                    this.resetSubmitButton(submitBtn, originalText);
                    this.showLoading(false);
                    return;
                }

                formData.append('name', nameInput.value.trim());
                formData.append('image', imageInput.files[0]);

                try {
                    const response = await fetch('/api/register', {
                        method: 'POST',
                        body: formData
                    });

                    if (!response.ok) {
                        const errorData = await response.json();
                        throw new Error(errorData.error || '登録に失敗しました');
                    }

                    const result = await response.json();
                    console.log('Registration result:', result);
                    
                    // 登録成功後、新しいターゲットのページに遷移
                    window.location.href = '?id=' + encodeURIComponent(result.id);

                } catch (error) {
                    console.error('Registration error:', error);
                    this.showError('登録に失敗しました: ' + error.message);
                    this.resetSubmitButton(submitBtn, originalText);
                } finally {
                    this.showLoading(false);
                }
            }

            resetSubmitButton(btn, originalText) {
                btn.disabled = false;
                btn.textContent = originalText;
            }

            previewImage(event) {
                const file = event.target.files[0];
                const preview = document.getElementById('previewImage');

                if (file) {
                    // ファイルタイプチェック
                    if (!file.type.startsWith('image/')) {
                        this.showError('画像ファイルを選択してください');
                        event.target.value = '';
                        preview.style.display = 'none';
                        return;
                    }

                    const reader = new FileReader();
                    reader.onload = (e) => {
                        preview.src = e.target.result;
                        preview.style.display = 'block';
                    };
                    reader.readAsDataURL(file);
                } else {
                    preview.style.display = 'none';
                }
            }

            async loadRanking() {
                this.showLoading(true);

                try {
                    const response = await fetch('/api/ranking');
                    if (!response.ok) {
                        const errorData = await response.json();
                        throw new Error(errorData.error || 'ランキングの読み込みに失敗しました');
                    }

                    const ranking = await response.json();
                    console.log('Ranking loaded:', ranking);
                    this.displayRanking(ranking);
                } catch (error) {
                    console.error('Error loading ranking:', error);
                    this.showError('ランキングの読み込みに失敗しました: ' + error.message);
                } finally {
                    this.showLoading(false);
                }
            }

            displayRanking(ranking) {
                const rankingList = document.getElementById('rankingList');
                rankingList.innerHTML = '';

                if (ranking.length === 0) {
                    rankingList.innerHTML = '<p style="text-align: center; color: #666;">まだランキングデータがありません</p>';
                    return;
                }

                ranking.forEach((item, index) => {
                    const rankingItem = document.createElement('div');
                    rankingItem.className = 'ranking-item';
                    
                    // HTMLエスケープ処理
                    const escapedName = this.escapeHtml(item.name);
                    const escapedImageUrl = this.escapeHtml(item.imageUrl);
                    
                    rankingItem.innerHTML = \`
                        <div class="rank">\${index + 1}</div>
                        <img src="\${escapedImageUrl}" alt="\${escapedName}" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSIjZjNmNGY2Ii8+Cjx0ZXh0IHg9IjQwIiB5PSI0MCIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjEyIiBmaWxsPSIjOWNhM2FmIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSI+Tm8gSW1hZ2U8L3RleHQ+Cjwvc3ZnPg=='">
                        <h3>\${escapedName}</h3>
                        <div class="count">\${item.explosionCount || 0} 回</div>
                    \`;

                    rankingItem.addEventListener('click', () => {
                        const url = new URL(window.location);
                        url.searchParams.set('id', item.id);
                        window.history.pushState({}, '', url.toString());
                        
                        this.currentTargetId = item.id;
                        this.loadTarget(item.id);
                    });

                    rankingList.appendChild(rankingItem);
                });
            }

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

            showLoading(show) {
                const loading = document.getElementById('loading');
                loading.style.display = show ? 'flex' : 'none';
            }

            showError(message) {
                console.error('Error:', message);
                const errorDiv = document.getElementById('errorMessage');
                errorDiv.textContent = message;
                errorDiv.style.display = 'block';

                setTimeout(() => {
                    errorDiv.style.display = 'none';
                }, 5000);
            }

            showSuccess(message) {
                console.log('Success:', message);
                const successDiv = document.getElementById('successMessage');
                successDiv.textContent = message;
                successDiv.style.display = 'block';

                setTimeout(() => {
                    successDiv.style.display = 'none';
                }, 3000);
            }

            shareToTwitter() {
                if (!this.currentTarget) {
                    this.showError('ターゲット情報が見つかりません');
                    return;
                }

                const targetName = this.currentTarget.name;
                const explosionCount = this.currentTarget.explosionCount || 0;
                const currentUrl = window.location.href;

                // ツイート内容を作成(改行を%0Aに変換)
                const tweetLines = [
                    \`💥 「\${targetName}」を爆破しました!\`,
                    \`現在の爆破回数: \${explosionCount}回\`,
                    '',
                    'あなたも爆破してみませんか?',
                    '#爆破ボタン',
                    '',
                ];
                const tweetText = tweetLines.join('\\n');
                
                // Twitter投稿URLを生成
                const twitterUrl = \`https://twitter.com/intent/tweet?text=\${encodeURIComponent(tweetText)}&url=\${encodeURIComponent(currentUrl)}\`;
                
                console.log('Opening Twitter with URL:', twitterUrl);
                
                // 新しいウィンドウでTwitterを開く
                window.open(twitterUrl, '_blank', 'width=550,height=420,scrollbars=yes,resizable=yes');
                
                this.showNotification('Twitterで共有しています...');
            }

            async copyUrl() {
                const currentUrl = window.location.href;
                
                try {
                    // Clipboard APIが利用可能な場合
                    if (navigator.clipboard && window.isSecureContext) {
                        await navigator.clipboard.writeText(currentUrl);
                        this.showNotification('URLをクリップボードにコピーしました!');
                    } else {
                        // フォールバック: テキストエリアを使用
                        const textArea = document.createElement('textarea');
                        textArea.value = currentUrl;
                        textArea.style.position = 'fixed';
                        textArea.style.left = '-999999px';
                        textArea.style.top = '-999999px';
                        document.body.appendChild(textArea);
                        textArea.focus();
                        textArea.select();
                        
                        try {
                            document.execCommand('copy');
                            this.showNotification('URLをクリップボードにコピーしました!');
                        } catch (err) {
                            console.error('Copy failed:', err);
                            this.showError('URLのコピーに失敗しました');
                        } finally {
                            textArea.remove();
                        }
                    }
                } catch (err) {
                    console.error('Copy failed:', err);
                    this.showError('URLのコピーに失敗しました');
                }
            }

            showNotification(message) {
                const notification = document.getElementById('notification');
                notification.textContent = message;
                notification.classList.add('show');

                setTimeout(() => {
                    notification.classList.remove('show');
                }, 2000);
            }

            updateOGP(ogpData) {
                // ページタイトルを更新
                document.title = ogpData.title;
                
                // メタタグを更新
                this.updateMetaTag('description', ogpData.description);
                this.updateMetaTag('og:title', ogpData.title, 'property');
                this.updateMetaTag('og:description', ogpData.description, 'property');
                this.updateMetaTag('og:image', ogpData.image, 'property');
                this.updateMetaTag('og:url', ogpData.url, 'property');
                this.updateMetaTag('twitter:title', ogpData.title);
                this.updateMetaTag('twitter:description', ogpData.description);
                this.updateMetaTag('twitter:image', ogpData.image);
                
                // canonical URLを更新
                let canonical = document.querySelector('link[rel="canonical"]');
                if (canonical) {
                    canonical.href = ogpData.url;
                }
            }

            updateMetaTag(name, content, attribute = 'name') {
                let meta = document.querySelector(\`meta[\${attribute}="\${name}"]\`);
                if (meta) {
                    meta.content = content;
                } else {
                    // メタタグが存在しない場合は新規作成
                    meta = document.createElement('meta');
                    meta.setAttribute(attribute, name);
                    meta.content = content;
                    document.head.appendChild(meta);
                }
            }
        }

        // アプリケーション初期化
        document.addEventListener('DOMContentLoaded', () => {
            console.log('DOM loaded, initializing app...');
            
            // ページが既に読み込まれている場合の対策
            if (document.readyState === 'loading') {
                window.app = new ExplosionApp();
            } else {
                // 既に読み込み済みの場合は少し遅延させて初期化
                setTimeout(() => {
                    if (!window.app) {
                        window.app = new ExplosionApp();
                    }
                }, 100);
            }
        });
    </script>
</body>
</html>`;

    return new Response(htmlContent, {
        headers: { 
            'Content-Type': 'text/html; charset=utf-8',
            'Cache-Control': 'no-cache' // デバッグ中はキャッシュを無効化
        }
    });
}

function generateUniqueId() {
    const timestamp = Date.now().toString(36);
    const randomPart = Math.random().toString(36).substr(2, 9);
    return `${timestamp}${randomPart}`;
}

function getFileExtension(mimeType) {
    const extensions = {
        'image/jpeg': 'jpg',
        'image/jpg': 'jpg',
        'image/png': 'png',
        'image/gif': 'gif',
        'image/webp': 'webp'
    };
    return extensions[mimeType] || 'jpg';
}

async function generateOGImage(request, env) {
    // SVGで簡単なOG画像を生成
    const svgImage = `
    <svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
        <defs>
            <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
                <stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
                <stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
            </linearGradient>
        </defs>
        <rect width="100%" height="100%" fill="url(#grad1)"/>
        
        <!-- 爆発エフェクト -->
        <circle cx="200" cy="150" r="30" fill="#ffff00" opacity="0.7"/>
        <circle cx="1000" cy="480" r="25" fill="#ff6b6b" opacity="0.6"/>
        <circle cx="800" cy="100" r="20" fill="#ffa500" opacity="0.8"/>
        
        <!-- メインタイトル -->
        <text x="600" y="280" font-family="Arial, sans-serif" font-size="72" font-weight="bold" 
              text-anchor="middle" fill="white" stroke="#333" stroke-width="2">
            💥 爆破ボタン 💥
        </text>
        
        <!-- サブタイトル -->
        <text x="600" y="360" font-family="Arial, sans-serif" font-size="36" 
              text-anchor="middle" fill="rgba(255,255,255,0.9)">
            ターゲットを爆破してランキングで競おう!
        </text>
        
        <!-- フッター -->
        <text x="600" y="480" font-family="Arial, sans-serif" font-size="24" 
              text-anchor="middle" fill="rgba(255,255,255,0.7)">
            Made by @kurosangurasu
        </text>
        
        <!-- 装飾的なボタン -->
        <rect x="450" y="520" width="300" height="60" rx="30" fill="#ff6b6b" opacity="0.8"/>
        <text x="600" y="560" font-family="Arial, sans-serif" font-size="28" font-weight="bold"
              text-anchor="middle" fill="white">
            今すぐ爆破する!
        </text>
    </svg>`;

    return new Response(svgImage, {
        headers: {
            'Content-Type': 'image/svg+xml',
            'Cache-Control': 'public, max-age=86400' // 24時間キャッシュ
        }
    });
}

🤯 作ってみて良かったこと

  • Cloudflare Workersと仲良くなれた
  • SVGでOGP対応も簡単にできた
  • ユーザー参加型の"バズれる"プロジェクトになった
  • 失恋の悲しみが和らいだ(???)

🏁 おわりに

失恋は辛いけど、何かを爆破すると少しだけ心が軽くなります。
Cloudflare Workersと爆破ボタンで、自分の感情に正直なプロダクトを作ってみませんか?

🌍 サイトはこちら!

👉 https://explosion-button.tokai.club/

ぜひターゲットを登録して、ストレス解消していってください💥

🐦 作った人

Twitter / X → @kurosangurasu

今後の励みになりますので、いいねやシェアをお願いします!!

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?