<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta name="theme-color" content="#06061A">
<title>🃏 カード能力データベース</title>
<!-- React + Babel -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
<!-- Firebase (compat SDK — no build step needed) -->
<script src="https://www.gstatic.com/firebasejs/10.7.0/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.7.0/firebase-firestore-compat.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: #06061A; }
body { color: #fff; font-family: 'Segoe UI', 'Noto Sans JP', sans-serif; -webkit-font-smoothing: antialiased; }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: #0A0A1E; }
::-webkit-scrollbar-thumb { background: #2A2A4A; border-radius: 3px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
// ================================================================
// ⚙️ FIREBASE 設定
// console.firebase.google.com でプロジェクトを作り
// 「プロジェクトの設定 → マイアプリ → SDK の設定と構成」
// から firebaseConfig をコピーして下記に貼り付けてください
// ================================================================
const FIREBASE_CONFIG = {
apiKey: "AIzaSyCbsABKk08t8IJMc28ulOy6STI5SnRj3_M",
authDomain: "bozeka-score-chart.firebaseapp.com",
projectId: "bozeka-score-chart",
storageBucket: "bozeka-score-chart.firebasestorage.app",
messagingSenderId: "460037256397",
appId: "1:460037256397:web:405f26a6242ec885266584 ",
};
// ================================================================
const IS_CONFIGURED = FIREBASE_CONFIG.apiKey !== "YOUR_API_KEY";
let db = null;
if (IS_CONFIGURED) {
firebase.initializeApp(FIREBASE_CONFIG);
db = firebase.firestore();
}
const COLL = "cards";
// ================================================================
// 🔑 パスワード設定
// 友達と共有するパスワードをここに書いてください
// 変更後はHTMLファイルを Netlify に再アップロードしてください
// ================================================================
const APP_PASSWORD = "cardgame"; // ← ここを自分たちのパスワードに変更!
const SESSION_KEY = "carddb-session";
// ================================================================
// ── constants ──────────────────────────────────────────────────
const CRITERIA = [
{ key: "damage", label: "打点" },
{ key: "board", label: "盤面形成力" },
{ key: "removal", label: "処理能力" },
{ key: "versatility", label: "汎用性" },
{ key: "efficiency", label: "コスト効率" },
{ key: "stability", label: "安定性" },
];
const DEFAULT_SCORES = Object.fromEntries(CRITERIA.map(c => [c.key, 5.0]));
const RARITIES = ["N","R","SR","SSR","UR"];
const RARITY_COLORS = { UR:"#FFD700", SSR:"#FF69B4", SR:"#A855F7", R:"#3B82F6", N:"#6B7280" };
const CARD_RATIO = 88 / 63;
const CARD_W = 152, CARD_H = Math.round(CARD_W * CARD_RATIO);
// ── image ──────────────────────────────────────────────────────
async function processImage(file) {
return new Promise(res => {
const img = new Image();
img.onload = () => {
const tR = CARD_RATIO, sR = img.height / img.width;
let sx, sy, sw, sh;
if (sR > tR) { sw = img.width; sh = sw * tR; sx = 0; sy = (img.height - sh) / 2; }
else { sh = img.height; sw = sh / tR; sy = 0; sx = (img.width - sw) / 2; }
const W = 200, H = Math.round(W * tR); // 200px — Firestore friendly
const c = document.createElement("canvas");
c.width = W; c.height = H;
c.getContext("2d").drawImage(img, sx, sy, sw, sh, 0, 0, W, H);
URL.revokeObjectURL(img.src);
res(c.toDataURL("image/jpeg", 0.60));
};
img.src = URL.createObjectURL(file);
});
}
// ── radar chart ────────────────────────────────────────────────
const CS = 460, CX = 230, CY = 230, MAX_R = 95, UNIT = MAX_R / 6;
function drawRadar(canvas, scores) {
if (!canvas) return;
const ctx = canvas.getContext("2d");
canvas.width = canvas.height = CS;
const n = 6;
const ang = i => 2 * Math.PI * i / n - Math.PI / 2;
const toR = s => UNIT * Math.min(Math.max(Number(s) || 0, 0), 10);
ctx.fillStyle = "#06061A"; ctx.fillRect(0, 0, CS, CS);
for (let i = 0; i < n; i++) {
ctx.beginPath(); ctx.moveTo(CX, CY);
ctx.lineTo(CX + UNIT*10*Math.cos(ang(i)), CY + UNIT*10*Math.sin(ang(i)));
ctx.strokeStyle = "rgba(255,255,255,0.14)"; ctx.lineWidth = 0.8; ctx.stroke();
}
const vals = CRITERIA.map(c => Number(scores[c.key]) || 0);
const pts = vals.map((s, i) => { const r = toR(s); return [CX + r*Math.cos(ang(i)), CY + r*Math.sin(ang(i))]; });
const hexAt = r => Array.from({ length: n }, (_, i) => [CX + r*Math.cos(ang(i)), CY + r*Math.sin(ang(i))]);
const tracePath = hp => { ctx.beginPath(); hp.forEach(([x,y],i) => i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)); ctx.closePath(); };
[2, 4].forEach(lvl => { tracePath(hexAt(lvl * UNIT)); ctx.strokeStyle="rgba(255,255,255,0.15)"; ctx.lineWidth=0.7; ctx.stroke(); });
const BC = [48,75,200], RC = [210,28,28];
const R35=3.5*UNIT, R8=8*UNIT, R10=10*UNIT;
const oc = document.createElement("canvas"); oc.width=oc.height=CS;
const ox = oc.getContext("2d");
for (let ri=100; ri>=0; ri--) {
const r=R35+(R8-R35)*ri/100, t=ri/100;
ox.beginPath(); hexAt(r).forEach(([x,y],j)=>j===0?ox.moveTo(x,y):ox.lineTo(x,y)); ox.closePath();
ox.fillStyle=`rgb(${Math.round(BC[0]+(RC[0]-BC[0])*t)},${Math.round(BC[1]+(RC[1]-BC[1])*t)},${Math.round(BC[2]+(RC[2]-BC[2])*t)})`; ox.fill();
}
ox.beginPath(); hexAt(R10).forEach(([x,y],j)=>j===0?ox.moveTo(x,y):ox.lineTo(x,y)); ox.closePath();
hexAt(R8).forEach(([x,y],j)=>j===0?ox.moveTo(x,y):ox.lineTo(x,y)); ox.closePath();
ox.fillStyle=`rgb(${RC[0]},${RC[1]},${RC[2]})`; ox.fill("evenodd");
ctx.save(); tracePath(pts); ctx.clip(); ctx.globalAlpha=0.72; ctx.drawImage(oc,0,0); ctx.globalAlpha=1; ctx.restore();
ctx.beginPath(); hexAt(MAX_R).forEach(([x,y],i)=>i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)); ctx.closePath();
ctx.strokeStyle="rgba(210,210,215,0.82)"; ctx.lineWidth=1.6; ctx.stroke();
ctx.font="bold 9px 'Segoe UI',sans-serif"; ctx.textAlign="center"; ctx.textBaseline="middle";
[2,4].forEach(lvl=>{ ctx.fillStyle="rgba(255,255,255,0.50)"; ctx.fillText(String(lvl), CX+lvl*UNIT*Math.cos(-Math.PI/2)+6, CY+lvl*UNIT*Math.sin(-Math.PI/2)); });
ctx.font="bold 11px 'Segoe UI',sans-serif";
CRITERIA.forEach((c,i)=>{
const ax=Math.cos(ang(i)),ay=Math.sin(ang(i));
ctx.textAlign=ax>0.3?"left":ax<-0.3?"right":"center";
ctx.textBaseline=ay>0.3?"top":ay<-0.3?"bottom":"middle";
ctx.fillStyle="#fff"; ctx.fillText(c.label, CX+(UNIT*10+14)*ax, CY+(UNIT*10+14)*ay);
});
}
// ── helpers ────────────────────────────────────────────────────
const calcRating = s => +(CRITERIA.map(c=>Number(s[c.key])||0).reduce((a,b)=>a+b,0)/CRITERIA.length).toFixed(1);
function ScoreLabel({ v, size=13 }) {
return <span style={{ fontSize:size, fontWeight:700, color:v>=8?"#FF5555":v>=6?"#FFD700":"#5BAEFF" }}>{Number(v).toFixed(1)}</span>;
}
function ImageUploadZone({ image, loading, onFile, onClear }) {
const [drag, setDrag] = useState(false);
const accept = async file => { if (!file||!file.type.startsWith("image/")) return; await onFile(file); };
if (image) return (
<div style={{ position:"relative", width:CARD_W, height:CARD_H, borderRadius:10, overflow:"hidden", border:"2px solid rgba(255,215,0,0.45)" }}>
<img src={image} alt="" style={{ width:"100%", height:"100%", objectFit:"cover" }} />
<button onClick={onClear} style={{ position:"absolute", top:5, right:5, padding:"2px 8px", borderRadius:5, border:"none", background:"rgba(0,0,0,0.8)", color:"#FF7070", cursor:"pointer", fontSize:13, zIndex:2 }}>✕</button>
<div style={{ position:"absolute", bottom:6, right:6, zIndex:2 }}>
<label style={{ padding:"3px 10px", borderRadius:6, border:"1px solid rgba(255,255,255,0.35)", background:"rgba(0,0,0,0.78)", color:"#fff", fontSize:11, cursor:"pointer", display:"block", position:"relative", overflow:"hidden" }}>
<input type="file" accept="image/*" style={{ position:"absolute", inset:0, opacity:0, cursor:"pointer", width:"100%", height:"100%" }} onChange={e=>{ const f=e.target.files[0]; if(f){e.target.value="";accept(f);} }} />変更
</label>
</div>
</div>
);
return (
<div onDragOver={e=>{e.preventDefault();setDrag(true);}} onDragLeave={()=>setDrag(false)}
onDrop={e=>{e.preventDefault();setDrag(false);accept(e.dataTransfer.files[0]);}}
style={{ width:"100%", borderRadius:12, border:`2px dashed ${drag?"#FFD700":"rgba(255,215,0,0.3)"}`, background:drag?"rgba(255,215,0,0.1)":"rgba(255,215,0,0.03)", padding:"22px 12px", textAlign:"center", boxSizing:"border-box" }}>
{loading ? <div style={{ color:"rgba(255,255,255,0.4)" }}>処理中…</div> : <>
<div style={{ fontSize:28, marginBottom:6 }}>🖼️</div>
<div style={{ fontSize:12, color:"rgba(255,215,0,0.8)", fontWeight:700, marginBottom:4 }}>ドラッグ&ドロップ</div>
<div style={{ fontSize:10, color:"rgba(255,255,255,0.35)", marginBottom:12 }}>または Ctrl+V でペースト</div>
<label style={{ display:"inline-block", position:"relative", overflow:"hidden", padding:"7px 16px", borderRadius:8, border:"1px solid rgba(255,215,0,0.5)", background:"rgba(255,215,0,0.12)", color:"rgba(255,215,0,0.9)", fontSize:12, cursor:"pointer", fontWeight:600 }}>
<input type="file" accept="image/*" style={{ position:"absolute", inset:0, opacity:0, cursor:"pointer", width:"100%", height:"100%", fontSize:0 }} onChange={e=>{ const f=e.target.files[0]; if(f){e.target.value="";accept(f);} }} />
📁 ファイルを選択
</label>
</>}
</div>
);
}
// ── style tokens ───────────────────────────────────────────────
const T = { bg:"#06061A", surf:"rgba(255,255,255,0.03)", bdr:"rgba(255,255,255,0.08)", muted:"rgba(255,255,255,0.38)", gold:"#FFD700", accent:"linear-gradient(135deg,#FFD700,#FF9500)", hdr:"linear-gradient(180deg,#120A28 0%,#0A0A1E 100%)" };
const inp = ex => ({ padding:"9px 12px", borderRadius:8, border:"1px solid rgba(255,215,0,0.2)", background:"rgba(0,0,0,0.35)", color:"#fff", fontSize:14, outline:"none", boxSizing:"border-box", ...ex });
const selSt = ex => ({ padding:"8px 10px", borderRadius:8, border:"1px solid rgba(255,255,255,0.1)", background:"#12122A", color:"#fff", fontSize:13, cursor:"pointer", ...ex });
// ── password gate ──────────────────────────────────────────────
function PasswordGate({ onAuth }) {
const [pw, setPw] = useState('');
const [err, setErr] = useState(false);
const [shake, setShake] = useState(false);
const submit = () => {
if (pw === APP_PASSWORD) {
sessionStorage.setItem(SESSION_KEY, '1');
onAuth();
} else {
setErr(true);
setShake(true);
setPw('');
setTimeout(() => { setErr(false); setShake(false); }, 1600);
}
};
return (
<div style={{ minHeight:"100vh", background:"#06061A", display:"flex", alignItems:"center", justifyContent:"center", padding:20 }}>
<div style={{ width:"100%", maxWidth:340, textAlign:"center" }}>
<div style={{ fontSize:52, marginBottom:16 }}>🃏</div>
<h1 style={{ fontSize:20, fontWeight:900, background:"linear-gradient(135deg,#FFD700,#FF9500)", WebkitBackgroundClip:"text", WebkitTextFillColor:"transparent", marginBottom:8 }}>カード能力データベース</h1>
<p style={{ fontSize:13, color:"rgba(255,255,255,0.38)", marginBottom:28 }}>パスワードを入力してください</p>
<input
type="password"
value={pw}
onChange={e => setPw(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="パスワード"
autoFocus
style={{ width:"100%", padding:"12px 16px", borderRadius:10, border:`1.5px solid ${err ? "rgba(255,80,80,0.7)" : "rgba(255,215,0,0.25)"}`, background:"rgba(0,0,0,0.4)", color:"#fff", fontSize:16, outline:"none", boxSizing:"border-box", marginBottom:10, textAlign:"center", transition:"border .2s", animation: shake ? "shake .4s" : "none" }}
/>
{err && <div style={{ fontSize:13, color:"#FF7070", marginBottom:10 }}>パスワードが違います…</div>}
{!err && <div style={{ marginBottom:10 }}></div>}
<button onClick={submit}
style={{ width:"100%", padding:"13px", borderRadius:10, border:"none", background:"linear-gradient(135deg,#FFD700,#FF9500)", color:"#000", fontWeight:900, fontSize:16, cursor:"pointer" }}>
入室する 🚪
</button>
</div>
<style>{`@keyframes shake { 0%,100%{transform:translateX(0)} 20%,60%{transform:translateX(-8px)} 40%,80%{transform:translateX(8px)} }`}</style>
</div>
);
}
// ── setup screen ───────────────────────────────────────────────
function SetupScreen() {
const step = s => <div style={{ display:"flex", gap:14, marginBottom:18, alignItems:"flex-start" }}>
<div style={{ flexShrink:0, width:28, height:28, borderRadius:"50%", background:T.accent, color:"#000", fontWeight:900, display:"flex", alignItems:"center", justifyContent:"center", fontSize:14 }}>{s.n}</div>
<div>
<div style={{ fontWeight:700, marginBottom:4, fontSize:15 }}>{s.title}</div>
<div style={{ fontSize:13, color:T.muted, lineHeight:1.6 }}>{s.body}</div>
</div>
</div>;
return (
<div style={{ minHeight:"100vh", background:T.bg, display:"flex", alignItems:"center", justifyContent:"center", padding:20 }}>
<div style={{ maxWidth:520, width:"100%" }}>
<div style={{ textAlign:"center", marginBottom:32 }}>
<div style={{ fontSize:48, marginBottom:12 }}>🃏</div>
<h1 style={{ fontSize:22, fontWeight:900, background:T.accent, WebkitBackgroundClip:"text", WebkitTextFillColor:"transparent", marginBottom:8 }}>初回セットアップ</h1>
<p style={{ color:T.muted, fontSize:14 }}>Firebase に接続して友達と共有できるようにしましょう(無料・5分でできます)</p>
</div>
<div style={{ background:T.surf, borderRadius:16, border:`1px solid ${T.bdr}`, padding:24 }}>
{step({ n:1, title:"Firebaseプロジェクトを作成", body:<>ブラウザで <a href="https://console.firebase.google.com" target="_blank" style={{ color:T.gold }}>console.firebase.google.com</a> を開き「プロジェクトを追加」をクリック。プロジェクト名は何でもOKです。</> })}
{step({ n:2, title:"Firestoreデータベースを有効化", body:"左メニュー「Firestore Database」→「データベースの作成」→「本番環境モード」で作成。リージョンは asia-northeast1(東京)推奨。" })}
{step({ n:3, title:"セキュリティルールを公開に設定", body:<>Firestore の「ルール」タブを開き、以下のルールを貼り付けて「公開」をクリック:<br/><code style={{ display:"block", marginTop:8, padding:"8px 10px", background:"rgba(0,0,0,0.4)", borderRadius:6, fontSize:11, whiteSpace:"pre", color:"#aaffaa" }}>{`rules_version = '2';\nservice cloud.firestore {\n match /databases/{database}/documents {\n match /{document=**} {\n allow read, write: if true;\n }\n }\n}`}</code></> })}
{step({ n:4, title:"設定コードをHTMLに貼り付け", body:"「プロジェクトの設定(⚙️)」→「マイアプリ」→「ウェブアプリを追加」→「SDK の設定と構成」からfirebaseConfigをコピーし、このHTMLファイルの上部 FIREBASE_CONFIG に貼り付けて保存。" })}
</div>
<div style={{ marginTop:16, padding:"12px 16px", borderRadius:10, background:"rgba(255,215,0,0.06)", border:"1px solid rgba(255,215,0,0.2)", fontSize:13, color:T.muted, textAlign:"center" }}>
設定後、このHTMLファイルを <a href="https://app.netlify.com/drop" target="_blank" style={{ color:T.gold }}>Netlify Drop</a> にドロップすればURLが発行されます。友達にそのURLを共有すれば共同編集できます!
</div>
</div>
</div>
);
}
// ── main app ───────────────────────────────────────────────────
function App() {
const [cards, setCards] = useState([]);
const [view, setView] = useState("list");
const [selected, setSelected] = useState(null);
const [editScores, setEditScores] = useState(null);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState("rating_desc");
const [fltClass, setFltClass] = useState("all");
const [fltCost, setFltCost] = useState("all");
const [form, setForm] = useState({ name:"", rarity:"N", cost:"", cardClass:"", image:null, ...DEFAULT_SCORES });
const [loaded, setLoaded] = useState(false);
const [imgLoading, setImgLoading] = useState(false);
const [detailSaved, setDetailSaved] = useState(false);
const [addSaved, setAddSaved] = useState(false);
const [error, setError] = useState("");
const previewRef = useRef(null);
const detailRef = useRef(null);
// ── Firestore CRUD ──
const loadAll = async () => {
try {
const snap = await db.collection(COLL).get();
setCards(snap.docs.map(d => d.data()).sort((a,b)=>b.rating-a.rating));
} catch(e) { setError("読み込みエラー: " + e.message); }
setLoaded(true);
};
const fsSet = async card => {
try { await db.collection(COLL).doc(String(card.id)).set(card); }
catch(e) { setError("保存エラー: " + e.message); }
};
const fsDel = async id => {
try { await db.collection(COLL).doc(String(id)).delete(); }
catch(e) { setError("削除エラー: " + e.message); }
};
useEffect(() => { loadAll(); }, []);
useEffect(() => {
if (view !== "add") return;
const handler = async e => {
const items = Array.from(e.clipboardData?.items || []);
const imgItem = items.find(i => i.type.startsWith("image/"));
if (!imgItem) return;
const file = imgItem.getAsFile();
if (file) { e.preventDefault(); await handleImg(file); }
};
document.addEventListener("paste", handler);
return () => document.removeEventListener("paste", handler);
}, [view]);
useEffect(() => { if (view==="add" && previewRef.current) drawRadar(previewRef.current, form); }, [form, view]);
useEffect(() => { if (view==="detail" && editScores && detailRef.current) drawRadar(detailRef.current, editScores); }, [editScores, view]);
const handleImg = async file => { setImgLoading(true); const b64=await processImage(file); setForm(f=>({...f,image:b64})); setImgLoading(false); };
const resetForm = () => setForm({ name:"", rarity:"N", cost:"", cardClass:"", image:null, ...DEFAULT_SCORES });
const addCard = async () => {
if (!form.name.trim()) return;
const card = { id:Date.now(), ...form, cost:form.cost===""?null:Number(form.cost), rating:calcRating(form) };
await fsSet(card);
setCards(prev => [...prev, card]);
setAddSaved(true);
setTimeout(() => { setAddSaved(false); resetForm(); setView("list"); }, 1000);
};
const saveCard = async () => {
if (!selected||!editScores) return;
const updated = { ...selected, ...editScores, rating:calcRating(editScores) };
await fsSet(updated);
setCards(prev => prev.map(c => c.id===selected.id ? updated : c));
setSelected(updated);
setDetailSaved(true);
setTimeout(() => setDetailSaved(false), 1800);
};
const deleteCard = async id => {
await fsDel(id);
setCards(prev => prev.filter(c => c.id!==id));
setSelected(null); setEditScores(null); setView("list");
};
const openDetail = card => { setSelected(card); setEditScores(Object.fromEntries(CRITERIA.map(c=>[c.key,Number(card[c.key])||0]))); setDetailSaved(false); setView("detail"); };
const allClasses = [...new Set(cards.map(c=>c.cardClass).filter(Boolean))].sort();
const allCosts = [...new Set(cards.map(c=>c.cost).filter(v=>v!==null&&v!==undefined))].sort((a,b)=>a-b);
const filtered = [...cards]
.filter(c => c.name.toLowerCase().includes(search.toLowerCase()) && (fltClass==="all"||c.cardClass===fltClass) && (fltCost==="all"||String(c.cost)===fltCost))
.sort((a,b) => { if(sortBy==="rating_desc")return b.rating-a.rating; if(sortBy==="rating_asc")return a.rating-b.rating; if(sortBy==="cost_asc")return(a.cost??999)-(b.cost??999); if(sortBy==="cost_desc")return(b.cost??-1)-(a.cost??-1); return a.name.localeCompare(b.name,"ja"); });
if (!loaded) return <div style={{ background:T.bg, minHeight:"100vh", display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center", color:"#fff", gap:16 }}><div style={{ fontSize:36 }}>🃏</div><div>Firebaseから読み込み中…</div></div>;
return (
<div style={{ minHeight:"100vh", background:T.bg, color:"#fff", fontFamily:"'Segoe UI','Noto Sans JP',sans-serif" }}>
{error && <div style={{ background:"rgba(255,50,50,0.15)", border:"1px solid rgba(255,50,50,0.4)", padding:"10px 16px", fontSize:13, color:"#FF9090", display:"flex", justifyContent:"space-between", alignItems:"center" }}>{error}<button onClick={()=>setError("")} style={{ background:"none", border:"none", color:"#FF9090", cursor:"pointer", fontSize:16 }}>✕</button></div>}
<header style={{ background:T.hdr, borderBottom:"1px solid rgba(255,215,0,0.18)", padding:"13px 20px", display:"flex", alignItems:"center", justifyContent:"space-between", position:"sticky", top:0, zIndex:100 }}>
<div style={{ display:"flex", alignItems:"center", gap:10 }}>
<span style={{ fontSize:26 }}>🃏</span>
<div>
<div style={{ fontSize:17, fontWeight:900, background:T.accent, WebkitBackgroundClip:"text", WebkitTextFillColor:"transparent" }}>カード能力データベース</div>
<div style={{ fontSize:11, color:T.muted, marginTop:-1 }}>{cards.length} 枚登録済み</div>
</div>
</div>
<div style={{ display:"flex", gap:8 }}>
<button onClick={()=>{ loadAll(); }} style={{ padding:"7px 12px", borderRadius:7, border:`1px solid ${T.bdr}`, background:"transparent", color:"rgba(255,255,255,0.55)", cursor:"pointer", fontSize:12 }}>↺ 更新</button>
<button onClick={()=>setView("list")} style={{ padding:"7px 14px", borderRadius:7, border:`1px solid ${view==="list"?"rgba(255,215,0,0.5)":T.bdr}`, background:view==="list"?"rgba(255,215,0,0.1)":"transparent", color:view==="list"?T.gold:"rgba(255,255,255,0.65)", cursor:"pointer", fontSize:13 }}>一覧</button>
<button onClick={()=>{ resetForm(); setAddSaved(false); setView("add"); }} style={{ padding:"7px 15px", borderRadius:7, border:"none", background:T.accent, color:"#000", cursor:"pointer", fontWeight:800, fontSize:13 }}>+ 追加</button>
</div>
</header>
{/* LIST */}
{view==="list" && (
<div style={{ padding:20, maxWidth:1100, margin:"0 auto" }}>
<div style={{ display:"flex", gap:8, flexWrap:"wrap", marginBottom:10 }}>
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="🔍 カード名で検索…" style={{ ...inp(), flex:"1 1 160px", border:"1px solid rgba(255,255,255,0.1)" }} />
<select value={sortBy} onChange={e=>setSortBy(e.target.value)} style={selSt()}>
<option value="rating_desc">評価 高い順</option><option value="rating_asc">評価 低い順</option>
<option value="cost_asc">コスト 低い順</option><option value="cost_desc">コスト 高い順</option><option value="name">名前順</option>
</select>
<select value={fltCost} onChange={e=>setFltCost(e.target.value)} style={selSt()}>
<option value="all">コスト: すべて</option>{allCosts.map(c=><option key={c} value={String(c)}>コスト: {c}</option>)}
</select>
<select value={fltClass} onChange={e=>setFltClass(e.target.value)} style={selSt()}>
<option value="all">クラス: すべて</option>{allClasses.map(cl=><option key={cl} value={cl}>{cl}</option>)}
</select>
</div>
<div style={{ fontSize:12, color:T.muted, marginBottom:18 }}>{filtered.length} 件表示</div>
{filtered.length===0 ? (
<div style={{ textAlign:"center", padding:"80px 20px", color:"rgba(255,255,255,0.25)" }}>
<div style={{ fontSize:56, marginBottom:14 }}>🃏</div>
<div style={{ fontSize:16 }}>{cards.length===0?"カードが登録されていません":"条件に一致するカードがありません"}</div>
{cards.length===0&&<div style={{ fontSize:13, marginTop:8 }}>「+ 追加」からカードを登録してください</div>}
</div>
) : (
<div style={{ display:"flex", flexWrap:"wrap", gap:18 }}>
{filtered.map(card=>{
const rc=RARITY_COLORS[card.rarity]||"#444";
return (
<div key={card.id} onClick={()=>openDetail(card)} style={{ cursor:"pointer", flexShrink:0, transition:"transform .18s" }}
onMouseEnter={e=>e.currentTarget.style.transform="translateY(-5px) scale(1.04)"}
onMouseLeave={e=>e.currentTarget.style.transform=""}>
<div style={{ width:CARD_W, height:CARD_H, borderRadius:10, border:`2px solid ${rc}99`, overflow:"hidden", background:"linear-gradient(155deg,#1C0A38,#080A1E)", position:"relative", boxShadow:`0 6px 22px rgba(0,0,0,0.55)` }}>
{card.image ? <img src={card.image} alt={card.name} style={{ width:"100%", height:"100%", objectFit:"cover" }} />
: <div style={{ width:"100%", height:"100%", display:"flex", flexDirection:"column", alignItems:"center", justifyContent:"center", gap:8, padding:10, boxSizing:"border-box" }}>
<div style={{ fontSize:34 }}>🃏</div>
<div style={{ fontSize:12, fontWeight:700, textAlign:"center", color:"rgba(255,255,255,0.7)", wordBreak:"break-all", lineHeight:1.4 }}>{card.name}</div>
</div>}
<div style={{ position:"absolute", top:5, left:5, fontSize:9, fontWeight:900, color:rc, background:"rgba(0,0,0,0.78)", border:`1px solid ${rc}`, borderRadius:4, padding:"1px 5px" }}>{card.rarity}</div>
{card.cost!==null&&card.cost!==undefined&&<div style={{ position:"absolute", top:5, right:5, fontSize:14, fontWeight:900, color:"#fff", background:"rgba(0,0,0,0.82)", borderRadius:6, padding:"1px 7px", minWidth:20, textAlign:"center" }}>{card.cost}</div>}
</div>
<div style={{ marginTop:7, textAlign:"center", width:CARD_W }}>
<div style={{ fontSize:11, color:"rgba(255,255,255,0.45)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", marginBottom:1 }}>{card.name}</div>
<div style={{ fontSize:24, fontWeight:900, color:T.gold, lineHeight:1.15 }}>{card.rating}</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* ADD */}
{view==="add" && (
<div style={{ padding:20, maxWidth:660, margin:"0 auto" }}>
{/* ① カード基本情報 */}
<div style={{ background:T.surf, borderRadius:16, border:`1px solid ${T.bdr}`, padding:22, marginBottom:16 }}>
<h2 style={{ margin:"0 0 16px", fontSize:16, color:T.gold, fontWeight:800 }}>カード情報を入力</h2>
<div style={{ marginBottom:12 }}><div style={{ fontSize:11, color:T.muted, marginBottom:5 }}>カード名</div><input value={form.name} onChange={e=>setForm(f=>({...f,name:e.target.value}))} placeholder="例:フレイムドラゴン" style={{ ...inp(), width:"100%" }} /></div>
<div style={{ display:"flex", gap:10, marginBottom:12 }}>
<div style={{ flex:"0 0 80px" }}><div style={{ fontSize:11, color:T.muted, marginBottom:5 }}>コスト</div><input type="number" min="0" max="99" value={form.cost} onChange={e=>setForm(f=>({...f,cost:e.target.value}))} placeholder="0" style={{ ...inp(), width:"100%" }} /></div>
<div style={{ flex:1 }}><div style={{ fontSize:11, color:T.muted, marginBottom:5 }}>クラス</div><input value={form.cardClass} onChange={e=>setForm(f=>({...f,cardClass:e.target.value}))} placeholder="例:ドラゴン" style={{ ...inp(), width:"100%" }} /></div>
</div>
<div style={{ marginBottom:14 }}><div style={{ fontSize:11, color:T.muted, marginBottom:5 }}>レアリティ</div>
<div style={{ display:"flex", gap:6 }}>{RARITIES.map(r=>{const rc=RARITY_COLORS[r];return(<button key={r} onClick={()=>setForm(f=>({...f,rarity:r}))} style={{ flex:1, padding:"7px 0", borderRadius:7, border:`1px solid ${rc}`, background:form.rarity===r?`${rc}33`:"transparent", color:rc, cursor:"pointer", fontWeight:800, fontSize:12 }}>{r}</button>);})}</div>
</div>
<div><div style={{ fontSize:11, color:T.muted, marginBottom:5 }}>カード画像(任意)</div><ImageUploadZone image={form.image} loading={imgLoading} onFile={handleImg} onClear={()=>setForm(f=>({...f,image:null}))} /></div>
</div>
{/* ② レーダーチャート プレビュー */}
<div style={{ background:T.surf, borderRadius:16, border:`1px solid ${T.bdr}`, padding:"16px 16px 10px", marginBottom:16, textAlign:"center" }}>
<div style={{ fontSize:11, color:T.muted, marginBottom:8 }}>レーダーチャート プレビュー</div>
<canvas ref={previewRef} style={{ display:"block", width:"100%", height:"auto", borderRadius:8, maxWidth:500, margin:"0 auto" }} />
</div>
{/* ③ 各項目スライダー */}
<div style={{ background:T.surf, borderRadius:16, border:`1px solid ${T.bdr}`, padding:22, marginBottom:16 }}>
<div style={{ fontSize:12, color:T.muted, marginBottom:14 }}>各項目スコア</div>
{CRITERIA.map(c=>(
<div key={c.key} style={{ marginBottom:14 }}>
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:4 }}><span style={{ fontSize:14, fontWeight:600 }}>{c.label}</span><ScoreLabel v={form[c.key]} size={15} /></div>
<input type="range" min="0" max="10" step="0.1" value={form[c.key]} onChange={e=>setForm(f=>({...f,[c.key]:parseFloat(e.target.value)}))} style={{ width:"100%", accentColor:form[c.key]>6?"#FF5555":"#5BAEFF", cursor:"pointer" }} />
</div>
))}
</div>
{/* ④ 総合評価 + 保存 */}
<div style={{ margin:"0 0 12px", padding:"12px 16px", borderRadius:12, background:"rgba(255,215,0,0.06)", border:"1px solid rgba(255,215,0,0.2)", display:"flex", justifyContent:"space-between", alignItems:"center" }}>
<span style={{ fontSize:13, color:T.muted }}>総合評価</span>
<div><span style={{ fontSize:30, fontWeight:900, color:T.gold }}>{calcRating(form)}</span><span style={{ fontSize:12, color:"rgba(255,255,255,0.28)" }}> / 10</span></div>
</div>
<button onClick={addCard} disabled={!form.name.trim()||addSaved}
style={{ width:"100%", padding:"14px", borderRadius:10, border:"none", background:addSaved?"rgba(80,200,100,0.25)":form.name.trim()?T.accent:"rgba(255,255,255,0.07)", color:addSaved?"#88ffaa":form.name.trim()?"#000":"rgba(255,255,255,0.22)", fontWeight:900, fontSize:16, cursor:form.name.trim()&&!addSaved?"pointer":"not-allowed", transition:"all .2s" }}>
{addSaved?"✓ 保存しました!":"💾 保存してデータベースに登録"}
</button>
</div>
)}
{/* DETAIL */}
{view==="detail" && selected && editScores && (()=>{
const rc=RARITY_COLORS[selected.rarity]||"#444";
const smallW=Math.round(CARD_W*0.55), smallH=Math.round(CARD_H*0.55);
return (
<div style={{ padding:20, maxWidth:660, margin:"0 auto" }}>
<button onClick={()=>setView("list")} style={{ marginBottom:16, padding:"7px 14px", borderRadius:7, border:`1px solid ${T.bdr}`, background:"transparent", color:"rgba(255,255,255,0.65)", cursor:"pointer", fontSize:13 }}>← 一覧に戻る</button>
<div style={{ display:"flex", alignItems:"center", gap:14, marginBottom:18, background:T.surf, borderRadius:14, border:`1px solid ${rc}44`, padding:"14px 18px" }}>
<div style={{ flexShrink:0, width:smallW, height:smallH, borderRadius:7, overflow:"hidden", border:`1.5px solid ${rc}88`, background:"linear-gradient(155deg,#1C0A38,#080A1E)" }}>
{selected.image ? <img src={selected.image} alt={selected.name} style={{ width:"100%", height:"100%", objectFit:"cover" }} /> : <div style={{ width:"100%", height:"100%", display:"flex", alignItems:"center", justifyContent:"center", fontSize:22 }}>🃏</div>}
</div>
<div style={{ flex:1, minWidth:0 }}>
<div style={{ fontSize:20, fontWeight:900, marginBottom:7, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{selected.name}</div>
<div style={{ display:"flex", gap:5, flexWrap:"wrap" }}>
<span style={{ fontSize:11, fontWeight:800, color:rc, border:`1px solid ${rc}`, borderRadius:4, padding:"1px 8px" }}>{selected.rarity}</span>
{selected.cardClass&&<span style={{ fontSize:11, color:"rgba(255,255,255,0.6)", border:"1px solid rgba(255,255,255,0.2)", borderRadius:4, padding:"1px 8px" }}>{selected.cardClass}</span>}
{selected.cost!==null&&selected.cost!==undefined&&<span style={{ fontSize:11, color:"#5BAEFF", border:"1px solid #5BAEFF55", borderRadius:4, padding:"1px 8px" }}>コスト {selected.cost}</span>}
</div>
</div>
<div style={{ textAlign:"center", flexShrink:0 }}>
<div style={{ fontSize:38, fontWeight:900, color:T.gold, lineHeight:1 }}>{calcRating(editScores)}</div>
<div style={{ fontSize:10, color:T.muted }}>総合評価</div>
</div>
</div>
<div style={{ background:T.surf, borderRadius:16, border:`1px solid ${rc}33`, padding:"18px 18px 12px", marginBottom:18, textAlign:"center" }}>
<canvas ref={detailRef} style={{ display:"block", width:"100%", height:"auto", borderRadius:8, maxWidth:500, margin:"0 auto" }} />
</div>
<div style={{ background:T.surf, borderRadius:16, border:`1px solid ${T.bdr}`, padding:"18px 20px", marginBottom:14 }}>
<div style={{ fontSize:12, color:T.muted, marginBottom:14 }}>各項目スコア</div>
{CRITERIA.map(c=>{
const v=editScores[c.key];
return (
<div key={c.key} style={{ marginBottom:16 }}>
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"center", marginBottom:6 }}><span style={{ fontSize:14, fontWeight:600 }}>{c.label}</span><ScoreLabel v={v} size={15} /></div>
<input type="range" min="0" max="10" step="0.1" value={v} onChange={e=>setEditScores(s=>({...s,[c.key]:parseFloat(e.target.value)}))} style={{ width:"100%", accentColor:v>6?"#FF5555":"#5BAEFF", cursor:"pointer" }} />
</div>
);
})}
</div>
<div style={{ display:"flex", gap:10 }}>
<button onClick={saveCard} style={{ flex:1, padding:"14px", borderRadius:10, border:"none", background:detailSaved?"rgba(80,200,100,0.22)":T.accent, color:detailSaved?"#88ffaa":"#000", fontWeight:900, fontSize:16, cursor:"pointer", transition:"all .2s" }}>
{detailSaved?"✓ 保存しました!":"💾 保存"}
</button>
<button onClick={()=>deleteCard(selected.id)} style={{ padding:"14px 20px", borderRadius:10, border:"1px solid rgba(255,80,80,0.35)", background:"rgba(255,50,50,0.07)", color:"#FF7070", cursor:"pointer", fontSize:15 }}>🗑️ 削除</button>
</div>
</div>
);
})()}
</div>
);
}
// ── render ─────────────────────────────────────────────────────
function Root() {
const [authed, setAuthed] = useState(() => sessionStorage.getItem(SESSION_KEY) === '1');
if (!authed) return <PasswordGate onAuth={() => setAuthed(true)} />;
if (!IS_CONFIGURED) return <SetupScreen />;
return <App />;
}
ReactDOM.createRoot(document.getElementById("root")).render(<Root />);
</script>
</body>
</html>