ゲーム投稿サイト.html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>GameWorks — ゲーム作品投稿</title>
  <meta name="description" content="ゲーム作品を投稿・管理できるローカル保存型ポートフォリオサイト" />
  <style>
    :root{
      --bg:#0b0d12; --panel:#111520; --panel2:#0f1320; --card:#0f1524;
      --text:#e9edf7; --muted:#aab3c7; --line:#1f2a44;
      --accent:#7c5cff; --accent2:#22c55e; --warn:#f59e0b; --danger:#ef4444;
      --shadow: 0 12px 30px rgba(0,0,0,.35);
      --r: 18px;
      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
      --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
    }
    *{box-sizing:border-box}
    html,body{height:100%}
    body{
      margin:0;
      background:
        radial-gradient(900px 600px at 20% -10%, rgba(124,92,255,.25), transparent 60%),
        radial-gradient(900px 600px at 85% 0%, rgba(34,197,94,.18), transparent 60%),
        var(--bg);
      color:var(--text);
      font-family:var(--sans);
      letter-spacing:.2px;
    }
    a{color:inherit}
    .app{
      min-height:100%;
      display:grid;
      grid-template-columns: 360px 1fr;
      gap:16px;
      padding:16px;
      max-width:1400px;
      margin:0 auto;
    }
    @media (max-width: 980px){
      .app{grid-template-columns: 1fr; }
    }
    header{
      grid-column: 1 / -1;
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:12px;
      padding:14px 16px;
      border:1px solid var(--line);
      border-radius: var(--r);
      background: linear-gradient(180deg, rgba(17,21,32,.9), rgba(15,19,32,.75));
      box-shadow: var(--shadow);
      position:sticky;
      top:16px;
      z-index:10;
      backdrop-filter: blur(10px);
    }
    .brand{
      display:flex; align-items:center; gap:12px;
    }
    .logo{
      width:38px; height:38px; border-radius:14px;
      background: conic-gradient(from 180deg, var(--accent), #3b82f6, var(--accent2), var(--accent));
      box-shadow: 0 10px 20px rgba(124,92,255,.25);
    }
    .brand h1{font-size:16px; margin:0}
    .brand p{margin:2px 0 0 0; color:var(--muted); font-size:12px}
    .headerActions{display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end}
    .chip{
      display:inline-flex; align-items:center; gap:8px;
      padding:8px 10px;
      border:1px solid var(--line);
      border-radius: 999px;
      color:var(--muted);
      background: rgba(10,12,18,.35);
      font-size:12px;
      user-select:none;
    }
    .btn{
      appearance:none; border:1px solid var(--line);
      background: rgba(15,19,32,.85);
      color:var(--text);
      padding:10px 12px;
      border-radius: 12px;
      cursor:pointer;
      font-weight:600;
      transition: transform .08s ease, border-color .2s ease, background .2s ease, opacity .2s ease;
    }
    .btn:hover{border-color: rgba(124,92,255,.55)}
    .btn:active{transform: translateY(1px)}
    .btn.primary{
      border-color: rgba(124,92,255,.6);
      background: linear-gradient(180deg, rgba(124,92,255,.35), rgba(124,92,255,.15));
    }
    .btn.good{
      border-color: rgba(34,197,94,.55);
      background: linear-gradient(180deg, rgba(34,197,94,.25), rgba(34,197,94,.10));
    }
    .btn.danger{
      border-color: rgba(239,68,68,.6);
      background: linear-gradient(180deg, rgba(239,68,68,.25), rgba(239,68,68,.10));
    }
    .btn.ghost{background: transparent}
    .btn.small{padding:8px 10px; border-radius: 10px; font-size:12px}
    .btn:disabled{opacity:.55; cursor:not-allowed}
    .panel{
      border:1px solid var(--line);
      border-radius: var(--r);
      background: linear-gradient(180deg, rgba(17,21,32,.92), rgba(15,19,32,.78));
      box-shadow: var(--shadow);
      overflow:hidden;
    }
    .panelHeader{
      padding:14px 16px;
      border-bottom:1px solid var(--line);
      display:flex; align-items:center; justify-content:space-between; gap:10px;
    }
    .panelHeader h2{font-size:14px; margin:0}
    .panelHeader .hint{font-size:12px; color:var(--muted)}
    .panelBody{padding:14px 16px}
    .field{display:flex; flex-direction:column; gap:6px; margin-bottom:12px}
    .field label{font-size:12px; color:var(--muted)}
    .row{display:grid; grid-template-columns: 1fr 1fr; gap:10px}
    @media (max-width: 980px){ .row{grid-template-columns: 1fr} }
    input, select, textarea{
      width:100%;
      padding:10px 12px;
      border-radius: 12px;
      border:1px solid var(--line);
      background: rgba(10,12,18,.35);
      color:var(--text);
      outline:none;
    }
    textarea{min-height:110px; resize:vertical; line-height:1.5}
    input:focus, select:focus, textarea:focus{border-color: rgba(124,92,255,.55)}
    .help{font-size:11px; color:var(--muted); line-height:1.5}
    .divider{height:1px; background: var(--line); margin:14px 0}
    .toolbar{
      display:flex; gap:10px; align-items:center; flex-wrap:wrap;
      padding:12px 16px;
      border-bottom:1px solid var(--line);
      background: rgba(10,12,18,.18);
    }
    .toolbar input, .toolbar select{
      padding:10px 12px; border-radius: 999px;
      min-width: 200px;
    }
    .toolbar .grow{flex:1}
    .stats{
      display:flex; gap:8px; flex-wrap:wrap;
      padding:0 16px 14px 16px;
      color:var(--muted);
      font-size:12px;
    }
    .grid{
      padding:16px;
      display:grid;
      grid-template-columns: repeat(3, minmax(0, 1fr));
      gap:14px;
    }
    @media (max-width: 1200px){ .grid{grid-template-columns: repeat(2, minmax(0, 1fr));} }
    @media (max-width: 680px){ .grid{grid-template-columns: 1fr;} }
    .card{
      border:1px solid var(--line);
      border-radius: 18px;
      overflow:hidden;
      background: linear-gradient(180deg, rgba(15,21,36,.95), rgba(12,16,28,.88));
      box-shadow: 0 10px 25px rgba(0,0,0,.22);
      display:flex; flex-direction:column;
      min-height: 260px;
    }
    .thumb{
      height:160px;
      background: radial-gradient(1200px 260px at 10% 0%, rgba(124,92,255,.18), transparent 60%),
                  radial-gradient(1200px 260px at 80% 0%, rgba(34,197,94,.12), transparent 60%),
                  rgba(10,12,18,.25);
      border-bottom:1px solid var(--line);
      display:flex; align-items:center; justify-content:center;
      position:relative;
      overflow:hidden;
    }
    .thumb img{
      width:100%; height:100%;
      object-fit:cover;
      display:block;
      filter:saturate(1.02) contrast(1.02);
    }
    .badgeRow{
      position:absolute; left:10px; top:10px;
      display:flex; gap:8px; flex-wrap:wrap;
    }
    .badge{
      font-size:11px; color:var(--text);
      padding:6px 9px;
      border-radius: 999px;
      border:1px solid rgba(255,255,255,.12);
      background: rgba(0,0,0,.35);
      backdrop-filter: blur(8px);
    }
    .badge.good{border-color: rgba(34,197,94,.35)}
    .badge.warn{border-color: rgba(245,158,11,.35)}
    .badge.muted{color:var(--muted)}
    .cardBody{padding:12px 12px 10px 12px; display:flex; flex-direction:column; gap:8px; flex:1}
    .titleRow{display:flex; align-items:flex-start; justify-content:space-between; gap:10px}
    .card h3{margin:0; font-size:15px; line-height:1.25}
    .tagline{margin:0; color:var(--muted); font-size:12px; line-height:1.45}
    .meta{
      display:flex; gap:8px; flex-wrap:wrap;
      color:var(--muted); font-size:12px;
    }
    .pill{
      border:1px solid var(--line);
      background: rgba(10,12,18,.25);
      padding:6px 9px; border-radius:999px;
      font-size:12px;
    }
    .cardFooter{
      padding:10px 12px 12px 12px;
      display:flex; align-items:center; justify-content:space-between; gap:10px;
      border-top:1px solid var(--line);
      background: rgba(10,12,18,.18);
    }
    .actions{display:flex; gap:8px; flex-wrap:wrap}
    .muted{color:var(--muted)}
    .mono{font-family:var(--mono)}
    .empty{
      padding:28px 16px 40px 16px;
      text-align:center;
      color:var(--muted);
    }
    dialog{
      border:none;
      border-radius: 18px;
      padding:0;
      width:min(920px, calc(100vw - 24px));
      background: rgba(15,19,32,.96);
      color:var(--text);
      box-shadow: 0 30px 80px rgba(0,0,0,.55);
    }
    dialog::backdrop{background: rgba(0,0,0,.6)}
    .modalHeader{
      padding:14px 16px;
      border-bottom:1px solid var(--line);
      display:flex; align-items:center; justify-content:space-between; gap:10px;
    }
    .modalHeader h3{margin:0; font-size:14px}
    .modalBody{padding:14px 16px}
    .modalGrid{
      display:grid; grid-template-columns: 1.2fr .8fr; gap:14px;
    }
    @media (max-width: 900px){ .modalGrid{grid-template-columns: 1fr} }
    .gallery{
      display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap:10px;
    }
    @media (max-width: 680px){ .gallery{grid-template-columns: repeat(2, minmax(0, 1fr));} }
    .gimg{
      border:1px solid var(--line);
      border-radius: 14px;
      overflow:hidden;
      background: rgba(10,12,18,.25);
      aspect-ratio: 16 / 10;
      display:flex; align-items:center; justify-content:center;
    }
    .gimg img{width:100%; height:100%; object-fit:cover}
    .kvs{display:grid; grid-template-columns: 110px 1fr; gap:8px; align-items:start; font-size:12px; color:var(--muted)}
    .kvs b{color:var(--text); font-weight:700}
    .note{
      padding:10px 12px;
      border:1px solid var(--line);
      border-radius: 14px;
      background: rgba(10,12,18,.25);
      color:var(--muted);
      font-size:12px;
      line-height:1.55;
    }
    .toast{
      position: fixed;
      left:50%;
      bottom:16px;
      transform: translateX(-50%);
      background: rgba(15,19,32,.95);
      border: 1px solid var(--line);
      border-radius: 999px;
      padding: 10px 14px;
      color: var(--text);
      box-shadow: var(--shadow);
      opacity:0;
      pointer-events:none;
      transition: opacity .2s ease, transform .2s ease;
      display:flex; gap:8px; align-items:center;
      z-index:100;
      max-width: calc(100vw - 24px);
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .toast.show{opacity:1; transform: translateX(-50%) translateY(-2px)}
    .smallLink{
      color: var(--muted);
      font-size:12px;
      text-decoration:none;
      border-bottom:1px dashed rgba(170,179,199,.35);
    }
    .smallLink:hover{color: var(--text); border-bottom-color: rgba(233,237,247,.55)}
    .dangerText{color: #ffb4b4}
    .goodText{color: #b5ffd1}
    .warnText{color: #ffe2b0}
  </style>
</head>
<body>
  <div class="app">
    <header>
      <div class="brand">
        <div class="logo" aria-hidden="true"></div>
        <div>
          <h1>GameWorks <span class="muted">— ゲーム作品投稿</span></h1>
          <p>サーバー不要 / ブラウザに保存 / 作品ポートフォリオを即作る</p>
        </div>
      </div>
      <div class="headerActions">
        <span class="chip" id="chipCount">作品: 0</span>
        <button class="btn small" id="btnSeed" type="button">サンプル追加</button>
        <button class="btn small" id="btnExport" type="button">エクスポート</button>
        <button class="btn small" id="btnImport" type="button">インポート</button>
        <button class="btn small danger" id="btnWipe" type="button">全削除</button>
      </div>
    </header>

    <!-- Left: 投稿フォーム -->
    <section class="panel" aria-label="投稿フォーム">
      <div class="panelHeader">
        <div>
          <h2 id="formTitle">新規投稿</h2>
          <div class="hint" id="formHint">作品情報を入力して保存</div>
        </div>
        <button class="btn small ghost" id="btnResetForm" type="button">リセット</button>
      </div>
      <div class="panelBody">
        <div class="field">
          <label for="author">投稿者名(任意)</label>
          <input id="author" type="text" maxlength="40" placeholder="例:Yuhei" />
          <div class="help">同じブラウザ内だけの表示です。</div>
        </div>

        <div class="field">
          <label for="title">作品タイトル *</label>
          <input id="title" type="text" maxlength="60" placeholder="例:Elder Chronicle VR" required />
        </div>

        <div class="field">
          <label for="tagline">ひとこと(キャッチコピー)</label>
          <input id="tagline" type="text" maxlength="80" placeholder="例:探索と戦闘が気持ちいいVRアクションRPG" />
        </div>

        <div class="row">
          <div class="field">
            <label for="engine">使用エンジン</label>
            <select id="engine">
              <option value="">未設定</option>
              <option>Unity</option>
              <option>Unreal Engine</option>
              <option>Godot</option>
              <option>RPGツクール</option>
              <option>自作</option>
              <option>その他</option>
            </select>
          </div>
          <div class="field">
            <label for="status">状態</label>
            <select id="status">
              <option>開発中</option>
              <option>体験版あり</option>
              <option>公開中</option>
              <option>凍結</option>
            </select>
          </div>
        </div>

        <div class="row">
          <div class="field">
            <label for="platform">対応プラットフォーム</label>
            <input id="platform" type="text" maxlength="60" placeholder="例:PC / Web / Quest / Android" />
          </div>
          <div class="field">
            <label for="genre">ジャンル</label>
            <input id="genre" type="text" maxlength="60" placeholder="例:アクション / VR / サバイバル" />
          </div>
        </div>

        <div class="field">
          <label for="tags">タグ(カンマ区切り)</label>
          <input id="tags" type="text" maxlength="120" placeholder="例:VR, 探索, ダンジョン, ボス戦" />
        </div>

        <div class="field">
          <label for="desc">説明 *</label>
          <textarea id="desc" maxlength="2000" placeholder="作品の魅力、遊び方、特徴、今後の予定など"></textarea>
          <div class="help">最大2000文字。長い場合は要点→詳細の順で書くと強い。</div>
        </div>

        <div class="row">
          <div class="field">
            <label for="linkPlay">プレイURL / 公開ページ</label>
            <input id="linkPlay" type="url" placeholder="https://..." />
          </div>
          <div class="field">
            <label for="linkRepo">GitHub / リポジトリ</label>
            <input id="linkRepo" type="url" placeholder="https://github.com/..." />
          </div>
        </div>

        <div class="row">
          <div class="field">
            <label for="linkVideo">動画URL(YouTubeなど)</label>
            <input id="linkVideo" type="url" placeholder="https://www.youtube.com/watch?v=..." />
          </div>
          <div class="field">
            <label for="rating">自己評価(1〜5)</label>
            <select id="rating">
              <option value="0">未設定</option>
              <option value="1">★1</option>
              <option value="2">★2</option>
              <option value="3">★3</option>
              <option value="4">★4</option>
              <option value="5">★5</option>
            </select>
          </div>
        </div>

        <div class="field">
          <label for="shots">スクリーンショット(複数可 / 自動でデータ化して保存)</label>
          <input id="shots" type="file" accept="image/*" multiple />
          <div class="help">
            画像はブラウザ内に保存されます(容量が大きいと重くなります)。<br/>
            目安:1枚 500KB〜1MB程度に圧縮すると快適。
          </div>
        </div>

        <div class="divider"></div>

        <div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center">
          <button class="btn primary" id="btnSave" type="button">保存</button>
          <button class="btn" id="btnDraft" type="button">下書き保存</button>
          <span class="help" id="saveHelp">* 必須:タイトル / 説明</span>
        </div>

        <div class="divider"></div>

        <div class="note">
          <b>保存方式:</b>このページは <span class="mono">localStorage</span> に保存します。<br/>
          つまり「あなたのブラウザ内だけ」に残ります。公開サイトで運用したいなら、次はサーバー保存(PHP/DB)に切り替える。
        </div>
      </div>
    </section>

    <!-- Right: 一覧 -->
    <main class="panel" aria-label="作品一覧">
      <div class="toolbar">
        <input class="grow" id="q" type="search" placeholder="検索:タイトル/説明/タグ/エンジン/ジャンル..." />
        <select id="filterStatus" title="状態">
          <option value="">状態:すべて</option>
          <option>開発中</option>
          <option>体験版あり</option>
          <option>公開中</option>
          <option>凍結</option>
        </select>
        <select id="sort" title="並び替え">
          <option value="new">新しい順</option>
          <option value="old">古い順</option>
          <option value="likes">いいね順</option>
          <option value="rating">評価順</option>
          <option value="title">タイトル順</option>
        </select>
        <button class="btn small good" id="btnNew" type="button">+ 新規投稿</button>
      </div>

      <div class="stats" id="stats"></div>

      <div id="list" class="grid" aria-live="polite"></div>
      <div id="empty" class="empty" hidden>
        まだ作品がありません。左のフォームから投稿して、あなたの作品集を完成させよう。
      </div>
    </main>
  </div>

  <!-- 詳細モーダル -->
  <dialog id="modal">
    <div class="modalHeader">
      <h3 id="mTitle">詳細</h3>
      <div style="display:flex; gap:8px; align-items:center">
        <button class="btn small" id="mEdit" type="button">編集</button>
        <button class="btn small" id="mLike" type="button">いいね</button>
        <button class="btn small danger" id="mDelete" type="button">削除</button>
        <button class="btn small ghost" id="mClose" type="button">閉じる</button>
      </div>
    </div>
    <div class="modalBody">
      <div class="modalGrid">
        <div>
          <div class="note" id="mTagline"></div>
          <div class="divider"></div>

          <div class="gallery" id="mGallery"></div>

          <div class="divider"></div>

          <div class="note" id="mDesc"></div>

          <div class="divider"></div>

          <div class="panel" style="background: rgba(10,12,18,.12); box-shadow:none">
            <div class="panelHeader" style="border-bottom:1px solid var(--line)">
              <div>
                <h2 style="margin:0;font-size:14px">コメント</h2>
                <div class="hint">ローカル保存(このブラウザだけ)</div>
              </div>
            </div>
            <div class="panelBody">
              <div class="field">
                <label for="cName">名前(任意)</label>
                <input id="cName" type="text" maxlength="40" placeholder="例:Anonymous" />
              </div>
              <div class="field">
                <label for="cText">コメント</label>
                <textarea id="cText" maxlength="400" placeholder="感想 / フィードバック"></textarea>
                <div class="help">最大400文字</div>
              </div>
              <div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap">
                <button class="btn good" id="cAdd" type="button">コメント追加</button>
                <span class="help" id="cHint"></span>
              </div>
              <div class="divider"></div>
              <div id="cList" style="display:flex;flex-direction:column;gap:10px"></div>
            </div>
          </div>
        </div>

        <div>
          <div class="note">
            <div class="kvs" id="mMeta"></div>
          </div>
          <div class="divider"></div>

          <div class="note" id="mLinks"></div>

          <div class="divider"></div>

          <div class="note" id="mSystem"></div>
        </div>
      </div>
    </div>
  </dialog>

  <div class="toast" id="toast" role="status" aria-live="polite"></div>

  <input id="importFile" type="file" accept="application/json" hidden />

  <script>
    "use strict";

    // ====== Storage Keys ======
    const KEY = "gameworks_posts_v1";
    const KEY_DRAFT = "gameworks_draft_v1";

    // ====== DOM ======
    const el = (id) => document.getElementById(id);

    const chipCount = el("chipCount");
    const listEl = el("list");
    const emptyEl = el("empty");
    const statsEl = el("stats");

    const qEl = el("q");
    const filterStatusEl = el("filterStatus");
    const sortEl = el("sort");

    const formTitleEl = el("formTitle");
    const formHintEl = el("formHint");

    // form fields
    const authorEl = el("author");
    const titleEl = el("title");
    const taglineEl = el("tagline");
    const engineEl = el("engine");
    const statusEl = el("status");
    const platformEl = el("platform");
    const genreEl = el("genre");
    const tagsEl = el("tags");
    const descEl = el("desc");
    const linkPlayEl = el("linkPlay");
    const linkRepoEl = el("linkRepo");
    const linkVideoEl = el("linkVideo");
    const ratingEl = el("rating");
    const shotsEl = el("shots");

    const btnSave = el("btnSave");
    const btnDraft = el("btnDraft");
    const btnResetForm = el("btnResetForm");

    const btnSeed = el("btnSeed");
    const btnExport = el("btnExport");
    const btnImport = el("btnImport");
    const btnWipe = el("btnWipe");
    const btnNew = el("btnNew");

    const toastEl = el("toast");
    const importFileEl = el("importFile");

    // modal
    const modal = el("modal");
    const mTitle = el("mTitle");
    const mTagline = el("mTagline");
    const mGallery = el("mGallery");
    const mDesc = el("mDesc");
    const mMeta = el("mMeta");
    const mLinks = el("mLinks");
    const mSystem = el("mSystem");

    const mEdit = el("mEdit");
    const mLike = el("mLike");
    const mDelete = el("mDelete");
    const mClose = el("mClose");

    // comments
    const cName = el("cName");
    const cText = el("cText");
    const cAdd = el("cAdd");
    const cHint = el("cHint");
    const cList = el("cList");

    // ====== State ======
    let posts = loadPosts();
    let editingId = null;
    let pendingImages = []; // base64 array for current form
    let currentModalId = null;

    // ====== Utils ======
    const nowISO = () => new Date().toISOString();
    const uid = () => "p_" + Math.random().toString(16).slice(2) + Date.now().toString(16);

    function toast(msg){
      toastEl.textContent = msg;
      toastEl.classList.add("show");
      clearTimeout(toastEl._t);
      toastEl._t = setTimeout(()=> toastEl.classList.remove("show"), 1700);
    }

    function safeText(str){
      // basic escape for text rendering into HTML
      return String(str ?? "")
        .replaceAll("&","&amp;")
        .replaceAll("<","&lt;")
        .replaceAll(">","&gt;")
        .replaceAll('"',"&quot;")
        .replaceAll("'","&#39;");
    }

    function normalizeTags(input){
      return String(input ?? "")
        .split(",")
        .map(s => s.trim())
        .filter(Boolean)
        .slice(0, 20);
    }

    function stars(n){
      const v = Number(n || 0);
      if(!v) return "未設定";
      return "★".repeat(v) + "☆".repeat(5 - v);
    }

    function fmtDate(iso){
      try{
        const d = new Date(iso);
        const y = d.getFullYear();
        const m = String(d.getMonth()+1).padStart(2,"0");
        const dd = String(d.getDate()).padStart(2,"0");
        const hh = String(d.getHours()).padStart(2,"0");
        const mm = String(d.getMinutes()).padStart(2,"0");
        return `${y}/${m}/${dd} ${hh}:${mm}`;
      }catch{
        return String(iso);
      }
    }

    function loadPosts(){
      try{
        const raw = localStorage.getItem(KEY);
        const arr = raw ? JSON.parse(raw) : [];
        return Array.isArray(arr) ? arr : [];
      }catch{
        return [];
      }
    }

    function savePosts(){
      localStorage.setItem(KEY, JSON.stringify(posts));
      chipCount.textContent = `作品: ${posts.length}`;
    }

    function loadDraft(){
      try{
        const raw = localStorage.getItem(KEY_DRAFT);
        return raw ? JSON.parse(raw) : null;
      }catch{
        return null;
      }
    }

    function saveDraft(draft){
      localStorage.setItem(KEY_DRAFT, JSON.stringify(draft));
    }

    function clearDraft(){
      localStorage.removeItem(KEY_DRAFT);
    }

    function bytesApprox(){
      // rough localStorage usage for our key
      const raw = localStorage.getItem(KEY) || "";
      return raw.length;
    }

    function buildStats(filteredCount){
      const likeSum = posts.reduce((a,p)=> a + (p.likes||0), 0);
      const bytes = bytesApprox();
      const mb = (bytes / (1024*1024)).toFixed(2);
      statsEl.innerHTML = `
        <span class="chip">表示: <b>${filteredCount}</b></span>
        <span class="chip">総いいね: <b>${likeSum}</b></span>
        <span class="chip">保存サイズ目安: <b>${mb} MB</b></span>
      `;
    }

    // ====== Image handling (compress to JPEG) ======
    async function fileToDataUrlCompressed(file, maxW=1280, quality=0.82){
      const img = await new Promise((res, rej)=>{
        const i = new Image();
        i.onload = ()=> res(i);
        i.onerror = rej;
        i.src = URL.createObjectURL(file);
      });

      const scale = Math.min(1, maxW / img.width);
      const w = Math.round(img.width * scale);
      const h = Math.round(img.height * scale);

      const canvas = document.createElement("canvas");
      canvas.width = w; canvas.height = h;
      const ctx = canvas.getContext("2d");
      ctx.drawImage(img, 0, 0, w, h);

      URL.revokeObjectURL(img.src);

      return canvas.toDataURL("image/jpeg", quality);
    }

    shotsEl.addEventListener("change", async () => {
      const files = Array.from(shotsEl.files || []);
      if(!files.length) return;

      toast("画像を処理中…");
      const max = 9;
      const slice = files.slice(0, max);

      const out = [];
      for(const f of slice){
        if(!f.type.startsWith("image/")) continue;
        try{
          const d = await fileToDataUrlCompressed(f);
          out.push(d);
        }catch{}
      }
      pendingImages = out;
      toast(`スクショ ${pendingImages.length}枚 準備OK`);
    });

    // ====== Render ======
    function getFiltered(){
      const q = (qEl.value || "").trim().toLowerCase();
      const st = filterStatusEl.value || "";

      let arr = [...posts];

      if(q){
        arr = arr.filter(p=>{
          const blob = [
            p.title, p.tagline, p.desc, p.engine, p.platform, p.genre,
            ...(p.tags||[]),
          ].join(" ").toLowerCase();
          return blob.includes(q);
        });
      }
      if(st){
        arr = arr.filter(p => (p.status || "") === st);
      }

      // sort
      const s = sortEl.value;
      arr.sort((a,b)=>{
        if(s === "new") return (b.createdAt||"").localeCompare(a.createdAt||"");
        if(s === "old") return (a.createdAt||"").localeCompare(b.createdAt||"");
        if(s === "likes") return (b.likes||0) - (a.likes||0);
        if(s === "rating") return (Number(b.rating||0) - Number(a.rating||0)) || (b.likes||0)-(a.likes||0);
        if(s === "title") return (a.title||"").localeCompare(b.title||"", "ja");
        return 0;
      });

      return arr;
    }

    function statusBadgeClass(status){
      if(status === "公開中") return "good";
      if(status === "体験版あり") return "warn";
      return "";
    }

    function render(){
      savePosts(); // also updates chipCount
      const arr = getFiltered();
      buildStats(arr.length);

      listEl.innerHTML = "";
      emptyEl.hidden = arr.length !== 0;

      for(const p of arr){
        const cover = (p.images && p.images[0]) ? `<img alt="cover" src="${p.images[0]}">` : "";
        const tags = (p.tags||[]).slice(0,3).map(t=>`<span class="pill">#${safeText(t)}</span>`).join("");
        const engine = p.engine ? `<span class="pill">${safeText(p.engine)}</span>` : "";
        const genre = p.genre ? `<span class="pill">${safeText(p.genre)}</span>` : "";
        const rating = Number(p.rating||0) ? `<span class="pill">${safeText(stars(p.rating))}</span>` : "";

        const card = document.createElement("article");
        card.className = "card";
        card.innerHTML = `
          <div class="thumb">
            ${cover || `<div class="muted">No Image</div>`}
            <div class="badgeRow">
              <span class="badge ${statusBadgeClass(p.status)}">${safeText(p.status || "未設定")}</span>
              ${p.platform ? `<span class="badge muted">${safeText(p.platform)}</span>` : ""}
            </div>
          </div>
          <div class="cardBody">
            <div class="titleRow">
              <div style="min-width:0">
                <h3 title="${safeText(p.title)}">${safeText(p.title)}</h3>
                <p class="tagline">${safeText(p.tagline || "—")}</p>
              </div>
              <div class="muted" style="font-size:12px; white-space:nowrap">
                ❤ ${p.likes||0}
              </div>
            </div>
            <div class="meta">
              ${engine}
              ${genre}
              ${rating}
              ${tags}
            </div>
            <div class="muted" style="font-size:12px; line-height:1.55; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden">
              ${safeText(p.desc || "")}
            </div>
          </div>
          <div class="cardFooter">
            <div class="muted" style="font-size:12px">更新: ${safeText(fmtDate(p.updatedAt || p.createdAt))}</div>
            <div class="actions">
              <button class="btn small" data-act="open" data-id="${p.id}">詳細</button>
              <button class="btn small good" data-act="like" data-id="${p.id}">いいね</button>
            </div>
          </div>
        `;
        listEl.appendChild(card);
      }
    }

    listEl.addEventListener("click", (e)=>{
      const btn = e.target.closest("button");
      if(!btn) return;
      const id = btn.getAttribute("data-id");
      const act = btn.getAttribute("data-act");
      if(!id || !act) return;

      if(act === "open") openModal(id);
      if(act === "like") { likePost(id); render(); }
    });

    // ====== CRUD ======
    function validateForm(){
      const title = titleEl.value.trim();
      const desc = descEl.value.trim();
      if(!title || !desc){
        toast("タイトルと説明は必須");
        return false;
      }
      return true;
    }

    function readFormAsPost(){
      const tags = normalizeTags(tagsEl.value);
      return {
        id: editingId || uid(),
        author: authorEl.value.trim(),
        title: titleEl.value.trim(),
        tagline: taglineEl.value.trim(),
        engine: engineEl.value,
        status: statusEl.value,
        platform: platformEl.value.trim(),
        genre: genreEl.value.trim(),
        tags,
        desc: descEl.value.trim(),
        linkPlay: linkPlayEl.value.trim(),
        linkRepo: linkRepoEl.value.trim(),
        linkVideo: linkVideoEl.value.trim(),
        rating: Number(ratingEl.value || 0),
        images: (pendingImages && pendingImages.length) ? pendingImages : [],
        likes: 0,
        comments: [],
        createdAt: nowISO(),
        updatedAt: nowISO()
      };
    }

    function resetForm(keepDraft=false){
      editingId = null;
      pendingImages = [];
      if(!keepDraft){
        authorEl.value = "";
        titleEl.value = "";
        taglineEl.value = "";
        engineEl.value = "";
        statusEl.value = "開発中";
        platformEl.value = "";
        genreEl.value = "";
        tagsEl.value = "";
        descEl.value = "";
        linkPlayEl.value = "";
        linkRepoEl.value = "";
        linkVideoEl.value = "";
        ratingEl.value = "0";
        shotsEl.value = "";
      }
      formTitleEl.textContent = "新規投稿";
      formHintEl.textContent = "作品情報を入力して保存";
      btnSave.textContent = "保存";
    }

    function setFormFromPost(p){
      editingId = p.id;
      pendingImages = Array.isArray(p.images) ? [...p.images] : [];

      authorEl.value = p.author || "";
      titleEl.value = p.title || "";
      taglineEl.value = p.tagline || "";
      engineEl.value = p.engine || "";
      statusEl.value = p.status || "開発中";
      platformEl.value = p.platform || "";
      genreEl.value = p.genre || "";
      tagsEl.value = (p.tags || []).join(", ");
      descEl.value = p.desc || "";
      linkPlayEl.value = p.linkPlay || "";
      linkRepoEl.value = p.linkRepo || "";
      linkVideoEl.value = p.linkVideo || "";
      ratingEl.value = String(p.rating || 0);
      shotsEl.value = "";

      formTitleEl.textContent = "編集";
      formHintEl.textContent = "内容を更新して保存";
      btnSave.textContent = "更新";
      toast("編集モード");
      window.scrollTo({top:0, behavior:"smooth"});
    }

    function upsertPost(p){
      const idx = posts.findIndex(x=>x.id===p.id);
      if(idx >= 0){
        const prev = posts[idx];
        posts[idx] = {
          ...prev,
          ...p,
          likes: prev.likes || 0,
          comments: Array.isArray(prev.comments) ? prev.comments : [],
          createdAt: prev.createdAt || nowISO(),
          updatedAt: nowISO()
        };
      }else{
        posts.unshift(p);
      }
      savePosts();
    }

    btnSave.addEventListener("click", ()=>{
      if(!validateForm()) return;

      const p = readFormAsPost();
      // if editing, keep previous likes/comments/createdAt
      if(editingId){
        const prev = posts.find(x=>x.id===editingId);
        if(prev){
          p.likes = prev.likes || 0;
          p.comments = Array.isArray(prev.comments) ? prev.comments : [];
          p.createdAt = prev.createdAt || nowISO();
          p.updatedAt = nowISO();
          // if user did not reselect images, keep old images
          if(!pendingImages.length && Array.isArray(prev.images)) p.images = prev.images;
        }
      }

      upsertPost(p);
      clearDraft();
      resetForm();
      render();
      toast(editingId ? "更新した" : "保存した");
    });

    btnDraft.addEventListener("click", ()=>{
      const draft = {
        author: authorEl.value,
        title: titleEl.value,
        tagline: taglineEl.value,
        engine: engineEl.value,
        status: statusEl.value,
        platform: platformEl.value,
        genre: genreEl.value,
        tags: tagsEl.value,
        desc: descEl.value,
        linkPlay: linkPlayEl.value,
        linkRepo: linkRepoEl.value,
        linkVideo: linkVideoEl.value,
        rating: ratingEl.value,
        images: pendingImages
      };
      saveDraft(draft);
      toast("下書きを保存した");
    });

    btnResetForm.addEventListener("click", ()=>{
      resetForm();
      toast("フォームをリセット");
    });

    btnNew.addEventListener("click", ()=>{
      resetForm();
      toast("新規投稿モード");
      window.scrollTo({top:0, behavior:"smooth"});
    });

    // ====== Like / Delete ======
    function likePost(id){
      const p = posts.find(x=>x.id===id);
      if(!p) return;
      p.likes = (p.likes||0) + 1;
      p.updatedAt = nowISO();
      savePosts();
      toast("いいね +1");
      if(currentModalId === id) refreshModal();
    }

    function deletePost(id){
      const idx = posts.findIndex(x=>x.id===id);
      if(idx < 0) return;
      posts.splice(idx, 1);
      savePosts();
      toast("削除した");
    }

    // ====== Modal ======
    function openModal(id){
      const p = posts.find(x=>x.id===id);
      if(!p) return;
      currentModalId = id;
      // count view? (optional)
      modal.showModal();
      refreshModal();
    }

    function refreshModal(){
      const p = posts.find(x=>x.id===currentModalId);
      if(!p) return;

      mTitle.textContent = p.title || "詳細";
      mTagline.innerHTML = `<b>${safeText(p.tagline || "—")}</b><br><span class="muted">❤ ${p.likes||0} / 評価: ${safeText(stars(p.rating))}</span>`;

      // gallery
      mGallery.innerHTML = "";
      const imgs = Array.isArray(p.images) ? p.images : [];
      if(imgs.length){
        for(const src of imgs){
          const div = document.createElement("div");
          div.className = "gimg";
          div.innerHTML = `<img alt="screenshot" src="${src}">`;
          mGallery.appendChild(div);
        }
      }else{
        mGallery.innerHTML = `<div class="note">スクショなし</div>`;
      }

      mDesc.innerHTML = safeText(p.desc || "").replaceAll("\n","<br>");

      // meta
      const t = (p.tags||[]).map(x=>`#${x}`).join(" ");
      mMeta.innerHTML = `
        <b>投稿者</b><div>${safeText(p.author || "—")}</div>
        <b>状態</b><div>${safeText(p.status || "—")}</div>
        <b>エンジン</b><div>${safeText(p.engine || "—")}</div>
        <b>プラットフォーム</b><div>${safeText(p.platform || "—")}</div>
        <b>ジャンル</b><div>${safeText(p.genre || "—")}</div>
        <b>タグ</b><div>${safeText(t || "—")}</div>
        <b>作成</b><div>${safeText(fmtDate(p.createdAt))}</div>
        <b>更新</b><div>${safeText(fmtDate(p.updatedAt || p.createdAt))}</div>
      `;

      // links
      const links = [];
      if(p.linkPlay) links.push(`<a class="smallLink" href="${safeText(p.linkPlay)}" target="_blank" rel="noopener">▶ 公開/プレイページ</a>`);
      if(p.linkRepo) links.push(`<a class="smallLink" href="${safeText(p.linkRepo)}" target="_blank" rel="noopener">⌂ リポジトリ</a>`);
      if(p.linkVideo) links.push(`<a class="smallLink" href="${safeText(p.linkVideo)}" target="_blank" rel="noopener">🎬 動画</a>`);
      mLinks.innerHTML = links.length ? links.join("<br>") : `<span class="muted">リンクなし</span>`;

      mSystem.innerHTML = `
        <b>操作</b><br>
        ・編集で左フォームに読み込み<br>
        ・いいねはローカルカウント<br>
        ・削除は取り消し不可
      `;

      // buttons
      mLike.textContent = `いいね (${p.likes||0})`;

      renderComments(p);
    }

    mClose.addEventListener("click", ()=> modal.close());
    modal.addEventListener("click", (e)=>{
      const rect = modal.getBoundingClientRect();
      const inDialog = (
        rect.top <= e.clientY && e.clientY <= rect.top + rect.height &&
        rect.left <= e.clientX && e.clientX <= rect.left + rect.width
      );
      if(!inDialog) modal.close();
    });

    mLike.addEventListener("click", ()=>{
      if(!currentModalId) return;
      likePost(currentModalId);
      render();
    });

    mDelete.addEventListener("click", ()=>{
      if(!currentModalId) return;
      const p = posts.find(x=>x.id===currentModalId);
      if(!p) return;
      const ok = confirm(`「${p.title}」を削除します。よろしいですか?`);
      if(!ok) return;
      deletePost(currentModalId);
      modal.close();
      currentModalId = null;
      render();
    });

    mEdit.addEventListener("click", ()=>{
      const p = posts.find(x=>x.id===currentModalId);
      if(!p) return;
      setFormFromPost(p);
      modal.close();
    });

    // ====== Comments ======
    function renderComments(p){
      const arr = Array.isArray(p.comments) ? p.comments : [];
      cHint.textContent = `コメント数: ${arr.length}`;

      cList.innerHTML = "";
      if(!arr.length){
        cList.innerHTML = `<div class="note">まだコメントはありません。</div>`;
        return;
      }
      for(const c of arr.slice().reverse()){
        const box = document.createElement("div");
        box.className = "note";
        box.innerHTML = `
          <b>${safeText(c.name || "Anonymous")}</b>
          <span class="muted"> / ${safeText(fmtDate(c.at))}</span><br>
          ${safeText(c.text || "").replaceAll("\n","<br>")}
        `;
        cList.appendChild(box);
      }
    }

    cAdd.addEventListener("click", ()=>{
      const p = posts.find(x=>x.id===currentModalId);
      if(!p) return;

      const text = (cText.value || "").trim();
      if(!text){
        toast("コメントを入力");
        return;
      }
      const name = (cName.value || "").trim();

      p.comments = Array.isArray(p.comments) ? p.comments : [];
      p.comments.push({ name, text, at: nowISO() });
      p.updatedAt = nowISO();
      savePosts();
      cText.value = "";
      toast("コメント追加");
      refreshModal();
      render();
    });

    // ====== Search / filter / sort ======
    [qEl, filterStatusEl, sortEl].forEach(x => x.addEventListener("input", render));

    // ====== Export / Import ======
    btnExport.addEventListener("click", ()=>{
      const payload = {
        version: 1,
        exportedAt: nowISO(),
        posts
      };
      const blob = new Blob([JSON.stringify(payload, null, 2)], {type:"application/json"});
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `gameworks_export_${new Date().toISOString().slice(0,10)}.json`;
      a.click();
      URL.revokeObjectURL(url);
      toast("エクスポートした");
    });

    btnImport.addEventListener("click", ()=>{
      importFileEl.value = "";
      importFileEl.click();
    });

    importFileEl.addEventListener("change", async ()=>{
      const file = importFileEl.files && importFileEl.files[0];
      if(!file) return;

      try{
        const text = await file.text();
        const data = JSON.parse(text);
        const arr = data && data.posts;
        if(!Array.isArray(arr)) throw new Error("invalid");
        // merge (by id)
        const map = new Map(posts.map(p=>[p.id, p]));
        for(const p of arr){
          if(!p || !p.id) continue;
          const prev = map.get(p.id);
          map.set(p.id, prev ? ({...prev, ...p}) : p);
        }
        posts = Array.from(map.values());
        // normalize
        posts = posts.map(p=>({
          id: p.id || uid(),
          author: p.author || "",
          title: p.title || "Untitled",
          tagline: p.tagline || "",
          engine: p.engine || "",
          status: p.status || "開発中",
          platform: p.platform || "",
          genre: p.genre || "",
          tags: Array.isArray(p.tags) ? p.tags : normalizeTags(p.tags),
          desc: p.desc || "",
          linkPlay: p.linkPlay || "",
          linkRepo: p.linkRepo || "",
          linkVideo: p.linkVideo || "",
          rating: Number(p.rating || 0),
          images: Array.isArray(p.images) ? p.images : [],
          likes: Number(p.likes || 0),
          comments: Array.isArray(p.comments) ? p.comments : [],
          createdAt: p.createdAt || nowISO(),
          updatedAt: p.updatedAt || p.createdAt || nowISO()
        }));

        savePosts();
        render();
        toast("インポート完了");
      }catch{
        toast("インポート失敗(JSON形式を確認)");
      }
    });

    // ====== Wipe ======
    btnWipe.addEventListener("click", ()=>{
      const ok = confirm("全作品データを削除します。取り消しできません。よろしいですか?");
      if(!ok) return;
      posts = [];
      savePosts();
      render();
      resetForm();
      clearDraft();
      toast("全削除した");
    });

    // ====== Seed ======
    btnSeed.addEventListener("click", ()=>{
      const seed = [
        {
          id: uid(),
          author: "Yuhei",
          title: "KnightSurvivors",
          tagline: "短時間で熱くなれるサバイバルアクション",
          engine: "Unity",
          status: "体験版あり",
          platform: "PC / Web",
          genre: "サバイバル / アクション",
          tags: ["サバイバル","爽快","ローグライト"],
          desc: "敵の波をさばき、ビルドを組み替えて最適解を探す。\n短時間でも気持ちよく終われるテンポを意識。",
          linkPlay: "",
          linkRepo: "",
          linkVideo: "",
          rating: 4,
          images: [],
          likes: 12,
          comments: [],
          createdAt: nowISO(),
          updatedAt: nowISO()
        },
        {
          id: uid(),
          author: "Yuhei",
          title: "Elder Chronicle VR",
          tagline: "探索・クエスト・戦闘を1つにまとめたVR世界",
          engine: "A-Frame",
          status: "開発中",
          platform: "Quest / WebXR",
          genre: "VR / RPG",
          tags: ["VR","RPG","ダンジョン","クエスト"],
          desc: "場所移動・クエスト受注・戦闘のループを磨いていく。\nUIと世界観の一体感を最優先。",
          linkPlay: "",
          linkRepo: "",
          linkVideo: "",
          rating: 5,
          images: [],
          likes: 30,
          comments: [],
          createdAt: nowISO(),
          updatedAt: nowISO()
        }
      ];
      posts = [...seed, ...posts];
      savePosts();
      render();
      toast("サンプルを追加した");
    });

    // ====== Load draft on start ======
    (function init(){
      chipCount.textContent = `作品: ${posts.length}`;

      const draft = loadDraft();
      if(draft){
        authorEl.value = draft.author || "";
        titleEl.value = draft.title || "";
        taglineEl.value = draft.tagline || "";
        engineEl.value = draft.engine || "";
        statusEl.value = draft.status || "開発中";
        platformEl.value = draft.platform || "";
        genreEl.value = draft.genre || "";
        tagsEl.value = draft.tags || "";
        descEl.value = draft.desc || "";
        linkPlayEl.value = draft.linkPlay || "";
        linkRepoEl.value = draft.linkRepo || "";
        linkVideoEl.value = draft.linkVideo || "";
        ratingEl.value = String(draft.rating || 0);
        pendingImages = Array.isArray(draft.images) ? draft.images : [];
        formHintEl.textContent = "下書きを復元しました(保存を押すと投稿になります)";
        toast("下書きを復元");
      }

      render();
    })();
  </script>
</body>
</html>

投稿者: chosuke

趣味はゲームやアニメや漫画などです

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です