<!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("&","&")
.replaceAll("<","<")
.replaceAll(">",">")
.replaceAll('"',""")
.replaceAll("'","'");
}
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>