こんにちは、たんぽぽです。
普段は教育系アプリのエンジニアをしながら、京都の大学で学園祭実行委員としても活動しています。
この前、他大学の実行委員室で雑談しているときにふと思いました。
「リア充爆破したくね?」
…とまあ、そんな軽いノリで始まったのがこのプロジェクトです。
⚠️注意書き
実際に試す場合はcloudflareは従量課金制ということを理解して行なってください。
🫠 ちょっとだけ個人的な話
この記事を書く1週間前、彼女と別れました。
それが関係あるかって?めちゃくちゃあります。
心にぽっかり穴が開いた僕は、無意識に“爆破”というキーワードに惹かれていたのかもしれません。
🎯 作ったもの
- 誰でも「爆破ターゲット」を登録できる
- 登録されたターゲットを「爆破ボタン」で爆破可能
- 爆破回数が記録されてランキングに反映
- X(Twitter)共有やURLコピーも対応
- OGPでSNS映えする爆破ページ生成
🧠 技術スタック
- Cloudflare Workers:軽量で高速なサーバーレス実行環境
- KV Storage:爆破対象データとカウント記録
- R2:画像ストレージとして利用
- HTML/CSS/JS:OOPベースでUI構築
- OGP生成:SVGベースで動的にOpen Graph対応
🏗️ アーキテクチャ図
超ざっくり言うと、
/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
今後の励みになりますので、いいねやシェアをお願いします!!